mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-16 07:36:55 -04:00
Compare commits
489 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3018a29a0 | |||
| 6bd1c877aa | |||
| 753dc48b69 | |||
| 5ee5d4f1b1 | |||
| 8d2bca59c4 | |||
| f867cbce92 | |||
| a659c89385 | |||
| 8290e99cf9 | |||
| e648ddd9ff | |||
| 85fd567fb2 | |||
| 20ea2980a2 | |||
| af2d4784c6 | |||
| 526083d7c9 | |||
| 8e792e1bfd | |||
| f3e6f86857 | |||
| 25dbfe4c7d | |||
| 0a586eddb3 | |||
| b6d81ba2a0 | |||
| 8f3dc8daea | |||
| ced434a1cf | |||
| 96626fce25 | |||
| c74707e804 | |||
| 0174e08639 | |||
| af855a0c77 | |||
| 604a1b770c | |||
| 2aa978ca5b | |||
| 0571a8dc7d | |||
| a8a919f581 | |||
| bf21c4cf02 | |||
| 741ff3f3f0 | |||
| 4476921069 | |||
| 8c5e4a5780 | |||
| 34ae1bb305 | |||
| 0cf11adc2c | |||
| fd0209dbc0 | |||
| b4e00538f8 | |||
| 7a5020aa71 | |||
| a205f7d873 | |||
| 0e9c50bf0a | |||
| e837c516c5 | |||
| 3b2e9fa0b4 | |||
| 6a47139e0d | |||
| eefae851d5 | |||
| 40f9f4cde0 | |||
| f7f8c56c0a | |||
| c7e88e1ff2 | |||
| f6a43f1b10 | |||
| a3b183d304 | |||
| f7175c0505 | |||
| a1d1bd0e11 | |||
| e26040d154 | |||
| 17a1b75e5b | |||
| 7df9688bb9 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 2875814a36 | |||
| 157b05536a | |||
| f8c9425569 | |||
| be3a88b09c | |||
| aabf122161 | |||
| 3c6de3be6d | |||
| 03d2f363f1 | |||
| ff370cf86f | |||
| 9cbeaef88d | |||
| ca936aada7 | |||
| fe70a2df43 | |||
| 6baca67cd3 | |||
| 85f6454340 | |||
| 16f885c010 | |||
| 5a70599aac | |||
| 7b74a902a2 | |||
| 95700e1196 | |||
| e7578be6c5 | |||
| 28df4259b8 | |||
| 5e27358fe8 | |||
| daea207aff | |||
| 92d3fe496d | |||
| 98031676d6 | |||
| 498624f6fe | |||
| bf702c3b2a | |||
| 6e4dae4dfa | |||
| a8ea9a1b63 | |||
| a2b76bde14 | |||
| 0f652e8b5b | |||
| 2a0635879c | |||
| f5fc0ff9d1 | |||
| 7774364ac7 | |||
| 329289fc35 | |||
| 8b3c0d2a4a | |||
| 120b46c4a5 | |||
| 4bdaf577c0 | |||
| 428a4b3dd7 | |||
| ff83994733 | |||
| 418bca79b2 | |||
| b3715dc5df | |||
| 9a47e0f11e | |||
| 908c0e44ff | |||
| 920776fdfe | |||
| 0fbde61b2d | |||
| 4c49cd9297 | |||
| 70d2da81d8 | |||
| 92a82ce4bd | |||
| b2ce4591d9 | |||
| 1be9f40574 | |||
| 47c85f8540 | |||
| fd03d1fdfb | |||
| 35016b5847 | |||
| 0fe7edc157 | |||
| d6de160224 | |||
| cc4452ce58 | |||
| bd73d96847 | |||
| 7cf9db4872 | |||
| c9381a11f9 | |||
| f4cf77f5c6 | |||
| 23aa8ef509 | |||
| 945b325336 | |||
| 502f0b5c23 | |||
| 2ccbb0596d | |||
| 862b88b6c4 | |||
| e4070b86f8 | |||
| 9eeb6da3e6 | |||
| 869141b0a3 | |||
| ea017e7622 | |||
| 763f900c69 | |||
| cd7865a66f | |||
| 9c8e39d83d | |||
| 0a4b6a01b3 | |||
| b3e433291c | |||
| 969c9663ef | |||
| 5e4e678800 | |||
| b8423f0ed1 | |||
| 5d5159a08f | |||
| 1e96d59ac8 | |||
| 6a9739bed5 | |||
| d8f0d70908 | |||
| e25eab11ad | |||
| efb337677c | |||
| 4a70ac5a99 | |||
| 554d6118c6 | |||
| 03271ec5fb | |||
| a0760f9468 | |||
| 058d950121 | |||
| 7f103e77e3 |
@@ -1,6 +1,9 @@
|
||||
name: Build and Deploy to beta (warm.strudel.cc)
|
||||
|
||||
on: [workflow_dispatch]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# changelog
|
||||
|
||||
NOTE: you can generate this with `node warm.js`. it might still not be perfectly sorted...
|
||||
|
||||
## january 2026
|
||||
|
||||
- 2026-01-11T19:00:25+01:00 Fix sounds example to work in the REPL by JesCoding in: [#1851](https://codeberg.org/uzu/strudel/pulls/1851)
|
||||
- 2026-01-11T13:52:02+01:00 Improve hint text when sound search has no results by floy in: [#1883](https://codeberg.org/uzu/strudel/pulls/1883)
|
||||
- 2026-01-11T12:45:07+01:00 fix: remove faulty default readme by froos in: [#1889](https://codeberg.org/uzu/strudel/pulls/1889)
|
||||
- 2026-01-11T12:15:34+01:00 fix/self-hosted-config by alienmind in: [#1880](https://codeberg.org/uzu/strudel/pulls/1880)
|
||||
- 2026-01-11T12:07:48+01:00 Feat: Support External AudioContext Injection by 1d10t in: [#1833](https://codeberg.org/uzu/strudel/pulls/1833)
|
||||
- 2026-01-11T11:37:37+01:00 fix: add trem to top level by froos in: [#1887](https://codeberg.org/uzu/strudel/pulls/1887)
|
||||
- 2026-01-11T11:37:18+01:00 fix: export start cycle min 0 by froos in: [#1888](https://codeberg.org/uzu/strudel/pulls/1888)
|
||||
- 2026-01-11T11:05:44+01:00 fix: repl package init audio properly by froos in: [#1836](https://codeberg.org/uzu/strudel/pulls/1836)
|
||||
- 2026-01-11T06:51:24+01:00 Fix: show reload dialog when uploading prebake script by daslyfe in: [#1886](https://codeberg.org/uzu/strudel/pulls/1886)
|
||||
- 2026-01-11T06:01:42+01:00 fixes Serial onTrigger() params #1633 by gueejla in: [#1885](https://codeberg.org/uzu/strudel/pulls/1885)
|
||||
- 2026-01-10T20:52:40+01:00 Allow top level distortions for the purpose of FX by glossing in: [#1884](https://codeberg.org/uzu/strudel/pulls/1884)
|
||||
- 2026-01-10T23:12:48+01:00 Perf: Targeted node pools by glossing in: [#1810](https://codeberg.org/uzu/strudel/pulls/1810)
|
||||
- 2026-01-09T03:43:37+01:00 Bake in scaling by `freq` for FM with a gain node by glossing in: [#1878](https://codeberg.org/uzu/strudel/pulls/1878)
|
||||
- 2026-01-09T02:53:38+01:00 Bug fix: Properly handle subcontrols by glossing in: [#1877](https://codeberg.org/uzu/strudel/pulls/1877)
|
||||
- 2026-01-07T20:06:18+01:00 Feat: Add ability to turn mini parsing off with mini-off decorator by glossing in: [#1786](https://codeberg.org/uzu/strudel/pulls/1786)
|
||||
- 2026-01-07T19:22:24+01:00 Bug Fix: Update loopStart/End to not be offset by glossing in: [#1826](https://codeberg.org/uzu/strudel/pulls/1826)
|
||||
- 2026-01-06T06:35:49+01:00 Update modulator docstrings and allow ids to be patterns by glossing in: [#1874](https://codeberg.org/uzu/strudel/pulls/1874)
|
||||
- 2026-01-04T17:14:11+01:00 Fix doc link in @strudel/osc README.md by forrcaho in: [#1872](https://codeberg.org/uzu/strudel/pulls/1872)
|
||||
- 2026-01-04T02:01:07+01:00 Make stretch modulatable by glossing in: [#1870](https://codeberg.org/uzu/strudel/pulls/1870)
|
||||
- 2026-01-02T18:44:53+01:00 Make pan modulatable by glossing in: [#1865](https://codeberg.org/uzu/strudel/pulls/1865)
|
||||
- 2026-01-01T21:42:23+01:00 Fix transpilation example to have same mini-notation by JesCoding in: [#1850](https://codeberg.org/uzu/strudel/pulls/1850)
|
||||
- 2026-01-01T21:39:05+01:00 fix: missing punctuation by eddyflux in: [#1860](https://codeberg.org/uzu/strudel/pulls/1860)
|
||||
- 2026-01-01T20:20:34+01:00 Fix formatting of docstring by glossing in: [#1864](https://codeberg.org/uzu/strudel/pulls/1864)
|
||||
- 2026-01-01T19:52:15+01:00 Feat: FX Chains by glossing in: [#1861](https://codeberg.org/uzu/strudel/pulls/1861)
|
||||
|
||||
## december 2025
|
||||
|
||||
- 2025-12-29T21:59:11+01:00 Bugfix: Fix modulator clamping when min/max not specified by glossing in: [#1859](https://codeberg.org/uzu/strudel/pulls/1859)
|
||||
- 2025-12-29T16:07:18+01:00 Fix AudioContext change detection. Use AudioNode.context by jeromew in: [#1858](https://codeberg.org/uzu/strudel/pulls/1858)
|
||||
- 2025-12-28T14:20:03+01:00 dough repl fixes by froos in: [#1855](https://codeberg.org/uzu/strudel/pulls/1855)
|
||||
- 2025-12-19T08:11:50+01:00 fix: visual block selection mode for vim bindings by Dsm0 in: [#1839](https://codeberg.org/uzu/strudel/pulls/1839)
|
||||
- 2025-12-28T22:58:38+01:00 Say that @license should use SPDX identifier by Wuzzy in: [#1817](https://codeberg.org/uzu/strudel/pulls/1817)
|
||||
- 2025-12-28T22:56:07+01:00 Expose Vim object in order to create custom keybindings by JohnBjrk in: [#1816](https://codeberg.org/uzu/strudel/pulls/1816)
|
||||
- 2025-12-20T22:27:52+01:00 Document "-" in mini-notation by Wuzzy in: [#1818](https://codeberg.org/uzu/strudel/pulls/1818)
|
||||
- 2025-12-20T22:20:27+01:00 simplify envValAtTime and remove asymmetric behavior (fix #1653) by pulu in: [#1815](https://codeberg.org/uzu/strudel/pulls/1815)
|
||||
- 2025-12-14T15:09:12+01:00 delta -> per / perx / cyclesPer refinements by yaxu in: [#1832](https://codeberg.org/uzu/strudel/pulls/1832)
|
||||
- 2025-12-14T19:33:37+01:00 Fix: wrong warning in build environments by jeromew in: [#1835](https://codeberg.org/uzu/strudel/pulls/1835)
|
||||
- 2025-12-12T10:28:27+01:00 Add delta signal for representing the duration of events in patterns that are combined with it by yaxu in: [#1831](https://codeberg.org/uzu/strudel/pulls/1831)
|
||||
- 2025-12-11T16:37:58+01:00 Updates relating to LLM, github, etc by yaxu in: [#1830](https://codeberg.org/uzu/strudel/pulls/1830)
|
||||
- 2025-12-10T15:32:44+01:00 fix .as so it doesn't set undefined values by yaxu in: [#1827](https://codeberg.org/uzu/strudel/pulls/1827)
|
||||
- 2025-12-08T19:27:31+01:00 [perf] propagate `onceEnded` and `releaseAudioNode` by jeromew in: [#1809](https://codeberg.org/uzu/strudel/pulls/1809)
|
||||
- 2025-12-07T19:15:43+01:00 Add vel as a synonym for velocity & update a few docstrings by glossing in: [#1781](https://codeberg.org/uzu/strudel/pulls/1781)
|
||||
- 2025-12-07T18:53:04+01:00 [perf] fix phaser leak of unused biquads by jeromew in: [#1800](https://codeberg.org/uzu/strudel/pulls/1800)
|
||||
- 2025-12-07T18:42:50+01:00 Bug fix: Remove failing tests due to shabda removal by glossing in: [#1820](https://codeberg.org/uzu/strudel/pulls/1820)
|
||||
- 2025-12-03T17:35:05+01:00 [perf] release unused AudioBufferSourceNode + releaseAudioNode by jeromew in: [#1805](https://codeberg.org/uzu/strudel/pulls/1805)
|
||||
- **2025-12-01 strudel.cc deployed**
|
||||
|
||||
## november 2025
|
||||
|
||||
- 2025-11-29T01:00:42+01:00 added export to getSuperdoughAudioController() so that its possible to route superdough audio through other webaudio applications. by ndr0n in: [#1796](https://codeberg.org/uzu/strudel/pulls/1796)
|
||||
- 2025-11-28T23:26:20+01:00 add revv() for reversing whole patterns by yaxu in: [#1791](https://codeberg.org/uzu/strudel/pulls/1791)
|
||||
- 2025-11-28T20:19:16+01:00 [perf] Disconnect lfos for phaser and filters by glossing in: [#1787](https://codeberg.org/uzu/strudel/pulls/1787)
|
||||
- 2025-11-27T23:08:52+01:00 fix: return silence when no pattern is returned by froos in: [#1795](https://codeberg.org/uzu/strudel/pulls/1795)
|
||||
- 2025-11-27T22:38:05+01:00 add CHANGELOG.md + basic script to generate by froos in: [#1794](https://codeberg.org/uzu/strudel/pulls/1794)
|
||||
- 2025-11-27T22:03:53+01:00 [perf] in `noise`, let noiseMix do the disconnect when it exists by jeromew in: [#1783](https://codeberg.org/uzu/strudel/pulls/1783)
|
||||
- 2025-11-27T22:01:01+01:00 Bug Fix: Retries for sounds tab by glossing in: [#1754](https://codeberg.org/uzu/strudel/pulls/1754)
|
||||
- 2025-11-27T20:36:46+01:00 [hydra] return the hydra object when await initHydra(..) is called by jeromew in: [#1784](https://codeberg.org/uzu/strudel/pulls/1784)
|
||||
- 2025-11-27T20:36:14+01:00 Use errorLogger for query and tonal errors by glossing in: [#1782](https://codeberg.org/uzu/strudel/pulls/1782)
|
||||
- 2025-11-27T20:27:10+01:00 [perf] level 5 `connect-leak` on `vowel` by jeromew in: [#1779](https://codeberg.org/uzu/strudel/pulls/1779)
|
||||
- 2025-11-26T08:23:38+01:00 feat: add prebake script import button for loading .strudel files by daslyfe in: [#1774](https://codeberg.org/uzu/strudel/pulls/1774)
|
||||
- 2025-11-26T07:39:28+01:00 Fix Sampler port trampling by Dayglo in: [#1717](https://codeberg.org/uzu/strudel/pulls/1717)
|
||||
- 2025-11-25T20:59:09+01:00 Feat: Hook up octave and fix example by glossing in: [#1773](https://codeberg.org/uzu/strudel/pulls/1773)
|
||||
- 2025-11-25T20:48:09+01:00 [perf] fix `connect-leak` in `tremolo` param by jeromew in: [#1780](https://codeberg.org/uzu/strudel/pulls/1780)
|
||||
- 2025-11-24T17:51:20+01:00 filter modulation improvements! by daslyfe in: [#1769](https://codeberg.org/uzu/strudel/pulls/1769)
|
||||
- 2025-11-24T01:20:53+01:00 Add o as a synonym for orbit by daslyfe in: [#1766](https://codeberg.org/uzu/strudel/pulls/1766)
|
||||
- 2025-11-23T22:23:26+01:00 [perf] fix `connect-leak` in `fm` modulation by jeromew in: [#1758](https://codeberg.org/uzu/strudel/pulls/1758)
|
||||
- 2025-11-23T09:47:24+01:00 Fix interoperability issue between `all` and `await initHydra()` by jeromew in: [#1663](https://codeberg.org/uzu/strudel/pulls/1663)
|
||||
- 2025-11-23T04:23:06+01:00 Bug fix: Swap l/r gains with temp variable by glossing in: [#1768](https://codeberg.org/uzu/strudel/pulls/1768)
|
||||
- 2025-11-23T00:52:28+01:00 FIX: prevent LFO filter modulation from popping from negative values by daslyfe in: [#1767](https://codeberg.org/uzu/strudel/pulls/1767)
|
||||
- 2025-11-21T01:56:11+01:00 Bug Fix: Use frac due to negative frequencies from FM by glossing in: [#1759](https://codeberg.org/uzu/strudel/pulls/1759)
|
||||
- 2025-11-20T19:48:16+01:00 [perf] fix `connect-leak` added by #1742 when noise() is not used by jeromew in: [#1757](https://codeberg.org/uzu/strudel/pulls/1757)
|
||||
- 2025-11-20T14:51:00+01:00 [perf] fix `connect-leak` in `delay` effect by jeromew in: [#1755](https://codeberg.org/uzu/strudel/pulls/1755)
|
||||
- 2025-11-20T14:49:41+01:00 [perf] fix `connect leak` when .noise() is in the mix by jeromew in: [#1742](https://codeberg.org/uzu/strudel/pulls/1742)
|
||||
- 2025-11-18T23:52:45+01:00 wchooseCycles has now notes in an example by scrappy_fiddler in: [#1748](https://codeberg.org/uzu/strudel/pulls/1748)
|
||||
- 2025-11-17T05:31:54+01:00 Feature: Partials by glossing in: [#1659](https://codeberg.org/uzu/strudel/pulls/1659)
|
||||
- 2025-11-16T22:06:14+01:00 README: update superdough documentation by TristanMlct in: [#1741](https://codeberg.org/uzu/strudel/pulls/1741)
|
||||
- 2025-11-16T21:08:54+01:00 Worklet optimizations by glossing in: [#1730](https://codeberg.org/uzu/strudel/pulls/1730)
|
||||
- 2025-11-15T14:59:30+01:00 [perf] disconnect Offline AudioNode connections in generateReverb by jeromew in: [#1740](https://codeberg.org/uzu/strudel/pulls/1740)
|
||||
- 2025-11-15T14:57:47+01:00 [perf] disconnect `send` effect AudioNode when `room` is used by jeromew in: [#1736](https://codeberg.org/uzu/strudel/pulls/1736)
|
||||
- 2025-11-12T21:22:34+01:00 [supradough] fix: unstable filter by froos in: [#1593](https://codeberg.org/uzu/strudel/pulls/1593)
|
||||
- 2025-11-12T21:12:07+01:00 Fix link syntax in `project-start` by Kissaki in: [#1724](https://codeberg.org/uzu/strudel/pulls/1724)
|
||||
- 2025-11-12T21:06:13+01:00 Fix web README sample code by Kissaki in: [#1725](https://codeberg.org/uzu/strudel/pulls/1725)
|
||||
- 2025-11-12T20:59:39+01:00 Fix typo: 'studel' -> 'strudel' by drhayes in: [#1726](https://codeberg.org/uzu/strudel/pulls/1726)
|
||||
- 2025-11-12T15:23:41+01:00 fix for node 24 support in tests - #1718 by ausav in: [#1719](https://codeberg.org/uzu/strudel/pulls/1719)
|
||||
- 2025-11-11T09:00:49+01:00 soundAlias example fix by PepsiiMan in: [#1720](https://codeberg.org/uzu/strudel/pulls/1720)
|
||||
- 2025-11-10T08:18:36+01:00 fix: Make Supradough package build by munshkr in: [#1711](https://codeberg.org/uzu/strudel/pulls/1711)
|
||||
- 2025-11-10T08:18:00+01:00 added docs for spaces in scale names by ondras in: [#1715](https://codeberg.org/uzu/strudel/pulls/1715)
|
||||
- 2025-11-10T02:44:01+01:00 Feature: LFOs for Filters by glossing in: [#1636](https://codeberg.org/uzu/strudel/pulls/1636)
|
||||
- 2025-11-05T17:07:00+01:00 improvement: sync midi messages to audio context clock by daslyfe in: [#1708](https://codeberg.org/uzu/strudel/pulls/1708)
|
||||
- 2025-11-04T19:36:14+01:00 Add stick button mappings to gamepad implementation and improve docs by kaosuryoko in: [#1696](https://codeberg.org/uzu/strudel/pulls/1696)
|
||||
- 2025-11-04T19:29:35+01:00 Refactor sound stopping and triggering logic in SoundsTab component by IJOL in: [#1688](https://codeberg.org/uzu/strudel/pulls/1688)
|
||||
- 2025-11-04T19:13:21+01:00 Add setting to toggle pattern auto-start on pattern change by moumar in: [#1690](https://codeberg.org/uzu/strudel/pulls/1690)
|
||||
- 2025-11-04T18:58:50+01:00 Voicings JSDoc by sharkeys_lunchbox in: [#1686](https://codeberg.org/uzu/strudel/pulls/1686)
|
||||
- 2025-11-01T07:50:40+01:00 FIX: Loading local samples uses too much memory by daslyfe in: [#1706](https://codeberg.org/uzu/strudel/pulls/1706)
|
||||
|
||||
|
||||
## october 2025
|
||||
|
||||
- 2025-10-28T22:58:24+01:00 Bug Fix: Handle scale-for-notes when n also supplied by glossing in: [#1625](https://codeberg.org/uzu/strudel/pulls/1625)
|
||||
- 2025-10-28T22:51:16+01:00 Repurpose vim shortcuts for usability by dtricks in: [#1624](https://codeberg.org/uzu/strudel/pulls/1624)
|
||||
- 2025-10-28T22:35:21+01:00 Add support for euclidian in mondo with `bd&3:8` by TristanCacqueray in: [#1630](https://codeberg.org/uzu/strudel/pulls/1630)
|
||||
- 2025-10-27T10:42:07+01:00 Use bunny cdn for all samples and json files by yaxu in: [#1701](https://codeberg.org/uzu/strudel/pulls/1701)
|
||||
- **2025-10-27 @strudel/core@1.2.5**
|
||||
- 2025-10-26T22:52:54+01:00 degithub - switch some samples to bunnycdn by yaxu in: [#1697](https://codeberg.org/uzu/strudel/pulls/1697)
|
||||
- 2025-10-26T17:09:22+01:00 Fix ZZFX example by moumar in: [#1685](https://codeberg.org/uzu/strudel/pulls/1685)
|
||||
- 2025-10-23T15:56:04+02:00 Make osc port and host configurable. Changes dependencies. by yaxu in: [#1682](https://codeberg.org/uzu/strudel/pulls/1682)
|
||||
- 2025-10-23T03:47:31+02:00 Adds back shape to superdough by glossing in: [#1672](https://codeberg.org/uzu/strudel/pulls/1672)
|
||||
- 2025-10-22T22:15:19+02:00 Fix sampler.mjs githubPath by jeromew in: [#1684](https://codeberg.org/uzu/strudel/pulls/1684)
|
||||
- 2025-10-22T16:20:50+02:00 Fix onPaint for widgets by yaxu in: [#1658](https://codeberg.org/uzu/strudel/pulls/1658)
|
||||
- 2025-10-22T15:19:12+02:00 Fix a bug introduced by #4e17cfbdd6 by milliganf in: [#1679](https://codeberg.org/uzu/strudel/pulls/1679)
|
||||
- 2025-10-18T04:29:20+02:00 Bug Fix: Wavetable: phase wrapping at 1 and detune by glossing in: [#1620](https://codeberg.org/uzu/strudel/pulls/1620)
|
||||
- 2025-10-16T20:26:28+02:00 github samples: default to "samples" if repository is not specified by prezmop in: [#1644](https://codeberg.org/uzu/strudel/pulls/1644)
|
||||
- 2025-10-16T17:33:39+02:00 Docs: add example of custom chained function by dariusk in: [#1642](https://codeberg.org/uzu/strudel/pulls/1642)
|
||||
- 2025-10-14T12:39:48+02:00 textbox by yaxu in: [#1650](https://codeberg.org/uzu/strudel/pulls/1650)
|
||||
- 2025-10-13T07:05:57+02:00 Feature: Wavetable FM by glossing in: [#1623](https://codeberg.org/uzu/strudel/pulls/1623)
|
||||
- 2025-10-12T20:43:42+02:00 fix: repair REPL sample sources and URL concat (#1640) by erikfox in: [#1646](https://codeberg.org/uzu/strudel/pulls/1646)
|
||||
- 2025-10-10T11:25:02+02:00 Add control-metadata by yaxu in: [#1634](https://codeberg.org/uzu/strudel/pulls/1634)
|
||||
- 2025-10-07T22:59:39+02:00 Fix MQTT: change the trigger handler to match new hap by jurrchen in: [#1629](https://codeberg.org/uzu/strudel/pulls/1629)
|
||||
- 2025-10-05T21:42:19+02:00 Fix case sensitivity for synonym search by vvolhejn in: [#1618](https://codeberg.org/uzu/strudel/pulls/1618)
|
||||
- 2025-10-01T08:56:35+02:00 Optimize wavetable synth by glossing in: [#1613](https://codeberg.org/uzu/strudel/pulls/1613)
|
||||
- 2025-10-01T02:02:33+02:00 fix: pattern switching by daslyfe in: [#1616](https://codeberg.org/uzu/strudel/pulls/1616)
|
||||
|
||||
## September 2025
|
||||
|
||||
- 2025-09-29T02:10:54+02:00 fix wavetable JSON by daslyfe in: [#1611](https://codeberg.org/uzu/strudel/pulls/1611)
|
||||
- 2025-09-28T21:28:16+02:00 rename wavetable controls to match existing naming conventions by daslyfe in: [#1610](https://codeberg.org/uzu/strudel/pulls/1610)
|
||||
- 2025-09-27T23:28:13+02:00 Feature: Wavetable synth improvements by glossing in: [#1607](https://codeberg.org/uzu/strudel/pulls/1607)
|
||||
- 2025-09-27T21:43:10+02:00 wavetable improvements by daslyfe in: [#1608](https://codeberg.org/uzu/strudel/pulls/1608)
|
||||
- 2025-09-26T08:48:46+02:00 create orbit based DJ filter by daslyfe in: [#1603](https://codeberg.org/uzu/strudel/pulls/1603)
|
||||
- 2025-09-23T13:28:20+02:00 fix: time signal by daslyfe in: [#1583](https://codeberg.org/uzu/strudel/pulls/1583)
|
||||
- 2025-09-21T16:16:06+02:00 mondo: fix all + setcpm + setcps by froos in: [#1600](https://codeberg.org/uzu/strudel/pulls/1600)
|
||||
- 2025-09-19T21:59:43+02:00 fix: osc error message by froos in: [#1597](https://codeberg.org/uzu/strudel/pulls/1597)
|
||||
- 2025-09-18T21:09:56+02:00 osc: --debug flag to see incoming messages by froos in: [#1595](https://codeberg.org/uzu/strudel/pulls/1595)
|
||||
- 2025-09-17T14:12:10+02:00 simplify osc usage by froos in: [#1588](https://codeberg.org/uzu/strudel/pulls/1588)
|
||||
- 2025-09-16T16:59:58+02:00 dev: logger errors to console in dev mode by daslyfe in: [#1585](https://codeberg.org/uzu/strudel/pulls/1585)
|
||||
- 2025-09-15T22:52:14+02:00 add tic80 font by froos in: [#1579](https://codeberg.org/uzu/strudel/pulls/1579)
|
||||
- 2025-09-15T00:00:19+02:00 supradough: fix delay + add some jsdoc types by froos in: [#1578](https://codeberg.org/uzu/strudel/pulls/1578)
|
||||
+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
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# 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,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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script src="https://unpkg.com/@strudel/web@1.2.6"></script>
|
||||
<script src="https://unpkg.com/@strudel/web@1.0.3"></script>
|
||||
<button id="play">PLAY</button>
|
||||
<script>
|
||||
initStrudel({
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strudel/codemirror": "1.2.6",
|
||||
"@strudel/core": "1.2.5",
|
||||
"@strudel/draw": "1.2.5",
|
||||
"@strudel/mini": "1.2.5",
|
||||
"@strudel/soundfonts": "1.2.6",
|
||||
"@strudel/tonal": "1.2.5",
|
||||
"@strudel/transpiler": "1.2.5",
|
||||
"@strudel/webaudio": "1.2.6"
|
||||
"@strudel/codemirror": "workspace:*",
|
||||
"@strudel/core": "workspace:*",
|
||||
"@strudel/draw": "workspace:*",
|
||||
"@strudel/mini": "workspace:*",
|
||||
"@strudel/soundfonts": "workspace:*",
|
||||
"@strudel/tonal": "workspace:*",
|
||||
"@strudel/transpiler": "workspace:*",
|
||||
"@strudel/webaudio": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const bumpStreet = `// froos - "22 bump street", licensed with CC BY-NC-SA 4.0
|
||||
samples('github:felixroos/samples')
|
||||
samples('https://strudel.b-cdn.net/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
|
||||
samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
|
||||
|
||||
"<[0,<6 7 9>,13,<17 20 22 26>]!2>/2"
|
||||
// make it 22 edo
|
||||
@@ -28,12 +28,13 @@ samples('https://strudel.b-cdn.net/tidal-drum-machines.json', 'github:ritchse/ti
|
||||
.mask("<0@4 1@12>/4")
|
||||
.reset("<x@15 [x(3,8) x*[4 8]]>")
|
||||
// wait for it...
|
||||
).fast(4/3)
|
||||
).fast(2/3)
|
||||
//.crush(6) // remove "//" if you dare`;
|
||||
|
||||
export const trafficFlam = `// froos - "traffic flam", licensed with CC BY-NC-SA 4.0
|
||||
|
||||
samples('https://strudel.b-cdn.net/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
|
||||
samples('github:felixroos/samples')
|
||||
samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
|
||||
|
||||
addVoicings('hip', {
|
||||
m11: ['2M 3m 4P 7m'],
|
||||
@@ -60,14 +61,16 @@ stack(
|
||||
s("rim*4").end(.05).bank('RolandTR808').speed(.8).room(.2)
|
||||
)
|
||||
)
|
||||
.late("[0 .05]*2").late(12).fast(2)
|
||||
.late("[0 .05]*2").late(12)
|
||||
|
||||
`;
|
||||
|
||||
export const funk42 = `// froos - how to funk in 42 lines of code
|
||||
// adapted from "how to funk in two minutes" by marc rebillet https://www.youtube.com/watch?v=3vBwRfQbXkg
|
||||
// thanks to peach for the transcription: https://www.youtube.com/watch?v=8eiPXvIgda4
|
||||
|
||||
samples('https://strudel.b-cdn.net/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
|
||||
samples('github:felixroos/samples')
|
||||
samples('https://strudel.cc/tidal-drum-machines.json', 'github:ritchse/tidal-drum-machines/main/machines/')
|
||||
|
||||
setcps(.5)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<button id="c">C</button>
|
||||
<button id="stop">stop</button>
|
||||
<script type="module">
|
||||
import { initStrudel, hush } from '@strudel/web';
|
||||
import { initStrudel } from '@strudel/web';
|
||||
initStrudel({
|
||||
prebake: () => samples('github:tidalcycles/dirt-samples'),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"vite": "^6.0.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strudel/web": "1.2.6"
|
||||
"@strudel/web": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -27,7 +27,7 @@ import { updateWidgets, widgetPlugin } from './widget.mjs';
|
||||
|
||||
export { toggleBlockComment, toggleBlockCommentByLine, toggleComment, toggleLineComment } from '@codemirror/commands';
|
||||
|
||||
const extensions = {
|
||||
export const extensions = {
|
||||
isLineWrappingEnabled: (on) => (on ? EditorView.lineWrapping : []),
|
||||
isBracketMatchingEnabled: (on) => (on ? bracketMatching({ brackets: '()[]{}<>' }) : []),
|
||||
isBracketClosingEnabled: (on) => (on ? closeBrackets() : []),
|
||||
@@ -48,7 +48,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',
|
||||
@@ -293,9 +293,9 @@ 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();
|
||||
@@ -411,7 +411,7 @@ export class StrudelMirror {
|
||||
}
|
||||
}
|
||||
|
||||
function parseBooleans(value) {
|
||||
export function parseBooleans(value) {
|
||||
return { true: true, false: false }[value] ?? value;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { Prec, EditorState } from '@codemirror/state';
|
||||
import { keymap, ViewPlugin } from '@codemirror/view';
|
||||
// import { searchKeymap } from '@codemirror/search';
|
||||
import { emacs } from '@replit/codemirror-emacs';
|
||||
@@ -131,7 +131,13 @@ const keymaps = {
|
||||
vscode: vscodeExtension,
|
||||
};
|
||||
|
||||
export { Vim } from '@replit/codemirror-vim';
|
||||
|
||||
export function keybindings(name) {
|
||||
const active = keymaps[name];
|
||||
return [active ? Prec.high(active()) : []];
|
||||
const extensions = active ? [Prec.high(active())] : [];
|
||||
if (name === 'vim') {
|
||||
extensions.push(EditorState.allowMultipleSelections.of(true));
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
+529
-25
@@ -4,7 +4,8 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Pattern, register, reify } from './pattern.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { Pattern, pure, register, reify } from './pattern.mjs';
|
||||
|
||||
export function createParam(names) {
|
||||
let isMulti = Array.isArray(names);
|
||||
@@ -72,6 +73,27 @@ export function registerControl(names, ...aliases) {
|
||||
return bag;
|
||||
}
|
||||
|
||||
export function registerMultiControl(names, maxControls, ...aliases) {
|
||||
names = Array.isArray(names) ? names : [names];
|
||||
let bag = {};
|
||||
for (let i = 1; i <= maxControls; i++) {
|
||||
let theseAliases = [...aliases];
|
||||
let theseNames = [...names];
|
||||
if (i === 1) {
|
||||
// adds e.g. fm1 as an alias for fm
|
||||
const aliases1 = theseAliases.map((a) => `${a}1`);
|
||||
const names1 = theseNames.map((n) => `${n}1`);
|
||||
theseAliases = theseAliases.concat(aliases1).concat(names1);
|
||||
} else {
|
||||
theseAliases = theseAliases.map((a) => `${a}${i}`);
|
||||
theseNames = theseNames.map((n) => `${n}${i}`);
|
||||
}
|
||||
const subBag = registerControl(theseNames, ...theseAliases);
|
||||
bag = { ...bag, ...subBag };
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a sound / sample by name. When using mininotation, you can also optionally supply 'n' and 'gain' parameters
|
||||
* separated by ':'.
|
||||
@@ -380,12 +402,13 @@ export const { accelerate } = registerControl('accelerate');
|
||||
* Sets the velocity from 0 to 1. Is multiplied together with gain.
|
||||
*
|
||||
* @name velocity
|
||||
* @synonyms vel
|
||||
* @example
|
||||
* s("hh*8")
|
||||
* .gain(".4!2 1 .4!2 1 .4 1")
|
||||
* .velocity(".4 1")
|
||||
*/
|
||||
export const { velocity } = registerControl('velocity');
|
||||
export const { velocity, vel } = registerControl('velocity', 'vel');
|
||||
/**
|
||||
* Controls the gain by an exponential amount.
|
||||
*
|
||||
@@ -435,6 +458,9 @@ export const { attack, att } = registerControl('attack', 'att');
|
||||
* Whole numbers and simple ratios sound more natural,
|
||||
* while decimal numbers and complex ratios sound metallic.
|
||||
*
|
||||
* A number may be added afterwards to control the harmonicity of
|
||||
* any of the 8 individual FMs (e.g. `fmh2`)
|
||||
*
|
||||
* @name fmh
|
||||
* @param {number | Pattern} harmonicity
|
||||
* @example
|
||||
@@ -444,25 +470,40 @@ export const { attack, att } = registerControl('attack', 'att');
|
||||
* ._scope()
|
||||
*
|
||||
*/
|
||||
export const { fmh } = registerControl(['fmh', 'fmi'], 'fmh');
|
||||
export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerMultiControl(['fmh', 'fmi'], 8, 'fmh');
|
||||
|
||||
/**
|
||||
* Sets the Frequency Modulation of the synth.
|
||||
* Controls the modulation index, which defines the brightness of the sound.
|
||||
*
|
||||
* @name fm
|
||||
* A number may be added afterwards to control the modulation index of
|
||||
* any of the 8 individual FMs (e.g. `fm3`). Also, FMs may be routed into
|
||||
* each other with matrix commands like `fm13`, which would send `fm1` back into
|
||||
* `fm3`
|
||||
*
|
||||
* @name fmi
|
||||
* @param {number | Pattern} brightness modulation index
|
||||
* @synonyms fmi
|
||||
* @synonyms fm
|
||||
* @example
|
||||
* note("c e g b g e")
|
||||
* .fm("<0 1 2 8 32>")
|
||||
* ._scope()
|
||||
* @example
|
||||
* s("sine").note("F1").seg(8)
|
||||
* .fm(4).fm2(rand.mul(4)).fm3(saw.mul(8).slow(8))
|
||||
* .fmh(1.06).fmh2(10).fmh3(0.1)
|
||||
*
|
||||
*/
|
||||
export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm');
|
||||
export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2, fm3, fm4, fm5, fm6, fm7, fm8 } =
|
||||
registerMultiControl(['fmi', 'fmh'], 8, 'fm');
|
||||
|
||||
// fm envelope
|
||||
/**
|
||||
* Ramp type of fm envelope. Exp might be a bit broken..
|
||||
*
|
||||
* A number may be added afterwards to control the envelope of
|
||||
* any of the 8 individual FMs (e.g. `fmenv4`)
|
||||
*
|
||||
* @name fmenv
|
||||
* @param {number | Pattern} type lin | exp
|
||||
* @example
|
||||
@@ -474,11 +515,19 @@ export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm');
|
||||
* ._scope()
|
||||
*
|
||||
*/
|
||||
export const { fmenv } = registerControl('fmenv');
|
||||
export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fmenv8 } = registerMultiControl(
|
||||
'fmenv',
|
||||
8,
|
||||
);
|
||||
|
||||
/**
|
||||
* Attack time for the FM envelope: time it takes to reach maximum modulation
|
||||
*
|
||||
* A number may be added afterwards to control the attack of the envelope of
|
||||
* any of the 8 individual FMs (e.g. `fmatt5`)
|
||||
*
|
||||
* @name fmattack
|
||||
* @synonyms fmatt
|
||||
* @param {number | Pattern} time attack time
|
||||
* @example
|
||||
* note("c e g b g e")
|
||||
@@ -487,11 +536,33 @@ export const { fmenv } = registerControl('fmenv');
|
||||
* ._scope()
|
||||
*
|
||||
*/
|
||||
export const { fmattack } = registerControl('fmattack');
|
||||
export const {
|
||||
fmattack,
|
||||
fmattack1,
|
||||
fmattack2,
|
||||
fmattack3,
|
||||
fmattack4,
|
||||
fmattack5,
|
||||
fmattack6,
|
||||
fmattack7,
|
||||
fmattack8,
|
||||
fmatt,
|
||||
fmatt1,
|
||||
fmatt2,
|
||||
fmatt3,
|
||||
fmatt4,
|
||||
fmatt5,
|
||||
fmatt6,
|
||||
fmatt7,
|
||||
fmatt8,
|
||||
} = registerMultiControl('fmattack', 8, 'fmatt');
|
||||
|
||||
/**
|
||||
* Waveform of the fm modulator
|
||||
*
|
||||
* A number may be added afterwards to control the waveform
|
||||
* any of the 8 individual FMs (e.g. `fmwave6`)
|
||||
*
|
||||
* @name fmwave
|
||||
* @param {number | Pattern} wave waveform
|
||||
* @example
|
||||
@@ -500,12 +571,19 @@ export const { fmattack } = registerControl('fmattack');
|
||||
* n("0 1 2 3".fast(4)).chord("<Dm Am F G>").voicing().s("sawtooth").fmwave("brown").fm(.6)
|
||||
*
|
||||
*/
|
||||
export const { fmwave } = registerControl('fmwave');
|
||||
export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmwave7, fmwave8 } = registerMultiControl(
|
||||
'fmwave',
|
||||
8,
|
||||
);
|
||||
|
||||
/**
|
||||
* Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase.
|
||||
*
|
||||
* A number may be added afterwards to control the decay of the envelope of
|
||||
* any of the 8 individual FMs (e.g. `fmdec6`)
|
||||
*
|
||||
* @name fmdecay
|
||||
* @synonyms fmdec
|
||||
* @param {number | Pattern} time decay time
|
||||
* @example
|
||||
* note("c e g b g e")
|
||||
@@ -515,11 +593,35 @@ export const { fmwave } = registerControl('fmwave');
|
||||
* ._scope()
|
||||
*
|
||||
*/
|
||||
export const { fmdecay } = registerControl('fmdecay');
|
||||
export const {
|
||||
fmdecay,
|
||||
fmdecay1,
|
||||
fmdecay2,
|
||||
fmdecay3,
|
||||
fmdecay4,
|
||||
fmdecay5,
|
||||
fmdecay6,
|
||||
fmdecay7,
|
||||
fmdecay8,
|
||||
fmdec,
|
||||
fmdec1,
|
||||
fmdec2,
|
||||
fmdec3,
|
||||
fmdec4,
|
||||
fmdec5,
|
||||
fmdec6,
|
||||
fmdec7,
|
||||
fmdec8,
|
||||
} = registerMultiControl('fmdecay', 8, 'fmdec');
|
||||
|
||||
/**
|
||||
* Sustain level for the FM envelope: how much modulation is applied after the decay phase
|
||||
*
|
||||
* A number may be added afterwards to control the sustain of the envelope of
|
||||
* any of the 8 individual FMs (e.g. `fmsus7`)
|
||||
*
|
||||
* @name fmsustain
|
||||
* @synonyms fmsus
|
||||
* @param {number | Pattern} level sustain level
|
||||
* @example
|
||||
* note("c e g b g e")
|
||||
@@ -529,10 +631,69 @@ export const { fmdecay } = registerControl('fmdecay');
|
||||
* ._scope()
|
||||
*
|
||||
*/
|
||||
export const { fmsustain } = registerControl('fmsustain');
|
||||
// these are not really useful... skipping for now
|
||||
export const { fmrelease } = registerControl('fmrelease');
|
||||
export const { fmvelocity } = registerControl('fmvelocity');
|
||||
export const {
|
||||
fmsustain,
|
||||
fmsustain1,
|
||||
fmsustain2,
|
||||
fmsustain3,
|
||||
fmsustain4,
|
||||
fmsustain5,
|
||||
fmsustain6,
|
||||
fmsustain7,
|
||||
fmsustain8,
|
||||
fmsus,
|
||||
fmsus1,
|
||||
fmsus2,
|
||||
fmsus3,
|
||||
fmsus4,
|
||||
fmsus5,
|
||||
fmsus6,
|
||||
fmsus7,
|
||||
fmsus8,
|
||||
} = registerMultiControl('fmsustain', 8, 'fmsus');
|
||||
|
||||
/**
|
||||
* Release time for the FM envelope: how much modulation is applied after the note is released
|
||||
*
|
||||
* A number may be added afterwards to control the release of the envelope of
|
||||
* any of the 8 individual FMs (e.g. `fmrel8`)
|
||||
*
|
||||
* @name fmrelease
|
||||
* @synonyms fmrel
|
||||
* @param {number | Pattern} time release time
|
||||
*
|
||||
*/
|
||||
export const {
|
||||
fmrelease,
|
||||
fmrelease1,
|
||||
fmrelease2,
|
||||
fmrelease3,
|
||||
fmrelease4,
|
||||
fmrelease5,
|
||||
fmrelease6,
|
||||
fmrelease7,
|
||||
fmrelease8,
|
||||
fmrel,
|
||||
fmrel1,
|
||||
fmrel2,
|
||||
fmrel3,
|
||||
fmrel4,
|
||||
fmrel5,
|
||||
fmrel6,
|
||||
fmrel7,
|
||||
fmrel8,
|
||||
} = registerMultiControl('fmrelease', 8, 'fmrel');
|
||||
|
||||
// FM Matrix
|
||||
// Note: we do not declare top-level exports here since it would add
|
||||
// ~162 more explicit exports. This is likely fine as the most common use-case would be to at least
|
||||
// declare one other FM prior to utilizing the matrix functionality, but if we ever decide we need it,
|
||||
// TODO to add it explicitly / go with the globalThis approach
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
for (let j = 0; j <= 8; j++) {
|
||||
registerControl(`fmi${i}${j}`, `fm${i}${j}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the sound bank to use. To be used together with `s`. The bank name (+ "_") will be prepended to the value of `s`.
|
||||
@@ -716,7 +877,7 @@ export const { coarse } = registerControl('coarse');
|
||||
* note("d d d# d".fast(4)).s("supersaw").tremolo("<3 2 100> ").tremoloskew("<.5>")
|
||||
*
|
||||
*/
|
||||
export const { tremolo } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem');
|
||||
export const { tremolo, trem } = registerControl(['tremolo', 'tremolodepth', 'tremoloskew', 'tremolophase'], 'trem');
|
||||
|
||||
/**
|
||||
* Modulate the amplitude of a sound with a continuous waveform
|
||||
@@ -1294,6 +1455,8 @@ export const { fanchor } = registerControl('fanchor');
|
||||
*
|
||||
* @name lprate
|
||||
* @param {number | Pattern} rate rate in hertz
|
||||
* @example
|
||||
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lprate("<4 8 2 1>")
|
||||
*/
|
||||
export const { lprate } = registerControl('lprate');
|
||||
|
||||
@@ -1302,6 +1465,8 @@ export const { lprate } = registerControl('lprate');
|
||||
*
|
||||
* @name lpsync
|
||||
* @param {number | Pattern} rate rate in cycles
|
||||
* @example
|
||||
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpsync("<4 8 2 1>")
|
||||
*/
|
||||
export const { lpsync } = registerControl('lpsync');
|
||||
|
||||
@@ -1310,8 +1475,22 @@ export const { lpsync } = registerControl('lpsync');
|
||||
*
|
||||
* @name lpdepth
|
||||
* @param {number | Pattern} depth depth of modulation
|
||||
* @example
|
||||
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepth("<1 .5 1.8 0>")
|
||||
*/
|
||||
|
||||
export const { lpdepth } = registerControl('lpdepth');
|
||||
/**
|
||||
* Depth of the LFO for the lowpass filter, in HZ
|
||||
*
|
||||
* @name lpdepthfrequency
|
||||
* @synonyms lpdepthfreq
|
||||
* @param {number | Pattern} depth depth of modulation
|
||||
* @example
|
||||
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).lpdepthfrequency("<200 500 100 0>")
|
||||
*/
|
||||
|
||||
export const { lpdepthfrequency, lpdepthfreq } = registerControl('lpdepthfrequency', 'lpdepthfreq');
|
||||
|
||||
/**
|
||||
* Shape of the LFO for the lowpass filter
|
||||
@@ -1361,6 +1540,18 @@ export const { bpsync } = registerControl('bpsync');
|
||||
*/
|
||||
export const { bpdepth } = registerControl('bpdepth');
|
||||
|
||||
/**
|
||||
* Depth of the LFO for the bandpass filter, in HZ
|
||||
*
|
||||
* @name bpdepthfrequency
|
||||
* @synonyms bpdepthfreq
|
||||
* @param {number | Pattern} depth depth of modulation
|
||||
* @example
|
||||
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).bpdepthfrequency("<200 500 100 0>")
|
||||
*/
|
||||
|
||||
export const { bpdepthfrequency, bpdepthfreq } = registerControl('bpdepthfrequency', 'bpdepthfreq');
|
||||
|
||||
/**
|
||||
* Shape of the LFO for the bandpass filter
|
||||
*
|
||||
@@ -1409,6 +1600,18 @@ export const { hpsync } = registerControl('hpsync');
|
||||
*/
|
||||
export const { hpdepth } = registerControl('hpdepth');
|
||||
|
||||
/**
|
||||
* Depth of the LFO for the hipass filter, in hz
|
||||
*
|
||||
* @name hpdepthfrequency
|
||||
* @synonyms hpdepthfreq
|
||||
* @param {number | Pattern} depth depth of modulation
|
||||
* @example
|
||||
* note("<c c c# c c c4>*16").s("sawtooth").lpf(600).hpdepthfrequency("<200 500 100 0>")
|
||||
*/
|
||||
|
||||
export const { hpdepthfrequency, hpdepthfreq } = registerControl('hpdepthfrequency', 'hpdepthfreq');
|
||||
|
||||
/**
|
||||
* Shape of the LFO for the highpass filter
|
||||
*
|
||||
@@ -1820,12 +2023,12 @@ export const { nudge } = registerControl('nudge');
|
||||
* Sets the default octave of a synth.
|
||||
*
|
||||
* @name octave
|
||||
* @synonyms oct
|
||||
* @param {number | Pattern} octave octave number
|
||||
* @example
|
||||
* n("0,4,7").s('supersquare').octave("<3 4 5 6>").osc()
|
||||
* @superDirtOnly
|
||||
* n("0,4,7").scale("F:minor").s('supersaw').octave("<0 1 2 3>")
|
||||
*/
|
||||
export const { octave } = registerControl('octave');
|
||||
export const { octave, oct } = registerControl('octave', 'oct');
|
||||
|
||||
// ['ophatdecay'],
|
||||
// TODO: example
|
||||
@@ -1833,6 +2036,7 @@ export const { octave } = registerControl('octave');
|
||||
* An `orbit` is a global parameter context for patterns. Patterns with the same orbit will share the same global effects.
|
||||
*
|
||||
* @name orbit
|
||||
* @synonyms o
|
||||
* @param {number | Pattern} number
|
||||
* @example
|
||||
* stack(
|
||||
@@ -1840,7 +2044,29 @@ export const { octave } = registerControl('octave');
|
||||
* s("~ sd ~ sd").delay(.5).delaytime(.125).orbit(2)
|
||||
* )
|
||||
*/
|
||||
export const { orbit } = registerControl('orbit');
|
||||
export const { orbit } = registerControl('orbit', 'o');
|
||||
|
||||
/**
|
||||
* A `bus` is a send which can be used for mixing patterns. It combines with..
|
||||
* s("bus") to play that bus through another pattern (for, say, applying non-linear
|
||||
* effects like distortion to multiple signals)
|
||||
*
|
||||
* otherPat.bmod(..) (to modulate another pattern with the bus)
|
||||
*
|
||||
* @name bus
|
||||
* @param {number | Pattern} number
|
||||
*/
|
||||
export const { bus } = registerControl('bus');
|
||||
|
||||
/**
|
||||
* Postgain multiplier prior to sending the signal to the audio bus.
|
||||
*
|
||||
* @name busgain
|
||||
* @synonyms bgain
|
||||
* @param {number | Pattern} number
|
||||
*/
|
||||
export const { busgain, bgain } = registerControl('busgain', 'bgain');
|
||||
|
||||
// TODO: what is this? not found in tidal doc Answer: gain is limited to maximum of 2. This allows you to go over that
|
||||
export const { overgain } = registerControl('overgain');
|
||||
// TODO: what is this? not found in tidal doc. Similar to above, but limited to 1
|
||||
@@ -1885,8 +2111,7 @@ export const { panorient } = registerControl('panorient');
|
||||
// ['pitch2'],
|
||||
// ['pitch3'],
|
||||
// ['portamento'],
|
||||
// TODO: LFO rate see https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
|
||||
export const { rate } = registerControl('rate');
|
||||
|
||||
// TODO: slide param for certain synths
|
||||
export const { slide } = registerControl('slide');
|
||||
// TODO: detune? https://tidalcycles.org/docs/patternlib/tutorials/synthesizers/#supersquare
|
||||
@@ -2264,7 +2489,6 @@ export const { tsdelay } = registerControl('tsdelay');
|
||||
export const { real } = registerControl('real');
|
||||
export const { imag } = registerControl('imag');
|
||||
export const { enhance } = registerControl('enhance');
|
||||
export const { partials } = registerControl('partials');
|
||||
export const { comb } = registerControl('comb');
|
||||
export const { smear } = registerControl('smear');
|
||||
export const { scram } = registerControl('scram');
|
||||
@@ -2313,7 +2537,6 @@ export const { curve } = registerControl('curve');
|
||||
export const { deltaSlide } = registerControl('deltaSlide');
|
||||
export const { pitchJump } = registerControl('pitchJump');
|
||||
export const { pitchJumpTime } = registerControl('pitchJumpTime');
|
||||
export const { lfo, repeatTime } = registerControl('lfo', 'repeatTime');
|
||||
// noise on the frequency or as bubo calls it "frequency fog" :)
|
||||
export const { znoise } = registerControl('znoise');
|
||||
export const { zmod } = registerControl('zmod');
|
||||
@@ -2551,8 +2774,13 @@ export const as = register('as', (mapping, pat) => {
|
||||
mapping = Array.isArray(mapping) ? mapping : [mapping];
|
||||
return pat.fmap((v) => {
|
||||
v = Array.isArray(v) ? v : [v];
|
||||
v = Object.fromEntries(mapping.map((prop, i) => [getControlName(prop), v[i]]));
|
||||
return v;
|
||||
const entries = [];
|
||||
for (let i = 0; i < mapping.length; ++i) {
|
||||
if (v[i] !== undefined) {
|
||||
entries.push([getControlName(mapping[i]), v[i]]);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2584,3 +2812,279 @@ export const scrub = register(
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const subControlAliases = new Map();
|
||||
const registerSubControl = (control, subControl, ...aliases) => {
|
||||
const aliasMap = subControlAliases.get(control) ?? new Map();
|
||||
const allKeys = new Set([subControl, ...aliases]);
|
||||
for (const alias of allKeys) {
|
||||
aliasMap.set(String(alias).toLowerCase(), subControl);
|
||||
}
|
||||
subControlAliases.set(control, aliasMap);
|
||||
};
|
||||
|
||||
const registerSubControls = (control, subControlAliases = []) => {
|
||||
for (const [subControl, ...aliases] of subControlAliases) {
|
||||
registerSubControl(control, subControl, ...aliases);
|
||||
}
|
||||
};
|
||||
|
||||
const getMainSubcontrolName = (control, subKey) => {
|
||||
const aliasMap = subControlAliases.get(control);
|
||||
if (!aliasMap) return subKey;
|
||||
return aliasMap.get(String(subKey).toLowerCase()) ?? subKey;
|
||||
};
|
||||
|
||||
registerSubControls('lfo', [
|
||||
['control', 'c'],
|
||||
['subControl', 'sc'],
|
||||
['rate', 'r'],
|
||||
['depth', 'dep', 'dr'],
|
||||
['depthabs', 'da'],
|
||||
['dcoffset', 'dc'],
|
||||
['shape', 'sh'],
|
||||
['skew', 'sk'],
|
||||
['curve', 'cu'],
|
||||
['sync', 's'],
|
||||
['fxi'],
|
||||
]);
|
||||
registerSubControls('env', [
|
||||
['control', 'c'],
|
||||
['subControl', 'sc'],
|
||||
['attack', 'att', 'a'],
|
||||
['decay', 'dec', 'd'],
|
||||
['sustain', 'sus', 's'],
|
||||
['release', 'rel', 'r'],
|
||||
['depth', 'dep', 'dr'],
|
||||
['depthabs', 'da'],
|
||||
['acurve', 'ac'],
|
||||
['dcurve', 'dc'],
|
||||
['rcurve', 'rc'],
|
||||
['fxi'],
|
||||
]);
|
||||
registerSubControls('bmod', [
|
||||
['bus', 'b'],
|
||||
['control', 'c'],
|
||||
['subControl', 'sc'],
|
||||
['depth', 'dep', 'dr'],
|
||||
['depthabs', 'da'],
|
||||
['dc'],
|
||||
['fxi'],
|
||||
]);
|
||||
|
||||
Pattern.prototype.modulate = function (type, config, idPat) {
|
||||
config = { control: undefined, ...config };
|
||||
const modulatorKeys = ['lfo', 'env', 'bmod'];
|
||||
if (!modulatorKeys.includes(type)) {
|
||||
logger(`[core] Modulation type ${type} not found. Please use one of 'lfo', 'env', 'bmod'`);
|
||||
return this;
|
||||
}
|
||||
let output = this;
|
||||
let defaultValue = undefined;
|
||||
// Copy value into a temporary `v` container and attach a single `id` (to be shared across
|
||||
// each config entry). At the output we destructure and throw away the id
|
||||
output = output.fmap((v) => (id) => ({ v, id })).appLeft(reify(idPat));
|
||||
for (const [rawKey, value] of Object.entries(config)) {
|
||||
const key = getMainSubcontrolName(type, rawKey);
|
||||
const valuePat = reify(value);
|
||||
output = output
|
||||
.fmap(({ v, id }) => (c) => {
|
||||
if (defaultValue === undefined) {
|
||||
// default control to the control set just before this in the chain
|
||||
// e.g. pat.gain(0.5).lfo({..}) will be a gain-LFO
|
||||
let control = getControlName(Object.keys(v).at(-1));
|
||||
if (modulatorKeys.includes(control)) {
|
||||
control = `${control}_${[...v[control].__ids].at(-1)}`;
|
||||
}
|
||||
defaultValue = control;
|
||||
}
|
||||
v[type] ??= { __ids: new Set() };
|
||||
const t = v[type];
|
||||
id ??= t.__ids.size;
|
||||
t[id] ??= { control: defaultValue };
|
||||
t.__ids.add(id); // keeps track of insertion order
|
||||
if (c === undefined) return { v, id };
|
||||
if (key === 'control' || key === 'subControl') {
|
||||
t[id][key] = getControlName(c);
|
||||
} else {
|
||||
t[id][key] = c;
|
||||
}
|
||||
return { v, id };
|
||||
})
|
||||
.appLeft(valuePat);
|
||||
}
|
||||
return output.fmap(({ v }) => v);
|
||||
};
|
||||
|
||||
/**
|
||||
* Configures an LFO. Can be called in sequence like pat.lfo(...).lfo(...) to set up multiple LFOs.
|
||||
* There are two ways to declare which control will be modulated:
|
||||
* 1. Explicitly put `control` in the config (e.g. `lfo({ c: "lpf" })`)
|
||||
* 2. If the control parameter is absent, the control _immediately before_ the `lfo` call will be used
|
||||
* (e.g. `s("saw").lpf(500).lfo()` to modulate `lpf`)
|
||||
*
|
||||
* Modulators can be referred to by `id` so that they can be updated later e.g. inside
|
||||
* a `sometimes`. See example below.
|
||||
*
|
||||
* @name lfo
|
||||
* @param {Object} config LFO configuration.
|
||||
* @param {string | Pattern} [config.control] Node to modulate. Aliases: c
|
||||
* @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc
|
||||
* @param {number | Pattern} [config.rate] Modulation rate. Aliases: r
|
||||
* @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr
|
||||
* @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da
|
||||
* @param {number | Pattern} [config.dcoffset] DC offset / bias for the waveform. Aliases: dc
|
||||
* @param {number | Pattern} [config.shape] Shape index. Aliases: sh
|
||||
* @param {number | Pattern} [config.skew] Skew amount. Aliases: sk
|
||||
* @param {number | Pattern} [config.curve] Exponential curve amount. Aliases: cu
|
||||
* @param {number | Pattern} [config.sync] Tempo-synced modulation rate. Aliases: s
|
||||
* @param {number | Pattern} [config.fxi] FX index to target
|
||||
* @param {string | Pattern} id ID to use for this modulator
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* s("saw").note("F1").lpf(500).lfo()
|
||||
*
|
||||
* @example
|
||||
* s("saw").lfo().lpf(500).lfo({ s: 0.3 })
|
||||
*
|
||||
* @example
|
||||
* s("saw").lpf(500).diode(0.3)
|
||||
* .lfo({ c: "lpf" })
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).lfo()
|
||||
* .lfo({ c: "s" })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.lfo({ s: "8" }, 1)) // lfo #1 (0-indexed)
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).lfo({ depth: 4 }, 'lpf_mod')
|
||||
* .lfo({ c: "s" })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.lfo({ s: "8" }, 'lpf_mod'))
|
||||
*/
|
||||
Pattern.prototype.lfo = function (config, id) {
|
||||
return this.modulate('lfo', config, id);
|
||||
};
|
||||
export const lfo = (config) => pure({}).lfo(config);
|
||||
|
||||
/**
|
||||
* Configures an envelope. Can be called in sequence like pat.env(...).env(...) to set up multiple envelopes
|
||||
* There are two ways to declare which control will be modulated:
|
||||
* 1. Explicitly put `control` in the config (e.g. `env({ c: "lpf" })`)
|
||||
* 2. If the control parameter is absent, the control _immediately before_ the `env` call will be used
|
||||
* (e.g. `s("saw").lpf(500).env({ a: 1 })` to modulate `lpf`)
|
||||
*
|
||||
* Modulators can be referred to by `id` so that they can be updated later e.g. inside
|
||||
* a `sometimes`. See example below.
|
||||
*
|
||||
* @name env
|
||||
* @param {Object} config Envelope configuration.
|
||||
* @param {string | Pattern} [config.control] Node to modulate. Aliases: c
|
||||
* @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc
|
||||
* @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr
|
||||
* @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da
|
||||
* @param {number | Pattern} [config.attack] Time to reach depth. Aliases: att, a
|
||||
* @param {number | Pattern} [config.decay] Time to reach sustain. Aliases: dec, d
|
||||
* @param {number | Pattern} [config.sustain] Sustain depth. Aliases: sus, s
|
||||
* @param {number | Pattern} [config.release] Time to return to nominal value. Aliases: rel, r
|
||||
* @param {number | Pattern} [config.acurve] Snappiness of attack curve (-1 = relaxed, 1 = snappy). Aliases: ac
|
||||
* @param {number | Pattern} [config.dcurve] Snappiness of decay curve (-1 = relaxed, 1 = snappy). Aliases: dc
|
||||
* @param {number | Pattern} [config.rcurve] Snappiness of release curve (-1 = relaxed, 1 = snappy). Aliases: rc
|
||||
* @param {number | Pattern} [config.fxi] FX index to target
|
||||
* @param {string | Pattern} id ID to use for this modulator
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* s("saw").note("F1").lpf(500).env({ a: 1 })
|
||||
*
|
||||
* @example
|
||||
* s("saw").env({ d: 1 }).note("F1")
|
||||
* .lpq(4).lpf(50)
|
||||
* .env({ a: 0.1, d: 1, ac: 0.8, dc: 0.3, depth: 50 })
|
||||
*
|
||||
* @example
|
||||
* s("saw").lpf(500).diode(0.3)
|
||||
* .env({ c: "lpf", a: 0.5, d: 0.5 })
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).env({ a: 1 })
|
||||
* .env({ c: "s", a: 1 })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.env({ a: "0.5" }, 1)) // envelope #1 (0-indexed)
|
||||
*
|
||||
* @example
|
||||
* s("pulse").lpf(500).env({ a: 1 }, 'lpf_mod')
|
||||
* .env({ c: "s", a: 1 })
|
||||
* .diode(0.3)
|
||||
* .sometimes(x => x.env({ a: "0.5" }, 'lpf_mod'))
|
||||
*/
|
||||
Pattern.prototype.env = function (config, id) {
|
||||
return this.modulate('env', config, id);
|
||||
};
|
||||
export const env = (config) => pure({}).env(config);
|
||||
|
||||
/**
|
||||
* Modulates with the output from a given `bus`.
|
||||
* Can be called in sequence like pat.bmod(...).bmod(...) to set up multiple modulators
|
||||
*
|
||||
* Send to an audio bus with `otherPat.bus(..)`.
|
||||
*
|
||||
* There are two ways to declare which control will be modulated:
|
||||
* 1. Explicitly put `control` in the config (e.g. `bmod({ id: 2, c: "lpf" })`)
|
||||
* 2. If the control parameter is absent, the control _immediately before_ the `bmod` call will be used
|
||||
* (e.g. `s("saw").lpf(500).bmod({ id: 2 })` to modulate `lpf`)
|
||||
*
|
||||
* Modulators can be referred to by `id` so that they can be updated later e.g. inside
|
||||
* a `sometimes`. See example below.
|
||||
*
|
||||
* @name bmod
|
||||
* @param {Object} config Bus modulation configuration.
|
||||
* @param {string | Pattern} [config.bus] Bus to get modulation signal from
|
||||
* @param {string | Pattern} [config.control] Node to modulate. Aliases: c
|
||||
* @param {string | Pattern} [config.subControl] Sub-control name to append to the control key. Aliases: sc
|
||||
* @param {number | Pattern} [config.depth] Relative modulation depth. Aliases: dep, dr
|
||||
* @param {number | Pattern} [config.depthabs] Absolute modulation depth. Aliases: da
|
||||
* @param {number | Pattern} [config.dc] DC offset prior to application
|
||||
* @param {number | Pattern} [config.fxi] FX index to target
|
||||
* @param {string | Pattern} id ID to use for this modulator
|
||||
* @returns Pattern
|
||||
*
|
||||
* @example
|
||||
* modulator: s("one").seg(64).gain(slider(0, 0, 1)).bus(1).dry(0)
|
||||
* carrier: s("saw").bmod({ b: 1 })
|
||||
*
|
||||
*/
|
||||
Pattern.prototype.bmod = function (config, id) {
|
||||
return this.modulate('bmod', config, id);
|
||||
};
|
||||
export const bmod = (config) => pure({}).bmod(config);
|
||||
|
||||
/**
|
||||
* Transient shaper. Gives independent control over the emphasis on transients
|
||||
* and sustains
|
||||
*
|
||||
* @name transient
|
||||
* @param {number | Pattern} attack Emphasis on transients; between -1 (deaccentuate) and 1 (accentuate)
|
||||
* @param {number | Pattern} sustain Emphasis on the sustains; between -1 (deaccentuate) and 1 (accentuate)
|
||||
* @example
|
||||
* s("bd").transient("<-1 -0.5 0 0.5 1>")
|
||||
* @example
|
||||
* s("hh*16").bank("tr909").transient("<-1:1 1:-1>")
|
||||
*/
|
||||
export const { transient } = registerControl(['transient', 'transsustain']);
|
||||
|
||||
export const { FXrelease, FXrel, FXr, fxr } = registerControl('FXrelease', 'FXrel', 'FXr', 'fxr');
|
||||
|
||||
/**
|
||||
* Input audio device to be used as a sound source in a pattern
|
||||
*
|
||||
* @name input
|
||||
* @param {string | number | Pattern} input Name or index of the input audio device to use
|
||||
* @example
|
||||
* // Please be careful about feedback!
|
||||
* input("1 2").gain(0.5)
|
||||
* .stretch(0.5).lfo({ s: 0.5 })
|
||||
*/
|
||||
export const { input } = registerControl('input');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
+12
-11
@@ -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 './pattern.mjs';
|
||||
export * from './pick.mjs';
|
||||
export * from './repl.mjs';
|
||||
export * from './signal.mjs';
|
||||
export * from './speak.mjs';
|
||||
export * from './state.mjs';
|
||||
export * from './time.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';
|
||||
|
||||
+167
-17
@@ -25,7 +25,7 @@ import {
|
||||
stringifyValues,
|
||||
} from './util.mjs';
|
||||
import drawLine from './drawLine.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { errorLogger, logger } from './logger.mjs';
|
||||
|
||||
let stringParser;
|
||||
|
||||
@@ -414,7 +414,7 @@ export class Pattern {
|
||||
try {
|
||||
return this.query(new State(new TimeSpan(begin, end), controls));
|
||||
} catch (err) {
|
||||
logger(`[query]: ${err.message}`, 'error');
|
||||
errorLogger(err, 'query');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -574,7 +574,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));
|
||||
@@ -585,7 +586,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);
|
||||
@@ -1587,7 +1592,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()) {
|
||||
@@ -2234,7 +2245,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
|
||||
@@ -2266,6 +2277,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.
|
||||
@@ -2659,8 +2687,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]) }));
|
||||
@@ -2671,15 +2704,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)));
|
||||
|
||||
@@ -3552,6 +3586,20 @@ export const morph = (frompat, topat, 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
|
||||
*
|
||||
@@ -3560,6 +3608,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const soft = _distortWithAlg('soft');
|
||||
|
||||
/**
|
||||
* Hard-clipping distortion
|
||||
*
|
||||
@@ -3568,6 +3618,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const hard = _distortWithAlg('hard');
|
||||
|
||||
/**
|
||||
* Cubic polynomial distortion
|
||||
*
|
||||
@@ -3576,6 +3628,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const cubic = _distortWithAlg('cubic');
|
||||
|
||||
/**
|
||||
* Diode-emulating distortion
|
||||
*
|
||||
@@ -3584,6 +3638,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const diode = _distortWithAlg('diode');
|
||||
|
||||
/**
|
||||
* Asymmetrical diode distortion
|
||||
*
|
||||
@@ -3592,6 +3648,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const asym = _distortWithAlg('asym');
|
||||
|
||||
/**
|
||||
* Wavefolding distortion
|
||||
*
|
||||
@@ -3600,6 +3658,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const fold = _distortWithAlg('fold');
|
||||
|
||||
/**
|
||||
* Wavefolding distortion composed with sinusoid
|
||||
*
|
||||
@@ -3608,6 +3668,8 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
export const sinefold = _distortWithAlg('sinefold');
|
||||
|
||||
/**
|
||||
* Distortion via Chebyshev polynomials
|
||||
*
|
||||
@@ -3616,11 +3678,99 @@ export const morph = (frompat, topat, bypat) => {
|
||||
* @param {number | Pattern} volume linear postgain of the distortion
|
||||
*
|
||||
*/
|
||||
const distAlgoNames = ['scurve', 'soft', 'hard', 'cubic', 'diode', 'asym', 'fold', 'sinefold', 'chebyshev'];
|
||||
for (const name of distAlgoNames) {
|
||||
// Add aliases for distortion algorithms
|
||||
Pattern.prototype[name] = function (args) {
|
||||
const argsPat = reify(args).fmap((v) => (Array.isArray(v) ? [...v, name] : [v, 1, name]));
|
||||
return this.distort(argsPat);
|
||||
};
|
||||
}
|
||||
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));
|
||||
};
|
||||
|
||||
+21
-7
@@ -5,6 +5,7 @@ import { errorLogger, logger } from './logger.mjs';
|
||||
import { setTime } from './time.mjs';
|
||||
import { evalScope } from './evaluate.mjs';
|
||||
import { register, Pattern, isPattern, silence, stack } from './pattern.mjs';
|
||||
import { reset_state } from './impure.mjs';
|
||||
|
||||
export function repl({
|
||||
defaultOutput,
|
||||
@@ -52,6 +53,9 @@ export function repl({
|
||||
onToggle: (started) => {
|
||||
updateState({ started });
|
||||
onToggle?.(started);
|
||||
if (!started) {
|
||||
reset_state();
|
||||
}
|
||||
},
|
||||
setInterval,
|
||||
clearInterval,
|
||||
@@ -152,9 +156,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;
|
||||
@@ -215,8 +219,19 @@ export function repl({
|
||||
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
|
||||
if (Object.keys(pPatterns).length) {
|
||||
let patterns = [];
|
||||
let soloActive = false;
|
||||
for (const [key, value] of Object.entries(pPatterns)) {
|
||||
patterns.push(value.withState((state) => state.setControls({ id: key })));
|
||||
// handle soloed patterns ex: S$: s("bd!4")
|
||||
const isSolod = key.length > 1 && key.startsWith('S');
|
||||
if (isSolod && soloActive === false) {
|
||||
// first time we see a soloed pattern, clear existing patterns
|
||||
patterns = [];
|
||||
soloActive = true;
|
||||
}
|
||||
if (!soloActive || (soloActive && isSolod)) {
|
||||
const valWithState = value.withState((state) => state.setControls({ id: key }));
|
||||
patterns.push(valWithState);
|
||||
}
|
||||
}
|
||||
if (eachTransform) {
|
||||
// Explicit lambda so only element (not index and array) are passed
|
||||
@@ -227,14 +242,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);
|
||||
|
||||
+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)];
|
||||
});
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
# @strudel/draw
|
||||
# @strudel/canvas
|
||||
|
||||
Helpers for drawing with the Canvas API and Strudel
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm i @strudel/draw --save
|
||||
npm i @strudel/canvas --save
|
||||
```
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function initHydra(options = {}) {
|
||||
hydra.synth.s0.init({ src: canvas });
|
||||
}
|
||||
}
|
||||
return hydra;
|
||||
}
|
||||
|
||||
export function clearHydra() {
|
||||
|
||||
+26
-9
@@ -479,14 +479,24 @@ Pattern.prototype.midi = function (midiport, options = {}) {
|
||||
|
||||
let listeners = {};
|
||||
const refs = {};
|
||||
const refsByChan = {};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {string | number} input MIDI device name or index defaulting to 0
|
||||
* @returns {Function}
|
||||
* @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
|
||||
* let cc = await midin('IAC Driver Bus 1')
|
||||
* 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))
|
||||
*/
|
||||
export async function midin(input) {
|
||||
if (isPattern(input)) {
|
||||
@@ -511,17 +521,24 @@ export async function midin(input) {
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// ensure refs for this input are initialized
|
||||
if (!refs[input]) {
|
||||
refs[input] = {};
|
||||
}
|
||||
const cc = (cc) => ref(() => refs[input][cc] || 0);
|
||||
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 ccNum = e.dataBytes[0];
|
||||
const v = e.dataBytes[1];
|
||||
refs[input] && (refs[input][cc] = v / 127);
|
||||
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;
|
||||
|
||||
@@ -46,4 +46,4 @@ all(osc)
|
||||
|
||||
[open in repl](https://strudel.cc/#JDogcygiYmQqNCIpCgphbGwob3NjKQ%3D%3D)
|
||||
|
||||
You can read more about [how to use Superdirt with Strudel the Tutorial](https://strudel.cc/learn/input-output/#superdirt-api)
|
||||
You can read more about [how to use Superdirt with Strudel](https://strudel.cc/learn/input-output/#oscsuperdirtstrudeldirt) in the tutorial.
|
||||
|
||||
Executable → Regular
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/*
|
||||
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 = () => {
|
||||
@@ -5,6 +14,11 @@ export const setDefaultAudioContext = () => {
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const setAudioContext = (context) => {
|
||||
audioContext = context;
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
export const getAudioContext = () => {
|
||||
if (!audioContext) {
|
||||
return setDefaultAudioContext();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
+237
-103
@@ -1,7 +1,8 @@
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||
import { getNoiseBuffer } from './noise.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'];
|
||||
|
||||
@@ -42,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,
|
||||
@@ -59,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;
|
||||
@@ -105,22 +105,40 @@ function getModulationShapeInput(val) {
|
||||
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
|
||||
}
|
||||
|
||||
export function getLfo(audioContext, begin, end, properties = {}) {
|
||||
const { shape = 0, ...props } = properties;
|
||||
const { dcoffset = -0.5, depth = 1 } = properties;
|
||||
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 = {
|
||||
frequency: 1,
|
||||
depth,
|
||||
skew: 0.5,
|
||||
phaseoffset: 0,
|
||||
time: begin,
|
||||
begin,
|
||||
end,
|
||||
shape: getModulationShapeInput(shape),
|
||||
time: time ?? begin,
|
||||
depth,
|
||||
dcoffset,
|
||||
min: dcoffset * depth,
|
||||
max: dcoffset * depth + depth,
|
||||
curve: 1,
|
||||
frequency,
|
||||
skew,
|
||||
phaseoffset,
|
||||
curve,
|
||||
shape: getModulationShapeInput(shape),
|
||||
min: min ?? dcoffset * depth,
|
||||
max: max ?? dcoffset * depth + depth,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -128,6 +146,7 @@ export function getLfo(audioContext, begin, end, properties = {}) {
|
||||
}
|
||||
|
||||
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
|
||||
const options = {
|
||||
threshold: threshold ?? -3,
|
||||
ratio: ratio ?? 10,
|
||||
@@ -135,7 +154,11 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
||||
attack: attack ?? 0.005,
|
||||
release: release ?? 0.05,
|
||||
};
|
||||
return new DynamicsCompressorNode(ac, options);
|
||||
const now = ac.currentTime;
|
||||
Object.entries(options).forEach(([key, value]) => {
|
||||
node[key].setValueAtTime(value, now);
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
// changes the default values of the envelope based on what parameters the user has defined
|
||||
@@ -162,7 +185,9 @@ export function getParamLfo(audioContext, param, start, end, lfoValues) {
|
||||
}
|
||||
let lfo;
|
||||
if (depth) {
|
||||
lfo = getLfo(audioContext, start, end, {
|
||||
lfo = getLfo(audioContext, {
|
||||
begin: start,
|
||||
end,
|
||||
depth,
|
||||
dcoffset,
|
||||
...getLfoInputs,
|
||||
@@ -189,10 +214,9 @@ export function applyParameterModulators(audioContext, param, start, end, envelo
|
||||
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
|
||||
}
|
||||
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
|
||||
return { lfo, disconnect: () => lfo?.disconnect() };
|
||||
return lfo;
|
||||
}
|
||||
|
||||
export function createFilter(context, start, end, params, cps) {
|
||||
export function createFilter(context, start, end, params, cps, cycle) {
|
||||
let {
|
||||
frequency,
|
||||
anchor,
|
||||
@@ -202,21 +226,26 @@ export function createFilter(context, start, end, params, cps) {
|
||||
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 {
|
||||
filter = context.createBiquadFilter();
|
||||
const factory = () => context.createBiquadFilter();
|
||||
filter = getNodeFromPool('filter', factory);
|
||||
filter.type = type;
|
||||
filter.Q.value = q;
|
||||
filter.frequency.value = frequency;
|
||||
const now = context.currentTime;
|
||||
Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
|
||||
filter[key].setValueAtTime(value, now);
|
||||
});
|
||||
frequencyParam = filter.frequency;
|
||||
}
|
||||
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
|
||||
@@ -238,9 +267,27 @@ export function createFilter(context, start, end, params, cps) {
|
||||
if (sync != null) {
|
||||
rate = cps * sync;
|
||||
}
|
||||
const lfoValues = { depth, dcoffset, skew, shape, frequency: rate };
|
||||
getParamLfo(context, frequencyParam, start, end, lfoValues);
|
||||
return filter;
|
||||
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
|
||||
@@ -262,7 +309,18 @@ 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'];
|
||||
@@ -297,8 +355,12 @@ 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] } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,25 +380,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)) {
|
||||
@@ -348,64 +402,94 @@ 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
|
||||
@@ -505,7 +589,7 @@ export const getDistortion = (distort, postgain, algorithm) => {
|
||||
};
|
||||
|
||||
export const getFrequencyFromValue = (value, defaultNote = 36) => {
|
||||
let { note, freq } = value;
|
||||
let { note, freq, octave = 0 } = value;
|
||||
note = note || defaultNote;
|
||||
if (typeof note === 'string') {
|
||||
note = noteToMidi(note); // e.g. c3 => 48
|
||||
@@ -514,14 +598,64 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => {
|
||||
if (!freq && typeof note === 'number') {
|
||||
freq = midiToFreq(note); // + 48);
|
||||
}
|
||||
|
||||
freq *= Math.pow(2, octave);
|
||||
return Number(freq);
|
||||
};
|
||||
|
||||
export const destroyAudioWorkletNode = (node) => {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
node.disconnect();
|
||||
node.parameters.get('end')?.setValueAtTime(0, 0);
|
||||
// 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,6 +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';
|
||||
|
||||
@@ -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,73 @@
|
||||
/*
|
||||
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');
|
||||
const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead');
|
||||
const MAX_POOL_SIZE = 64;
|
||||
|
||||
export const isPoolable = (node) => !!node[POOL_KEY];
|
||||
|
||||
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;
|
||||
}
|
||||
if (node[IS_WORKLET_DEAD]) {
|
||||
// Worklet already terminated, don't pool it
|
||||
return;
|
||||
}
|
||||
const key = node[POOL_KEY];
|
||||
if (key == null) return;
|
||||
const now = node.context?.currentTime ?? 0;
|
||||
getParams(node).forEach((param) => param.cancelScheduledValues(now));
|
||||
const pool = nodePools.get(key) ?? [];
|
||||
if (pool.length < MAX_POOL_SIZE) {
|
||||
pool.push(new WeakRef(node));
|
||||
nodePools.set(key, pool);
|
||||
}
|
||||
};
|
||||
|
||||
export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true);
|
||||
|
||||
// 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;
|
||||
while (pool.length) {
|
||||
const ref = pool.pop();
|
||||
node = ref?.deref();
|
||||
if (node != null && !node[IS_WORKLET_DEAD]) break;
|
||||
}
|
||||
if (node == null || node[IS_WORKLET_DEAD]) {
|
||||
node = factory();
|
||||
}
|
||||
node[POOL_KEY] = key;
|
||||
return node;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { drywet } from './helpers.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { getCommonSampleInfo } from './util.mjs';
|
||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
||||
import { registerSound, registerWaveTable } from './index.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
|
||||
import {
|
||||
getADSRValues,
|
||||
getParamADSR,
|
||||
getPitchEnvelope,
|
||||
getVibratoOscillator,
|
||||
onceEnded,
|
||||
releaseAudioNode,
|
||||
} from './helpers.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
const bufferCache = {}; // string: Promise<ArrayBuffer>
|
||||
@@ -58,21 +65,22 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
|
||||
bufferSource.playbackRate.value = playbackRate;
|
||||
|
||||
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 = 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) => {
|
||||
@@ -211,7 +219,7 @@ export async function fetchSampleMap(url) {
|
||||
// not a browser
|
||||
return;
|
||||
}
|
||||
const base = url.split('/').slice(0, -1).join('/');
|
||||
const base = getBaseURL(url);
|
||||
if (typeof fetch === 'undefined') {
|
||||
// skip fetch when in node / testing
|
||||
return;
|
||||
@@ -236,12 +244,6 @@ export async function fetchSampleMap(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 = {}) => {
|
||||
@@ -286,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);
|
||||
@@ -319,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) {
|
||||
|
||||
+590
-235
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;
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
/*
|
||||
superdoughoutput.mjs - Output controller for superdough
|
||||
|
||||
Handles setting up and mixing to the outputs as well as all global (orbit) effects
|
||||
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughoutput.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 { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
|
||||
import { errorLogger } from './logger.mjs';
|
||||
import { clamp } from './util.mjs';
|
||||
|
||||
let hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
const hasChanged = (now, before) => now !== undefined && now !== before;
|
||||
// Node with fixed stereo channel count to prevent clicking when the input signal
|
||||
// switches from mono to stereo
|
||||
const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
|
||||
export class Orbit {
|
||||
reverbNode;
|
||||
@@ -11,10 +23,11 @@ export class Orbit {
|
||||
summingNode;
|
||||
djfNode;
|
||||
audioContext;
|
||||
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
this.output = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
this.summingNode = new GainNode(audioContext, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
|
||||
this.output = getStereoNode(audioContext);
|
||||
this.summingNode = getStereoNode(audioContext);
|
||||
this.summingNode.connect(this.output);
|
||||
}
|
||||
|
||||
@@ -34,6 +47,7 @@ export class Orbit {
|
||||
}
|
||||
const val = this.djfNode.parameters.get('value');
|
||||
val.setValueAtTime(value, t);
|
||||
return this.djfNode;
|
||||
}
|
||||
|
||||
getDelay(delaytime = 0, feedback = 0.5, t) {
|
||||
@@ -78,11 +92,11 @@ export class Orbit {
|
||||
return this.reverbNode;
|
||||
}
|
||||
sendReverb(node, amount) {
|
||||
effectSend(node, this.reverbNode, amount);
|
||||
return effectSend(node, this.reverbNode, amount);
|
||||
}
|
||||
|
||||
sendDelay(node, amount) {
|
||||
effectSend(node, this.delayNode, amount);
|
||||
return effectSend(node, this.delayNode, amount);
|
||||
}
|
||||
|
||||
duck(t, onsettime = 0, attacktime = 0.1, depth = 1) {
|
||||
@@ -164,6 +178,7 @@ export class SuperdoughAudioController {
|
||||
audioContext;
|
||||
output;
|
||||
nodes = {};
|
||||
buses = {};
|
||||
|
||||
constructor(audioContext) {
|
||||
this.audioContext = audioContext;
|
||||
@@ -171,10 +186,14 @@ export class SuperdoughAudioController {
|
||||
}
|
||||
|
||||
reset() {
|
||||
Array.from(this.nodes).forEach((node) => {
|
||||
Object.values(this.nodes).forEach((node) => {
|
||||
node.disconnect();
|
||||
});
|
||||
Object.values(this.buses).forEach((bus) => {
|
||||
bus.disconnect();
|
||||
});
|
||||
this.nodes = {};
|
||||
this.buses = {};
|
||||
this.output.reset();
|
||||
}
|
||||
|
||||
@@ -206,4 +225,11 @@ export class SuperdoughAudioController {
|
||||
}
|
||||
return this.nodes[orbitNum];
|
||||
}
|
||||
|
||||
getBus(busNum) {
|
||||
if (this.buses[busNum] == null) {
|
||||
this.buses[busNum] = getStereoNode(this.audioContext);
|
||||
}
|
||||
return this.buses[busNum];
|
||||
}
|
||||
}
|
||||
|
||||
+162
-80
@@ -1,9 +1,8 @@
|
||||
import { clamp } from './util.mjs';
|
||||
import { registerSound, soundMap } from './superdough.mjs';
|
||||
import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import {
|
||||
applyFM,
|
||||
destroyAudioWorkletNode,
|
||||
gainNode,
|
||||
getADSRValues,
|
||||
getFrequencyFromValue,
|
||||
@@ -13,11 +12,15 @@ import {
|
||||
getVibratoOscillator,
|
||||
getWorklet,
|
||||
noises,
|
||||
onceEnded,
|
||||
releaseAudioNode,
|
||||
webAudioTimeout,
|
||||
} from './helpers.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||
|
||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
|
||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
|
||||
const waveformAliases = [
|
||||
['tri', 'triangle'],
|
||||
['sqr', 'square'],
|
||||
@@ -47,22 +50,20 @@ export function registerSynthSounds() {
|
||||
[0.001, 0.05, 0.6, 0.01],
|
||||
);
|
||||
|
||||
let sound = getOscillator(s, t, value);
|
||||
let { node: o, stop, triggerRelease } = sound;
|
||||
|
||||
// turn down
|
||||
const g = gainNode(0.3);
|
||||
|
||||
const sound = getOscillator(s, t, value, () => {
|
||||
releaseAudioNode(g);
|
||||
onended();
|
||||
});
|
||||
|
||||
const { node: o, nodes, stop, triggerRelease } = sound;
|
||||
|
||||
const { duration } = value;
|
||||
|
||||
o.onended = () => {
|
||||
o.disconnect();
|
||||
g.disconnect();
|
||||
onended();
|
||||
};
|
||||
|
||||
const envGain = gainNode(1);
|
||||
let node = o.connect(g).connect(envGain);
|
||||
const node = o.connect(g).connect(envGain);
|
||||
const holdEnd = t + duration;
|
||||
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
|
||||
const envEnd = holdEnd + release + 0.01;
|
||||
@@ -70,6 +71,7 @@ export function registerSynthSounds() {
|
||||
stop(envEnd);
|
||||
return {
|
||||
node,
|
||||
nodes,
|
||||
stop: (endTime) => {
|
||||
stop(endTime);
|
||||
},
|
||||
@@ -111,15 +113,15 @@ export function registerSynthSounds() {
|
||||
|
||||
const mix = gainNode(mixGain);
|
||||
|
||||
o.onended = () => {
|
||||
o.disconnect();
|
||||
g.disconnect();
|
||||
sat.disconnect();
|
||||
noise.node.disconnect();
|
||||
noiseGain.disconnect();
|
||||
mix.disconnect();
|
||||
onceEnded(o, () => {
|
||||
releaseAudioNode(o);
|
||||
releaseAudioNode(g);
|
||||
releaseAudioNode(sat);
|
||||
releaseAudioNode(noise.node);
|
||||
releaseAudioNode(noiseGain);
|
||||
releaseAudioNode(mix);
|
||||
onended();
|
||||
};
|
||||
});
|
||||
|
||||
const node = o.connect(sat).connect(g).connect(mix);
|
||||
noise.node.connect(noiseGain).connect(mix);
|
||||
@@ -139,6 +141,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node,
|
||||
nodes: { source: [o] },
|
||||
stop: (endTime) => {
|
||||
o.stop(endTime);
|
||||
},
|
||||
@@ -165,26 +168,31 @@ export function registerSynthSounds() {
|
||||
const end = holdend + release + 0.01;
|
||||
const voices = clamp(unison, 1, 100);
|
||||
let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
|
||||
let o = getWorklet(
|
||||
ac,
|
||||
'supersaw-oscillator',
|
||||
{
|
||||
frequency,
|
||||
begin,
|
||||
end,
|
||||
freqspread: detune,
|
||||
voices,
|
||||
panspread,
|
||||
},
|
||||
{
|
||||
outputChannelCount: [2],
|
||||
},
|
||||
);
|
||||
|
||||
const params = {
|
||||
frequency,
|
||||
begin,
|
||||
end,
|
||||
freqspread: detune,
|
||||
voices,
|
||||
panspread,
|
||||
};
|
||||
const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] });
|
||||
const o = getNodeFromPool('supersaw', factory);
|
||||
const now = ac.currentTime;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const param = o.parameters.get(key);
|
||||
const target = value !== undefined ? value : param.defaultValue;
|
||||
param.setValueAtTime(target, now);
|
||||
});
|
||||
o.port.postMessage({ type: 'initialize' });
|
||||
o.port.onmessage = (e) => {
|
||||
if (e.data.type === 'died') markWorkletAsDead(o);
|
||||
o.port.onmessage = null;
|
||||
};
|
||||
const gainAdjustment = 1 / Math.sqrt(voices);
|
||||
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
||||
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fm = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
let envGain = gainNode(1);
|
||||
envGain = o.connect(envGain);
|
||||
|
||||
@@ -193,11 +201,10 @@ export function registerSynthSounds() {
|
||||
let timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
destroyAudioWorkletNode(o);
|
||||
envGain.disconnect();
|
||||
releaseNodeToPool(o);
|
||||
onended();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
fmHandle?.stop();
|
||||
vibratoHandle?.stop();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
@@ -205,6 +212,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
@@ -271,8 +279,7 @@ export function registerSynthSounds() {
|
||||
let timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
destroyAudioWorkletNode(o);
|
||||
envGain.disconnect();
|
||||
releaseAudioNode(o);
|
||||
onended();
|
||||
},
|
||||
begin,
|
||||
@@ -281,6 +288,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
source: o,
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
@@ -331,26 +339,25 @@ export function registerSynthSounds() {
|
||||
);
|
||||
|
||||
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
|
||||
const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fm = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
|
||||
const fmHandle = applyFM(o.parameters.get('frequency'), value, begin);
|
||||
let envGain = gainNode(1);
|
||||
envGain = o.connect(envGain);
|
||||
|
||||
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
|
||||
let lfo;
|
||||
let pw_lfo;
|
||||
if (pwsweep != 0) {
|
||||
lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep });
|
||||
lfo.connect(o.parameters.get('pulsewidth'));
|
||||
pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end });
|
||||
pw_lfo.connect(o.parameters.get('pulsewidth'));
|
||||
}
|
||||
let timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
destroyAudioWorkletNode(o);
|
||||
destroyAudioWorkletNode(lfo);
|
||||
envGain.disconnect();
|
||||
releaseAudioNode(o);
|
||||
releaseAudioNode(pw_lfo);
|
||||
onended();
|
||||
fm?.stop();
|
||||
vibratoOscillator?.stop();
|
||||
fmHandle?.stop();
|
||||
vibratoHandle?.stop();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
@@ -358,6 +365,7 @@ export function registerSynthSounds() {
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
@@ -366,6 +374,41 @@ export function registerSynthSounds() {
|
||||
{ prebake: true, type: 'synth' },
|
||||
);
|
||||
|
||||
registerSound(
|
||||
'bus',
|
||||
(begin, value, onended) => {
|
||||
const ac = getAudioContext();
|
||||
const [attack, decay, sustain, release] = getADSRValues(
|
||||
[value.attack, value.decay, value.sustain, value.release],
|
||||
'linear',
|
||||
[0.001, 0.05, 1, 0.01],
|
||||
);
|
||||
const holdend = begin + value.duration;
|
||||
const end = holdend + release + 0.01;
|
||||
const bus = getSuperdoughAudioController().getBus(value.n ?? 0);
|
||||
const envGain = bus.connect(gainNode(0));
|
||||
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
|
||||
const timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
bus.disconnect(envGain);
|
||||
onended();
|
||||
},
|
||||
begin,
|
||||
end,
|
||||
);
|
||||
|
||||
return {
|
||||
node: envGain,
|
||||
nodes: { source: [bus] },
|
||||
stop: (time) => {
|
||||
timeoutNode.stop(time);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ prebake: true, type: 'input' },
|
||||
);
|
||||
|
||||
[...noises].forEach((s) => {
|
||||
registerSound(
|
||||
s,
|
||||
@@ -388,11 +431,11 @@ export function registerSynthSounds() {
|
||||
|
||||
const { duration } = value;
|
||||
|
||||
o.onended = () => {
|
||||
o.disconnect();
|
||||
g.disconnect();
|
||||
onceEnded(o, () => {
|
||||
releaseAudioNode(o);
|
||||
releaseAudioNode(g);
|
||||
onended();
|
||||
};
|
||||
});
|
||||
|
||||
const envGain = gainNode(1);
|
||||
let node = o.connect(g).connect(envGain);
|
||||
@@ -403,6 +446,7 @@ export function registerSynthSounds() {
|
||||
stop(envEnd);
|
||||
return {
|
||||
node,
|
||||
nodes: { source: [o] },
|
||||
stop: (endTime) => {
|
||||
stop(endTime);
|
||||
},
|
||||
@@ -414,9 +458,13 @@ export function registerSynthSounds() {
|
||||
waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] }));
|
||||
}
|
||||
|
||||
export function waveformN(partials, type) {
|
||||
const real = new Float32Array(partials + 1);
|
||||
const imag = new Float32Array(partials + 1);
|
||||
const PI2 = 2 * Math.PI;
|
||||
export function waveformN(partials, phases, type) {
|
||||
const isList = typeof partials === 'object';
|
||||
partials = isList ? partials : new Float32Array(partials).fill(1);
|
||||
const len = partials.length;
|
||||
const real = new Float32Array(len + 1);
|
||||
const imag = new Float32Array(len + 1);
|
||||
const ac = getAudioContext();
|
||||
const osc = ac.createOscillator();
|
||||
|
||||
@@ -424,20 +472,29 @@ export function waveformN(partials, type) {
|
||||
sawtooth: (n) => [0, -1 / n],
|
||||
square: (n) => [0, n % 2 === 0 ? 0 : 1 / n],
|
||||
triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0],
|
||||
user: (_n) => [0, 1],
|
||||
};
|
||||
|
||||
if (!terms[type]) {
|
||||
throw new Error(`unknown wave type ${type}`);
|
||||
}
|
||||
|
||||
real[0] = 0; // dc offset
|
||||
imag[0] = 0;
|
||||
let n = 1;
|
||||
while (n <= partials) {
|
||||
const [r, i] = terms[type](n);
|
||||
real[n] = r;
|
||||
imag[n] = i;
|
||||
n++;
|
||||
for (let n = 0; n < len; n++) {
|
||||
const mag = partials[n];
|
||||
const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset
|
||||
const phase = phases?.[n] ?? 0;
|
||||
// Scale by `partials`
|
||||
let R = r * mag;
|
||||
let I = i * mag;
|
||||
// Apply rotation by the phase
|
||||
if (phase !== 0) {
|
||||
const c = Math.cos(PI2 * phase);
|
||||
const s = Math.sin(PI2 * phase);
|
||||
R = c * R - s * I;
|
||||
I = s * R + c * I;
|
||||
}
|
||||
real[n + 1] = R;
|
||||
imag[n + 1] = I;
|
||||
}
|
||||
|
||||
const wave = ac.createPeriodicWave(real, imag);
|
||||
@@ -446,38 +503,63 @@ export function waveformN(partials, type) {
|
||||
}
|
||||
|
||||
// expects one of waveforms as s
|
||||
export function getOscillator(s, t, value) {
|
||||
let { n: partials, duration, noise = 0 } = value;
|
||||
export function getOscillator(s, t, value, onended) {
|
||||
const { duration, noise = 0 } = value;
|
||||
const partials = value.partials ?? value.n;
|
||||
let o;
|
||||
// If no partials are given, use stock waveforms
|
||||
if (!partials || s === 'sine') {
|
||||
if (s === 'user' && !partials) {
|
||||
logger(
|
||||
`[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`,
|
||||
);
|
||||
s = 'triangle';
|
||||
}
|
||||
s = s === 'user' && !partials ? 'triangle' : s;
|
||||
if (s === 'one') {
|
||||
// Constant 1 oscillator (used for modulation)
|
||||
o = new ConstantSourceNode(getAudioContext(), { offset: 1 });
|
||||
o.start(t);
|
||||
return {
|
||||
node: o,
|
||||
nodes: { source: o },
|
||||
stop: (time) => o?.stop(time),
|
||||
};
|
||||
} else if (!partials || partials?.length === 0 || s === 'sine') {
|
||||
// If no partials are given, use stock waveforms
|
||||
o = getAudioContext().createOscillator();
|
||||
o.type = s || 'triangle';
|
||||
}
|
||||
// generate custom waveform if partials are given
|
||||
else {
|
||||
o = waveformN(partials, s);
|
||||
o = waveformN(partials, value.phases, s);
|
||||
}
|
||||
// set frequency
|
||||
o.frequency.value = getFrequencyFromValue(value);
|
||||
o.start(t);
|
||||
|
||||
let vibratoOscillator = getVibratoOscillator(o.detune, value, t);
|
||||
const vibratoHandle = getVibratoOscillator(o.detune, value, t);
|
||||
|
||||
// pitch envelope
|
||||
getPitchEnvelope(o.detune, value, t, t + duration);
|
||||
const fmModulator = applyFM(o.frequency, value, t);
|
||||
const fmHandle = applyFM(o.frequency, value, t);
|
||||
|
||||
let noiseMix;
|
||||
if (noise) {
|
||||
noiseMix = getNoiseMix(o, noise, t);
|
||||
}
|
||||
|
||||
onceEnded(o, () => {
|
||||
noiseMix?.teardown();
|
||||
releaseAudioNode(o);
|
||||
releaseAudioNode(noiseMix?.node);
|
||||
onended();
|
||||
});
|
||||
o.start(t);
|
||||
|
||||
return {
|
||||
node: noiseMix?.node || o,
|
||||
nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes },
|
||||
stop: (time) => {
|
||||
fmModulator.stop(time);
|
||||
vibratoOscillator?.stop(time);
|
||||
fmHandle.stop(time);
|
||||
vibratoHandle?.stop(time);
|
||||
noiseMix?.stop(time);
|
||||
o.stop(time);
|
||||
},
|
||||
|
||||
@@ -114,3 +114,13 @@ export function getCommonSampleInfo(hapValue, bank) {
|
||||
export const pickAndRename = (source, map) => {
|
||||
return Object.fromEntries(Object.entries(map).map(([newKey, oldKey]) => [newKey, source[oldKey]]));
|
||||
};
|
||||
|
||||
export const getBaseURL = (url) => {
|
||||
try {
|
||||
// For real URLs
|
||||
return new URL('.', new URL(url)).href.replace(/\/$/, ''); // removes trailing slash
|
||||
} catch {
|
||||
// For pseudo URLS
|
||||
return url.split('/').slice(0, -1).join('/');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597
|
||||
export var vowelFormant = {
|
||||
a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] },
|
||||
@@ -45,7 +47,9 @@ if (typeof GainNode !== 'undefined') {
|
||||
throw new Error('vowel: unknown vowel ' + letter);
|
||||
}
|
||||
const { gains, qs, freqs } = vowelFormant[letter];
|
||||
const makeupGain = ac.createGain();
|
||||
this.makeupGain = ac.createGain();
|
||||
this.filters = [];
|
||||
this.gains = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const gain = ac.createGain();
|
||||
gain.gain.value = gains[i];
|
||||
@@ -53,17 +57,30 @@ if (typeof GainNode !== 'undefined') {
|
||||
filter.type = 'bandpass';
|
||||
filter.Q.value = qs[i];
|
||||
filter.frequency.value = freqs[i];
|
||||
this.connect(filter);
|
||||
super.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(makeupGain);
|
||||
this.filters.push(filter);
|
||||
gain.connect(this.makeupGain);
|
||||
this.gains.push(gain);
|
||||
}
|
||||
makeupGain.gain.value = 8; // how much makeup gain to add?
|
||||
this.connect = (target) => makeupGain.connect(target);
|
||||
this.makeupGain.gain.value = 8; // how much makeup gain to add?
|
||||
return this;
|
||||
}
|
||||
connect(target) {
|
||||
this.makeupGain.connect(target);
|
||||
}
|
||||
disconnect() {
|
||||
releaseAudioNode(this.makeupGain);
|
||||
this.filters.forEach(releaseAudioNode);
|
||||
this.gains.forEach(releaseAudioNode);
|
||||
super.disconnect();
|
||||
this.makeupGain = null;
|
||||
this.filters = null;
|
||||
this.gains = null;
|
||||
}
|
||||
}
|
||||
|
||||
AudioContext.prototype.createVowelFilter = function (letter) {
|
||||
BaseAudioContext.prototype.createVowelFilter = function (letter) {
|
||||
return new VowelNode(this, letter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { getAudioContext, registerSound } from './index.mjs';
|
||||
import { getCommonSampleInfo } from './util.mjs';
|
||||
import { getBaseURL, getCommonSampleInfo } from './util.mjs';
|
||||
import {
|
||||
applyFM,
|
||||
applyParameterModulators,
|
||||
destroyAudioWorkletNode,
|
||||
getADSRValues,
|
||||
getFrequencyFromValue,
|
||||
getParamADSR,
|
||||
getPitchEnvelope,
|
||||
getVibratoOscillator,
|
||||
getWorklet,
|
||||
webAudioTimeout,
|
||||
releaseAudioNode,
|
||||
} from './helpers.mjs';
|
||||
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
|
||||
import { logger } from './logger.mjs';
|
||||
|
||||
export const Warpmode = Object.freeze({
|
||||
@@ -40,6 +40,11 @@ export const Warpmode = Object.freeze({
|
||||
});
|
||||
|
||||
const seenKeys = new Set();
|
||||
|
||||
export function resetSeenKeys() {
|
||||
seenKeys.clear();
|
||||
}
|
||||
|
||||
async function getPayload(url, label, frameLen = 2048) {
|
||||
const key = `${url},${frameLen}`;
|
||||
if (!seenKeys.has(key)) {
|
||||
@@ -190,6 +195,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
|
||||
if (url.startsWith('local:')) {
|
||||
url = `http://localhost:5432`;
|
||||
}
|
||||
const base = getBaseURL(url);
|
||||
if (typeof fetch !== 'function') {
|
||||
// not a browser
|
||||
return;
|
||||
@@ -200,7 +206,7 @@ export const tables = async (url, frameLen, json, options = {}) => {
|
||||
}
|
||||
return fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((json) => _processTables(json, url, frameLen, options))
|
||||
.then((json) => _processTables(json, base, frameLen, options))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error(`error loading "${url}"`);
|
||||
@@ -224,24 +230,31 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
||||
}
|
||||
const endWithRelease = holdEnd + release;
|
||||
const envEnd = endWithRelease + 0.01;
|
||||
const source = getWorklet(
|
||||
ac,
|
||||
'wavetable-oscillator-processor',
|
||||
{
|
||||
begin: t,
|
||||
end: envEnd,
|
||||
frequency,
|
||||
freqspread: value.detune,
|
||||
position: value.wt,
|
||||
warp: value.warp,
|
||||
warpMode: warpmode,
|
||||
voices: Math.max(value.unison ?? 1, 1),
|
||||
panspread: value.spread,
|
||||
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
||||
},
|
||||
{ outputChannelCount: [2] },
|
||||
);
|
||||
source.port.postMessage({ type: 'table', payload });
|
||||
const params = {
|
||||
begin: t,
|
||||
end: envEnd,
|
||||
frequency,
|
||||
freqspread: value.detune,
|
||||
position: value.wt,
|
||||
warp: value.warp,
|
||||
warpMode: warpmode,
|
||||
voices: Math.max(value.unison ?? 1, 1),
|
||||
panspread: value.spread,
|
||||
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
|
||||
};
|
||||
const factory = () => new AudioWorkletNode(ac, 'wavetable-oscillator-processor', { outputChannelCount: [2] });
|
||||
const source = getNodeFromPool('wavetable', factory);
|
||||
const now = ac.currentTime;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const param = source.parameters.get(key);
|
||||
const target = value !== undefined ? value : param.defaultValue;
|
||||
param.setValueAtTime(target, now);
|
||||
});
|
||||
source.port.postMessage({ type: 'initialize', payload });
|
||||
source.port.onmessage = (e) => {
|
||||
if (e.data.type === 'died') markWorkletAsDead(source);
|
||||
source.port.onmessage = null;
|
||||
};
|
||||
if (ac.currentTime > t) {
|
||||
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
|
||||
return;
|
||||
@@ -308,22 +321,30 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
|
||||
dcoffset: value.warpdc ?? 0,
|
||||
},
|
||||
);
|
||||
const vibratoOscillator = getVibratoOscillator(source.parameters.get('detune'), value, t);
|
||||
const fm = applyFM(source.parameters.get('frequency'), value, t);
|
||||
const vibratoHandle = getVibratoOscillator(source.parameters.get('detune'), value, t);
|
||||
const fmHandle = applyFM(source.parameters.get('frequency'), value, t);
|
||||
const envGain = ac.createGain();
|
||||
const node = source.connect(envGain);
|
||||
getParamADSR(node.gain, attack, decay, sustain, release, 0, 0.3, t, holdEnd, 'linear');
|
||||
getPitchEnvelope(source.parameters.get('detune'), value, t, holdEnd);
|
||||
const handle = { node, source };
|
||||
const handle = {
|
||||
node,
|
||||
nodes: {
|
||||
source: [source],
|
||||
wt_lfo: [wtPosModulators],
|
||||
warp_lfo: [wtWarpModulators],
|
||||
...fmHandle?.nodes,
|
||||
...vibratoHandle?.nodes,
|
||||
},
|
||||
};
|
||||
const timeoutNode = webAudioTimeout(
|
||||
ac,
|
||||
() => {
|
||||
destroyAudioWorkletNode(source);
|
||||
vibratoOscillator?.stop();
|
||||
fm?.stop();
|
||||
node.disconnect();
|
||||
wtPosModulators?.disconnect();
|
||||
wtWarpModulators?.disconnect();
|
||||
releaseNodeToPool(source);
|
||||
vibratoHandle?.stop();
|
||||
fmHandle?.stop();
|
||||
releaseAudioNode(wtPosModulators);
|
||||
releaseAudioNode(wtWarpModulators);
|
||||
onended();
|
||||
},
|
||||
t,
|
||||
|
||||
+386
-226
@@ -6,51 +6,60 @@ import OLAProcessor from './ola-processor';
|
||||
import FFT from './fft.js';
|
||||
import { getDistortionAlgorithm } from './helpers.mjs';
|
||||
|
||||
const blockSize = 128;
|
||||
const PI = Math.PI;
|
||||
const TWO_PI = 2 * PI;
|
||||
const INVSR = 1 / sampleRate;
|
||||
|
||||
const timeToCoeff = (t) => 1 - Math.exp(-INVSR / t);
|
||||
const dbToLin = (db) => Math.pow(10, db / 20);
|
||||
|
||||
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||
const mod = (n, m) => ((n % m) + m) % m;
|
||||
const lerp = (a, b, n) => n * (b - a) + a;
|
||||
const pv = (arr, n) => arr[n] ?? arr[0];
|
||||
const frac = (x) => x - Math.floor(x);
|
||||
const ffloor = (x) => x | 0; // fast floor for non-negative
|
||||
|
||||
const getUnisonDetune = (unison, detune, voiceIndex) => {
|
||||
if (unison < 2) {
|
||||
return 0;
|
||||
}
|
||||
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
|
||||
// Fast integer ops for non-negative values
|
||||
const ffloor = (x) => x | 0;
|
||||
const fround = (x) => ffloor(x + 0.5);
|
||||
const fceil = (x) => ffloor(x + 1);
|
||||
const ffrac = (x) => x - ffloor(x);
|
||||
|
||||
const fast_tanh = (x) => {
|
||||
const x2 = x ** 2;
|
||||
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
|
||||
};
|
||||
|
||||
// Optimized per-voice detuner which precomputes constants
|
||||
const getDetuner = (unison, detune) => {
|
||||
if (unison < 2) {
|
||||
return (_voiceIdx) => 0;
|
||||
}
|
||||
const scale = detune / (unison - 1);
|
||||
const center = detune * 0.5;
|
||||
return (voiceIdx) => voiceIdx * scale - center;
|
||||
};
|
||||
|
||||
const applySemitoneDetuneToFrequency = (frequency, detune) => {
|
||||
return frequency * Math.pow(2, detune / 12);
|
||||
};
|
||||
|
||||
// Restrict phase to the range [0, maxPhase) via wrapping
|
||||
function wrapPhase(phase, maxPhase = 1) {
|
||||
if (phase >= maxPhase) {
|
||||
phase -= maxPhase;
|
||||
} else if (phase < 0) {
|
||||
phase += maxPhase;
|
||||
}
|
||||
return phase;
|
||||
}
|
||||
const blockSize = 128;
|
||||
// Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing
|
||||
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
|
||||
function polyBlep(phase, dt) {
|
||||
dt = Math.min(dt, 1 - dt);
|
||||
const invdt = 1 / dt;
|
||||
// Start of cycle
|
||||
if (phase < dt) {
|
||||
phase /= dt;
|
||||
// 2 * (phase - phase^2/2 - 0.5)
|
||||
return phase + phase - phase * phase - 1;
|
||||
phase *= invdt;
|
||||
return 2 * phase - phase ** 2 - 1;
|
||||
}
|
||||
|
||||
// End of cycle
|
||||
else if (phase > 1 - dt) {
|
||||
phase = (phase - 1) / dt;
|
||||
// 2 * (phase^2/2 + phase + 0.5)
|
||||
return phase * phase + phase + phase + 1;
|
||||
phase = (phase - 1) * invdt;
|
||||
return phase ** 2 + 2 * phase + 1;
|
||||
}
|
||||
|
||||
// 0 otherwise
|
||||
else {
|
||||
return 0;
|
||||
@@ -66,7 +75,7 @@ const waveshapes = {
|
||||
return phase / skew;
|
||||
},
|
||||
sine(phase) {
|
||||
return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5;
|
||||
return Math.sin(TWO_PI * phase) * 0.5 + 0.5;
|
||||
},
|
||||
ramp(phase) {
|
||||
return phase;
|
||||
@@ -100,12 +109,6 @@ const waveshapes = {
|
||||
return v - polyBlep(phase, dt);
|
||||
},
|
||||
};
|
||||
function getParamValue(block, param) {
|
||||
if (param.length > 1) {
|
||||
return param[block];
|
||||
}
|
||||
return param[0];
|
||||
}
|
||||
|
||||
const waveShapeNames = Object.keys(waveshapes);
|
||||
class LFOProcessor extends AudioWorkletProcessor {
|
||||
@@ -121,8 +124,8 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
{ name: 'shape', defaultValue: 0 },
|
||||
{ name: 'curve', defaultValue: 1 },
|
||||
{ name: 'dcoffset', defaultValue: 0 },
|
||||
{ name: 'min', defaultValue: 0 },
|
||||
{ name: 'max', defaultValue: 1 },
|
||||
{ name: 'min', defaultValue: -1e9 },
|
||||
{ name: 'max', defaultValue: 1e9 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -140,7 +143,8 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
|
||||
process(_inputs, outputs, parameters) {
|
||||
const begin = parameters['begin'][0];
|
||||
if (currentTime >= parameters.end[0]) {
|
||||
const end = parameters['end'][0];
|
||||
if (currentTime >= end) {
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= begin) {
|
||||
@@ -158,6 +162,7 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
const curve = parameters['curve'][0];
|
||||
|
||||
const dcoffset = parameters['dcoffset'][0];
|
||||
|
||||
const min = parameters['min'][0];
|
||||
const max = parameters['max'][0];
|
||||
const shape = waveShapeNames[parameters['shape'][0]];
|
||||
@@ -165,9 +170,9 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||
const blockSize = output[0].length ?? 0;
|
||||
|
||||
if (this.phase == null) {
|
||||
this.phase = mod(time * frequency + phaseoffset, 1);
|
||||
this.phase = ffrac(time * frequency + phaseoffset);
|
||||
}
|
||||
const dt = frequency / sampleRate;
|
||||
const dt = frequency * INVSR;
|
||||
for (let n = 0; n < blockSize; n++) {
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
|
||||
@@ -293,8 +298,8 @@ class TwoPoleFilter {
|
||||
// Out of bound values can produce NaNs
|
||||
resonance = clamp(resonance, 0, 1);
|
||||
cutoff = clamp(cutoff, 0, sampleRate / 2 - 1);
|
||||
const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14);
|
||||
const r = Math.pow(0.5, (resonance + 0.125) / 0.125);
|
||||
const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14);
|
||||
const r = Math.pow(0.5, 8 * resonance + 1);
|
||||
const mrc = 1 - r * c;
|
||||
this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
|
||||
this.s1 = mrc * this.s1 + c * this.s0; // lpf
|
||||
@@ -353,11 +358,6 @@ class DJFProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
registerProcessor('djf-processor', DJFProcessor);
|
||||
|
||||
function fast_tanh(x) {
|
||||
const x2 = x * x;
|
||||
return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
|
||||
}
|
||||
const _PI = 3.14159265359;
|
||||
//adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file
|
||||
class LadderProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
@@ -395,7 +395,7 @@ class LadderProcessor extends AudioWorkletProcessor {
|
||||
const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
|
||||
|
||||
let cutoff = parameters.frequency[0];
|
||||
cutoff = (cutoff * 2 * _PI) / sampleRate;
|
||||
cutoff = cutoff * TWO_PI * INVSR;
|
||||
cutoff = cutoff > 1 ? 1 : cutoff;
|
||||
|
||||
const k = Math.min(8, resonance * 0.13);
|
||||
@@ -463,6 +463,16 @@ registerProcessor('distort-processor', DistortProcessor);
|
||||
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.isAlive = true; // used internally to prevent multiple death messages
|
||||
this.port.onmessage = (e) => {
|
||||
const { type, payload } = e.data || {};
|
||||
if (type === 'initialize') {
|
||||
this.initialize(payload);
|
||||
}
|
||||
};
|
||||
this.initialize();
|
||||
}
|
||||
initialize(_options) {
|
||||
this.phase = [];
|
||||
}
|
||||
static get parameterDescriptors() {
|
||||
@@ -508,51 +518,54 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||
name: 'voices',
|
||||
defaultValue: 5,
|
||||
min: 1,
|
||||
automationRate: 'k-rate',
|
||||
},
|
||||
];
|
||||
}
|
||||
process(_input, outputs, params) {
|
||||
if (currentTime <= params.begin[0]) {
|
||||
return true;
|
||||
}
|
||||
if (currentTime >= params.end[0]) {
|
||||
// this.port.postMessage({ type: 'onended' });
|
||||
if (currentTime >= params.end[0] + 0.5) {
|
||||
// Outside of grace period - should terminate
|
||||
if (this.isAlive) {
|
||||
this.port.postMessage({ type: 'died' });
|
||||
this.isAlive = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentTime >= params.end[0] || currentTime <= params.begin[0]) {
|
||||
// Inside of grace period or not yet started
|
||||
return true;
|
||||
}
|
||||
const output = outputs[0];
|
||||
|
||||
const voices = params.voices[0]; // k-rate
|
||||
for (let i = 0; i < output[0].length; i++) {
|
||||
const detune = pv(params.detune, i);
|
||||
const voices = pv(params.voices, i);
|
||||
const freqspread = pv(params.freqspread, i);
|
||||
const panspread = pv(params.panspread, i) * 0.5 + 0.5;
|
||||
const gain1 = Math.sqrt(1 - panspread);
|
||||
const gain2 = Math.sqrt(panspread);
|
||||
let gainL = Math.sqrt(1 - panspread);
|
||||
let gainR = Math.sqrt(panspread);
|
||||
let freq = pv(params.frequency, i);
|
||||
// Main detuning
|
||||
freq = applySemitoneDetuneToFrequency(freq, detune / 100);
|
||||
const detuner = getDetuner(voices, freqspread);
|
||||
for (let n = 0; n < voices; n++) {
|
||||
const isOdd = (n & 1) == 1;
|
||||
let gainL = gain1;
|
||||
let gainR = gain2;
|
||||
// invert right and left gain
|
||||
if (isOdd) {
|
||||
gainL = gain2;
|
||||
gainR = gain1;
|
||||
}
|
||||
// Individual voice detuning
|
||||
const freqVoice = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n));
|
||||
const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n));
|
||||
// We must wrap this here because it is passed into sawblep below which
|
||||
// has domain [0, 1]
|
||||
const dt = mod(freqVoice / sampleRate, 1);
|
||||
const dt = frac(freqVoice * INVSR);
|
||||
this.phase[n] = this.phase[n] ?? Math.random();
|
||||
const v = waveshapes.sawblep(this.phase[n], dt);
|
||||
|
||||
output[0][i] = output[0][i] + v * gainL;
|
||||
output[1][i] = output[1][i] + v * gainR;
|
||||
output[0][i] += v * gainL;
|
||||
output[1][i] += v * gainR;
|
||||
|
||||
this.phase[n] = wrapPhase(this.phase[n] + dt);
|
||||
let pn = this.phase[n] + dt;
|
||||
if (pn >= 1.0) pn -= 1.0;
|
||||
this.phase[n] = pn;
|
||||
// invert right and left gain
|
||||
const tmp = gainL;
|
||||
gainL = gainR;
|
||||
gainR = tmp;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -564,12 +577,16 @@ registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);
|
||||
// Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
|
||||
const BUFFERED_BLOCK_SIZE = 2048;
|
||||
|
||||
const hannCache = new Map();
|
||||
function genHannWindow(length) {
|
||||
let win = new Float32Array(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length));
|
||||
if (!hannCache.has(length)) {
|
||||
const win = new Float32Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length));
|
||||
}
|
||||
hannCache.set(length, win);
|
||||
}
|
||||
return win;
|
||||
return hannCache.get(length);
|
||||
}
|
||||
|
||||
class PhaseVocoderProcessor extends OLAProcessor {
|
||||
@@ -587,11 +604,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
||||
blockSize: BUFFERED_BLOCK_SIZE,
|
||||
};
|
||||
super(options);
|
||||
|
||||
this.fftSize = this.blockSize;
|
||||
this.timeCursor = 0;
|
||||
|
||||
this.hannWindow = genHannWindow(this.blockSize);
|
||||
this.fftSize = this.blockSize;
|
||||
this.invfftSize = 1 / this.fftSize;
|
||||
this.hannWindow = genHannWindow(this.fftSize);
|
||||
// prepare FFT and pre-allocate buffers
|
||||
this.fft = new FFT(this.fftSize);
|
||||
this.freqComplexBuffer = this.fft.createComplexArray();
|
||||
@@ -604,52 +620,43 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
||||
|
||||
processOLA(inputs, outputs, parameters) {
|
||||
// no automation, take last value
|
||||
|
||||
let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1];
|
||||
|
||||
if (pitchFactor < 0) {
|
||||
pitchFactor = pitchFactor * 0.25;
|
||||
}
|
||||
pitchFactor = Math.max(0, pitchFactor + 1);
|
||||
|
||||
for (var i = 0; i < this.nbInputs; i++) {
|
||||
for (var j = 0; j < inputs[i].length; j++) {
|
||||
// big assumption here: output is symetric to input
|
||||
var input = inputs[i][j];
|
||||
var output = outputs[i][j];
|
||||
|
||||
for (let i = 0; i < this.nbInputs; i++) {
|
||||
for (let j = 0; j < inputs[i].length; j++) {
|
||||
const input = inputs[i][j];
|
||||
const output = outputs[i][j];
|
||||
this.applyHannWindow(input);
|
||||
|
||||
this.fft.realTransform(this.freqComplexBuffer, input);
|
||||
|
||||
this.computeMagnitudes();
|
||||
this.findPeaks();
|
||||
this.shiftPeaks(pitchFactor);
|
||||
|
||||
this.fft.completeSpectrum(this.freqComplexBufferShifted);
|
||||
this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted);
|
||||
this.fft.fromComplexArray(this.timeComplexBuffer, output);
|
||||
this.applyHannWindow(output);
|
||||
}
|
||||
}
|
||||
|
||||
this.timeCursor += this.hopSize;
|
||||
}
|
||||
|
||||
/** Apply Hann window in-place */
|
||||
applyHannWindow(input) {
|
||||
for (var i = 0; i < this.blockSize; i++) {
|
||||
input[i] = input[i] * this.hannWindow[i] * 1.62;
|
||||
for (let i = 0; i < this.blockSize; i++) {
|
||||
input[i] *= this.hannWindow[i] * 1.62;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute squared magnitudes for peak finding **/
|
||||
computeMagnitudes() {
|
||||
var i = 0,
|
||||
let i = 0,
|
||||
j = 0;
|
||||
while (i < this.magnitudes.length) {
|
||||
let real = this.freqComplexBuffer[j];
|
||||
let imag = this.freqComplexBuffer[j + 1];
|
||||
const real = this.freqComplexBuffer[j];
|
||||
const imag = this.freqComplexBuffer[j + 1];
|
||||
// no need to sqrt for peak finding
|
||||
this.magnitudes[i] = real ** 2 + imag ** 2;
|
||||
i += 1;
|
||||
@@ -660,12 +667,10 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
||||
/** Find peaks in spectrum magnitudes **/
|
||||
findPeaks() {
|
||||
this.nbPeaks = 0;
|
||||
var i = 2;
|
||||
let end = this.magnitudes.length - 2;
|
||||
|
||||
let i = 2;
|
||||
const end = this.magnitudes.length - 2;
|
||||
while (i < end) {
|
||||
let mag = this.magnitudes[i];
|
||||
|
||||
const mag = this.magnitudes[i];
|
||||
if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
|
||||
i++;
|
||||
continue;
|
||||
@@ -674,7 +679,6 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.peakIndexes[this.nbPeaks] = i;
|
||||
this.nbPeaks++;
|
||||
i += 2;
|
||||
@@ -685,53 +689,44 @@ class PhaseVocoderProcessor extends OLAProcessor {
|
||||
shiftPeaks(pitchFactor) {
|
||||
// zero-fill new spectrum
|
||||
this.freqComplexBufferShifted.fill(0);
|
||||
|
||||
for (var i = 0; i < this.nbPeaks; i++) {
|
||||
let peakIndex = this.peakIndexes[i];
|
||||
let peakIndexShifted = Math.round(peakIndex * pitchFactor);
|
||||
|
||||
for (let i = 0; i < this.nbPeaks; i++) {
|
||||
const peakIndex = this.peakIndexes[i];
|
||||
const peakIndexShifted = fround(peakIndex * pitchFactor);
|
||||
if (peakIndexShifted > this.magnitudes.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// find region of influence
|
||||
var startIndex = 0;
|
||||
var endIndex = this.fftSize;
|
||||
let startIndex = 0;
|
||||
let endIndex = this.fftSize;
|
||||
if (i > 0) {
|
||||
let peakIndexBefore = this.peakIndexes[i - 1];
|
||||
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
|
||||
startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2);
|
||||
}
|
||||
if (i < this.nbPeaks - 1) {
|
||||
let peakIndexAfter = this.peakIndexes[i + 1];
|
||||
endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2);
|
||||
endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2);
|
||||
}
|
||||
|
||||
// shift whole region of influence around peak to shifted peak
|
||||
let startOffset = startIndex - peakIndex;
|
||||
let endOffset = endIndex - peakIndex;
|
||||
for (var j = startOffset; j < endOffset; j++) {
|
||||
let binIndex = peakIndex + j;
|
||||
let binIndexShifted = peakIndexShifted + j;
|
||||
|
||||
const startOffset = startIndex - peakIndex;
|
||||
const endOffset = endIndex - peakIndex;
|
||||
const omegaDelta = TWO_PI * this.invfftSize * (peakIndexShifted - peakIndex);
|
||||
const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
|
||||
const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
|
||||
for (let j = startOffset; j < endOffset; j++) {
|
||||
const binIndex = peakIndex + j;
|
||||
const binIndexShifted = peakIndexShifted + j;
|
||||
if (binIndexShifted >= this.magnitudes.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// apply phase correction
|
||||
let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize;
|
||||
let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
|
||||
let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
|
||||
const indexReal = 2 * binIndex;
|
||||
const indexImag = indexReal + 1;
|
||||
const valueReal = this.freqComplexBuffer[indexReal];
|
||||
const valueImag = this.freqComplexBuffer[indexImag];
|
||||
|
||||
let indexReal = binIndex * 2;
|
||||
let indexImag = indexReal + 1;
|
||||
let valueReal = this.freqComplexBuffer[indexReal];
|
||||
let valueImag = this.freqComplexBuffer[indexImag];
|
||||
const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
|
||||
const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
|
||||
|
||||
let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
|
||||
let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
|
||||
|
||||
let indexShiftedReal = binIndexShifted * 2;
|
||||
let indexShiftedImag = indexShiftedReal + 1;
|
||||
const indexShiftedReal = 2 * binIndexShifted;
|
||||
const indexShiftedImag = indexShiftedReal + 1;
|
||||
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
|
||||
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
|
||||
}
|
||||
@@ -745,11 +740,10 @@ registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
|
||||
class PulseOscillatorProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.pi = _PI;
|
||||
this.phi = -this.pi; // phase
|
||||
this.phi = -PI; // phase
|
||||
this.Y0 = 0; // feedback memories
|
||||
this.Y1 = 0;
|
||||
this.PW = this.pi; // pulse width
|
||||
this.PW = PI; // pulse width
|
||||
this.B = 2.3; // feedback coefficient
|
||||
this.dphif = 0; // filtered phase increment
|
||||
this.envf = 0; // filtered envelope
|
||||
@@ -806,11 +800,11 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
|
||||
dphi;
|
||||
|
||||
for (let i = 0; i < (output[0].length ?? 0); i++) {
|
||||
const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi;
|
||||
const detune = getParamValue(i, params.detune);
|
||||
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
|
||||
const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI;
|
||||
const detune = pv(params.detune, i);
|
||||
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
|
||||
|
||||
dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment
|
||||
dphi = freq * TWO_PI * INVSR; // phase increment
|
||||
this.dphif += 0.1 * (dphi - this.dphif);
|
||||
|
||||
env *= 0.9998; // exponential decay envelope
|
||||
@@ -822,7 +816,7 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
|
||||
|
||||
// Waveform generation (half-Tomisawa oscillators)
|
||||
this.phi += this.dphif; // phase increment
|
||||
if (this.phi >= this.pi) this.phi -= 2 * this.pi; // phase wrapping
|
||||
if (this.phi >= PI) this.phi -= TWO_PI; // phase wrapping
|
||||
|
||||
// First half-Tomisawa generator
|
||||
let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation
|
||||
@@ -852,24 +846,23 @@ const chyx = {
|
||||
/*bit reverse*/ br: function (x, size = 8) {
|
||||
if (size > 32) {
|
||||
throw new Error('br() Size cannot be greater than 32');
|
||||
} else {
|
||||
let result = 0;
|
||||
for (let idx = 0; idx < size - 0; idx++) {
|
||||
result += chyx.bitC(x, 2 ** idx, 2 ** (size - (idx + 1)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let result = 0;
|
||||
for (let idx = 0; idx < size; idx++) {
|
||||
result |= chyx.bitC(x, 1 << idx, 1 << (size - (idx + 1)));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
/*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) {
|
||||
return Math.sin(x / (128 / Math.PI));
|
||||
return Math.sin((x * PI) / 128);
|
||||
},
|
||||
/*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) {
|
||||
return Math.cos(x / (128 / Math.PI));
|
||||
return Math.cos((x * PI) / 128);
|
||||
},
|
||||
/*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) {
|
||||
return Math.tan(x / (128 / Math.PI));
|
||||
return Math.tan((x * PI) / 128);
|
||||
},
|
||||
/*converts t into a string composed of it's bits, regex's that*/ regG: function (t, X) {
|
||||
/*converts t into a string composed of its bits; regexes that*/ regG: function (t, X) {
|
||||
return X.test(t.toString(2));
|
||||
},
|
||||
};
|
||||
@@ -877,7 +870,7 @@ const chyx = {
|
||||
// Create shortened Math functions
|
||||
let mathParams, byteBeatHelperFuncs;
|
||||
function getByteBeatFunc(codetext) {
|
||||
if ((mathParams || byteBeatHelperFuncs) == null) {
|
||||
if (mathParams == null) {
|
||||
mathParams = Object.getOwnPropertyNames(Math);
|
||||
byteBeatHelperFuncs = mathParams.map((k) => Math[k]);
|
||||
const chyxNames = Object.getOwnPropertyNames(chyx);
|
||||
@@ -910,7 +903,7 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
|
||||
|
||||
this.func = getByteBeatFunc(codeText);
|
||||
};
|
||||
this.initialOffset = null;
|
||||
this.initialOffset = 0;
|
||||
this.t = null;
|
||||
this.func = null;
|
||||
}
|
||||
@@ -957,18 +950,19 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
|
||||
this.t = params.begin[0] * sampleRate;
|
||||
}
|
||||
const output = outputs[0];
|
||||
const scale = 256 * INVSR;
|
||||
for (let i = 0; i < output[0].length; i++) {
|
||||
const detune = getParamValue(i, params.detune);
|
||||
const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100);
|
||||
let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset;
|
||||
const detune = pv(params.detune, i);
|
||||
const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
|
||||
const local_t = scale * freq * this.t + this.initialOffset;
|
||||
const funcValue = this.func(local_t);
|
||||
let signal = (funcValue & 255) / 127.5 - 1;
|
||||
const out = signal * 0.2;
|
||||
const signal = (funcValue & 255) / 127.5 - 1;
|
||||
//prevent speaker blowout via clipping if threshold exceeds
|
||||
const out = clamp(signal * 0.2, -0.4, 0.4);
|
||||
for (let c = 0; c < output.length; c++) {
|
||||
//prevent speaker blowout via clipping if threshold exceeds
|
||||
output[c][i] = clamp(out, -0.4, 0.4);
|
||||
output[c][i] = out;
|
||||
}
|
||||
this.t = this.t + 1;
|
||||
this.t++;
|
||||
}
|
||||
|
||||
return true; // keep the audio processing going
|
||||
@@ -977,6 +971,112 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
|
||||
|
||||
registerProcessor('byte-beat-processor', ByteBeatProcessor);
|
||||
|
||||
class EnvelopeProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [
|
||||
{ name: 'begin', defaultValue: 0 },
|
||||
{ name: 'end', defaultValue: 0 },
|
||||
{ name: 'attack', defaultValue: 0.005, minValue: 0 },
|
||||
{ name: 'decay', defaultValue: 0.14, minValue: 0 },
|
||||
{ name: 'sustain', defaultValue: 0, minValue: 0, maxValue: 1 },
|
||||
{ name: 'release', defaultValue: 0.1, minValue: 0 },
|
||||
{ name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
|
||||
{ name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
|
||||
{ name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
|
||||
{ name: 'depth', defaultValue: 1 },
|
||||
{ name: 'min', defaultValue: -1e9 },
|
||||
{ name: 'max', defaultValue: 1e9 },
|
||||
{ name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.val = 0;
|
||||
this.segIdx = 0;
|
||||
this.state = 0;
|
||||
this.beginTime = 0;
|
||||
this.endTime = 0;
|
||||
this.attackStart = 0;
|
||||
}
|
||||
|
||||
_warp(phase, curvature, strength = 8) {
|
||||
if (phase === 0 || phase === 1) return phase; // fast exit
|
||||
if (curvature > 0) {
|
||||
// snappier
|
||||
const exp = 1 + strength * curvature;
|
||||
return 1 - Math.pow(1 - phase, exp);
|
||||
} else {
|
||||
// more calm
|
||||
const exp = 1 - strength * curvature;
|
||||
return Math.pow(phase, exp);
|
||||
}
|
||||
}
|
||||
|
||||
_advance(start, target, time, curvature) {
|
||||
if (time === 0 || start === target) {
|
||||
this.val = target;
|
||||
} else {
|
||||
// We compute our progress through this section of the envelope in time
|
||||
// as a `phase` value, which is warped by the curvature, and then used
|
||||
// to compute the value of the envelope at that time
|
||||
const phase = Math.min(1, (currentTime - this.beginTime) / time);
|
||||
const phaseWarped = this._warp(phase, curvature);
|
||||
this.val = start + (target - start) * phaseWarped;
|
||||
}
|
||||
}
|
||||
|
||||
process(_inputs, outputs, params) {
|
||||
const begin = params['begin'][0];
|
||||
const end = params['end'][0];
|
||||
if (currentTime >= end) {
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= begin) {
|
||||
return true;
|
||||
}
|
||||
const out = outputs[0][0];
|
||||
const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
|
||||
if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
|
||||
// triggered
|
||||
this.beginTime = begin;
|
||||
this.state = 1;
|
||||
this.endTime = pv(params.end, 0);
|
||||
this.attackStart = this.val;
|
||||
}
|
||||
const susTime = this.endTime - this.beginTime;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const attack = pv(params.attack, i);
|
||||
const decay = pv(params.decay, i);
|
||||
const sustain = pv(params.sustain, i);
|
||||
const release = pv(params.release, i);
|
||||
const aCurve = pv(params.attackCurve, i);
|
||||
const dCurve = pv(params.decayCurve, i);
|
||||
const rCurve = pv(params.releaseCurve, i);
|
||||
const depth = pv(params.depth, i);
|
||||
const min = pv(params.min, i);
|
||||
const max = pv(params.max, i);
|
||||
const states = [
|
||||
{ time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
|
||||
{ time: attack, start: this.attackStart, target: 1, curve: aCurve },
|
||||
{ time: attack + decay, start: 1, target: sustain, curve: dCurve },
|
||||
{ time: susTime, start: sustain, target: sustain },
|
||||
{ time: susTime + release, start: sustain, target: 0, curve: rCurve },
|
||||
];
|
||||
let { time, start, target, curve } = states[this.state];
|
||||
this._advance(start, target, time, curve);
|
||||
while (currentTime - this.beginTime >= time) {
|
||||
this.state = (this.state + 1) % states.length;
|
||||
time = states[this.state].time;
|
||||
}
|
||||
out[i] = clamp(this.val * depth, min, max);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('envelope-processor', EnvelopeProcessor);
|
||||
|
||||
export const WarpMode = Object.freeze({
|
||||
NONE: 0,
|
||||
ASYM: 1,
|
||||
@@ -1056,7 +1156,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
{ name: 'position', defaultValue: 0, min: 0, max: 1 },
|
||||
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
|
||||
{ name: 'warpMode', defaultValue: 0 },
|
||||
{ name: 'voices', defaultValue: 1, min: 1 },
|
||||
{ name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
|
||||
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
|
||||
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
|
||||
];
|
||||
@@ -1064,38 +1164,29 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.frameLen = 0;
|
||||
this.numFrames = 0;
|
||||
this.phase = [];
|
||||
this.invSR = 1 / sampleRate;
|
||||
|
||||
this.isAlive = true; // used internally to prevent multiple death messages
|
||||
this.port.onmessage = (e) => {
|
||||
const { type, payload } = e.data || {};
|
||||
if (type === 'table') {
|
||||
const key = payload.key;
|
||||
this.frameLen = payload.frameLen;
|
||||
if (!tablesCache[key]) {
|
||||
const tables = [payload.frames];
|
||||
let table = tables[0];
|
||||
for (let level = 1; level < 1; level++) {
|
||||
const nextLen = table.length >> 1;
|
||||
const nextTable = table.map((frame) => {
|
||||
const avg = new Float32Array(nextLen);
|
||||
for (let i = 0; i < nextLen; i++) {
|
||||
avg[i] = (frame[2 * i] + frame[2 * i + 1]) / 2;
|
||||
}
|
||||
return avg;
|
||||
});
|
||||
tables.push(nextTable);
|
||||
table = nextTable;
|
||||
if (nextLen <= 32) break;
|
||||
}
|
||||
tablesCache[key] = tables;
|
||||
}
|
||||
this.tables = tablesCache[key];
|
||||
this.numFrames = this.tables[0].length;
|
||||
if (type === 'initialize') {
|
||||
this.initialize(payload);
|
||||
}
|
||||
};
|
||||
this.initialize();
|
||||
}
|
||||
initialize(options) {
|
||||
this.table = null;
|
||||
this.frameLen = null;
|
||||
this.numFrames = null;
|
||||
this.phase = [];
|
||||
if (options?.key) {
|
||||
const key = options.key;
|
||||
this.frameLen = options.frameLen;
|
||||
if (!tablesCache[key]) {
|
||||
tablesCache[key] = options.frames;
|
||||
}
|
||||
this.table = tablesCache[key];
|
||||
this.numFrames = this.table.length;
|
||||
}
|
||||
}
|
||||
|
||||
_mirror(x) {
|
||||
@@ -1104,7 +1195,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
|
||||
_toBits(amt, min = 2, max = 12) {
|
||||
const b = max + (min - max) * amt;
|
||||
return { b, n: Math.round(Math.pow(2, b)) };
|
||||
return { b, n: fround(Math.pow(2, b)) };
|
||||
}
|
||||
|
||||
_warpPhase(phase, amt, mode) {
|
||||
@@ -1130,7 +1221,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2);
|
||||
}
|
||||
case WarpMode.SYNC: {
|
||||
const syncRatio = Math.pow(16, amt * amt);
|
||||
const syncRatio = Math.pow(16, amt ** 2);
|
||||
return (phase * syncRatio) % 1;
|
||||
}
|
||||
case WarpMode.QUANT: {
|
||||
@@ -1139,8 +1230,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
case WarpMode.FOLD: {
|
||||
const K = 7;
|
||||
const k = 1 + Math.max(1, Math.round(K * amt));
|
||||
return Math.abs(frac(k * phase) - 0.5) * 2;
|
||||
const k = 1 + Math.max(1, fround(K * amt));
|
||||
return Math.abs(ffrac(k * phase) - 0.5) * 2;
|
||||
}
|
||||
case WarpMode.PWM: {
|
||||
const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1);
|
||||
@@ -1150,12 +1241,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
case WarpMode.ORBIT: {
|
||||
const depth = 0.5 * amt;
|
||||
const n = 3;
|
||||
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
|
||||
return frac(phase + depth * Math.sin(TWO_PI * n * phase));
|
||||
}
|
||||
case WarpMode.SPIN: {
|
||||
const depth = 0.5 * amt;
|
||||
const { n } = this._toBits(amt, 1, 6);
|
||||
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
|
||||
return frac(phase + depth * Math.sin(TWO_PI * n * phase));
|
||||
}
|
||||
case WarpMode.CHAOS: {
|
||||
const r = 3.7 + 0.3 * amt;
|
||||
@@ -1166,7 +1257,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
const isPrime = (n) => {
|
||||
if (n < 2) return false;
|
||||
if (n % 2 === 0) return n === 2;
|
||||
for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
|
||||
for (let d = 3; d ** 2 <= n; d += 2) if (n % d === 0) return false;
|
||||
return true;
|
||||
};
|
||||
let { n } = this._toBits(amt, 3);
|
||||
@@ -1175,18 +1266,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
case WarpMode.BINARY: {
|
||||
let { b } = this._toBits(amt, 3);
|
||||
b = Math.round(b);
|
||||
b = fround(b);
|
||||
const n = 1 << b;
|
||||
const idx = ffloor(phase * n);
|
||||
const ridx = bitReverse(idx, b);
|
||||
return ridx / n;
|
||||
}
|
||||
case WarpMode.MODULAR: {
|
||||
const { n } = this._toBits(amt);
|
||||
const depth = 0.5 * amt;
|
||||
const jump = frac(phase * n) / n;
|
||||
return frac(phase + depth * jump);
|
||||
}
|
||||
case WarpMode.BROWNIAN: {
|
||||
const disp = 0.25 * amt * brownian(64 * phase, 4);
|
||||
return frac(phase + disp);
|
||||
@@ -1209,7 +1294,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
case WarpMode.LOGISTIC: {
|
||||
let x = phase;
|
||||
const r = 3.6 + 0.4 * amt;
|
||||
const iters = 1 + Math.round(2 * amt);
|
||||
const iters = 1 + fround(2 * amt);
|
||||
for (let i = 0; i < iters; i++) x = r * x * (1 - x);
|
||||
return clamp(x, 0, 1);
|
||||
}
|
||||
@@ -1222,7 +1307,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
return (y - y0) / (y1 - y0);
|
||||
}
|
||||
case WarpMode.FRACTAL: {
|
||||
const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt;
|
||||
const d = 0.5 * Math.sin(TWO_PI * phase) * amt;
|
||||
return frac(phase + d);
|
||||
}
|
||||
case WarpMode.FLIP: {
|
||||
@@ -1246,39 +1331,36 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
return a + (b - a) * frac;
|
||||
}
|
||||
|
||||
_chooseMip(dphi) {
|
||||
const approxHarm = clamp(dphi, 1e-6, 64);
|
||||
let level = 0;
|
||||
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
|
||||
level++;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
process(_inputs, outputs, parameters) {
|
||||
if (currentTime >= parameters.end[0]) {
|
||||
if (currentTime >= parameters.end[0] + 0.5) {
|
||||
// Outside of grace period - should terminate
|
||||
if (this.isAlive) {
|
||||
this.port.postMessage({ type: 'died' });
|
||||
this.isAlive = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= parameters.begin[0]) {
|
||||
if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) {
|
||||
// Inside of grace period or not yet started
|
||||
return true;
|
||||
}
|
||||
const outL = outputs[0][0];
|
||||
const outR = outputs[0][1] || outputs[0][0];
|
||||
if (!this.tables) {
|
||||
if (!this.table) {
|
||||
outL.fill(0);
|
||||
if (outR !== outL) outR.set(outL);
|
||||
return true;
|
||||
}
|
||||
const voices = parameters.voices[0]; // k-rate
|
||||
for (let i = 0; i < outL.length; i++) {
|
||||
const detune = pv(parameters.detune, i);
|
||||
const freqspread = pv(parameters.freqspread, i);
|
||||
const tablePos = clamp(pv(parameters.position, i), 0, 1);
|
||||
const idx = tablePos * (this.numFrames - 1);
|
||||
const fIdx = idx | 0;
|
||||
const frac = idx - fIdx;
|
||||
const interpT = idx - fIdx;
|
||||
const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
|
||||
const warpMode = pv(parameters.warpMode, i);
|
||||
const voices = pv(parameters.voices, i);
|
||||
const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
|
||||
const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0;
|
||||
const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
|
||||
@@ -1286,6 +1368,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
let f = pv(parameters.frequency, i);
|
||||
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
|
||||
const normalizer = 1 / Math.sqrt(voices);
|
||||
const detuner = getDetuner(voices, freqspread);
|
||||
for (let n = 0; n < voices; n++) {
|
||||
const isOdd = (n & 1) == 1;
|
||||
let gainL = gain1;
|
||||
@@ -1295,23 +1378,21 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
gainL = gain2;
|
||||
gainR = gain1;
|
||||
}
|
||||
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune
|
||||
const dPhase = fVoice * this.invSR;
|
||||
const level = this._chooseMip(dPhase);
|
||||
const table = this.tables[level];
|
||||
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
|
||||
const dPhase = fVoice * INVSR;
|
||||
|
||||
// warp phase then sample
|
||||
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
|
||||
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
|
||||
const s0 = this._sampleFrame(table[fIdx], ph);
|
||||
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
|
||||
let s = s0 + (s1 - s0) * frac;
|
||||
const s0 = this._sampleFrame(this.table[fIdx], ph);
|
||||
const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
|
||||
let s = lerp(s0, s1, interpT);
|
||||
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
|
||||
s = -s;
|
||||
}
|
||||
outL[i] += s * gainL * normalizer;
|
||||
outR[i] += s * gainR * normalizer;
|
||||
this.phase[n] = wrapPhase(this.phase[n] + dPhase);
|
||||
this.phase[n] = frac(this.phase[n] + dPhase);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -1319,3 +1400,82 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
|
||||
registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor);
|
||||
|
||||
class TransientProcessor extends AudioWorkletProcessor {
|
||||
static get parameterDescriptors() {
|
||||
return [];
|
||||
}
|
||||
|
||||
constructor(options) {
|
||||
super();
|
||||
this.gainCoeff = timeToCoeff(0.2);
|
||||
this.avgGain = 1;
|
||||
let {
|
||||
attackTime = 0.003,
|
||||
sustainTime = 0.08,
|
||||
attack = 0,
|
||||
sustain = 0,
|
||||
sensitivity = 0.1,
|
||||
mix = 1,
|
||||
begin = 0,
|
||||
end = 0,
|
||||
} = options.processorOptions;
|
||||
attackTime = clamp(attackTime, 0.0005, 0.05);
|
||||
sustainTime = clamp(sustainTime, 0.01, 0.5);
|
||||
this.attackCoeff = timeToCoeff(attackTime);
|
||||
this.sustainCoeff = timeToCoeff(sustainTime);
|
||||
this.attackAmt = clamp(attack, -1, 1);
|
||||
this.sustainAmt = clamp(sustain, -1, 1);
|
||||
this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1);
|
||||
this.mix = clamp(mix, 0, 1);
|
||||
this.begin = begin;
|
||||
this.end = end;
|
||||
this.attackEnv = new Float32Array(2); // assume stereo
|
||||
this.sustainEnv = new Float32Array(2);
|
||||
}
|
||||
|
||||
process(inputs, outputs, _params) {
|
||||
const input = inputs[0];
|
||||
const output = outputs[0];
|
||||
if (currentTime >= this.end) {
|
||||
return false;
|
||||
}
|
||||
if (currentTime <= this.begin) {
|
||||
return true;
|
||||
}
|
||||
const channels = input.length;
|
||||
if (channels > this.attackEnv.length) {
|
||||
this.attackEnv = new Float32Array(channels);
|
||||
this.sustainEnv = new Float32Array(channels);
|
||||
}
|
||||
let avgGain = this.avgGain;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
let attEnv = this.attackEnv[ch];
|
||||
let susEnv = this.sustainEnv[ch];
|
||||
for (let n = 0; n < blockSize; n++) {
|
||||
const sample = input[ch][n];
|
||||
const x = Math.abs(sample);
|
||||
attEnv = lerp(attEnv, x, this.attackCoeff);
|
||||
susEnv = lerp(susEnv, x, this.sustainCoeff);
|
||||
const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5);
|
||||
const attScale = peakiness > 0 ? peakiness : 0;
|
||||
const susScale = peakiness < 0 ? -peakiness : 0;
|
||||
const attackGain = dbToLin(this.attackAmt * attScale * 18);
|
||||
const sustainGain = dbToLin(this.sustainAmt * susScale * 36);
|
||||
const gain = clamp(attackGain * sustainGain, 0, 8);
|
||||
avgGain = lerp(avgGain, gain, this.gainCoeff);
|
||||
const makeup = avgGain > 1e-3 ? 1 / avgGain : 1;
|
||||
const wet = sample * gain * makeup;
|
||||
let y = lerp(sample, wet, this.mix);
|
||||
y /= 1 + Math.abs(y); // soft clip
|
||||
output[ch][n] = y;
|
||||
}
|
||||
this.attackEnv[ch] = attEnv;
|
||||
this.sustainEnv[ch] = susEnv;
|
||||
}
|
||||
this.avgGain = avgGain;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('transient-processor', TransientProcessor);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { midiToFreq, noteToMidi } from './util.mjs';
|
||||
import { registerSound } from './superdough.mjs';
|
||||
import { getAudioContext } from './audioContext.mjs';
|
||||
import { buildSamples } from './zzfx_fork.mjs';
|
||||
import { onceEnded, releaseAudioNode } from './helpers.mjs';
|
||||
|
||||
export const getZZFX = (value, t) => {
|
||||
let {
|
||||
@@ -83,12 +84,13 @@ export function registerZZFXSounds() {
|
||||
wave,
|
||||
(t, value, onended) => {
|
||||
const { node: o } = getZZFX({ s: wave, ...value }, t);
|
||||
o.onended = () => {
|
||||
o.disconnect();
|
||||
onceEnded(o, () => {
|
||||
releaseAudioNode(o);
|
||||
onended();
|
||||
};
|
||||
});
|
||||
return {
|
||||
node: o,
|
||||
nodes: { source: [o] },
|
||||
stop: () => {},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -5,7 +5,16 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import { Note, Interval, Scale } from '@tonaljs/tonal';
|
||||
import { register, _mod, logger, isNote, noteToMidi, removeUndefineds, getAccidentalsOffset } from '@strudel/core';
|
||||
import {
|
||||
_mod,
|
||||
errorLogger,
|
||||
getAccidentalsOffset,
|
||||
isNote,
|
||||
logger,
|
||||
noteToMidi,
|
||||
register,
|
||||
removeUndefineds,
|
||||
} from '@strudel/core';
|
||||
import { stepInNamedScale, nearestNumberIndex } from './tonleiter.mjs';
|
||||
|
||||
const octavesInterval = (octaves) => (octaves <= 0 ? -1 : 1) + octaves * 7 + 'P';
|
||||
@@ -294,7 +303,7 @@ export const scale = register(
|
||||
}
|
||||
if (offset != 0) scaleNote = Note.transpose(scaleNote, Interval.fromSemitones(offset));
|
||||
} catch (err) {
|
||||
logger(`[tonal] ${err.message}`, 'error');
|
||||
errorLogger(err, 'tonal');
|
||||
return; // will be removed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,18 @@ describe('transpiler', () => {
|
||||
[12, 14],
|
||||
]);
|
||||
});
|
||||
it('allows disabling mini', () => {
|
||||
const code = `/* mini-off */
|
||||
const randPrefix = Math.random() > 0.5 ? "b" : "s";
|
||||
const drumPat = \`\${randPrefix}d\`;
|
||||
// mini-on
|
||||
s(drumPat).lpf("5000 10000") // make sure mini still runs;
|
||||
`;
|
||||
const { output, miniLocations } = transpiler(code, { ...simple, emitMiniLocations: true });
|
||||
expect(output).not.toContain("m('b'");
|
||||
expect(output).not.toContain("m('s'");
|
||||
const cutoffIdx = code.indexOf('5000 10000');
|
||||
expect(miniLocations).toHaveLength(2);
|
||||
expect(miniLocations[0][0]).toEqual(cutoffIdx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,15 @@ export function registerLanguage(type, config) {
|
||||
export function transpiler(input, options = {}) {
|
||||
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
||||
|
||||
const comments = [];
|
||||
let ast = parse(input, {
|
||||
ecmaVersion: 2022,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
onComment: comments,
|
||||
});
|
||||
|
||||
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
||||
let miniLocations = [];
|
||||
const collectMiniLocations = (value, node) => {
|
||||
const minilang = languages.get('minilang');
|
||||
@@ -66,6 +69,9 @@ export function transpiler(input, options = {}) {
|
||||
return this.replace(tidalWithLocation(raw, offset));
|
||||
}
|
||||
if (isBackTickString(node, parent)) {
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { quasis } = node;
|
||||
const { raw } = quasis[0].value;
|
||||
this.skip();
|
||||
@@ -73,6 +79,9 @@ export function transpiler(input, options = {}) {
|
||||
return this.replace(miniWithLocation(raw, node));
|
||||
}
|
||||
if (isStringWithDoubleQuotes(node)) {
|
||||
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
||||
return;
|
||||
}
|
||||
const { value } = node;
|
||||
this.skip();
|
||||
emitMiniLocations && collectMiniLocations(value, node);
|
||||
@@ -327,3 +336,32 @@ function languageWithLocation(name, value, offset) {
|
||||
optional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function findMiniDisableRanges(comments, codeEnd) {
|
||||
const ranges = [];
|
||||
const stack = []; // used to track on/off pairs
|
||||
for (const comment of comments) {
|
||||
const value = comment.value.trim();
|
||||
if (value.startsWith('mini-off')) {
|
||||
stack.push(comment.start);
|
||||
} else if (value.startsWith('mini-on')) {
|
||||
const start = stack.pop();
|
||||
ranges.push([start, comment.end]);
|
||||
}
|
||||
}
|
||||
while (stack.length) {
|
||||
// If no closing mini-on is found, just turn it off until `codeEnd`
|
||||
const start = stack.pop();
|
||||
ranges.push([start, codeEnd]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isMiniDisabled(offset, miniDisableRanges) {
|
||||
for (const [start, end] of miniDisableRanges) {
|
||||
if (offset >= start && offset < end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,21 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
*/
|
||||
|
||||
import * as strudel from '@strudel/core';
|
||||
import { superdough, getAudioContext, setLogger, doughTrigger, registerWorklet } from 'superdough';
|
||||
import {
|
||||
superdough,
|
||||
getAudioContext,
|
||||
setLogger,
|
||||
doughTrigger,
|
||||
registerWorklet,
|
||||
setAudioContext,
|
||||
initAudio,
|
||||
setSuperdoughAudioController,
|
||||
resetGlobalEffects,
|
||||
errorLogger,
|
||||
} from 'superdough';
|
||||
import './supradough.mjs';
|
||||
import { workletUrl } from 'supradough';
|
||||
|
||||
import { SuperdoughAudioController } from 'superdough/superdoughoutput.mjs';
|
||||
registerWorklet(workletUrl);
|
||||
|
||||
const { Pattern, logger, repl } = strudel;
|
||||
@@ -26,9 +37,76 @@ export const webaudioOutput = (hap, _deadline, hapDuration, cps, t) => {
|
||||
return superdough(hap2value(hap), t, hapDuration, cps, hap.whole?.begin.valueOf());
|
||||
};
|
||||
|
||||
export async function renderPatternAudio(
|
||||
pattern,
|
||||
cps,
|
||||
begin,
|
||||
end,
|
||||
sampleRate,
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
downloadName = undefined,
|
||||
) {
|
||||
let audioContext = getAudioContext();
|
||||
await audioContext.close();
|
||||
audioContext = new OfflineAudioContext(2, ((end - begin) / cps) * sampleRate, sampleRate);
|
||||
setAudioContext(audioContext);
|
||||
setSuperdoughAudioController(new SuperdoughAudioController(audioContext));
|
||||
await initAudio({
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
logger('[webaudio] preloading');
|
||||
|
||||
// Calling superdough(...) in ascending onset time order is important
|
||||
// for controls that depend on the audio graph state like `cut`
|
||||
let haps = pattern
|
||||
.queryArc(begin, end, { _cps: cps })
|
||||
.sort((a, b) => a.whole.begin.valueOf() - b.whole.begin.valueOf());
|
||||
for (const hap of haps) {
|
||||
if (hap.hasOnset()) {
|
||||
try {
|
||||
await superdough(
|
||||
hap2value(hap),
|
||||
(hap.whole.begin.valueOf() - begin) / cps,
|
||||
hap.duration / cps,
|
||||
cps,
|
||||
(hap.whole?.begin.valueOf() - begin) / cps,
|
||||
);
|
||||
} catch (err) {
|
||||
errorLogger(err, 'webaudio');
|
||||
}
|
||||
}
|
||||
}
|
||||
logger('[webaudio] start rendering');
|
||||
|
||||
return audioContext
|
||||
.startRendering()
|
||||
.then((renderedBuffer) => {
|
||||
const wavBuffer = audioBufferToWav(renderedBuffer);
|
||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
downloadName = downloadName ? `${downloadName}.wav` : `${new Date().toISOString()}.wav`;
|
||||
a.download = `${downloadName}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.finally(async () => {
|
||||
setAudioContext(null);
|
||||
setSuperdoughAudioController(null);
|
||||
resetGlobalEffects();
|
||||
});
|
||||
}
|
||||
|
||||
export function webaudioRepl(options = {}) {
|
||||
const audioContext = options.audioContext ?? getAudioContext();
|
||||
setAudioContext(audioContext);
|
||||
options = {
|
||||
getTime: () => getAudioContext().currentTime,
|
||||
getTime: () => audioContext.currentTime,
|
||||
defaultOutput: webaudioOutput,
|
||||
...options,
|
||||
};
|
||||
@@ -38,3 +116,98 @@ export function webaudioRepl(options = {}) {
|
||||
Pattern.prototype.dough = function () {
|
||||
return this.onTrigger(doughTrigger, 1);
|
||||
};
|
||||
|
||||
function audioBufferToWav(buffer, opt) {
|
||||
opt = opt || {};
|
||||
|
||||
var numChannels = buffer.numberOfChannels;
|
||||
var sampleRate = buffer.sampleRate;
|
||||
var format = opt.float32 ? 3 : 1;
|
||||
var bitDepth = format === 3 ? 32 : 16;
|
||||
|
||||
var result;
|
||||
if (numChannels === 2) {
|
||||
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
|
||||
} else {
|
||||
result = buffer.getChannelData(0);
|
||||
}
|
||||
|
||||
return encodeWAV(result, format, sampleRate, numChannels, bitDepth);
|
||||
}
|
||||
|
||||
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) {
|
||||
var bytesPerSample = bitDepth / 8;
|
||||
var blockAlign = numChannels * bytesPerSample;
|
||||
|
||||
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
||||
var view = new DataView(buffer);
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString(view, 0, 'RIFF');
|
||||
/* RIFF chunk length */
|
||||
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
||||
/* RIFF type */
|
||||
writeString(view, 8, 'WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString(view, 12, 'fmt ');
|
||||
/* format chunk length */
|
||||
view.setUint32(16, 16, true);
|
||||
/* sample format (raw) */
|
||||
view.setUint16(20, format, true);
|
||||
/* channel count */
|
||||
view.setUint16(22, numChannels, true);
|
||||
/* sample rate */
|
||||
view.setUint32(24, sampleRate, true);
|
||||
/* byte rate (sample rate * block align) */
|
||||
view.setUint32(28, sampleRate * blockAlign, true);
|
||||
/* block align (channel count * bytes per sample) */
|
||||
view.setUint16(32, blockAlign, true);
|
||||
/* bits per sample */
|
||||
view.setUint16(34, bitDepth, true);
|
||||
/* data chunk identifier */
|
||||
writeString(view, 36, 'data');
|
||||
/* data chunk length */
|
||||
view.setUint32(40, samples.length * bytesPerSample, true);
|
||||
if (format === 1) {
|
||||
// Raw PCM
|
||||
floatTo16BitPCM(view, 44, samples);
|
||||
} else {
|
||||
writeFloat32(view, 44, samples);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Float32Array(length);
|
||||
|
||||
var index = 0;
|
||||
var inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
inputIndex++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeFloat32(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 4) {
|
||||
output.setFloat32(offset, input[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 2) {
|
||||
var s = Math.max(-1, Math.min(1, input[i]));
|
||||
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (var i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+20
-132
@@ -41,7 +41,7 @@ importers:
|
||||
version: 2.2.7
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 3.0.4
|
||||
version: 3.0.4(vitest@3.0.4)
|
||||
version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))
|
||||
'@vitest/ui':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(vitest@3.0.4)
|
||||
@@ -85,29 +85,29 @@ importers:
|
||||
examples/codemirror-repl:
|
||||
dependencies:
|
||||
'@strudel/codemirror':
|
||||
specifier: 1.2.6
|
||||
version: 1.2.6(@codemirror/lint@6.8.4)
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/codemirror
|
||||
'@strudel/core':
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
'@strudel/draw':
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/draw
|
||||
'@strudel/mini':
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/mini
|
||||
'@strudel/soundfonts':
|
||||
specifier: 1.2.6
|
||||
version: 1.2.6
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/soundfonts
|
||||
'@strudel/tonal':
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tonal
|
||||
'@strudel/transpiler':
|
||||
specifier: 1.2.5
|
||||
version: 1.2.5
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/transpiler
|
||||
'@strudel/webaudio':
|
||||
specifier: 1.2.6
|
||||
version: 1.2.6
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/webaudio
|
||||
devDependencies:
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
@@ -116,8 +116,8 @@ importers:
|
||||
examples/headless-repl:
|
||||
dependencies:
|
||||
'@strudel/web':
|
||||
specifier: 1.2.6
|
||||
version: 1.2.6
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/web
|
||||
devDependencies:
|
||||
vite:
|
||||
specifier: ^6.0.11
|
||||
@@ -2544,33 +2544,6 @@ packages:
|
||||
'@sinclair/typebox@0.27.8':
|
||||
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
||||
|
||||
'@strudel/codemirror@1.2.6':
|
||||
resolution: {integrity: sha512-zjKbnbnOY40dlIAQu1poq28rcngYliLsuDwXXVnJsaXzCZSVuXEOMq91LqY4IGz/2QUOIDGm663ziz6Nb4IXeg==}
|
||||
|
||||
'@strudel/core@1.2.5':
|
||||
resolution: {integrity: sha512-XO6ICbfuGfvaQ4sZmfIFsPDcjFez/+AVpNgJnmks5gtMMQNXpr0reuee61tvMiu2k5QYKzxWgzDl2FF9pJeIgA==}
|
||||
|
||||
'@strudel/draw@1.2.5':
|
||||
resolution: {integrity: sha512-ZI4WPxUsVsxgDoeZMHo6VfFf+S1tsj8ubKBeSLcf6lH29JqPp7Nz6tPmbomOV1fzazZ7+xpkC7z772MdtgWy2g==}
|
||||
|
||||
'@strudel/mini@1.2.5':
|
||||
resolution: {integrity: sha512-GmTWSBt49C3ogBy0hjM3bmf6K12PaJ1JJLsEvQu7j5J+lUd2n7GmLYR1UL6fntY4yVqT8ZwMhfUf6vIgUCTNbw==}
|
||||
|
||||
'@strudel/soundfonts@1.2.6':
|
||||
resolution: {integrity: sha512-F7eBhohkCXCMo8WiufSAhD3JCfL5GTgCh9RlOJVT+1swiFeIss1vUAWJR3WjonB6moL8BiuNpcYOqO6i6cFy4A==}
|
||||
|
||||
'@strudel/tonal@1.2.5':
|
||||
resolution: {integrity: sha512-mX3uW0nWH8Sa52HTLwnxlR5BrejFX77abeaiMbp+kQCMrX9I+XRdf8rEr7gdeInB6f/y2TzTVFpnsNsB5jLdoA==}
|
||||
|
||||
'@strudel/transpiler@1.2.5':
|
||||
resolution: {integrity: sha512-mBZWfQBzknvnIYsjjzR8D67A+uIZ1iZ60U2WoXT21q5luXKz2c95RcTpqdx3oOGpLK9K00lDzcKjhQDJtj4OKw==}
|
||||
|
||||
'@strudel/web@1.2.6':
|
||||
resolution: {integrity: sha512-e6VltuX90zZz8JVe1FtN23DCaXA9p8zGDakp5OuUb5kZ5hC6cySFkkOsHdGC6dJ0EAsZSCKXMihRxgk9GhBwKA==}
|
||||
|
||||
'@strudel/webaudio@1.2.6':
|
||||
resolution: {integrity: sha512-4LnyNvyeAXYNmXQGGw2f0chIkDr9Rbwo+SK6Y0p8C3W8juDiOfKq2ihSVph95yaBBPRYFJ5Kttj1kLknRHYsog==}
|
||||
|
||||
'@supabase/auth-js@2.67.3':
|
||||
resolution: {integrity: sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==}
|
||||
|
||||
@@ -7121,9 +7094,6 @@ packages:
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
superdough@1.2.6:
|
||||
resolution: {integrity: sha512-zHEFfG+N4zhxJ9lUDf8p4Oj4sdGGQ5SrOPksdc0cKv5Yn0hzlNlMehEa5TkeIfDCiR27Y4H81ks7S8/CGSByhQ==}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7132,9 +7102,6 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
supradough@1.2.4:
|
||||
resolution: {integrity: sha512-/v3cL9fq05QCQVwbttLHNZcjyyFFz5wdKvg3j6V6moRXpDlQG89l8dQtUIpQvHsxYI5sXNod9rh5bf+Y4NMGeA==}
|
||||
|
||||
tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
|
||||
@@ -10052,79 +10019,6 @@ snapshots:
|
||||
|
||||
'@sinclair/typebox@0.27.8': {}
|
||||
|
||||
'@strudel/codemirror@1.2.6(@codemirror/lint@6.8.4)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.4
|
||||
'@codemirror/commands': 6.8.0
|
||||
'@codemirror/lang-javascript': 6.2.2
|
||||
'@codemirror/language': 6.10.8
|
||||
'@codemirror/search': 6.5.8
|
||||
'@codemirror/state': 6.5.1
|
||||
'@codemirror/view': 6.36.2
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@nanostores/persistent': 0.10.2(nanostores@0.11.3)
|
||||
'@replit/codemirror-emacs': 6.1.0(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
|
||||
'@replit/codemirror-vim': 6.3.0(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
|
||||
'@replit/codemirror-vscode-keymap': 6.0.2(@codemirror/autocomplete@6.18.4)(@codemirror/commands@6.8.0)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.4)(@codemirror/search@6.5.8)(@codemirror/state@6.5.1)(@codemirror/view@6.36.2)
|
||||
'@strudel/core': 1.2.5
|
||||
'@strudel/draw': 1.2.5
|
||||
'@strudel/tonal': 1.2.5
|
||||
'@strudel/transpiler': 1.2.5
|
||||
'@tonaljs/tonal': 4.10.0
|
||||
nanostores: 0.11.3
|
||||
superdough: 1.2.6
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/lint'
|
||||
|
||||
'@strudel/core@1.2.5':
|
||||
dependencies:
|
||||
fraction.js: 5.2.1
|
||||
|
||||
'@strudel/draw@1.2.5':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
|
||||
'@strudel/mini@1.2.5':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
|
||||
'@strudel/soundfonts@1.2.6':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
'@strudel/webaudio': 1.2.6
|
||||
sfumato: 0.1.2
|
||||
soundfont2: 0.5.0
|
||||
|
||||
'@strudel/tonal@1.2.5':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
'@tonaljs/tonal': 4.10.0
|
||||
chord-voicings: 0.0.1
|
||||
webmidi: 3.1.12
|
||||
|
||||
'@strudel/transpiler@1.2.5':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
'@strudel/mini': 1.2.5
|
||||
acorn: 8.14.0
|
||||
escodegen: 2.1.0
|
||||
estree-walker: 3.0.3
|
||||
|
||||
'@strudel/web@1.2.6':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
'@strudel/mini': 1.2.5
|
||||
'@strudel/tonal': 1.2.5
|
||||
'@strudel/transpiler': 1.2.5
|
||||
'@strudel/webaudio': 1.2.6
|
||||
|
||||
'@strudel/webaudio@1.2.6':
|
||||
dependencies:
|
||||
'@strudel/core': 1.2.5
|
||||
'@strudel/draw': 1.2.5
|
||||
superdough: 1.2.6
|
||||
supradough: 1.2.4
|
||||
|
||||
'@supabase/auth-js@2.67.3':
|
||||
dependencies:
|
||||
'@supabase/node-fetch': 2.6.15
|
||||
@@ -10660,7 +10554,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@3.0.4(vitest@3.0.4)':
|
||||
'@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
@@ -15771,18 +15665,12 @@ snapshots:
|
||||
pirates: 4.0.6
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
superdough@1.2.6:
|
||||
dependencies:
|
||||
nanostores: 0.11.3
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
supradough@1.2.4: {}
|
||||
|
||||
tabbable@6.2.0: {}
|
||||
|
||||
tailwindcss@3.4.17:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ const skippedExamples = [
|
||||
'accelerationX',
|
||||
'defaultmidimap',
|
||||
'midimaps',
|
||||
'bmod',
|
||||
];
|
||||
|
||||
describe('runs examples', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "dbpatch",
|
||||
"dependencies": {
|
||||
"csv": "^6.3.11"
|
||||
},
|
||||
|
||||
@@ -16,5 +16,6 @@ export default defineConfig({
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress}.config.*',
|
||||
'**/shared.test.mjs',
|
||||
],
|
||||
setupFiles: './vitest.setup.mjs',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { useRNG } from './packages/core/signal.mjs';
|
||||
|
||||
afterEach(() => {
|
||||
// Avoid bleed between tests
|
||||
useRNG('legacy');
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
fetch('https://codeberg.org/api/v1/repos/uzu/strudel/pulls?state=closed&page=1')
|
||||
.then((res) => res.json())
|
||||
.then((pulls) => {
|
||||
const r = pulls
|
||||
.filter((pull) => pull.merged)
|
||||
.sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))
|
||||
.map((pull) => `${pull.closed_at} ${pull.title} by ${pull.user.login || '?'} in: [#${pull.number}](${pull.url}) `)
|
||||
.join('\n');
|
||||
console.log(r);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
*/
|
||||
+1
-183
@@ -3,186 +3,4 @@
|
||||
This is the website for Strudel, deployed at [strudel.cc](https://strudel.cc).
|
||||
It includes the REPL live coding editor and the documentation site.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# from project root
|
||||
npm run setup
|
||||
npm run repl
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd website
|
||||
npm run build # <- builds repl + tutorial to ../docs
|
||||
npm run preview # <- test static build
|
||||
```
|
||||
|
||||
## Generate PWA icons
|
||||
|
||||
```sh
|
||||
cd website/public
|
||||
npx pwa-asset-generator icon.png icons
|
||||
```
|
||||
|
||||
# Standard Readme of Astro Starter Kit: Docs Site
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template docs
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/docs)
|
||||
[](https://codesandbox.io/s/github/withastro/astro/tree/latest/examples/docs)
|
||||
|
||||

|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Full Markdown support**
|
||||
- ✅ **Responsive mobile-friendly design**
|
||||
- ✅ **Sidebar navigation**
|
||||
- ✅ **Search (powered by Algolia)**
|
||||
- ✅ **Multi-language i18n**
|
||||
- ✅ **Automatic table of contents**
|
||||
- ✅ **Automatic list of contributors**
|
||||
- ✅ (and, best of all) **dark mode**
|
||||
|
||||
## Commands Cheatsheet
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :--------------------- | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro --help` | Get help using the Astro CLI |
|
||||
|
||||
To deploy your site to production, check out our [Deploy an Astro Website](https://docs.astro.build/guides/deploy) guide.
|
||||
|
||||
## New to Astro?
|
||||
|
||||
Welcome! Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
|
||||
## Customize This Theme
|
||||
|
||||
edit: removed, as css styles have been replaced with tailwind styles
|
||||
|
||||
### Site metadata
|
||||
|
||||
`src/config.ts` contains several data objects that describe metadata about your site like title, description, default language, and Open Graph details. You can customize these to match your project.
|
||||
|
||||
## Page metadata
|
||||
|
||||
Astro uses frontmatter in Markdown pages to choose layouts and pass properties to those layouts. If you are using the default layout, you can customize the page in many different ways to optimize SEO and other things. For example, you can use the `title` and `description` properties to set the document title, meta title, meta description, and Open Graph description.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Example title
|
||||
description: Really cool docs example that uses Astro
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
# Page content...
|
||||
```
|
||||
|
||||
For more SEO related properties, look at `src/components/HeadSEO.astro`
|
||||
|
||||
### Sidebar navigation
|
||||
|
||||
The sidebar navigation is controlled by the `SIDEBAR` variable in your `src/config.ts` file. You can customize the sidebar by modifying this object. A default, starter navigation has already been created for you.
|
||||
|
||||
```ts
|
||||
export const SIDEBAR = {
|
||||
en: [
|
||||
{ text: "Section Header", header: true },
|
||||
{ text: "Introduction", link: "en/introduction" },
|
||||
{ text: "Page 2", link: "en/page-2" },
|
||||
{ text: "Page 3", link: "en/page-3" },
|
||||
|
||||
{ text: "Another Section", header: true },
|
||||
{ text: "Page 4", link: "en/page-4" },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Note the top-level `en` key: This is needed for multi-language support. You can change it to whatever language you'd like, or add new languages as you go. More details on this below.
|
||||
|
||||
### Multiple Languages support
|
||||
|
||||
The Astro docs template supports multiple languages out of the box. The default theme only shows `en` documentation, but you can enable multi-language support features by adding a second language to your project.
|
||||
|
||||
To add a new language to your project, you'll want to extend the current `src/pages/[lang]/...` layout:
|
||||
|
||||
```diff
|
||||
📂 src/pages
|
||||
┣ 📂 en
|
||||
┃ ┣ 📜 page-1.md
|
||||
┃ ┣ 📜 page-2.md
|
||||
┃ ┣ 📜 page-3.astro
|
||||
+ ┣ 📂 es
|
||||
+ ┃ ┣ 📜 page-1.md
|
||||
+ ┃ ┣ 📜 page-2.md
|
||||
+ ┃ ┣ 📜 page-3.astro
|
||||
```
|
||||
|
||||
You'll also need to add the new language name to the `KNOWN_LANGUAGES` map in your `src/config.ts` file. This will enable your new language switcher in the site header.
|
||||
|
||||
```diff
|
||||
// src/config.ts
|
||||
export const KNOWN_LANGUAGES = {
|
||||
English: 'en',
|
||||
+ Spanish: 'es',
|
||||
};
|
||||
```
|
||||
|
||||
Last step: you'll need to add a new entry to your sidebar, to create the table of contents for that language. While duplicating every page might not sound ideal to everyone, this extra control allows you to create entirely custom content for every language.
|
||||
|
||||
> Make sure the sidebar `link` value points to the correct language!
|
||||
|
||||
```diff
|
||||
// src/config.ts
|
||||
export const SIDEBAR = {
|
||||
en: [
|
||||
{ text: 'Section Header', header: true, },
|
||||
{ text: 'Introduction', link: 'en/introduction' },
|
||||
// ...
|
||||
],
|
||||
+ es: [
|
||||
+ { text: 'Encabezado de sección', header: true, },
|
||||
+ { text: 'Introducción', link: 'es/introduction' },
|
||||
+ // ...
|
||||
+ ],
|
||||
};
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
If you plan to use Spanish as the default language, you just need to modify the redirect path in `src/pages/index.astro`:
|
||||
|
||||
```diff
|
||||
<script>
|
||||
- window.location.pathname = `/en/introduction`;
|
||||
+ window.location.pathname = `/es/introduction`;
|
||||
</script>
|
||||
```
|
||||
|
||||
You can also remove the above script and write a landing page in Spanish instead.
|
||||
|
||||
### What if I don't plan to support multiple languages?
|
||||
|
||||
That's totally fine! Not all projects need (or can support) multiple languages. You can continue to use this theme without ever adding a second language.
|
||||
|
||||
If that single language is not English, you can just replace `en` in directory layouts and configurations with the preferred language.
|
||||
|
||||
### Search (Powered by Algolia)
|
||||
|
||||
[Algolia](https://www.algolia.com/) offers a free service to qualified open source projects called [DocSearch](https://docsearch.algolia.com/). If you are accepted to the DocSearch program, provide your API Key & index name in `src/config.ts` and a search box will automatically appear in your site header.
|
||||
|
||||
Note that Aglolia and Astro are not affiliated. We have no say over acceptance to the DocSearch program.
|
||||
|
||||
If you'd prefer to remove Algolia's search and replace it with your own, check out the `src/components/Header.astro` component to see where the component is added.
|
||||
more setup info, see [project setup](../CONTRIBUTING.md#project-setup)
|
||||
@@ -10,8 +10,8 @@ import bundleAudioWorkletPlugin from 'vite-plugin-bundle-audioworklet';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import AstroPWA from '@vite-pwa/astro';
|
||||
|
||||
const site = `https://strudel.cc/`; // root url without a path
|
||||
const base = '/'; // base path of the strudel site
|
||||
const site = process.env.STRUDEL_SITE || `https://strudel.cc/`; // root url without a path
|
||||
const base = process.env.STRUDEL_BASE || '/'; // base path of the strudel site
|
||||
const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
|
||||
// this rehype plugin fixes relative links
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import '../../repl/Repl.css';
|
||||
---
|
||||
|
||||
<div id="code"></div>
|
||||
|
||||
<script>
|
||||
import { DoughMirror } from './dough-mirror.mjs';
|
||||
const root = document.getElementById('code');
|
||||
const doughmirror = new DoughMirror({ root });
|
||||
globalThis.settings = doughmirror.settings.bind(doughmirror);
|
||||
</script>
|
||||
<style>
|
||||
#code {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
initEditor,
|
||||
codemirrorSettings,
|
||||
flash,
|
||||
compartments,
|
||||
extensions,
|
||||
parseBooleans,
|
||||
activateTheme,
|
||||
updateMiniLocations,
|
||||
highlightMiniLocations,
|
||||
} from '@strudel/codemirror';
|
||||
import { evalScope, hash2code, code2hash } from '@strudel/core';
|
||||
import { Framer } from '@strudel/draw';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { DoughRepl } from './dough-repl.mjs';
|
||||
|
||||
let initialCode = '$: note("c a f e")';
|
||||
|
||||
export const code = persistentAtom('vanilla-repl-code', initialCode, {
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const codeParam = window.location.href.split('#')[1] || '';
|
||||
if (codeParam) {
|
||||
const codeFromHash = hash2code(codeParam);
|
||||
code.set(codeFromHash);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('could not init code from url');
|
||||
}
|
||||
}
|
||||
|
||||
export class DoughMirror {
|
||||
constructor(options) {
|
||||
const { root, initialCode = code.get(), bgFill = true } = options;
|
||||
this.root = root;
|
||||
this.code = initialCode;
|
||||
this.repl = new DoughRepl();
|
||||
this.prebaked = this.prebake();
|
||||
|
||||
// init codemirror
|
||||
this.editor = initEditor({
|
||||
root,
|
||||
initialCode: this.code,
|
||||
onChange: (v) => {
|
||||
if (v.docChanged) {
|
||||
this.code = v.state.doc.toString();
|
||||
code.set(this.code);
|
||||
}
|
||||
},
|
||||
onEvaluate: this.evaluate.bind(this),
|
||||
onStop: () => this.stop(),
|
||||
mondo: false,
|
||||
});
|
||||
const settings = codemirrorSettings.get();
|
||||
this.setFontSize(settings.fontSize);
|
||||
this.setFontFamily(settings.fontFamily);
|
||||
|
||||
// init event highlighting
|
||||
this.framer = new Framer(
|
||||
(time) => {
|
||||
const frameHaps = this.repl.processHaps();
|
||||
highlightMiniLocations(this.editor, time, frameHaps);
|
||||
},
|
||||
(err) => console.log('Framer error', err),
|
||||
);
|
||||
}
|
||||
prebake() {
|
||||
const modulesLoading = evalScope(import('@strudel/core'), import('@strudel/tonal'), import('@strudel/mini'));
|
||||
return Promise.all([modulesLoading, this.repl.prebake()]);
|
||||
}
|
||||
async evaluate() {
|
||||
this.framer.start();
|
||||
this.flash();
|
||||
await this.prebaked;
|
||||
const { miniLocations } = await this.repl.evaluate(this.code);
|
||||
window.location.hash = '#' + code2hash(this.code);
|
||||
updateMiniLocations(this.editor, miniLocations);
|
||||
}
|
||||
stop() {
|
||||
this.repl.stop();
|
||||
this.framer.stop();
|
||||
highlightMiniLocations(this.editor, 0, []);
|
||||
}
|
||||
// added synonym (compared to StrudelMirror)
|
||||
settings(settings = {}) {
|
||||
this.updateSettings(settings);
|
||||
}
|
||||
|
||||
// the rest is copy pasted from StrudelMirror:
|
||||
|
||||
updateSettings(settings = {}) {
|
||||
settings.fontSize && this.setFontSize(settings.fontSize);
|
||||
settings.fontFamily && this.setFontFamily(settings.fontFamily);
|
||||
for (let key in extensions) {
|
||||
if (key in settings) {
|
||||
this.reconfigureExtension(key, settings[key]);
|
||||
}
|
||||
}
|
||||
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||
// console.log(updated);
|
||||
codemirrorSettings.set(updated);
|
||||
}
|
||||
reconfigureExtension(key, value) {
|
||||
if (!extensions[key]) {
|
||||
console.warn(`extension ${key} is not known`);
|
||||
return;
|
||||
}
|
||||
value = parseBooleans(value);
|
||||
const newValue = extensions[key](value, this);
|
||||
this.editor.dispatch({
|
||||
effects: compartments[key].reconfigure(newValue),
|
||||
});
|
||||
if (key === 'theme') {
|
||||
activateTheme(value);
|
||||
}
|
||||
}
|
||||
flash(ms) {
|
||||
flash(this.editor, ms);
|
||||
}
|
||||
setFontSize(size) {
|
||||
this.root.style.fontSize = size + 'px';
|
||||
}
|
||||
setFontFamily(family) {
|
||||
this.root.style.fontFamily = family;
|
||||
const scroller = this.root.querySelector('.cm-scroller');
|
||||
if (scroller) {
|
||||
scroller.style.fontFamily = family;
|
||||
}
|
||||
}
|
||||
setCode(code, offset = 0) {
|
||||
const changes = {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length + offset,
|
||||
insert: code,
|
||||
};
|
||||
this.editor.dispatch({ changes });
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
}
|
||||
setCursorLocation(col) {
|
||||
return this.editor.dispatch({ selection: { anchor: col } });
|
||||
}
|
||||
appendCode(code) {
|
||||
const cursor = this.getCursorLocation();
|
||||
this.setCode(this.code + code);
|
||||
this.setCursorLocation(cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// import { Dough, doughsamples } from 'dough-synth';
|
||||
import { Dough, doughsamples } from 'https://unpkg.com/dough-synth@0.1.9/dough.js';
|
||||
import { Pattern, noteToMidi, evaluate, stack } from '@strudel/core';
|
||||
// import doughUrl from 'dough-synth?url';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
//const doughBaseUrl = doughUrl.split('/').slice(0, -1).join('/') + '/';
|
||||
const doughBaseUrl = 'https://unpkg.com/dough-synth@0.1.9/';
|
||||
|
||||
Object.assign(globalThis, { doughsamples });
|
||||
|
||||
export class DoughRepl {
|
||||
pattern;
|
||||
latency = 0.1;
|
||||
cps = 0.5;
|
||||
origin;
|
||||
t0;
|
||||
lasttime;
|
||||
strudel;
|
||||
q = [];
|
||||
constructor() {
|
||||
this.ready = this.init();
|
||||
}
|
||||
async init() {
|
||||
// init dough immediately, so that it can attach the document click event to initAudio immediately
|
||||
this.dough = new Dough({
|
||||
base: doughBaseUrl,
|
||||
//base: "../", // local dev
|
||||
onTick: ({ t0, t1 }) => {
|
||||
if (!this.pattern) {
|
||||
return;
|
||||
}
|
||||
this.origin ??= t0;
|
||||
this.t0 = t0;
|
||||
this.lasttime = performance.now();
|
||||
const a = (t0 - this.origin) * this.cps;
|
||||
const b = (t1 - this.origin) * this.cps;
|
||||
|
||||
const haps = this.pattern.queryArc(a, b).filter((hap) => hap.hasOnset());
|
||||
if (!haps.length) {
|
||||
return;
|
||||
}
|
||||
haps.forEach((hap) => {
|
||||
const time = hap.whole.begin.valueOf() / this.cps + this.origin + this.latency;
|
||||
const duration = hap.duration.valueOf() / this.cps;
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time,
|
||||
duration,
|
||||
};
|
||||
if (event.note && typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
//console.log("event", JSON.stringify(Object.entries(event)));
|
||||
this.dough.evaluate(event);
|
||||
this.q.push({ event, hap });
|
||||
});
|
||||
},
|
||||
});
|
||||
// miniAllStrings();
|
||||
const setcps = (cps) => (this.cps = cps);
|
||||
const setcpm = (cpm) => setcps(cpm / 60);
|
||||
const replScope = { setcps, setcpm };
|
||||
Object.assign(globalThis, replScope);
|
||||
}
|
||||
async evaluate(code) {
|
||||
await this.ready;
|
||||
let patterns = [];
|
||||
Pattern.prototype.p = function (id) {
|
||||
if (!id.startsWith('_')) {
|
||||
patterns.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
let { meta } = await evaluate(code, transpiler, { addReturn: false, wrapAsync: true, emitWidgets: false });
|
||||
const { miniLocations } = meta;
|
||||
|
||||
this.pattern = stack(...patterns);
|
||||
return { miniLocations, pattern: this.pattern };
|
||||
}
|
||||
stop() {
|
||||
this.pattern = undefined;
|
||||
this.origin = undefined;
|
||||
}
|
||||
prebake() {
|
||||
return Promise.all([
|
||||
// doughsamples('github:eddyflux/crate')
|
||||
]);
|
||||
}
|
||||
// tbd: move this to dough-synth
|
||||
get time() {
|
||||
return this.t0 + (performance.now() - this.lasttime) / 1000 + this.latency;
|
||||
}
|
||||
processHaps() {
|
||||
const currentHaps = [];
|
||||
const time = this.time;
|
||||
this.q = this.q.filter(({ event, hap }) => {
|
||||
const end = event.time + event.duration;
|
||||
const isActive = time >= event.time && time <= end;
|
||||
if (isActive) {
|
||||
currentHaps.push(hap);
|
||||
}
|
||||
return end > time; // delete old events
|
||||
// we do NOT return !isActive, because a frame might miss an event, which would cause a leak
|
||||
});
|
||||
// console.log(this.q.length); // to check for leaks
|
||||
return currentHaps;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
|
||||
<base href={BASE_URL} />
|
||||
|
||||
<!-- Scrollable a11y code helper -->
|
||||
<script {`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
|
||||
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
|
||||
|
||||
<script src="/src/pwa.ts"></script>
|
||||
<!-- this does not work for some reason: -->
|
||||
|
||||
@@ -24,7 +24,7 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
|
||||
<base href={BASE_URL} />
|
||||
|
||||
<!-- Scrollable a11y code helper -->
|
||||
<script src{`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
|
||||
<script src={`${baseNoTrailing}/make-scrollable-code-focusable.js`} is:inline></script>
|
||||
|
||||
<script src="/src/pwa.ts"></script>
|
||||
<!-- this does not work for some reason: -->
|
||||
|
||||
@@ -76,6 +76,7 @@ export const SIDEBAR: Sidebar = {
|
||||
{ text: 'MIDI & OSC', link: 'learn/input-output' },
|
||||
],
|
||||
More: [
|
||||
{ text: 'FAQ', link: 'learn/faq' },
|
||||
{ text: 'Recipes', link: 'recipes/recipes' },
|
||||
{ text: 'Mini-Notation', link: 'learn/mini-notation' },
|
||||
{ text: 'Visual Feedback', link: 'learn/visual-feedback' },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import HeadCommon from '../../components/HeadCommon.astro';
|
||||
import Dough from '../../components/Dough/Dough.astro';
|
||||
---
|
||||
|
||||
<html lang="en" class="m-0 dark">
|
||||
<head>
|
||||
<HeadCommon />
|
||||
<title>Strudel Dough REPL</title>
|
||||
</head>
|
||||
<body class="h-app-height bg-background m-0">
|
||||
<Dough />
|
||||
<a rel="me" href="https://social.toplap.org/@strudel" target="_blank" class="hidden">mastodon</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,331 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
import { MiniRepl } from '../../docs/MiniRepl';
|
||||
import { JsDoc } from '../../docs/JsDoc';
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
This page contains frequently asked questions, with answers. Usually, the topic is explained in more detail in a section which is linked in the answer.
|
||||
|
||||
## Is Strudel/Tidal free?
|
||||
|
||||
Yes - there is no charge, this is a collective open source project, and the music you make with it is your own. However if you can, please make a one-off or regular donation to our [opencollective fund](https://opencollective.com/tidalcycles), that supports the software and cultural development of Strudel and other Uzu languages.
|
||||
|
||||
While there is no charge there are some caveats, e.g.:
|
||||
|
||||
- the source code must stay free, i.e. you cannot distribute strudel or tidal as part of projects with incompatible licenses - see the [license](https://www.gnu.org/licenses/agpl-3.0.en.html) for details.
|
||||
- the contributed examples and tracks are also separately licensed, and must not e.g. be used to train AI models without permission.
|
||||
|
||||
## How to record or export audio?
|
||||
|
||||
Strudel is not a digital audio workstation and does not operate following the same principles shared by most traditional audio softwares. However, there are multiple ways to record the audio -- and video -- output of Strudel:
|
||||
|
||||
- Use the 'export' tab to render and download as an audio file.
|
||||
- capture the raw stereo signal coming out of your web browser. You will need an external audio editor/DAW such as Reaper/Audacity/Ardour, etc.
|
||||
- use the alternative SuperDirt audio engine. Read [this page](/learn/input-output/#oscsuperdirtstrudeldirt) to know more about it.
|
||||
- capture the audio/video stream using a capture tool such as [OBS](https://obsproject.com/fr), which is designed for live streaming, but also works very well for recording.
|
||||
- don't record anything and code it again in front of your friends.
|
||||
|
||||
## Can I use strudel with my IDE?
|
||||
|
||||
Yes you can. There are experimental modes, made by community members, for several IDEs such as:
|
||||
|
||||
- VS Code: [Strudel VS](https://marketplace.visualstudio.com/items?itemName=cmillsdev.strudelvs): an experimental mode for Microsoft VSCode. A revived version of [TidalStrudel](https://marketplace.visualstudio.com/items?itemName=roipoussiere.tidal-strudel), which is defunct.
|
||||
- nvim: [strudel.nvim](https://github.com/gruvw/strudel.nvim)
|
||||
|
||||
## How can I use my own samples?
|
||||
|
||||
There are multiple ways to load your sample collection. Some methods are good for quick experimentation, some others are good to share your audio collection with other musicians:
|
||||
|
||||
- Import folders [from the interface](/learn/samples/#from-disk-via-import-sounds-folder). These are stored locally in your web browser, and not uploaded.
|
||||
- Serve a folder of samples locally using the [strudel 'sampler' commandline tool](https://strudel.cc/learn/samples/#from-disk-via-strudelsampler). This can be most reliable method, but requires [nodejs](https://nodejs.org) to be installed.
|
||||
- Host your sound library online on the web and [load them from an URL](/learn/samples/#loading-custom-samples)
|
||||
|
||||
## Can I use Strudel with AI/LLM tools?
|
||||
|
||||
You are free to do what you like with Strudel, within the terms of the free/open source AGPLv3 license.
|
||||
However as a community we are interested in exploring human creativity. AI is _way_ over-hyped right now,
|
||||
including by people with very shady motives. Many in the community are very wary of people training models
|
||||
on their tunes that they've poured their love into. So please keep discussion and questions around AI and
|
||||
LLMs to channels dedicated to the topic and be fully respectful of other people's work.
|
||||
|
||||
Furthermore, tools like ChatGPT generally give wrong answers. Please don't ask the community to fix those
|
||||
answers for you, as generally they will be timewasting nonsense.
|
||||
|
||||
Human questions are always welcome!
|
||||
|
||||
## Where can I download loads of patterns to train my LLM?
|
||||
|
||||
You cannot, as there is no such place. For details regarding our stance towards AI/LLM, see [above](/learn/faq/#can-i-use-strudel-with-aillm-tools)
|
||||
|
||||
## How to run offline?
|
||||
|
||||
Strudel works offline just fine! There are multiple techniques for this, see [this explanation](learn/pwa/#using-strudel-offline).
|
||||
|
||||
## How to change tempo? How do I translate BPM to cpm?
|
||||
|
||||
Strudel works in cycles, rather than beats, but if you assume a certain number of beats per cycle, you can convert between them.
|
||||
|
||||
For example, if you have your tempo in beats per minute and use 4 beats per cycle (e.g. if your track is in 4/4ths) then you can do `setcpm(BPM/4)`
|
||||
where BPM is your beats per minute.
|
||||
|
||||
If you have a different number of beats per bar or are using more or less beats per cycle (e.g. If you want to put only half a bar or
|
||||
two bars into one cycle), adjust accordingly.
|
||||
|
||||
## Where can I see all the functions?
|
||||
|
||||
If you pop open the sidetab of strudel.cc (small white < on the right hand side), there is a tab "reference" which lists all the functions of strudel.
|
||||
|
||||
## Where can I see all the samples and synths?
|
||||
|
||||
If you pop open the sidetab of strudel.cc (small white < on the right hand side), there is a tab "sounds" which lists all the drum machines, samples and synths currently loaded.
|
||||
|
||||
## How do I use this exactly like a DAW?
|
||||
|
||||
Strudel has different design aims for a DAW, and so treating it like one will likely be frustrating. DAWs are geared towards
|
||||
sequencing notes over time in predictable ways, whereas Strudel and similar Uzu languages are geared towards combining and
|
||||
transforming patterns in ways that can be hard to predict.
|
||||
|
||||
If you want to emulate the functionality of a DAW in Strudel, you'll have to identify the operations
|
||||
executed by the DAW (sequencing, repeating, applying filters and envelopes) and write code that is equivalent to these
|
||||
operations. For example in Strudel, the 'arrange' and 'pick' methods are useful for sequencing patterns over time (see question on these later in this document).
|
||||
|
||||
You might still find that the typical DAW workflow is not really adapted to live coding because, despite
|
||||
both being ways of making music on the computer, they are two very different tools. You could then adapt your way of proceeding
|
||||
to the medium of code, which might mean leaving more place to serendipity and writing code that you don't predict the output of.
|
||||
|
||||
## Why doesn't everyone just use a DAW?
|
||||
|
||||
There is no easy answer to this question. Here are some thoughts:
|
||||
|
||||
- Live coding tools such as Strudel are excellent for improvising music and visuals using a computer. DAWs are valuable and robust companions for other activities such as producing, mastering and mixing audio, among other usages. Using a tool does not exclude from using any another tool, just build a toolbox.
|
||||
|
||||
- Live coding has developed over decades as a distinct creative practice. For example, live coding artists like to show their screens while playing in front of an audience. It is an essential part of what they do, of the way they share their activity with everybody.
|
||||
|
||||
- Code is a human language, it is made for other humans to read it. You can read the code and enjoy the music too. It has meaning, value, and there might even be something poetic/important about it! - Strudel is free and open source, you can inspect the code, reshape it, contribute to it if you can/want. It is not opaque and this matters for many people. There is no black box, no obscure abstractions, no business model, no user tracking or hidden features. We need open tools in the arts! - Live coders don't all shy away from using DAWs. Many use them all of the time, especially when it makes their life easier for... live coding!
|
||||
|
||||
- Code is an artistic material like any other. There is something valuable in the process of making music through code. More generally speaking, it is nice to tackle creative problems through the use of a programming language: creative thinking, building up your own solutions, DIY approach to music-making, unexpected outcome of algorithms, funny human errors, etc.
|
||||
|
||||
- There are pianos and trumpets in your DAW: why do people continue playing the piano or the trumpet? Think of live coding tools as instruments that you activate through the act of programming.
|
||||
|
||||
## How can I interface Strudel with my favorite music software? What can I do with it?
|
||||
|
||||
Strudel can send [MIDI and OSC](/learn/input-output/), which are protocols for communicating musical information.
|
||||
|
||||
Other music software (or hardware!) can then listen to these messages and process them according to its capabilities.
|
||||
|
||||
A simple example would be to send livecoded audio to a DAW like Ardour on different tracks and then use it to mix them.
|
||||
|
||||
You could also send the MIDI of a sequenced pattern to Musescore and then have it transcribe your livecoded work as a musical score.
|
||||
|
||||
You could also send MIDI to your hardware synths, if you like their sound.
|
||||
|
||||
## How do I use this in my closed source webgame or other software?
|
||||
|
||||
You don't. You need to license your game to a free/open source license fulfill the [AGPLv3 license](https://codeberg.org/uzu/strudel/src/branch/main/LICENSE) Strudel is distributed under.
|
||||
|
||||
## How to play different patterns simultaneously?
|
||||
|
||||
Using the $ operator, several patterns can be played at once:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`$: s("bd*4").bank("tr707")
|
||||
$: s("- sd").bank("tr909")`}
|
||||
/>
|
||||
|
||||
See also [stack](intro/#combining-patterns)
|
||||
|
||||
## Is it possible to mute a pattern?
|
||||
|
||||
With an additional underscore, a pattern can be muted.
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`$: s("bd*4").bank("tr707")
|
||||
_$: s("- sd").bank("tr909")`}
|
||||
/>
|
||||
|
||||
See also [hush](/learn/conditional-modifiers/#hush)
|
||||
|
||||
## How can I arrange in Strudel using `mask`?
|
||||
|
||||
With mini-notation, using the `<>` and `!` operators, you can try something like
|
||||
|
||||
```
|
||||
.mask("<0!24 1!40>")
|
||||
```
|
||||
|
||||
It mutes a pattern for 24 cycles and plays it for 40. You would gain 64 cycles total, a multiple of 2/4/8 commonly used in western music.
|
||||
|
||||
If each cycle is a bar, as a starting point, you could write a mask like that for any pattern:
|
||||
|
||||
```
|
||||
.mask("<0!16 0!16 0!16 0!16 0!16 0!10>")
|
||||
```
|
||||
|
||||
It mutes it throughout.
|
||||
|
||||
For arranging, you could add the same mask to each part and replace some zeroes with ones in your different masks to make parts play.
|
||||
|
||||
If you use `.mask()` on different patterns mess up your counting, then patterns do not align anymore.
|
||||
On the other hand, doing that on purpose is one of the things that could be considered a strength of tidalcycles and Strudel.
|
||||
You can make things quite lively and more organic with a little (controlled) interference, according to your own taste.
|
||||
And you are free to arrange in cycles like 3, 6 or 9 too.
|
||||
|
||||
To modify everything at once, you could try all and when, for example:
|
||||
|
||||
```
|
||||
all(x=>x.when("<0!7 1>", x=>x.lpf(saw.range(200, 2000))))
|
||||
```
|
||||
|
||||
This would lowpass filter sweep everything every 8 cycles.
|
||||
|
||||
## How can I arrange in Strudel using `arrange` or `pick`?
|
||||
|
||||
Take [Pachelbel's Canon in D](https://en.wikipedia.org/wiki/Pachelbel%27s_Canon#Analysis) as an example which has 4 voices (one cello and 3 violins) which have repeating patterns, as seen in the link above.
|
||||
|
||||
The following snipped defines the patterns as constants which can then be used for the different voices. `arrange` takes multiple arguments, which are each a number of cycles and a pattern which is played for the number of cycles, wrapped in `[]` If the pattern is shorter than the number, it is repeated.
|
||||
|
||||
<MiniRepl client:idle tune={`const cello = note(
|
||||
"<[d3 a2 b2 f#2] [g2 d3 g2 a2]>")
|
||||
.color("grey").sound("gm_tremolo_strings:3")
|
||||
const violin_p1 = note(
|
||||
"<[f#5 e5 d5 c#5] [b4 a4 b4 c#5]>")
|
||||
.color("blue")
|
||||
const violin_p2 = note(
|
||||
"<[d5 c#5 b4 a4] [ g4 f#4 g4 f#4]>")
|
||||
.color("green")
|
||||
const violin_p3 = note(
|
||||
"<[d4 f#4 a4 g4 f#4 d4 f#4 e4] [d4 b3 d4 a4 g4 b4 a4 g4]>")
|
||||
.color("purple")
|
||||
const violin_p4 = note(
|
||||
"<[f#4 d4 e4 c#5 d5 f#5 a5 a4] [b4 g4 a4 f#4 d4 d5 [d5@3 c#5]@2]>")
|
||||
.color("red")
|
||||
|
||||
cello$: arrange(
|
||||
[2, silence],
|
||||
[18,cello])
|
||||
violin1$: arrange(
|
||||
[4,silence],
|
||||
[2,violin_p1], [2,violin_p2],
|
||||
[2,violin_p3], [2,violin_p4],
|
||||
[2,violin_p1], [2,violin_p2],
|
||||
[2,violin_p3], [2,violin_p4]
|
||||
).sound("gm_tremolo_strings:0")
|
||||
violin2$: arrange(
|
||||
[6,silence], [2,violin_p1],
|
||||
[2,violin_p2], [2,violin_p3],
|
||||
[2,violin_p4], [2,violin_p1],
|
||||
[2,violin_p2], [2,violin_p3]
|
||||
).sound("gm_tremolo_strings:1")
|
||||
violin3$: arrange(
|
||||
[8,silence],
|
||||
[2,violin_p1], [2,violin_p2],
|
||||
[2,violin_p3], [2,violin_p4],
|
||||
[2,violin_p1], [2,violin_p2]
|
||||
).sound("gm_tremolo_strings:2")
|
||||
|
||||
all(x => x.release(.2))
|
||||
|
||||
`} />
|
||||
|
||||
Alternatively, you can also put the different patterns for the violins into one single array (`const violins = [violin_p1, violin_p2, violin_p3, violin_p4]`) and use a pattern as an index to `pick` the nth element of that array. This replaces the voices defined above. Here you use `0@2` to specifiy that the first item (i.e. with index `0`) is played for `2` cycles.
|
||||
|
||||
`pick` has better highlighting than `arrange`:
|
||||
|
||||
<MiniRepl client:idle tune={`const cello = note(
|
||||
"<[d3 a2 b2 f#2] [g2 d3 g2 a2]>")
|
||||
.color("grey").sound("gm_tremolo_strings:3")
|
||||
const violin_p1 = note(
|
||||
"<[f#5 e5 d5 c#5] [b4 a4 b4 c#5]>")
|
||||
.color("blue")
|
||||
const violin_p2 = note(
|
||||
"<[d5 c#5 b4 a4] [ g4 f#4 g4 f#4]>")
|
||||
.color("green")
|
||||
const violin_p3 = note(
|
||||
"<[d4 f#4 a4 g4 f#4 d4 f#4 e4] [d4 b3 d4 a4 g4 b4 a4 g4]>")
|
||||
.color("purple")
|
||||
const violin_p4 = note(
|
||||
"<[f#4 d4 e4 c#5 d5 f#5 a5 a4] [b4 g4 a4 f#4 d4 d5 [d5@3 c#5]@2]>")
|
||||
.color("red")
|
||||
|
||||
const violins = [violin_p1, violin_p2, violin_p3, violin_p4]
|
||||
|
||||
cello$: "<~@2 0@18>".pick([cello])
|
||||
violin1$: "<~@4 0@2 1@2 2@2 3@2 0@2 1@2 2@2 3@2>".pick(violins)
|
||||
.sound("gm_tremolo_strings:0")
|
||||
violin2$: "<~@6 0@2 1@2 2@2 3@2 0@2 1@2 2@2>".pick(violins)
|
||||
.sound("gm_tremolo_strings:1")
|
||||
violin3$: "<~@8 0@2 1@2 2@2 3@2 0@2 1@2 >".pick(violins)
|
||||
.sound("gm_tremolo_strings:2")
|
||||
all(x => x.release(.2))
|
||||
`} />
|
||||
|
||||
The `pick` method also works with jsons which have named elements, which makes it easier to read, see the [here](/learn/conditional-modifiers/#pick). `pickRestart` restarts the pattern upon picking it which can make a difference if the duration of the pick indexes doesn't line up with the patterns which are picked - which is not the case here.
|
||||
|
||||
Try adding `.punchcard()` after the `release(.2)` for a visualization.
|
||||
|
||||
## I saw Switch Angel using functions which I cannot find in the reference (e.g. `trancegate`). How do I make it work?
|
||||
|
||||
Methods like `trancegate()`, `rlpf()` and `acidenv()` are currently not pattern methods which come natively with strudel.
|
||||
|
||||
They are part of a script/prebake for strudel which was written by Switch Angel and published [here](https://github.com/switchangel/strudel-scripts)
|
||||
|
||||
You can find the instructions how to use that script in the readme.md there.
|
||||
|
||||
## Is there difference between `n` and `note`?
|
||||
|
||||
They are not aliases of each other, in contrast to `s` and `sound`.
|
||||
|
||||
The method `note` is used to reference a certain note (either as its name, such as `c` or `b2` or the midi number `69`, for example `note("c3 e3 g3")`).
|
||||
|
||||
On the other hand, `n` is a way to reference the nth index of something. This something can be a scale (eg `n("0 2 4").scale("C:major")`) , but it can also be a particular note in a chord (see https://strudel.cc/recipes/recipes/#arpeggios for an example) .
|
||||
|
||||
The method `n` can also be used for something completely unrelated to notes, in particular the nth sample from a sample map `s("hh*8").bank("RolandTR909").n("0 1 2 3")`.
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`
|
||||
n("<[0 1 2 3@3 -@2] [3 2 1 0@3 -@2] >")
|
||||
.scale("A:minor:pentatonic")
|
||||
.s("gm_acoustic_guitar_steel").n("<0 1 2 3>/2")`}
|
||||
/>
|
||||
|
||||
Note that `n` is not the only way that functions use indices, some take numbered patterns instead.
|
||||
|
||||
## Is there a cheat sheet for all symbols?
|
||||
|
||||
Yes!
|
||||
|
||||
```
|
||||
' marks start and end of strings, is different from "
|
||||
" marks start and end of single line patterns in mini notation, is different from '
|
||||
` marks start and end of patterns with line breaks in mini notation, is different from '
|
||||
[] used for patterns in mini notation, each item in it has the same length
|
||||
<> used for patterns, alternates between items each cycle
|
||||
{} historically used for polyrhythmic patterns. {a b c}%4 is the same as <a b c>*4.
|
||||
@3 elongates the item by a factor of 3 (other numbers work too, even non-integer, but for numbers between 0 and 1 you need a leading zero like this: @0.5)
|
||||
@ after an item: elongates the item once (multiple @ work too c @ @ is the same as c@3)
|
||||
_ after an item: also elongates an item once (multiple _ work too c _ _ is the same as c@3), see below for a different usage.
|
||||
. this divides equal parts of a pattern and is called a foot. Can be used instead of [] like this: "1 6 7 8 . 2 . 3 . 4" is the same as "[1 6 7 8] 2 3 4"
|
||||
- silence
|
||||
~ also silence
|
||||
x not silence (for the use in struct, any non-silence symbol works there)
|
||||
b decrease by one semitone, i.e. flat, works for steps of scales, note names (but not midi numbers) and chord names
|
||||
s increase by one semitone, i.e. sharp, works for steps of scales, note names (but not midi numbers) but not chord names
|
||||
# increase by one semitone, i.e. sharp works for steps of scales, note names (but not midi numbers) and chord names
|
||||
# also used in mondo notation
|
||||
*3 play the sample or pattern at thrice the speed, fast(3)
|
||||
!3 play the sample or pattern three times
|
||||
/2 play the sample or pattern at half speed, slow(2)
|
||||
? play the pattern sometimes
|
||||
| once per cycle, choose randomly a pattern of those separated by i.e. chooseCycles()
|
||||
, play all items separated by it at the same time, i.e. stack()
|
||||
: is used to separate multiple parameters, such as adsr(".1:.1:.5:.2"), this is is an operator which creates a list of these objects.
|
||||
$: at the start of a line, defines a member of the stack. is the only stack name that should occur multiple names
|
||||
_ before a stack name: mutes the stack, i.e. hush(), for example _$: s("bd"), see above for a different usage.
|
||||
```
|
||||
@@ -11,9 +11,9 @@ import { JsDoc } from '../../docs/JsDoc';
|
||||
You can optionally add some music metadata in your Strudel code, by using tags in code comments:
|
||||
|
||||
```js
|
||||
// @title Hey Hoo
|
||||
// @by Sam Tagada
|
||||
// @license CC BY-NC-SA
|
||||
// @title My Cool Song
|
||||
// @by John Doe
|
||||
// @license CC-BY-SA-4.0
|
||||
```
|
||||
|
||||
Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music.
|
||||
@@ -24,22 +24,22 @@ You can also use comment blocks:
|
||||
|
||||
```js
|
||||
/*
|
||||
@title Hey Hoo
|
||||
@by Sam Tagada
|
||||
@license CC BY-NC-SA
|
||||
@title My Cool Song
|
||||
@by John Doe
|
||||
@license CC-BY-SA-4.0
|
||||
*/
|
||||
```
|
||||
|
||||
Or define multiple tags in one line:
|
||||
|
||||
```js
|
||||
// @title Hey Hoo @by Sam Tagada @license CC BY-NC-SA
|
||||
// @title My Cool Song @by John Doe @license CC-BY-SA-4.0
|
||||
```
|
||||
|
||||
The `title` tag has an alternative syntax using quotes (must be defined at the very begining):
|
||||
|
||||
```js
|
||||
// "Hey Hoo" @by Sam Tagada
|
||||
// "My Cool Song" @by John Doe
|
||||
```
|
||||
|
||||
## Tags list
|
||||
@@ -48,20 +48,22 @@ Available tags are:
|
||||
|
||||
- `@title`: music title
|
||||
- `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe <https://example.com>`)
|
||||
- `@license`: music license(s), e.g. CC BY-NC-SA. Unsure? [Choose a creative commons license here](https://creativecommons.org/choose/)
|
||||
- `@license`: music license(s), separated by comma. Each license should be specified by using the correct identifier in the [https://spdx.org/licenses/](SPDX License List). Example: CC-BY-SA-4.0. Unsure? [Choose a Creative Commons license here](https://creativecommons.org/choose/).
|
||||
- `@details`: some additional information about the music
|
||||
- `@url`: web page(s) related to the music (git repo, soundcloud link, etc.)
|
||||
- `@genre`: music genre(s) (pop, jazz, etc)
|
||||
- `@url`: web page(s) related to the music (git repository, Soundcloud link, etc.)
|
||||
- `@genre`: music genre(s) (pop, jazz, etc.)
|
||||
- `@album`: music album name
|
||||
|
||||
Note to tool authors: _Never_ trust that a song has filled those fields with syntactically correct values; make sure your software is robust enough it doesn't break if it encounters bad values
|
||||
|
||||
## Multiple values
|
||||
|
||||
Some of them accepts several values, using the comma or new line separator, or duplicating the tag:
|
||||
|
||||
```js
|
||||
/*
|
||||
@by Sam Tagada
|
||||
Jimmy
|
||||
@by John Doe
|
||||
Jane Doe
|
||||
@genre pop, jazz
|
||||
@url https://example.com
|
||||
@url https://example.org
|
||||
@@ -72,11 +74,11 @@ You can also add optional prefixes and use tags where you want:
|
||||
|
||||
```js
|
||||
/*
|
||||
song @by Sam Tagada
|
||||
samples @by Jimmy
|
||||
song @by John Doe
|
||||
samples @by Jane Doe
|
||||
*/
|
||||
...
|
||||
note("a3 c#4 e4 a4") // @by Sandy
|
||||
note("a3 c#4 e4 a4") // @by Sandy Sue
|
||||
```
|
||||
|
||||
## Multiline
|
||||
|
||||
@@ -142,6 +142,8 @@ The "~" represents a rest, and will create silence between other events:
|
||||
|
||||
<MiniRepl client:idle tune={`note("[b4 [~ c5] d5 e5]")`} punchcard />
|
||||
|
||||
Alternatively, "-" can be used instead of "~". It means the same thing.
|
||||
|
||||
## Parallel / polyphony
|
||||
|
||||
Using commas, we can play chords.
|
||||
|
||||
@@ -48,28 +48,94 @@ You can also use the `crackle` type to play some subtle noise crackles. You can
|
||||
|
||||
### Additive Synthesis
|
||||
|
||||
To tame the harsh sound of the basic waveforms, we can set the `n` control to limit the overtones of the waveform:
|
||||
Periodic waveforms are composed of several [harmonics](https://en.wikipedia.org/wiki/Harmonic) above a fundamental frequency, lying at integer multiples. These overtones combine to give a sound its unique timbral quality.
|
||||
|
||||
For the basic waveforms, we offer you control over these harmonics with the `partials` and `phases` functions.
|
||||
|
||||
#### Partials
|
||||
|
||||
`partials` refers to the magnitude of each harmonic relative to the fundamental frequency. They can thus be used to spectrally filter these waveforms and tame some of their harshness:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
|
||||
.sound("sawtooth")
|
||||
.n("<32 16 8 4>")
|
||||
.partials([1, 1, "<1 0>", "<1 0>", "<1 0>", "<1 0>", "<1 0>"])
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
When the `n` control is used on a basic waveform, it defines the number of harmonic partials the sound is getting.
|
||||
You can also set `n` directly in mini notation with `sound`:
|
||||
`partials` can also be used to construct _new_ waveforms not present in our basic set with the 'user' sound source:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
|
||||
.sound("sawtooth:<32 16 8 4>")
|
||||
.sound("user")
|
||||
.partials([1, 0, 0.3, 0, 0.1, 0, 0, 0.3])
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
Note for tidal users: `n` in tidal is synonymous to `note` for synths only.
|
||||
In strudel, this is not the case, where `n` will always change timbre, be it though different samples or different waveforms.
|
||||
We may algorithmically construct lists of magnitudes with Javascript code like:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`const numHarmonics = 22;
|
||||
note("c2 <eb2 <g2 g1>>".fast(2))
|
||||
.sound("saw")
|
||||
.partials(new Array(numHarmonics).fill(1))
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
which acts as a spectral filter. Or:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>").fast(2)
|
||||
.sound("user")
|
||||
.partials(new Array(50).fill(0)
|
||||
.map((_, idx) => ((-1) ** (idx + 1)) / (idx + 1))
|
||||
)
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
which recovers a familiar waveform.
|
||||
|
||||
`partials` is also compatible with pattern functions designed to produce lists, like `randL` or `binaryL`:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>").fast(2)
|
||||
.sound("user")
|
||||
.partials(randL(10))
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
and with lists _of_ patterns:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>".fast(4))
|
||||
.sound("user")
|
||||
.partials([1, 0, "0 1", "0 1 0.3", rand])
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
Note that the first value in the `partials` array controls the magnitude of the fundamental harmonic rather than the DC offset, which is fixed at 0.
|
||||
|
||||
#### Phases
|
||||
|
||||
Earlier, we mentioned that periodic waveforms can be broken into a set of harmonics above a fundamental frequency. Each harmonic has two defining properties: its magnitude (how loud it is) and its phase, which determines where in its cycle that sine wave starts when the waveform is built.
|
||||
|
||||
These phases too can be declared in Strudel and can give your sounds interesting depth.
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`s("saw").seg(16).n(irand(12)).scale("F1:minor")
|
||||
.penv(48).panchor(0).pdec(0.05)
|
||||
.delay(0.25).room(0.25)
|
||||
.compressor(-20).vib(0.3)
|
||||
.partials(randL(200))
|
||||
.phases(randL(200))`}
|
||||
/>
|
||||
|
||||
## Vibrato
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ note("c3 [e3 g3]*2")
|
||||
is transpiled to:
|
||||
|
||||
```strudel
|
||||
note(m('c3 [e3 g3]', 5))
|
||||
note(m('c3 [e3 g3]*2', 5))
|
||||
```
|
||||
|
||||
Here, the string is wrapped in `m`, which will create a pattern from a mini-notation string. As the second parameter, it gets passed source code location of the string, which enables highlighting active events later.
|
||||
|
||||
@@ -13,7 +13,7 @@ Let's take a closer look about how sounds are implemented in the webaudio output
|
||||
|
||||
All sounds are registered in the sound map, using the the `registerSound` function:
|
||||
|
||||
```ts
|
||||
```js
|
||||
function registerSound(
|
||||
name: string, // The name of the sound that should be given to `s`, e.g. `mysaw`
|
||||
// The function called by the scheduler to trigger the sound:
|
||||
@@ -35,34 +35,36 @@ When `registerSound` is called, it registers `{ onTrigger, data }` under the giv
|
||||
|
||||
This might be a bit abstract, so here is a minimal example:
|
||||
|
||||
```js
|
||||
registerSound(
|
||||
'mysaw',
|
||||
(time, value, onended) => {
|
||||
let { freq } = value; // destructure control params
|
||||
const ctx = getAudioContext();
|
||||
// create oscillator
|
||||
const o = new OscillatorNode(ctx, { type: 'sawtooth', frequency: Number(freq) });
|
||||
o.start(time);
|
||||
// add gain node to level down osc
|
||||
const g = new GainNode(ctx, { gain: 0.3 });
|
||||
// connect osc to gain
|
||||
const node = o.connect(g);
|
||||
// this function can be called from outside to stop the sound
|
||||
const stop = (time) => o.stop(time);
|
||||
// ended will be fired when stop has been fired
|
||||
o.addEventListener('ended', () => {
|
||||
o.disconnect();
|
||||
g.disconnect();
|
||||
onended();
|
||||
});
|
||||
return { node, stop };
|
||||
},
|
||||
{ type: 'synth' },
|
||||
);
|
||||
// use the sound
|
||||
freq(220, 440, 330).s('mysaw');
|
||||
```
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`
|
||||
registerSound(
|
||||
'mysaw',
|
||||
(time, value, onended) => {
|
||||
let { freq } = value; // destructure control params
|
||||
const ctx = getAudioContext();
|
||||
// create oscillator
|
||||
const o = new OscillatorNode(ctx, { type: 'sawtooth', frequency: Number(freq) });
|
||||
o.start(time);
|
||||
// add gain node to level down osc
|
||||
const g = new GainNode(ctx, { gain: 0.3 });
|
||||
// connect osc to gain
|
||||
const node = o.connect(g);
|
||||
// this function can be called from outside to stop the sound
|
||||
const stop = (time) => o.stop(time);
|
||||
// ended will be fired when stop has been fired
|
||||
o.addEventListener('ended', () => {
|
||||
o.disconnect();
|
||||
g.disconnect();
|
||||
onended();
|
||||
});
|
||||
return { node, stop };
|
||||
},
|
||||
{ type: 'synth' },
|
||||
);
|
||||
// use the sound
|
||||
freq("220 440 330").s('mysaw');`}
|
||||
/>
|
||||
|
||||
You can actually use this code in the [REPL](https://strudel.cc/) and it'll work.
|
||||
After evaluating the code, you should see `mysaw` in listed in the sounds tab.
|
||||
|
||||
@@ -35,3 +35,24 @@ Troubleshooting
|
||||
|
||||
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
||||
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
||||
|
||||
## Adding custom keybindings
|
||||
|
||||
To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script)
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
// Map 'jk' to Escape in normal mode
|
||||
Vim.map('jk', '<Esc>', 'insert');
|
||||
// Map 'U' to :w (Evaluate the current code)
|
||||
Vim.map('U', ':w<CR>', 'normal');
|
||||
// Map 'Q' to :q — Stop/pause playback
|
||||
Vim.map('Q', ':q<CR>', 'normal');
|
||||
// Map 'J' to find next '$' (jump to next label)
|
||||
Vim.map('J', '/\\$<CR>', 'normal');
|
||||
// Map 'K' to find previous '$' (jump to previous label)
|
||||
Vim.map('K', '?\\$<CR>', 'normal');
|
||||
```
|
||||
|
||||
For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim)
|
||||
|
||||
@@ -377,4 +377,4 @@ insect [crow metal] - -,
|
||||
punchcard
|
||||
/>
|
||||
|
||||
Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes)
|
||||
Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes).
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
audiograph.mjs - show a svg view of the web audio API graph built during a playback
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/website/src/repl/audiograph.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/>.
|
||||
*/
|
||||
|
||||
// main entry point is `debugAudiograph`
|
||||
import { logger } from '@strudel/core';
|
||||
import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
|
||||
|
||||
let mermaid = null;
|
||||
let svgPanZoom = null;
|
||||
|
||||
let running = false;
|
||||
let hap_count = 0;
|
||||
|
||||
let cache = new Map();
|
||||
const initCache = JSON.stringify({
|
||||
connect: [],
|
||||
where: [],
|
||||
disconnectAll: 0,
|
||||
disconnectOne: 0,
|
||||
hasStop: false,
|
||||
stopCount: 0,
|
||||
ac: null,
|
||||
creation: null,
|
||||
});
|
||||
|
||||
let toggleOrig;
|
||||
|
||||
function stackTrace() {
|
||||
var err = new Error();
|
||||
const stacktrace = err.stack;
|
||||
const lines = stacktrace.split('\n');
|
||||
let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs'));
|
||||
if (lines[lineIndex].includes('gainNode')) lineIndex++;
|
||||
if (lines[lineIndex].includes('getWorklet')) lineIndex++;
|
||||
const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@');
|
||||
let match;
|
||||
match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/);
|
||||
if (match) {
|
||||
return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, '');
|
||||
}
|
||||
return '@';
|
||||
}
|
||||
|
||||
// This captures all AudioNodes lazily
|
||||
// when an `.audioid` property is called
|
||||
// no solution was found to hook
|
||||
// AudioNode's constructor directly
|
||||
let audioid = 0;
|
||||
const lazyRegister = (o) => {
|
||||
Object.defineProperty(o.prototype, 'audioid', {
|
||||
get: function () {
|
||||
if (!this._audioid) {
|
||||
this._audioid = ++audioid;
|
||||
const s = JSON.parse(initCache);
|
||||
s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name;
|
||||
// special case for subclassed AudioNodes
|
||||
// they are implemented in superdough but hard to get a reference on here
|
||||
// they are not AudioScheduledSourceNodes anyway
|
||||
if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) {
|
||||
s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode;
|
||||
}
|
||||
s.ac = this.context?.constructor.name || 'AudioParam';
|
||||
s.creation = s.creation || stackTrace();
|
||||
cache.set(this._audioid, s);
|
||||
}
|
||||
return this._audioid;
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// extend a specific AudioNode's constructor
|
||||
// necessary when creation is done direclty by
|
||||
// calling the constructor
|
||||
// eg: new GainNode(...)
|
||||
const audioNodeHook = (node) => {
|
||||
const name = node.prototype.constructor.name;
|
||||
const PatchedNode = class AudiographNode extends node {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
// trigger the lazy register
|
||||
this._audioid = this.audioid;
|
||||
}
|
||||
};
|
||||
PatchedNode._parentClassName = name;
|
||||
window[name] = PatchedNode;
|
||||
};
|
||||
|
||||
const drawMessage = async function (message) {
|
||||
const element = document.querySelector('.strudel-mermaid');
|
||||
let gd = '';
|
||||
gd += '---\n';
|
||||
gd += 'config:\n';
|
||||
gd += ' flowchart:\n';
|
||||
gd += ' wrappingWidth: 600\n';
|
||||
gd += '---\n';
|
||||
gd += 'flowchart LR\n';
|
||||
gd += 'id[' + message.replaceAll(' ', ' ') + ']\n';
|
||||
|
||||
let { svg } = await mermaid.render('strudelSvgId', gd);
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
svg = svg.replaceAll('&nbsp;', ' ');
|
||||
element.innerHTML = svg;
|
||||
};
|
||||
|
||||
const drawDiagram = async function () {
|
||||
const element = document.querySelector('.strudel-mermaid');
|
||||
let code = window.strudelMirror.code;
|
||||
code = code.replace(/^await debugAudiograph.*\n?/gm, '');
|
||||
code = '// date: ' + new Date().toISOString() + '\n\n' + code;
|
||||
code = '// host: ' + document.location.hostname + '\n' + code;
|
||||
const codeLines = code.split(/(?:\n|\r\n?)/);
|
||||
const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
|
||||
|
||||
// https://mermaid.js.org/syntax/flowchart.html
|
||||
let gd = '';
|
||||
gd += '---\n';
|
||||
gd += 'config:\n';
|
||||
gd += ' flowchart:\n';
|
||||
gd += ' wrappingWidth: ' + 14 * maxLineLength + '\n';
|
||||
gd += '---\n';
|
||||
gd += 'flowchart TB\n';
|
||||
gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';
|
||||
|
||||
// seed graph builder with all
|
||||
// unconnected nodes
|
||||
let lookup = [];
|
||||
cache.forEach((v, k) => {
|
||||
if (v.connect.length === 0) lookup.push(k);
|
||||
});
|
||||
const relations = [];
|
||||
let curRelations;
|
||||
const zombieCount = 0;
|
||||
const sourceLoc = (stack) => {
|
||||
if (stack === '@') return stack;
|
||||
return stack.replace('@', '\n').replace('/superdough/', '/');
|
||||
};
|
||||
const label = (s) => {
|
||||
const source = s.creation ? '\n' + sourceLoc(s.creation) : '';
|
||||
let lb = '[' + '**' + s.type + '**' + source + ']';
|
||||
if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']';
|
||||
return lb;
|
||||
};
|
||||
const isConnectLeak = (s) => {
|
||||
return (
|
||||
['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 &&
|
||||
s.disconnectAll === 0 &&
|
||||
s.connect.length > s.disconnectOne
|
||||
);
|
||||
};
|
||||
const isStopLeak = (s) => {
|
||||
return s.hasStop && s.stopCount === 0;
|
||||
};
|
||||
do {
|
||||
curRelations = relations.length;
|
||||
lookup.slice().forEach((n) => {
|
||||
cache.forEach((v, k) => {
|
||||
if (v.connect.indexOf(n) !== -1) {
|
||||
if (lookup.indexOf(k) === -1) lookup.push(k);
|
||||
gd += v.connect
|
||||
.map((i) => {
|
||||
if (lookup.indexOf(i) === -1) lookup.push(i);
|
||||
if (relations.indexOf(k + '-' + i) === -1) {
|
||||
relations.push(k + '-' + i);
|
||||
return (
|
||||
'\t\tnode' +
|
||||
k +
|
||||
label(v) +
|
||||
' -- ' +
|
||||
sourceLoc(v.where[0]) +
|
||||
' --> node' +
|
||||
i +
|
||||
label(cache.get(i)) +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
if (k === n) {
|
||||
gd += v.connect
|
||||
.map((i) => {
|
||||
if (lookup.indexOf(i) === -1) lookup.push(i);
|
||||
if (relations.indexOf(k + '-' + i) === -1) {
|
||||
relations.push(k + '-' + i);
|
||||
return (
|
||||
'\t\tnode' +
|
||||
k +
|
||||
label(v) +
|
||||
' -- ' +
|
||||
sourceLoc(v.where[0]) +
|
||||
' --> node' +
|
||||
i +
|
||||
label(cache.get(i)) +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
});
|
||||
});
|
||||
} while (relations.length > curRelations /*&& lookup.length < 100*/);
|
||||
// add orphan nodes
|
||||
const inRelation = '-' + relations.join('-') + '-';
|
||||
cache.forEach((v, k) => {
|
||||
if (!inRelation.includes('-' + k + '-')) {
|
||||
gd += '\t\tnode' + k + label(v) + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
const codePlaceholder = 'm'.repeat(maxLineLength);
|
||||
gd += '\tsubgraph LEGEND\n';
|
||||
gd += '\t\tlegend1[in AudioContext]\n';
|
||||
gd += '\t\tlegend2[[in OfflineAudioContext]]\n';
|
||||
gd += '\t\tlegend3[not disconnected]\n';
|
||||
gd += '\t\tlegend4[AudioParam]\n';
|
||||
gd += '\t\tlegend5[AudioDestinationNode]\n';
|
||||
gd += '\t\tlegend6[not stopped]\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tsubgraph CODE[Strudel Code]\n';
|
||||
// we use a codePlaceholder to
|
||||
// - avoid problems with special chars
|
||||
// - stop mermaid to split lines on space with multiple tspans
|
||||
// - force mermaid to prepare a sufficiently sized zone
|
||||
gd += '\ncode[' + (codePlaceholder + '<br>').repeat(codeLines.length) + ']\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tclassDef audioparam fill:#6f6;\n';
|
||||
gd += '\tclassDef destination fill:#99f;\n';
|
||||
gd += '\tclassDef connectleak fill:#f96,stroke:#f00,stroke-width:2px;\n';
|
||||
gd += '\tclassDef stopleak fill:#f55,stroke:#f00,stroke-width:2px;\n';
|
||||
gd += '\tclass legend3 connectleak;\n';
|
||||
gd += '\tclass legend4 audioparam;\n';
|
||||
gd += '\tclass legend5 destination;\n';
|
||||
gd += '\tclass legend6 stopleak;\n';
|
||||
cache.forEach((v, k) => {
|
||||
if (isConnectLeak(v)) {
|
||||
gd += '\tclass node' + k + ' connectleak;\n';
|
||||
} else if (isStopLeak(v)) {
|
||||
gd += '\tclass node' + k + ' stopleak;\n';
|
||||
}
|
||||
if (v.type === 'AudioParam') {
|
||||
gd += '\tclass node' + k + ' audioparam;\n';
|
||||
}
|
||||
if (v.type === 'AudioDestinationNode') {
|
||||
gd += '\tclass node' + k + ' destination;\n';
|
||||
}
|
||||
});
|
||||
let { svg } = await mermaid.render('strudelSvgId', gd);
|
||||
|
||||
// put real code in code zone
|
||||
let idx = 0;
|
||||
const escapeHtml = (unsafe) => {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));
|
||||
|
||||
// improve sizing on web page
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
element.innerHTML = svg;
|
||||
|
||||
// align the code lines
|
||||
let svgText = document.querySelector('[id^=flowchart-code] text');
|
||||
svgText.setAttributeNS(null, 'style', 'text-anchor: start;');
|
||||
const svgElement = document.querySelector('svg');
|
||||
const svgLabel = document.querySelector('svg [id^=flowchart-code] .label');
|
||||
const transformList = svgLabel.transform.baseVal;
|
||||
const svgTransform = svgElement.createSVGTransform();
|
||||
const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan'));
|
||||
let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0);
|
||||
svgTransform.setTranslate(-tspansMaxLength / 2, 0);
|
||||
transformList.appendItem(svgTransform);
|
||||
|
||||
let doPan = false;
|
||||
let eventsHandler;
|
||||
let panZoom;
|
||||
let mousepos;
|
||||
|
||||
eventsHandler = {
|
||||
haltEventListeners: ['mousedown', 'mousemove', 'mouseup'],
|
||||
mouseDownHandler: function (ev) {
|
||||
if (event.target.className == '[object SVGAnimatedString]') {
|
||||
doPan = true;
|
||||
mousepos = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
}
|
||||
},
|
||||
mouseMoveHandler: function (ev) {
|
||||
if (doPan) {
|
||||
panZoom.panBy({
|
||||
x: ev.clientX - mousepos.x,
|
||||
y: ev.clientY - mousepos.y,
|
||||
});
|
||||
mousepos = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
},
|
||||
mouseUpHandler: function (ev) {
|
||||
doPan = false;
|
||||
},
|
||||
init: function (options) {
|
||||
options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false);
|
||||
options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false);
|
||||
options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false);
|
||||
},
|
||||
destroy: function (options) {
|
||||
options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false);
|
||||
options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false);
|
||||
options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false);
|
||||
},
|
||||
};
|
||||
panZoom = svgPanZoom('#strudelSvgId', {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: true,
|
||||
fit: 1,
|
||||
center: 1,
|
||||
zoomScaleSensitivity: 0.4,
|
||||
customEventsHandler: eventsHandler,
|
||||
});
|
||||
};
|
||||
|
||||
const svgExport = async () => {
|
||||
const a = document.createElement('a');
|
||||
document.body.appendChild(a);
|
||||
a.style = 'display: none';
|
||||
|
||||
const selector = '.strudel-mermaid';
|
||||
const bbox = document.querySelector('svg g').getBBox();
|
||||
let transform, style;
|
||||
// clean pan-zoom viewport
|
||||
const pzViewport = document.querySelector('.svg-pan-zoom_viewport');
|
||||
if (pzViewport) {
|
||||
transform = pzViewport.transform;
|
||||
style = pzViewport.style;
|
||||
pzViewport.setAttribute('transform', '');
|
||||
pzViewport.style = '';
|
||||
}
|
||||
|
||||
const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>';
|
||||
// prepare svg
|
||||
const content = document
|
||||
.querySelector(selector)
|
||||
.innerHTML.replaceAll('<br>', '<br/>')
|
||||
// remove useless tags
|
||||
.replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>')
|
||||
.replace(/<defs>.*<\/defs>/, '')
|
||||
// give inkscape true sizes
|
||||
.replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
|
||||
|
||||
// restore pan-zoom viewport
|
||||
if (pzViewport) {
|
||||
pzViewport.setAttribute('transform', transform);
|
||||
pzViewport.style = style;
|
||||
}
|
||||
|
||||
// trigger download
|
||||
var blob = new Blob([content], { type: 'image/svg+xml' }),
|
||||
url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = 'audiograph.svg';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const resetAudioOutput = function (audioid) {
|
||||
// calling reset on SuperdoughAudioController
|
||||
// will discard output nodes AND recreate them
|
||||
// so we keep the same `cache` to handle the
|
||||
// `disconnects` knowing that new nodes will be
|
||||
// stricly after the current audioid.
|
||||
// then we purge the old nodes from the `cache`
|
||||
// to have a clean state
|
||||
|
||||
// make sure destination will be recreated in the
|
||||
// cache
|
||||
const destination = getAudioContext().destination;
|
||||
if (destination._audioid) delete destination._audioid;
|
||||
|
||||
const sac = getSuperdoughAudioController();
|
||||
sac.reset();
|
||||
Array.from(cache.keys()).map((k) => {
|
||||
if (k <= audioid) cache.delete(k);
|
||||
});
|
||||
};
|
||||
|
||||
const postProcessing = async function () {
|
||||
hap_count = 0;
|
||||
await drawDiagram();
|
||||
|
||||
resetAudioOutput(audioid);
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
StopAfterHapCount: 10,
|
||||
hapsBatch: 0,
|
||||
maxEdges: 10000,
|
||||
maxTextSize: 200000,
|
||||
audioAPIBreathingRoomSec: 5,
|
||||
};
|
||||
|
||||
// `StopAfterHapCount` :
|
||||
// The player will auto-stop after hap count have
|
||||
// been played. when StopAfterHapCount = 0, it will
|
||||
// continue playing until 'stop' is clicked
|
||||
// `audioAPIBreathingRoomSec` :
|
||||
// how much time should we wait after 'stop' to let
|
||||
// the audioAPI finish its tail of ondended calls
|
||||
// `hapsBatch` :
|
||||
// the AudioGraph will be displayed every hapsBatch haps
|
||||
// when hapsBatch = 0, AudioGraph will only be displayed
|
||||
// after and auto-stop or after 'stop' is clicked
|
||||
// In hapsBatch mode you will probably see a trailing of
|
||||
// non disconnected notes on the graph because the audio
|
||||
// API may have some lag disconnecting them
|
||||
// cf also audioAPIBreathingRoomSec
|
||||
// `maxEdges`
|
||||
// This is a mermaid.js config that forces a hard limit
|
||||
// on the maximum number of Edges of a graph
|
||||
// needs a reload to be taken into account
|
||||
// `maxTextSize`
|
||||
// This is a mermaid.js config that forces a hard limit
|
||||
// on the maximum text size of a graph definition
|
||||
// needs a reload to be taken into account
|
||||
|
||||
export const debugAudiograph = async (argOptions = {}) => {
|
||||
const options = Object.assign({}, defaultOptions, argOptions);
|
||||
const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options;
|
||||
const sm = window.strudelMirror;
|
||||
const code = sm.code;
|
||||
if (!code.match(/await\s+debugAudiograph/)) {
|
||||
throw new Error('you need to call `await debugAudiograph()` for audiograph to work');
|
||||
}
|
||||
const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code);
|
||||
if (emptyOptions) {
|
||||
const cutCode = emptyOptions.index + emptyOptions[0].length - 1;
|
||||
const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', '');
|
||||
sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode));
|
||||
}
|
||||
|
||||
if (window.audiograph === undefined) {
|
||||
const ag = (window.audiograph = {});
|
||||
|
||||
toggleOrig = sm.toggle;
|
||||
|
||||
////////////////////////////////////////
|
||||
// step 1: web audio api instrumentation
|
||||
////////////////////////////////////////
|
||||
|
||||
// path AudioNode & AudioParam
|
||||
// to give them lazy ids
|
||||
// this captures both `ac.createGain`
|
||||
// and `new GainNode(..)` patterns
|
||||
lazyRegister(AudioNode);
|
||||
lazyRegister(AudioParam);
|
||||
lazyRegister(PeriodicWave);
|
||||
|
||||
const audioNodes = [
|
||||
AudioBufferSourceNode,
|
||||
AudioWorkletNode,
|
||||
AnalyserNode,
|
||||
BiquadFilterNode,
|
||||
ChannelMergerNode,
|
||||
ChannelSplitterNode,
|
||||
ConstantSourceNode,
|
||||
ConvolverNode,
|
||||
DelayNode,
|
||||
DynamicsCompressorNode,
|
||||
GainNode,
|
||||
IIRFilterNode,
|
||||
OscillatorNode,
|
||||
PannerNode,
|
||||
StereoPannerNode,
|
||||
WaveShaperNode,
|
||||
];
|
||||
audioNodes.map((n) => {
|
||||
if (n.prototype instanceof AudioScheduledSourceNode) {
|
||||
const stopOrig = n.prototype.stop;
|
||||
n.prototype.stop = function (...args) {
|
||||
// stop called
|
||||
const result = stopOrig.call(this, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
s.stopCount++;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
audioNodeHook(n);
|
||||
});
|
||||
|
||||
// patch BaseAudioContext factory methods
|
||||
// to capture the source reference
|
||||
Object.getOwnPropertyNames(BaseAudioContext.prototype)
|
||||
.filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1)
|
||||
.map((name) => {
|
||||
const orig = BaseAudioContext.prototype[name];
|
||||
BaseAudioContext.prototype[name] = function (...args) {
|
||||
const result = orig.call(this, ...args);
|
||||
const s = cache.get(result.audioid);
|
||||
s.creation = stackTrace();
|
||||
return result;
|
||||
};
|
||||
});
|
||||
|
||||
const connectOrig = AudioNode.prototype.connect;
|
||||
AudioNode.prototype.connect = function (destination, ...args) {
|
||||
const result = connectOrig.call(this, destination, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
s.connect.push(destination.audioid);
|
||||
s.where.push(stackTrace());
|
||||
return result;
|
||||
};
|
||||
|
||||
const disconnectOrig = AudioNode.prototype.disconnect;
|
||||
AudioNode.prototype.disconnect = function (destination, ...args) {
|
||||
const result = disconnectOrig.call(this, destination, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
if (s.connect.length) {
|
||||
if (destination) {
|
||||
s.disconnectOne++;
|
||||
} else {
|
||||
s.disconnectAll++;
|
||||
}
|
||||
} else {
|
||||
logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !');
|
||||
//logger(new Error().stack);
|
||||
console.log(cache);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// call reset 2 times to handle reload + 'play'
|
||||
// the first reset's disconnect adds audioid tags on previous outputs
|
||||
// that were not tagged (wrong cutoff)
|
||||
resetAudioOutput(audioid);
|
||||
// the second reset has the correct audioid cutoff
|
||||
resetAudioOutput(audioid);
|
||||
|
||||
////////////////////////////////////////
|
||||
// step 2: Load external modules
|
||||
////////////////////////////////////////
|
||||
|
||||
const { default: mermaidModule } = await import(
|
||||
'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
|
||||
);
|
||||
mermaid = mermaidModule;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
themeCSS: '.flowchart { height: 100%; }',
|
||||
maxEdges: maxEdges,
|
||||
maxTextSize: maxTextSize,
|
||||
htmlLabels: false,
|
||||
flowchart: {
|
||||
htmlLabels: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom');
|
||||
svgPanZoom = svgPanZoomModule;
|
||||
|
||||
//////////////////////////////////////////
|
||||
// step 3: UI modifications
|
||||
//////////////////////////////////////////
|
||||
|
||||
// add audiograph panel
|
||||
if (!document.querySelector('.strudel-mermaid')) {
|
||||
const mermaidDiv = document.createElement('div');
|
||||
mermaidDiv.className = 'strudel-mermaid';
|
||||
mermaidDiv.style = 'min-height: 600px; width: 60%';
|
||||
const referenceNode = document.querySelector('#code');
|
||||
referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling);
|
||||
}
|
||||
|
||||
// add svg export button
|
||||
if (!document.querySelector('button[title=svg]')) {
|
||||
const exportButton = document.createElement('button');
|
||||
exportButton.innerHTML = '<span>ExportDiagram</span>';
|
||||
exportButton.title = 'svg';
|
||||
exportButton.onclick = svgExport;
|
||||
const updateButton = document.querySelector('button[title=update]');
|
||||
updateButton.parentNode.insertBefore(exportButton, updateButton);
|
||||
}
|
||||
}
|
||||
|
||||
if (!running) {
|
||||
running = true;
|
||||
}
|
||||
|
||||
if (hapsBatch === 0 || hap_count < hapsBatch) {
|
||||
let msg = '';
|
||||
msg += 'Recording activity...';
|
||||
msg += '\npress stop to build diagram';
|
||||
if (StopAfterHapCount) {
|
||||
msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps';
|
||||
}
|
||||
await drawMessage(msg);
|
||||
}
|
||||
|
||||
sm.toggle = async () => {
|
||||
running = false;
|
||||
sm.toggle = toggleOrig;
|
||||
// schedule `toggle` on the js main loop
|
||||
// to avoid interfering with any on-flight onTick
|
||||
// not doing this can lead to a phase > 0 which will
|
||||
// break the next start
|
||||
setTimeout(sm.toggle.bind(sm), 0);
|
||||
await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work');
|
||||
setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000);
|
||||
};
|
||||
|
||||
/*global all*/
|
||||
all((pat) =>
|
||||
pat.onTrigger(async (hap, duration, cps, t) => {
|
||||
hap_count++;
|
||||
const key = Object.entries(hap.value)
|
||||
.map((param) => param.join('/'))
|
||||
.join('/');
|
||||
|
||||
// if we reached StopAfterHapCount, click 'stop'
|
||||
if (StopAfterHapCount && hap_count > StopAfterHapCount) {
|
||||
if (running) {
|
||||
await sm.toggle();
|
||||
}
|
||||
// stop sending haps to superdough(...)
|
||||
return;
|
||||
}
|
||||
|
||||
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
|
||||
|
||||
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -8,3 +8,34 @@ export function ActionButton({ children, label, labelIsHidden, className, ...but
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialActionButton(props) {
|
||||
const { className, ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
{...buttonProps}
|
||||
className={cx('bg-background p-2 max-w-[300px] rounded-md hover:opacity-50', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionInput({ label, className, ...props }) {
|
||||
return (
|
||||
<label className={cx('inline-flex items-center cursor-pointer', className)}>
|
||||
<input {...props} className="sr-only peer" />
|
||||
|
||||
<span className="inline-flex items-center peer-hover:opacity-50">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialActionInput({ className, ...props }) {
|
||||
return (
|
||||
<ActionInput
|
||||
{...props}
|
||||
className={className}
|
||||
label={<span className="bg-background p-2 max-w-[300px] rounded-md">{props.label}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
import cx from '@src/cx.mjs';
|
||||
import NumberInput from '@src/repl/components/NumberInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Textbox } from '../textbox/Textbox';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
||||
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
<label className={cx(disabled && 'opacity-50')}>
|
||||
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
|
||||
{' ' + label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormItem({ label, children, disabled }) {
|
||||
return (
|
||||
<div className="grid gap-2 w-full">
|
||||
<label className={cx(disabled && 'opacity-50')}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportTab(Props) {
|
||||
const { handleExport } = Props;
|
||||
|
||||
const [downloadName, setDownloadName] = useState('');
|
||||
const [startCycle, setStartCycle] = useState(0);
|
||||
const [endCycle, setEndCycle] = useState(1);
|
||||
const [sampleRate, setSampleRate] = useState(48000);
|
||||
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
|
||||
const [maxPolyphony, setMaxPolyphony] = useState(1024);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [length, setLength] = useState(1);
|
||||
|
||||
const refreshProgress = () => {
|
||||
const audioContext = getAudioContext();
|
||||
if (audioContext instanceof OfflineAudioContext) {
|
||||
setProgress(audioContext.currentTime);
|
||||
setLength(audioContext.length / sampleRate);
|
||||
setTimeout(refreshProgress, 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-foreground w-full p-4 space-y-4">
|
||||
<FormItem label="File name" disabled={exporting}>
|
||||
<Textbox
|
||||
onBlur={(e) => {
|
||||
setDownloadName(e.target.value);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setDownloadName(v);
|
||||
}}
|
||||
disabled={exporting}
|
||||
placeholder="Leave empty to use current date"
|
||||
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
|
||||
value={downloadName ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<div className="flex flex-row gap-4 w-full">
|
||||
<FormItem label="Start cycle" disabled={exporting}>
|
||||
<Textbox
|
||||
min={0}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? 0 : Math.max(0, v);
|
||||
setStartCycle(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setStartCycle(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
|
||||
value={startCycle ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="End cycle" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
|
||||
setEndCycle(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setEndCycle(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
|
||||
value={endCycle ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormItem label="Sample rate" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? 1 : Math.max(1, v);
|
||||
setSampleRate(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setSampleRate(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50')}
|
||||
value={sampleRate ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Maximum polyphony" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
|
||||
setMaxPolyphony(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = Math.max(1, parseInt(v));
|
||||
setMaxPolyphony(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50')}
|
||||
value={maxPolyphony ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Multi Channel Orbits"
|
||||
onChange={(cbEvent) => {
|
||||
const val = cbEvent.target.checked;
|
||||
setMultiChannelOrbits(val);
|
||||
}}
|
||||
disabled={exporting}
|
||||
value={multiChannelOrbits}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={cx('bg-background p-2 w-full rounded-md hover:opacity-75 relative', exporting && 'opacity-50')}
|
||||
disabled={exporting}
|
||||
onClick={async () => {
|
||||
setExporting(true);
|
||||
setTimeout(refreshProgress, 2000);
|
||||
const modal = document.getElementById('exportProgressModal');
|
||||
modal.showModal();
|
||||
await handleExport(startCycle, endCycle, sampleRate, maxPolyphony, multiChannelOrbits, downloadName)
|
||||
.then(() => {
|
||||
const modal = document.getElementById('exportProgressModal');
|
||||
modal.close();
|
||||
})
|
||||
.finally(() => {
|
||||
setExporting(false);
|
||||
setProgress(0);
|
||||
setLength(1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 backdrop-invert"
|
||||
style={{
|
||||
width: `${(exporting ? 1 : 0) + (progress / length) * 99}%`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
closedby={exporting ? 'none' : 'closerequest'}
|
||||
id="exportProgressModal"
|
||||
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-25"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { errorLogger } from '@strudel/core';
|
||||
import { useSettings, storePrebakeScript } from '../../../settings.mjs';
|
||||
import { SpecialActionInput } from '../button/action-button';
|
||||
import { confirmDialog, SETTING_CHANGE_RELOAD_MSG } from '@src/repl/util.mjs';
|
||||
|
||||
async function importScript(script) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(script);
|
||||
|
||||
reader.onload = () => {
|
||||
const text = reader.result;
|
||||
storePrebakeScript(text);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
errorLogger(new Error('failed to import prebake script'), 'importScript');
|
||||
};
|
||||
}
|
||||
export function ImportPrebakeScriptButton() {
|
||||
const settings = useSettings();
|
||||
|
||||
return (
|
||||
<SpecialActionInput
|
||||
type="file"
|
||||
label="import prebake script"
|
||||
accept=".strudel"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files[0];
|
||||
const confirmed = await confirmDialog(SETTING_CHANGE_RELOAD_MSG);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await importScript(file);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
errorLogger(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useLogger } from '../useLogger';
|
||||
import { WelcomeTab } from './WelcomeTab';
|
||||
import { PatternsTab } from './PatternsTab';
|
||||
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
|
||||
import ExportTab from './ExportTab';
|
||||
|
||||
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
|
||||
|
||||
@@ -80,6 +81,7 @@ const tabNames = {
|
||||
patterns: 'patterns',
|
||||
sounds: 'sounds',
|
||||
reference: 'reference',
|
||||
export: 'export',
|
||||
console: 'console',
|
||||
settings: 'settings',
|
||||
};
|
||||
@@ -126,6 +128,8 @@ function PanelContent({ context, tab }) {
|
||||
return <SoundsTab />;
|
||||
case tabNames.reference:
|
||||
return <Reference />;
|
||||
case tabNames.export:
|
||||
return <ExportTab handleExport={context.handleExport} />;
|
||||
case tabNames.settings:
|
||||
return <SettingsTab started={context.started} />;
|
||||
case tabNames.files:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
|
||||
import { themes } from '@strudel/codemirror';
|
||||
import { Textbox } from '../textbox/Textbox.jsx';
|
||||
import { isUdels } from '../../util.mjs';
|
||||
import { confirmAndReloadPage, isUdels } from '../../util.mjs';
|
||||
import { ButtonGroup } from './Forms.jsx';
|
||||
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
|
||||
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
|
||||
import { confirmDialog } from '../../util.mjs';
|
||||
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
|
||||
import { SpecialActionButton } from '../button/action-button.jsx';
|
||||
import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
|
||||
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
@@ -84,8 +86,6 @@ const fontFamilyOptions = {
|
||||
galactico: 'galactico',
|
||||
};
|
||||
|
||||
const RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
|
||||
|
||||
export function SettingsTab({ started }) {
|
||||
const {
|
||||
theme,
|
||||
@@ -113,6 +113,7 @@ export function SettingsTab({ started }) {
|
||||
isTabIndentationEnabled,
|
||||
isMultiCursorEnabled,
|
||||
patternAutoStart,
|
||||
includePrebakeScriptInShare,
|
||||
} = useSettings();
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
@@ -124,11 +125,8 @@ export function SettingsTab({ started }) {
|
||||
isDisabled={started}
|
||||
audioDeviceName={audioDeviceName}
|
||||
onChange={(audioDeviceName) => {
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r == true) {
|
||||
settingsMap.setKey('audioDeviceName', audioDeviceName);
|
||||
return window.location.reload();
|
||||
}
|
||||
confirmAndReloadPage(() => {
|
||||
settingsMap.setKey('audioDeviceName', audioDeviceName);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -138,11 +136,8 @@ export function SettingsTab({ started }) {
|
||||
<AudioEngineTargetSelector
|
||||
target={audioEngineTarget}
|
||||
onChange={(target) => {
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r == true) {
|
||||
settingsMap.setKey('audioEngineTarget', target);
|
||||
return window.location.reload();
|
||||
}
|
||||
confirmAndReloadPage(() => {
|
||||
settingsMap.setKey('audioEngineTarget', target);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -172,12 +167,9 @@ export function SettingsTab({ started }) {
|
||||
label="Multi Channel Orbits"
|
||||
onChange={(cbEvent) => {
|
||||
const val = cbEvent.target.checked;
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r == true) {
|
||||
settingsMap.setKey('multiChannelOrbits', val);
|
||||
setMultiChannelOrbits(val);
|
||||
return window.location.reload();
|
||||
}
|
||||
confirmAndReloadPage(() => {
|
||||
settingsMap.setKey('multiChannelOrbits', val);
|
||||
setMultiChannelOrbits(val);
|
||||
});
|
||||
}}
|
||||
value={multiChannelOrbits}
|
||||
@@ -204,6 +196,15 @@ export function SettingsTab({ started }) {
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="Prebake">
|
||||
<ImportPrebakeScriptButton />
|
||||
<Checkbox
|
||||
label="Include prebake script in share"
|
||||
onChange={(cbEvent) => settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)}
|
||||
value={includePrebakeScriptInShare}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Keybindings">
|
||||
<ButtonGroup
|
||||
value={keybindings}
|
||||
@@ -285,11 +286,8 @@ export function SettingsTab({ started }) {
|
||||
label="Sync across Browser Tabs / Windows"
|
||||
onChange={(cbEvent) => {
|
||||
const newVal = cbEvent.target.checked;
|
||||
confirmDialog(RELOAD_MSG).then((r) => {
|
||||
if (r) {
|
||||
settingsMap.setKey('isSyncEnabled', newVal);
|
||||
window.location.reload();
|
||||
}
|
||||
confirmAndReloadPage(() => {
|
||||
settingsMap.setKey('isSyncEnabled', newVal);
|
||||
});
|
||||
}}
|
||||
disabled={shouldAlwaysSync}
|
||||
@@ -313,8 +311,7 @@ export function SettingsTab({ started }) {
|
||||
</FormItem>
|
||||
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
|
||||
<FormItem label="Reset Settings">
|
||||
<button
|
||||
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
|
||||
<SpecialActionButton
|
||||
onClick={() => {
|
||||
confirmDialog('Sure?').then((r) => {
|
||||
if (r) {
|
||||
@@ -325,7 +322,7 @@ export function SettingsTab({ started }) {
|
||||
}}
|
||||
>
|
||||
restore default settings
|
||||
</button>
|
||||
</SpecialActionButton>
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,10 @@ import { prebake } from '@src/repl/prebake.mjs';
|
||||
const getSamples = (samples) =>
|
||||
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function SoundsTab() {
|
||||
const sounds = useStore(soundMap);
|
||||
|
||||
@@ -56,9 +60,7 @@ export function SoundsTab() {
|
||||
|
||||
// holds mutable ref to current triggered sound
|
||||
const trigRef = useRef();
|
||||
|
||||
// Used to cycle through sound previews on banks with multiple sounds
|
||||
let soundPreviewIdx = 0;
|
||||
const numRef = useRef(0);
|
||||
|
||||
// stop current sound on mouseup
|
||||
useEvent('mouseup', () => {
|
||||
@@ -66,6 +68,14 @@ export function SoundsTab() {
|
||||
trigRef.current = undefined;
|
||||
ref?.stop?.(getAudioContext().currentTime + 0.01);
|
||||
});
|
||||
useEvent('keydown', (e) => {
|
||||
if (!isNaN(Number(e.key))) {
|
||||
numRef.current = Number(e.key);
|
||||
}
|
||||
});
|
||||
useEvent('keyup', (e) => {
|
||||
numRef.current = 0;
|
||||
});
|
||||
return (
|
||||
<div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground">
|
||||
<Textbox placeholder="Search" value={search} onChange={(v) => setSearch(v)} />
|
||||
@@ -115,25 +125,34 @@ export function SoundsTab() {
|
||||
const params = {
|
||||
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
|
||||
s: name,
|
||||
n: soundPreviewIdx,
|
||||
n: numRef.current,
|
||||
clip: 1,
|
||||
release: 0.5,
|
||||
sustain: 1,
|
||||
duration: 0.5,
|
||||
};
|
||||
soundPreviewIdx++;
|
||||
const onended = () => trigRef.current?.node?.disconnect();
|
||||
try {
|
||||
// Pre-load the sample by calling onTrigger with a future time
|
||||
// This triggers the loading but schedules playback for later
|
||||
const time = ctx.currentTime + 0.5; // Give 500ms for loading
|
||||
const ref = await onTrigger(time, params, onended);
|
||||
trigRef.current = ref;
|
||||
if (ref?.node) {
|
||||
connectToDestination(ref.node);
|
||||
// Attempt to play the sample and retry every 200ms until 10 attempts have been reached
|
||||
let errMsg;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
// Pre-load the sample by calling onTrigger with a future time
|
||||
// This triggers the loading but schedules playback for later
|
||||
const time = ctx.currentTime + 0.05; // Give 50ms for loading
|
||||
const ref = await onTrigger(time, params, onended);
|
||||
trigRef.current = ref;
|
||||
if (ref?.node) {
|
||||
connectToDestination(ref.node);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
errMsg = err;
|
||||
}
|
||||
if (attempt == 9) {
|
||||
console.warn('Failed to trigger sound after 10 attempts' + (errMsg ? `: ${errMsg}` : ''));
|
||||
} else {
|
||||
await wait(200);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to trigger sound:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -191,7 +210,11 @@ export function SoundsTab() {
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
{!soundEntries.length && soundsFilter !== 'importSounds' ? 'No sounds loaded' : ''}
|
||||
{!soundEntries.length && soundsFilter !== 'importSounds'
|
||||
? search == ''
|
||||
? 'No sounds loaded'
|
||||
: 'No sounds found'
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { aliasBank, registerSynthSounds, registerZZFXSounds, samples } from '@st
|
||||
import { registerSamplesFromDB } from './idbutils.mjs';
|
||||
import './piano.mjs';
|
||||
import './files.mjs';
|
||||
import { settingsMap } from '@src/settings.mjs';
|
||||
import { evaluate } from '@strudel/transpiler';
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
|
||||
@@ -393,6 +393,8 @@ samples({
|
||||
bass: { d2: 'https://cdn.freesound.org/previews/608/608286_13074022-lq.mp3' }
|
||||
})
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// bells
|
||||
n("0").euclidLegato(3,8)
|
||||
@@ -430,6 +432,7 @@ export const festivalOfFingers3 = `// "Festival of fingers 3"
|
||||
// @by Felix Roos
|
||||
|
||||
setcps(1)
|
||||
useRNG('legacy')
|
||||
|
||||
n("[-7*3],0,2,6,[8 7]")
|
||||
.echoWith(
|
||||
@@ -454,6 +457,8 @@ export const meltingsubmarine = `// "Melting submarine"
|
||||
// @by Felix Roos
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
s("bd:5,[~ <sd:1!3 sd:1(3,4,3)>],hh27(3,4,1)") // drums
|
||||
.speed(perlin.range(.7,.9)) // random sample speed variation
|
||||
@@ -602,6 +607,9 @@ export const belldub = `// "Belldub"
|
||||
|
||||
samples({ bell: {b4:'https://cdn.freesound.org/previews/339/339809_5121236-lq.mp3'}})
|
||||
// "Hand Bells, B, Single.wav" by InspectorJ (www.jshaw.co.uk) of Freesound.org
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// bass
|
||||
note("[0 ~] [2 [0 2]] [4 4*2] [[4 ~] [2 ~] 0@2]".scale('g1 dorian').superimpose(x=>x.add(.02)))
|
||||
@@ -638,6 +646,7 @@ export const dinofunk = `// "Dinofunk"
|
||||
// @by Felix Roos
|
||||
|
||||
setcps(1)
|
||||
useRNG('legacy')
|
||||
|
||||
samples({bass:'https://cdn.freesound.org/previews/614/614637_2434927-hq.mp3',
|
||||
dino:{b4:'https://cdn.freesound.org/previews/316/316403_5123851-hq.mp3'}})
|
||||
@@ -666,6 +675,8 @@ export const sampleDemo = `// "Sample demo"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// percussion
|
||||
s("[woodblock:1 woodblock:2*2] snare_rim:0,gong/8,brakedrum:1(3,8),~@3 cowbell:3")
|
||||
@@ -684,6 +695,8 @@ export const holyflute = `// "Holy flute"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
"c3 eb3(3,8) c4/2 g3*2"
|
||||
.superimpose(
|
||||
x=>x.slow(2).add(12),
|
||||
@@ -699,6 +712,8 @@ export const flatrave = `// "Flatrave"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
s("bd*2,~ [cp,sd]").bank('RolandTR909'),
|
||||
|
||||
@@ -727,6 +742,8 @@ export const amensister = `// "Amensister"
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// amen
|
||||
n("0 1 2 3 4 5 6 7")
|
||||
@@ -834,6 +851,8 @@ export const arpoon = `// "Arpoon"
|
||||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
n("[0,3] 2 [1,3] 2".fast(3).lastOf(4, fast(2))).clip(2)
|
||||
|
||||
@@ -6,14 +6,16 @@ This program is free software: you can redistribute it and/or modify it under th
|
||||
|
||||
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
|
||||
import { getDrawContext } from '@strudel/draw';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
import { evaluate, transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContextCurrentTime,
|
||||
renderPatternAudio,
|
||||
webaudioOutput,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
resetDefaults,
|
||||
initAudio,
|
||||
} from '@strudel/webaudio';
|
||||
import { setVersionDefaultsFrom } from './util.mjs';
|
||||
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
|
||||
@@ -36,6 +38,7 @@ import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
|
||||
import './Repl.css';
|
||||
import { setInterval, clearInterval } from 'worker-timers';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { debugAudiograph } from './audiograph';
|
||||
|
||||
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
|
||||
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
|
||||
@@ -63,11 +66,10 @@ async function getModule(name) {
|
||||
const initialCode = `// LOADING`;
|
||||
|
||||
export function useReplContext() {
|
||||
const { isSyncEnabled, audioEngineTarget } = useSettings();
|
||||
const { isSyncEnabled, audioEngineTarget, prebakeScript, includePrebakeScriptInShare } = useSettings();
|
||||
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
|
||||
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
|
||||
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
|
||||
|
||||
const init = useCallback(() => {
|
||||
const drawTime = [-2, 2];
|
||||
const drawContext = getDrawContext();
|
||||
@@ -84,7 +86,12 @@ export function useReplContext() {
|
||||
pattern: silence,
|
||||
drawTime,
|
||||
drawContext,
|
||||
prebake: async () => Promise.all([modulesLoading, presets]),
|
||||
prebake: async () =>
|
||||
Promise.all([modulesLoading, presets]).then(() => {
|
||||
if (prebakeScript?.length) {
|
||||
return evaluate(prebakeScript ?? '');
|
||||
}
|
||||
}),
|
||||
onUpdateState: (state) => {
|
||||
setReplState({ ...state });
|
||||
},
|
||||
@@ -125,6 +132,7 @@ export function useReplContext() {
|
||||
bgFill: false,
|
||||
});
|
||||
window.strudelMirror = editor;
|
||||
window.debugAudiograph = debugAudiograph;
|
||||
|
||||
// init settings
|
||||
initCode().then(async (decoded) => {
|
||||
@@ -203,6 +211,30 @@ export function useReplContext() {
|
||||
const handleEvaluate = () => {
|
||||
editorRef.current.evaluate();
|
||||
};
|
||||
|
||||
const handleExport = async (begin, end, sampleRate, maxPolyphony, multiChannelOrbits, downloadName = undefined) => {
|
||||
await editorRef.current.evaluate(false);
|
||||
editorRef.current.repl.scheduler.stop();
|
||||
await renderPatternAudio(
|
||||
editorRef.current.repl.state.pattern,
|
||||
editorRef.current.repl.scheduler.cps,
|
||||
begin,
|
||||
end,
|
||||
sampleRate,
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
downloadName,
|
||||
).finally(async () => {
|
||||
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
|
||||
await initAudio({
|
||||
latestCode,
|
||||
maxPolyphony,
|
||||
audioDeviceName,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
editorRef.current.repl.scheduler.stop();
|
||||
});
|
||||
};
|
||||
const handleShuffle = async () => {
|
||||
const patternData = await getRandomTune();
|
||||
const code = patternData.code;
|
||||
@@ -214,7 +246,13 @@ export function useReplContext() {
|
||||
editorRef.current.repl.evaluate(code);
|
||||
};
|
||||
|
||||
const handleShare = async () => shareCode(replState.code);
|
||||
const handleShare = async () => {
|
||||
let code = replState.code;
|
||||
if (includePrebakeScriptInShare) {
|
||||
code = prebakeScript + '\n' + code;
|
||||
}
|
||||
shareCode(code);
|
||||
};
|
||||
const context = {
|
||||
started,
|
||||
pending,
|
||||
@@ -225,6 +263,7 @@ export function useReplContext() {
|
||||
handleShuffle,
|
||||
handleShare,
|
||||
handleEvaluate,
|
||||
handleExport,
|
||||
init,
|
||||
error,
|
||||
editorRef,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { code2hash, errorLogger, evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { settingPatterns } from '../settings.mjs';
|
||||
import { setVersionDefaults } from '@strudel/webaudio';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { isTauri } from '../tauri.mjs';
|
||||
import './Repl.css';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { $featuredPatterns /* , loadDBPatterns */ } from '@src/user_pattern_utils.mjs';
|
||||
|
||||
@@ -108,10 +107,21 @@ export function confirmDialog(msg) {
|
||||
resolve(confirmed);
|
||||
});
|
||||
}
|
||||
|
||||
let lastShared;
|
||||
|
||||
export const SETTING_CHANGE_RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
|
||||
export function confirmAndReloadPage(onSuccess) {
|
||||
confirmDialog(SETTING_CHANGE_RELOAD_MSG).then((r) => {
|
||||
if (r == true) {
|
||||
try {
|
||||
onSuccess();
|
||||
return window.location.reload();
|
||||
} catch (e) {
|
||||
errorLogger(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
//RIP due to SPAM
|
||||
// let lastShared;
|
||||
// export async function shareCode(codeToShare) {
|
||||
// // const codeToShare = activeCode || code;
|
||||
// if (lastShared === codeToShare) {
|
||||
@@ -146,9 +156,10 @@ let lastShared;
|
||||
// });
|
||||
// }
|
||||
|
||||
export async function shareCode() {
|
||||
export async function shareCode(codeToShare) {
|
||||
try {
|
||||
const shareUrl = window.location.href;
|
||||
const hash = '#' + code2hash(codeToShare);
|
||||
const shareUrl = window.location.origin + window.location.pathname + hash;
|
||||
if (isTauri()) {
|
||||
await writeText(shareUrl);
|
||||
} else {
|
||||
|
||||
@@ -45,11 +45,13 @@ export const defaultSettings = {
|
||||
isPanelOpen: true,
|
||||
togglePanelTrigger: 'click', //click | hover
|
||||
userPatterns: '{}',
|
||||
prebakeScript: '',
|
||||
audioEngineTarget: audioEngineTargets.webaudio,
|
||||
isButtonRowHidden: false,
|
||||
isCSSAnimationDisabled: false,
|
||||
maxPolyphony: 128,
|
||||
multiChannelOrbits: false,
|
||||
includePrebakeScriptInShare: true,
|
||||
};
|
||||
|
||||
let search = null;
|
||||
@@ -96,6 +98,7 @@ export function useSettings() {
|
||||
isPanelOpen: parseBoolean(state.isPanelOpen),
|
||||
userPatterns: userPatterns,
|
||||
multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
|
||||
includePrebakeScriptInShare: parseBoolean(state.includePrebakeScriptInShare),
|
||||
patternAutoStart: isUdels()
|
||||
? false
|
||||
: state.patternAutoStart === undefined
|
||||
@@ -108,6 +111,8 @@ export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab);
|
||||
export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool);
|
||||
export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool);
|
||||
|
||||
export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script);
|
||||
|
||||
export const setIsZen = (active) => settingsMap.setKey('isZen', !!active);
|
||||
|
||||
const patternSetting = (key) =>
|
||||
|
||||
Reference in New Issue
Block a user