Compare commits

..

153 Commits

Author SHA1 Message Date
alex 85ee97c9ef minimini starting to work with some passing tests and highlights 2024-10-04 18:52:40 +01:00
alex 479041ebb6 paste in proto minimini 2024-10-04 16:00:36 +01:00
Felix Roos ad080d02b3 Merge pull request #1189 from kdiab/main
remove redundant example for cat, update snapshot
2024-09-27 00:17:00 +02:00
Khalid e9e7e02547 Fixed access to merged description in factories.mdx 2024-09-26 17:58:59 -04:00
Felix Roos 2ffc6c9601 Merge pull request #1185 from Bubobubobubobubo/search-bar
Adding search bar (soundtab.jsx)
2024-09-25 13:18:19 +02:00
Raphaël Forment 3a9349d9f5 format code for tests 2024-09-25 12:59:18 +02:00
Raphaël Forment bd3f2845f8 Reverting code 2024-09-25 12:57:20 +02:00
Alex McLean 2d320c459b Screenreader improvements (#1158)
* hide ascii art for screen readers
* aria-labels for minirepl buttons
2024-09-23 16:18:34 +01:00
Raphaël Forment ff9f4a8a0d Simplify search bar code 2024-09-22 23:40:27 +02:00
Raphaël Forment d7b94807cb Merge branch 'tidalcycles:main' into search-bar 2024-09-22 23:30:11 +02:00
kdiab 2c16a37a68 Combine examples for stack, match wording for cat, stack and seq 2024-09-22 10:42:46 -04:00
kdiab 717e4a2c1d Combine examples for seq and remove extra entry 2024-09-22 10:31:50 -04:00
kdiab c225b4719f Combine examples for cat 2024-09-21 11:01:00 -04:00
kdiab 25f762c393 remove redundant example for cat, update snapshot 2024-09-20 23:38:21 -04:00
Alex McLean b75561e3c7 Fix serial timing (#1188)
* fix serial timing
* format
2024-09-20 21:26:41 +01:00
Alex McLean abaab89061 Add bite function (#1187)
For patterning slices of patterns. ref #31.
2024-09-19 23:26:30 +01:00
Felix Roos e409086b34 Merge pull request #1147 from tidalcycles/bettern-zen-spacing
better spacing in zen mode
2024-09-14 13:30:53 +02:00
Felix Roos 532f1435d9 revert lockfile 2024-09-14 13:15:44 +02:00
Raphaël Forment 6cb61ade03 Adding search bar in soundtab.jsx 2024-09-14 11:39:59 +02:00
Felix Roos a40219ba06 Merge pull request #1039 from tidalcycles/filter
add filter + filterWhen + within
2024-09-14 10:50:23 +02:00
Felix Roos 9c99f5ada2 Merge pull request #1101 from tidalcycles/sampler-refactoring
refactor sampler
2024-09-14 10:49:21 +02:00
Felix Roos 2d72c8faa0 Merge pull request #1146 from tidalcycles/midi-error-msg
handle midin device not found error
2024-09-14 10:43:17 +02:00
Felix Roos b188fc6b6d Merge branch 'main' into sampler-refactoring 2024-09-14 10:38:27 +02:00
Martín Rodríguez bfb22228cc Add a search bar to the REPL Reference tab (#1165)
* Add a search bar to the REPL Reference tab
Simple text input that sticks to the top.
Filters by just checking if the search term is in the entry name or synonyms, if available.
2024-09-13 20:59:23 +01:00
Edward Betts 9714d88d61 Correct spelling mistakes (#1183) 2024-09-13 20:58:47 +01:00
Alex McLean bdddb91510 add seqPLoop, ref #31 (#1182) 2024-09-07 22:41:29 +01:00
Jade (Rose) Rowland 5bf1805534 Merge pull request #1180 from daslyfe/phaser_controls
make phaser control match superdirt
2024-09-04 23:52:50 -04:00
Jade (Rose) Rowland 202685e8c4 update test snapshot 2024-09-04 23:50:48 -04:00
Jade (Rose) Rowland aba633e16e fix 2024-09-04 23:42:38 -04:00
Jade (Rose) Rowland da45ce689c fixed formatting 2024-09-04 23:36:20 -04:00
Jade (Rose) Rowland acea7e435e Merge pull request #1179 from tidalcycles/revert-1178-consistent_phaser_controls
Revert "Make phaser control consistent with superdirt"
2024-09-04 23:33:41 -04:00
Jade (Rose) Rowland cb4810cbbc Revert "Make phaser control consistent with superdirt" 2024-09-04 23:32:35 -04:00
Jade (Rose) Rowland 5ec205b383 Merge pull request #1178 from daslyfe/consistent_phaser_controls
Make phaser control consistent with superdirt
2024-09-04 23:29:11 -04:00
Jade (Rose) Rowland ebc0b293a9 updated 2024-09-04 23:20:42 -04:00
Jade (Rose) Rowland 4608c321bd Merge pull request #1172 from daslyfe/speed-cps-fix
fix sample speed when using splice and fit with superdirt
2024-09-02 22:37:15 -04:00
Jade (Rose) Rowland c4243f41c6 spelling 2024-09-01 16:15:55 -04:00
Jade (Rose) Rowland d95b8bc667 Merge pull request #1160 from daslyfe/audio_target_selector
Create audio target selector for OSC/Superdirt
2024-09-01 10:02:24 -04:00
Alex McLean 263c13c36e Fixes fit so it works after a chop or slice (#1171)
* fix fit so it works after a chop or slice

* format
2024-09-01 14:03:47 +01:00
Jade (Rose) Rowland ddceb36613 prettier 2024-08-31 23:49:56 -04:00
Jade (Rose) Rowland ff8ec628be spelling 2024-08-31 23:43:07 -04:00
Jade (Rose) Rowland 11a07df120 add docs for OSC target 2024-08-31 23:38:23 -04:00
Jade (Rose) Rowland 8befa4382b Merge branch 'main' into audio_target_selector 2024-08-31 22:20:53 -04:00
Alex McLean 39006e4d71 polyJoin (#1168)
* create polymetric join - `polyJoin`
* add `poly` as an op alignment to support e.g. `"c e f [g a]".add.poly("0 12 7")`
2024-08-30 13:24:08 +01:00
Alex McLean 812ed6b60d snapshot 2024-08-23 16:12:28 +01:00
Alex McLean 8b6c8eb35d Add scramble and shuffle (#1167)
* add scramble and shuffle functions from Tidal
2024-08-23 16:05:10 +01:00
Jade (Rose) Rowland dcda9a5753 cleanup 2024-08-18 13:21:37 -04:00
Jade (Rose) Rowland d3733d4209 prettier 2024-08-18 13:07:41 -04:00
Jade (Rose) Rowland ef6af96947 Merge branch 'tidalcycles:main' into audio_target_selector 2024-08-18 12:39:28 -04:00
Jade (Rose) Rowland d86ff0a536 Merge pull request #1164 from daslyfe/improve_neocyclist_timing
Improve + simplify neocyclist timing
2024-08-18 12:22:20 -04:00
Jade (Rose) Rowland 2c9ea03fde prettier 2024-08-18 12:08:40 -04:00
Jade (Rose) Rowland 4eae366bee cleaning up 2024-08-18 12:05:03 -04:00
Jade (Rose) Rowland 117f7fa4b5 cleaning up 2024-08-17 17:26:46 -04:00
Jade (Rose) Rowland 73ee84a1fb working 2024-08-17 17:11:51 -04:00
Jade (Rose) Rowland 9c899d5308 testing 2024-08-17 15:31:21 -04:00
Jade (Rose) Rowland b60beaf508 prettier 2024-08-16 10:56:09 -04:00
Jade (Rose) Rowland 796f741a92 use performance clock for osc mode 2024-08-16 10:46:18 -04:00
Jade (Rose) Rowland 8da1d9fbf3 reset collator after n seconds 2024-08-16 00:44:18 -04:00
Jade (Rose) Rowland 5345986b5b Merge branch 'main' into audio_target_selector 2024-08-15 16:28:19 -04:00
Jade (Rose) Rowland 3945abd7e9 Merge pull request #1163 from daslyfe/cross_orgin_check
containerize/seperate out boolean checks for repl types/Repl logic into bespoke components.
2024-08-14 23:18:33 -04:00
Jade (Rose) Rowland 053e4ee54b Merge branch 'tidalcycles:main' into cross_orgin_check 2024-08-12 20:50:15 -04:00
Jade (Rose) Rowland 25d61a2e31 prettier 2024-08-12 20:38:29 -04:00
Jade (Rose) Rowland 5e6131af24 reorganized repl 2024-08-12 20:32:58 -04:00
Jade (Rose) Rowland 2899ee5aef testing: 2024-08-12 18:11:52 -04:00
Jade (Rose) Rowland 9d1674b13c Merge pull request #1162 from daslyfe/cors_pathname_issue
[CORS HOTFIX]
2024-08-12 12:57:21 -04:00
Jade (Rose) Rowland bfead58212 prettier 2024-08-12 12:56:04 -04:00
Jade (Rose) Rowland 926c893c4f check iframe 2024-08-12 12:54:05 -04:00
Jade (Rose) Rowland 3cd31e4841 fixed prettier 2024-08-11 12:50:14 -04:00
Jade (Rose) Rowland c2be2ed76a fixed osc clock 2024-08-11 12:40:38 -04:00
Jade (Rose) Rowland ac0dc7a8de remove desktop restriction 2024-08-11 11:04:20 -04:00
Jade (Rose) Rowland cba9169b2f update select dialog 2024-08-11 11:01:17 -04:00
Jade (Rose) Rowland e9a0a06b77 fixed dialog 2024-08-11 00:57:49 -04:00
Jade (Rose) Rowland d80c06cd55 fix confirm dialog 2024-08-11 00:35:20 -04:00
Jade (Rose) Rowland d850726910 Merge pull request #1157 from daslyfe/osc_clock_jitter
Fix OSC clock jitter
2024-08-08 23:11:28 -04:00
Jade (Rose) Rowland 1b01f41ffd fix test 2024-08-08 23:10:39 -04:00
Jade (Rose) Rowland ad00fa65f8 Merge branch 'tidalcycles:main' into osc_clock_jitter 2024-08-08 22:53:07 -04:00
Jade (Rose) Rowland 814babbf7a cleaning up 2024-08-08 22:52:06 -04:00
Jade (Rose) Rowland 28334ec94d rolling clock average 2024-08-08 20:55:49 -04:00
Jade (Rose) Rowland 7d2110d75f clock fail strategy 2024-08-08 20:35:12 -04:00
Jade (Rose) Rowland 94ccfa631e offset calc 2024-08-08 17:25:45 -04:00
Jack Armitage c7c56a1357 fix 2024-08-06 12:25:52 +00:00
Jack Armitage d40e4dac2f pickRestart example 2024-08-03 18:48:30 +00:00
Jade (Rose) Rowland 686247f0cb Merge branch 'main' of github.com:daslyfe/strudel 2024-07-28 13:33:57 -04:00
Felix Roos dffeac41af Merge remote-tracking branch 'origin/main' into bettern-zen-spacing 2024-07-27 15:16:14 +02:00
Alex McLean 6e496b7a4a Fix loop at tactus (#1145)
* fix loopAt tactus
2024-07-27 11:02:38 +02:00
Jade (Rose) Rowland 71e843e1a3 Merge pull request #1130 from daslyfe/phase_vocode
Phase vocode
2024-07-25 22:37:34 -04:00
Jade (Rose) Rowland 61ba33043e Merge pull request #1132 from daslyfe/udels
Udels (MultiFrame Strudel) Revisited
2024-07-25 22:37:17 -04:00
Felix Roos 943d78f268 better spacing in zen mode 2024-07-25 16:44:02 +02:00
Felix Roos 5ac59e95dd handle midin device not found error 2024-07-25 16:07:58 +02:00
Felix Roos 03bbc0bc27 hotfix: remove log 2024-07-25 09:19:41 +02:00
Alex McLean 4c3457438d Fix tactus marking in mininotation (#1144)
* more tactus tweaks
* format
2024-07-24 10:40:28 +01:00
Jade (Rose) Rowland 85bccdcc19 Merge branch 'main' of github.com:daslyfe/strudel 2024-07-13 13:17:28 -04:00
Jade (Rose) Rowland 94c07b6b7a prettier 2024-07-10 19:11:06 -04:00
Jade (Rose) Rowland 42000a7628 push code to window on aftereval 2024-07-09 22:48:14 -04:00
Felix Roos f514cd85b1 Merge pull request #1136 from tidalcycles/cm-commands
export comment commands
2024-06-25 18:13:27 +02:00
Felix Roos d53929cf68 export comment commands 2024-06-25 10:00:26 +02:00
Alex McLean a4bd0ae100 Chop chop (#1078)
* failing test

* support chopping chops
2024-06-24 17:19:22 +01:00
Alex McLean 283a071c86 Fix bug in Fraction.lcm (#1133)
* bugfix lcm
2024-06-18 22:58:08 +01:00
Jade (Rose) Rowland b5fbcd108f cleanup 2024-06-17 23:41:34 -04:00
Jade (Rose) Rowland 6350cfdb9c Merge pull request #1129 from mindofmatthew/worker-path-in-modules
Fix clock worker dependency path in module builds
2024-06-17 23:32:12 -04:00
Jade (Rose) Rowland 5e9da78e64 fixed window: 2024-06-14 20:42:39 -04:00
Jade (Rose) Rowland f3b83b41f5 prettier 2024-06-14 15:40:37 -04:00
Jade (Rose) Rowland 1d95da7a3a fixed build window issue: 2024-06-14 15:28:24 -04:00
Jade (Rose) Rowland 2a2ddf205a need to fix window message 2024-06-14 14:26:23 -04:00
Jade (Rose) Rowland 6c9e0aaa1a added udels editor and header 2024-06-14 01:24:08 -04:00
Jade (Rose) Rowland d23038d386 Merge branch 'tidalcycles:main' into udels 2024-06-13 22:52:06 -04:00
Jade (Rose) Rowland 248f67d1ab prettier 2024-06-12 10:28:12 -04:00
Jade (Rose) Rowland f3fa1483d7 fix pnpm lock 2024-06-12 01:14:59 -04:00
Jade (Rose) Rowland 11102adb75 unessecary import 2024-06-12 01:14:20 -04:00
Jade (Rose) Rowland 9f958abb08 fixed offset time 2024-06-12 01:11:58 -04:00
Matthew Kaney b72e71b0bd Update base path build setting for core and repl packages 2024-06-11 10:02:57 -04:00
Jade (Rose) Rowland 065b24a2ee working 2024-06-10 23:51:38 -04:00
Matthew Kaney 758b94776d Configure base url for @strudel/web package 2024-06-10 19:10:17 -04:00
Jade (Rose) Rowland e6f3b1f1ef copied files 2024-06-10 00:06:35 -04:00
Jade (Rose) Rowland e62d5759e7 revert 2024-06-10 00:00:43 -04:00
Felix Roos c403a92542 Merge pull request #1126 from tidalcycles/labeled-statements-doc
Labeled statements doc
2024-06-04 00:26:48 +02:00
Felix Roos 269d6dde31 remove stack from workshop 2024-06-04 00:22:49 +02:00
Felix Roos 666c695c63 don't use labeled statements in example for now 2024-06-04 00:10:46 +02:00
Felix Roos 771eb31d3f use labels instead of stacks 2024-06-04 00:05:51 +02:00
Felix Roos c05bbf3422 don't clear hydra in minirepl for now 2024-06-03 23:53:08 +02:00
Felix Roos 364f511a7c Merge pull request #1125 from tidalcycles/viz-doc
doc: visual functions + refactor onPaint
2024-06-03 22:40:21 +02:00
Felix Roos 300022d8ac no custom line height 2024-06-03 20:34:51 +02:00
Felix Roos 357fbf8c82 snapshot 2024-06-02 23:14:32 +02:00
Felix Roos 1acb675f5a doc: visual functions 2024-06-02 23:12:31 +02:00
Jade (Rose) Rowland ab1086318c Merge pull request #1124 from daslyfe/bug_sampledb
Fix indexDB failing with large amount of files
2024-06-02 12:36:29 -04:00
Jade (Rose) Rowland 8c73ad088a support aac 2024-06-02 12:32:54 -04:00
Felix Roos 25981092b1 snapshot 2024-06-02 14:11:52 +02:00
Felix Roos e6b55baed0 fix: step based naming 2024-06-02 14:08:56 +02:00
Felix Roos 6adf1ff977 snapshot 2024-06-02 14:03:30 +02:00
Felix Roos b6e277f1ea use inline scope in examples 2024-06-02 14:03:05 +02:00
Felix Roos d0510f862a clear hydra in mini repl 2024-06-02 14:02:39 +02:00
Felix Roos 4a87df6426 small example tweaks 2024-06-02 14:00:47 +02:00
Felix Roos bf90867685 fix: mini repl punchcard + claviature flags 2024-06-02 10:49:07 +02:00
Felix Roos 3dc5fd0e7e fix: editPattern bug 2024-06-02 10:48:20 +02:00
Felix Roos a7650f1af3 add autodraw flag + scope 2024-06-02 03:08:16 +02:00
Felix Roos aadb2fee84 support onload inline viz with autodraw 2024-06-02 03:05:26 +02:00
Felix Roos bf391ad6ce simplify: painters are now handled by Drawer 2024-06-02 03:03:36 +02:00
Felix Roos faba4a4e4e implement onPaint with pattern state 2024-06-02 03:00:57 +02:00
Felix Roos 94e411aa7a add beforeStart callback 2024-06-02 02:34:33 +02:00
Jade (Rose) Rowland ffa21ddf0d remove console statement 2024-06-01 19:19:47 -04:00
Jade (Rose) Rowland 6b2d080afc remove unessecary change 2024-06-01 19:17:00 -04:00
Jade (Rose) Rowland 321a888921 add catch 2024-06-01 12:42:07 -04:00
Jade (Rose) Rowland bf7fe2bf75 fixed indexdb 2024-06-01 12:39:45 -04:00
Jade (Rose) Rowland 1fd9ba5dbf convert to blob 2024-06-01 12:11:28 -04:00
Jade (Rose) Rowland 1fca6f25b8 faster upload 2024-06-01 11:35:24 -04:00
Felix Roos 0e6a17fc77 start visual feedback article 2024-06-01 15:42:09 +02:00
Felix Roos 375c68775c make sure draw logic works with multiple repls 2024-06-01 15:41:55 +02:00
Felix Roos 827993a8c9 chore: simplify publish docs 2024-05-31 23:29:38 +02:00
Jade (Rose) Rowland e29876e7f6 debugging 2024-05-31 13:57:02 -04:00
Felix Roos 69a5d62027 rename "samples" to "bank" 2024-05-17 09:42:03 +02:00
Felix Roos 226cf356d6 fix: lint 2024-05-16 11:05:38 +02:00
Felix Roos 73d49f152a refactor sampler:
- delegate getSampleBufferSource logic to getSampleInfo + getSampleBuffer
- move buffer logic up from onTriggerSample to those functions
2024-05-16 10:52:54 +02:00
Jade (Rose) Rowland 5ca9afa9e8 restored 2024-04-15 19:53:12 -04:00
Felix Roos cdba9efb59 add filter + filterWhen + within 2024-04-01 03:17:15 +02:00
92 changed files with 3736 additions and 1588 deletions
+3 -9
View File
@@ -130,20 +130,14 @@ npx lerna version --no-private
# publish all packages inside /packages using pnpm! don't use lerna to publish!!
pnpm --filter "./packages/**" publish --dry-run
# the last command was only a dry-run, make sure everything looks ok, if yes, run the same command without flag
# the last command was only a dry-run. if everything looks ok, run this:
pnpm --filter "./packages/**" publish --access public
```
To manually publish a single package, increase the version in the `package.json`, then run `pnpm publish`.
Important: Always publish with `pnpm`, as `npm` does not support overriding main files in `publishConfig`, which is done in all the packages.
### New Packages
To add a new package, you have to publish it manually the first time, using:
```sh
cd packages/<package-name> && pnpm publish --access public
```
## Have Fun
Remember to have fun, and that this project is driven by the passion of volunteers!
+14 -26
View File
@@ -1,4 +1,5 @@
import { closeBrackets } from '@codemirror/autocomplete';
export { toggleComment, toggleBlockComment, toggleLineComment, toggleBlockCommentByLine } from '@codemirror/commands';
// import { search, highlightSelectionMatches } from '@codemirror/search';
import { history } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
@@ -140,27 +141,25 @@ export class StrudelMirror {
this.root = root;
this.miniLocations = [];
this.widgets = [];
this.painters = [];
this.drawTime = drawTime;
this.drawContext = drawContext;
this.onDraw = onDraw || this.draw;
this.id = id || s4();
this.drawer = new Drawer((haps, time) => {
this.drawer = new Drawer((haps, time, _, painters) => {
const currentFrame = haps.filter((hap) => hap.isActive(time));
this.highlight(currentFrame, time);
this.onDraw(haps, time, this.painters);
this.onDraw(haps, time, painters);
}, drawTime);
this.prebaked = prebake();
autodraw && this.drawFirstFrame();
this.repl = repl({
...replOptions,
id,
onToggle: (started) => {
replOptions?.onToggle?.(started);
if (started) {
this.adjustDrawTime();
this.drawer.start(this.repl.scheduler);
// stop other repls when this one is started
document.dispatchEvent(
@@ -171,20 +170,11 @@ export class StrudelMirror {
} else {
this.drawer.stop();
updateMiniLocations(this.editor, []);
cleanupDraw(false);
cleanupDraw(true, id);
}
},
beforeEval: async () => {
cleanupDraw();
this.painters = [];
const self = this;
// this is similar to repl.mjs > injectPatternMethods
// maybe there is a solution without prototype hacking, but hey, it works
// we need to do this befor every eval to make sure it works with multiple StrudelMirror's side by side
Pattern.prototype.onPaint = function (onPaint) {
self.painters.push(onPaint);
return this;
};
cleanupDraw(true, id);
await this.prebaked;
await replOptions?.beforeEval?.();
},
@@ -198,8 +188,11 @@ export class StrudelMirror {
updateWidgets(this.editor, widgets);
updateMiniLocations(this.editor, this.miniLocations);
replOptions?.afterEval?.(options);
this.adjustDrawTime();
this.drawer.invalidate();
// if no painters are set (.onPaint was not called), then we only need the present moment (for highlighting)
const drawTime = options.pattern.getPainters().length ? this.drawTime : [0, 0];
this.drawer.setDrawTime(drawTime);
// invalidate drawer after we've set the appropriate drawTime
this.drawer.invalidate(this.repl.scheduler);
},
});
this.editor = initEditor({
@@ -234,13 +227,8 @@ export class StrudelMirror {
};
document.addEventListener('start-repl', this.onStartRepl);
}
// adjusts draw time depending on if there are painters
adjustDrawTime() {
// when no painters are set, [0,0] is enough (just highlighting)
this.drawer.setDrawTime(this.painters.length ? this.drawTime : [0, 0]);
}
draw(haps, time) {
this.painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
draw(haps, time, painters) {
painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
}
async drawFirstFrame() {
if (!this.onDraw) {
@@ -252,7 +240,7 @@ export class StrudelMirror {
await this.repl.evaluate(this.code, false);
this.drawer.invalidate(this.repl.scheduler, -0.001);
// draw at -0.001 to avoid haps at 0 to be visualized as active
this.onDraw?.(this.drawer.visibleHaps, -0.001, this.painters);
this.onDraw?.(this.drawer.visibleHaps, -0.001, this.drawer.painters);
} catch (err) {
console.warn('first frame could not be painted');
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const settings = {
gutterBackground: 'transparent',
gutterForeground: '#0f380f',
light: true,
customStyle: '.cm-line { line-height: 1 }',
// customStyle: '.cm-line { line-height: 1 }',
};
export default createTheme({
theme: 'light',
+1 -1
View File
@@ -17,7 +17,7 @@ export const settings = {
lineBackground: '#00000040',
gutterBackground: 'transparent',
gutterForeground: '#8a919966',
customStyle: '.cm-line { line-height: 1 }',
// customStyle: '.cm-line { line-height: 1 }',
};
let punctuation = colorD;
+4 -11
View File
@@ -4,9 +4,9 @@
// import createClock from './zyklus.mjs';
function getTime() {
const precision = 10 ** 4;
const seconds = performance.now() / 1000;
return Math.round(seconds * precision) / precision;
const seconds = performance.now() * 0.001;
return seconds;
// return Math.round(seconds * precision) / precision;
}
let num_cycles_at_cps_change = 0;
@@ -24,27 +24,20 @@ const sendMessage = (type, payload) => {
const sendTick = (phase, duration, tick, time) => {
const num_seconds_since_cps_change = num_ticks_since_cps_change * duration;
const tickdeadline = phase - time;
const lastTick = time + tickdeadline;
const num_cycles_since_cps_change = num_seconds_since_cps_change * cps;
const begin = num_cycles_at_cps_change + num_cycles_since_cps_change;
const secondsSinceLastTick = time - lastTick - duration;
const eventLength = duration * cps;
const end = begin + eventLength;
const cycle = begin + secondsSinceLastTick * cps;
sendMessage('tick', {
begin,
end,
cps,
tickdeadline,
num_cycles_at_cps_change,
num_seconds_at_cps_change,
num_seconds_since_cps_change,
time,
cycle,
});
num_ticks_since_cps_change++;
+29 -12
View File
@@ -191,7 +191,7 @@ export const { attack, att } = registerControl('attack', 'att');
* note("c e g b g e")
* .fm(4)
* .fmh("<1 2 1.5 1.61>")
* .scope()
* ._scope()
*
*/
export const { fmh } = registerControl(['fmh', 'fmi'], 'fmh');
@@ -205,7 +205,7 @@ export const { fmh } = registerControl(['fmh', 'fmi'], 'fmh');
* @example
* note("c e g b g e")
* .fm("<0 1 2 8 32>")
* .scope()
* ._scope()
*
*/
export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm');
@@ -221,7 +221,7 @@ export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm');
* .fmdecay(.2)
* .fmsustain(0)
* .fmenv("<exp lin>")
* .scope()
* ._scope()
*
*/
export const { fmenv } = registerControl('fmenv');
@@ -234,7 +234,7 @@ export const { fmenv } = registerControl('fmenv');
* note("c e g b g e")
* .fm(4)
* .fmattack("<0 .05 .1 .2>")
* .scope()
* ._scope()
*
*/
export const { fmattack } = registerControl('fmattack');
@@ -248,7 +248,7 @@ export const { fmattack } = registerControl('fmattack');
* .fm(4)
* .fmdecay("<.01 .05 .1 .2>")
* .fmsustain(.4)
* .scope()
* ._scope()
*
*/
export const { fmdecay } = registerControl('fmdecay');
@@ -262,7 +262,7 @@ export const { fmdecay } = registerControl('fmdecay');
* .fm(4)
* .fmdecay(.1)
* .fmsustain("<1 .75 .5 0>")
* .scope()
* ._scope()
*
*/
export const { fmsustain } = registerControl('fmsustain');
@@ -392,7 +392,7 @@ export const { loop } = registerControl('loop');
* @synonyms loopb
* @example
* s("space").loop(1)
* .loopBegin("<0 .125 .25>").scope()
* .loopBegin("<0 .125 .25>")._scope()
*/
export const { loopBegin, loopb } = registerControl('loopBegin', 'loopb');
/**
@@ -405,7 +405,7 @@ export const { loopBegin, loopb } = registerControl('loopBegin', 'loopb');
* @synonyms loope
* @example
* s("space").loop(1)
* .loopEnd("<1 .75 .5 .25>").scope()
* .loopEnd("<1 .75 .5 .25>")._scope()
*/
export const { loopEnd, loope } = registerControl('loopEnd', 'loope');
/**
@@ -454,9 +454,6 @@ export const { drive } = registerControl('drive');
*/
export const { channels, ch } = registerControl('channels', 'ch');
// superdirt only
export const { phaserrate, phasr } = registerControl('phaserrate', 'phasr');
/**
* Phaser audio effect that approximates popular guitar pedals.
*
@@ -468,7 +465,11 @@ export const { phaserrate, phasr } = registerControl('phaserrate', 'phasr');
* .phaser("<1 2 4 8>")
*
*/
export const { phaser, ph } = registerControl(['phaser', 'phaserdepth', 'phasercenter', 'phasersweep'], 'ph');
export const { phaserrate, ph, phaser } = registerControl(
['phaserrate', 'phaserdepth', 'phasercenter', 'phasersweep'],
'ph',
'phaser',
);
/**
* The frequency sweep range of the lfo for the phaser effect. Defaults to 2000
@@ -800,10 +801,12 @@ export const { fanchor } = registerControl('fanchor');
* @example
* note("a e")
* .vib("<.5 1 2 4 8 16>")
* ._scope()
* @example
* // change the modulation depth with ":"
* note("a e")
* .vib("<.5 1 2 4 8 16>:12")
* ._scope()
*/
export const { vib, vibrato, v } = registerControl(['vib', 'vibmod'], 'vibrato', 'v');
/**
@@ -824,10 +827,12 @@ export const { noise } = registerControl('noise');
* @example
* note("a e").vib(4)
* .vibmod("<.25 .5 1 2 12>")
* ._scope()
* @example
* // change the vibrato frequency with ":"
* note("a e")
* .vibmod("<.25 .5 1 2 12>:8")
* ._scope()
*/
export const { vibmod, vmod } = registerControl(['vibmod', 'vib'], 'vmod');
export const { hcutoff, hpf, hp } = registerControl(['hcutoff', 'hresonance', 'hpenv'], 'hpf', 'hp');
@@ -1379,6 +1384,17 @@ export const { compressorRelease } = registerControl('compressorRelease');
*
*/
export const { speed } = registerControl('speed');
/**
* Changes the speed of sample playback, i.e. a cheap way of changing pitch.
*
* @name stretch
* @param {number | Pattern} factor -inf to inf, negative numbers play the sample backwards.
* @example
* s("gm_flute").stretch("1 2 .5")
*
*/
export const { stretch } = registerControl('stretch');
/**
* Used in conjunction with `speed`, accepts values of "r" (rate, default behavior), "c" (cycles), or "s" (seconds). Using `unit "c"` means `speed` will be interpreted in units of cycles, e.g. `speed "1"` means samples will be stretched to fill a cycle. Using `unit "s"` means the playback speed will be adjusted so that the duration is the number of seconds specified by `speed`.
*
@@ -1389,6 +1405,7 @@ export const { speed } = registerControl('speed');
* @superdirtOnly
*
*/
export const { unit } = registerControl('unit');
/**
* Made by Calum Gunn. Reminiscent of some weird mixture of filter, ring-modulator and pitch-shifter. The SuperCollider manual defines Squiz as:
+16 -4
View File
@@ -8,8 +8,19 @@ import createClock from './zyklus.mjs';
import { logger } from './logger.mjs';
export class Cyclist {
constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1, setInterval, clearInterval }) {
constructor({
interval,
onTrigger,
onToggle,
onError,
getTime,
latency = 0.1,
setInterval,
clearInterval,
beforeStart,
}) {
this.started = false;
this.beforeStart = beforeStart;
this.cps = 0.5;
this.num_ticks_since_cps_change = 0;
this.lastTick = 0; // absolute time when last tick (clock callback) happened
@@ -82,7 +93,8 @@ export class Cyclist {
this.started = v;
this.onToggle?.(v);
}
start() {
async start() {
await this.beforeStart?.();
this.num_ticks_since_cps_change = 0;
this.num_cycles_at_cps_change = 0;
if (!this.pattern) {
@@ -103,10 +115,10 @@ export class Cyclist {
this.lastEnd = 0;
this.setStarted(false);
}
setPattern(pat, autostart = false) {
async setPattern(pat, autostart = false) {
this.pattern = pat;
if (autostart && !this.started) {
this.start();
await this.start();
}
}
setCps(cps = 0.5) {
+3 -4
View File
@@ -4,8 +4,6 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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 { isPattern } from './index.mjs';
export const evalScope = async (...args) => {
const results = await Promise.allSettled(args);
const modules = results.filter((result) => result.status === 'fulfilled').map((r) => r.value);
@@ -37,11 +35,12 @@ function safeEval(str, options = {}) {
return Function(body)();
}
export const evaluate = async (code, transpiler) => {
export const evaluate = async (code, transpiler, transpilerOptions) => {
let meta = {};
if (transpiler) {
// transform syntactically correct js code to semantically usable code
const transpiled = transpiler(code);
const transpiled = transpiler(code, transpilerOptions);
code = transpiled.output;
meta = transpiled;
}
+2 -2
View File
@@ -119,10 +119,10 @@ export const lcm = (...fractions) => {
if (fractions.length === 0) {
return undefined;
}
const x = fractions.pop();
return fractions.reduce(
(lcm, fraction) => (lcm === undefined || fraction === undefined ? undefined : lcm.lcm(fraction)),
fraction(1),
x,
);
};
+15 -55
View File
@@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/
import { logger } from './logger.mjs';
import { ClockCollator, cycleToSeconds } from './util.mjs';
export class NeoCyclist {
constructor({ onTrigger, onToggle, getTime }) {
@@ -13,79 +14,38 @@ export class NeoCyclist {
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.getTime = getTime; // get absolute time
this.time_at_last_tick_message = 0;
this.num_cycles_at_cps_change = 0;
// the clock of the worker and the audio context clock can drift apart over time
// aditionally, the message time of the worker pinging the callback to process haps can be inconsistent.
// we need to keep a rolling average of the time difference between the worker clock and audio context clock
// in order to schedule events consistently.
this.collator = new ClockCollator({ getTargetClockTime: getTime });
this.onToggle = onToggle;
this.latency = 0.1; // fixed trigger time offset
this.cycle = 0;
this.id = Math.round(Date.now() * Math.random());
this.worker_time_dif;
this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url));
this.worker.port.start();
this.channel = new BroadcastChannel('strudeltick');
let weight = 0; // the amount of weight that is applied to the current average when averaging a new time dif
const maxWeight = 20;
const precision = 10 ** 3; //round off time diff to prevent accumulating outliers
// the clock of the worker and the audio context clock can drift apart over time
// aditionally, the message time of the worker pinging the callback to process haps can be inconsistent.
// we need to keep a rolling weighted average of the time difference between the worker clock and audio context clock
// in order to schedule events consistently.
const setTimeReference = (num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline) => {
const time_dif = getTime() - (num_seconds_at_cps_change + num_seconds_since_cps_change) + tickdeadline;
if (this.worker_time_dif == null) {
this.worker_time_dif = time_dif;
} else {
const w = 1; //weight of new time diff;
const new_dif =
Math.round(((this.worker_time_dif * weight + time_dif * w) / (weight + w)) * precision) / precision;
if (new_dif != this.worker_time_dif) {
// reset the weight so the clock recovers faster from an audio context freeze/dropout if it happens
weight = 4;
}
this.worker_time_dif = new_dif;
}
weight = Math.min(weight + 1, maxWeight);
};
const tickCallback = (payload) => {
const {
num_cycles_at_cps_change,
cps,
num_seconds_at_cps_change,
num_seconds_since_cps_change,
begin,
end,
tickdeadline,
cycle,
} = payload;
const { cps, begin, end, cycle, time } = payload;
this.cps = cps;
this.cycle = cycle;
setTimeReference(num_seconds_at_cps_change, num_seconds_since_cps_change, tickdeadline);
processHaps(begin, end, num_cycles_at_cps_change, num_seconds_at_cps_change);
this.time_at_last_tick_message = this.getTime();
const currentTime = this.collator.calculateOffset(time) + time;
processHaps(begin, end, currentTime);
this.time_at_last_tick_message = currentTime;
};
const processHaps = (begin, end, num_cycles_at_cps_change, seconds_at_cps_change) => {
const processHaps = (begin, end, currentTime) => {
if (this.started === false) {
return;
}
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
haps.forEach((hap) => {
if (hap.hasOnset()) {
const targetTime =
(hap.whole.begin - num_cycles_at_cps_change) / this.cps +
seconds_at_cps_change +
this.latency +
this.worker_time_dif;
const duration = hap.duration / this.cps;
const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps);
const targetTime = timeUntilTrigger + currentTime + this.latency;
const duration = cycleToSeconds(hap.duration, this.cps);
onTrigger?.(hap, 0, duration, this.cps, targetTime);
}
});
@@ -129,8 +89,8 @@ export class NeoCyclist {
this.setStarted(true);
}
stop() {
this.worker_time_dif = null;
logger('[cyclist] stop');
this.collator.reset();
this.setStarted(false);
}
setPattern(pat, autostart = false) {
+166 -45
View File
@@ -94,6 +94,14 @@ export class Pattern {
return result;
}
// runs func on query state
withState(func) {
return this.withHaps((haps, state) => {
func(state);
return haps;
});
}
/**
* see `withValue`
* @noAutocomplete
@@ -378,6 +386,15 @@ export class Pattern {
return this.fmap(func).squeezeJoin();
}
polyJoin = function () {
const pp = this;
return pp.fmap((p) => p.s_extend(pp.tactus.div(p.tactus))).outerJoin();
};
polyBind(func) {
return this.fmap(func).polyJoin();
}
//////////////////////////////////////////////////////////////////////
// Utility methods mainly for internal use
@@ -746,6 +763,10 @@ export class Pattern {
const otherPat = reify(other);
return otherPat.fmap((b) => this.fmap((a) => func(a)(b))).restartJoin();
}
_opPoly(other, func) {
const otherPat = reify(other);
return this.fmap((b) => otherPat.fmap((a) => func(a)(b))).polyJoin();
}
//////////////////////////////////////////////////////////////////////
// End-user methods.
@@ -788,15 +809,6 @@ export class Pattern {
//////////////////////////////////////////////////////////////////////
// Multi-pattern functions
/**
* Stacks the given pattern(s) to the current pattern.
* @name stack
* @memberof Pattern
* @example
* s("hh*4").stack(
* note("c4(5,8)")
* )
*/
stack(...pats) {
return stack(this, ...pats);
}
@@ -805,30 +817,9 @@ export class Pattern {
return sequence(this, ...pats);
}
/**
* Appends the given pattern(s) to the current pattern.
* @name seq
* @memberof Pattern
* @synonyms sequence, fastcat
* @example
* s("hh*4").seq(
* note("c4(5,8)")
* )
*/
seq(...pats) {
return sequence(this, ...pats);
}
/**
* Appends the given pattern(s) to the next cycle.
* @name cat
* @memberof Pattern
* @synonyms slowcat
* @example
* s("hh*4").cat(
* note("c4(5,8)")
* )
*/
cat(...pats) {
return cat(this, ...pats);
}
@@ -1054,7 +1045,7 @@ function _composeOp(a, b, func) {
func: [(a, b) => b(a)],
};
const hows = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart'];
const hows = ['In', 'Out', 'Mix', 'Squeeze', 'SqueezeOut', 'Reset', 'Restart', 'Poly'];
// generate methods to do what and how
for (const [what, [op, preprocess]] of Object.entries(composers)) {
@@ -1259,6 +1250,12 @@ export function reify(thing) {
* @example
* stack("g3", "b3", ["e4", "d4"]).note()
* // "g3,b3,[e4,d4]".note()
*
* @example
* // As a chained function:
* s("hh*4").stack(
* note("c4(5,8)")
* )
*/
export function stack(...pats) {
// Array test here is to avoid infinite recursions..
@@ -1387,6 +1384,11 @@ export function slowcatPrime(...pats) {
* cat("e5", "b4", ["d5", "c5"]).note()
* // "<e5 b4 [d5 c5]>".note()
*
* @example
* // As a chained function:
* s("hh*4").cat(
* note("c4(5,8)")
* )
*/
export function cat(...pats) {
return slowcat(...pats);
@@ -1409,12 +1411,45 @@ export function arrange(...sections) {
return s_cat(...sections).slow(total);
}
/**
* Similarly to `arrange`, allows you to arrange multiple patterns together over multiple cycles.
* Unlike `arrange`, you specify a start and stop time for each pattern rather than duration, which
* means that patterns can overlap.
* @return {Pattern}
* @example
seqPLoop([0, 2, "bd(3,8)"],
[1, 3, "cp(3,8)"]
)
.sound()
*/
export function seqPLoop(...parts) {
let total = Fraction(0);
const pats = [];
for (let part of parts) {
if (part.length == 2) {
part.unshift(total);
}
total = part[1];
}
return stack(
...parts.map(([start, stop, pat]) =>
pure(reify(pat)).compress(Fraction(start).div(total), Fraction(stop).div(total)),
),
)
.slow(total)
.innerJoin(); // or resetJoin or restartJoin ??
}
export function fastcat(...pats) {
let result = slowcat(...pats);
if (pats.length > 1) {
result = result._fast(pats.length);
result.tactus = pats.length;
}
if (pats.length == 1 && pats[0].__tactus_source) {
pats.tactus = pats[0].tactus;
}
return result;
}
@@ -1424,12 +1459,18 @@ export function sequence(...pats) {
}
/** Like **cat**, but the items are crammed into one cycle.
* @synonyms fastcat, sequence
* @synonyms sequence, fastcat
* @example
* seq("e5", "b4", ["d5", "c5"]).note()
* // "e5 b4 [d5 c5]".note()
*
* @example
* // As a chained function:
* s("hh*4").seq(
* note("c4(5,8)")
* )
*/
export function seq(...pats) {
return fastcat(...pats);
}
@@ -1558,7 +1599,11 @@ export function register(name, func, patternify = true, preserveTactus = false,
// There are patternified args, so lets make an unpatternified
// version, prefixed by '_'
Pattern.prototype['_' + name] = function (...args) {
return func(...args, this);
const result = func(...args, this);
if (preserveTactus) {
result.setTactus(this.tactus);
}
return result;
};
}
@@ -1988,6 +2033,34 @@ export const { zoomArc, zoomarc } = register(['zoomArc', 'zoomarc'], function (a
return pat.zoom(a.begin, a.end);
});
/**
* Splits a pattern into the given number of slices, and plays them according to a pattern of slice numbers.
* Similar to `slice`, but slices up patterns rather than sound samples.
* @param {number} number of slices
* @param {number} slices to play
* @example
* note("0 1 2 3 4 5 6 7".scale('c:mixolydian'))
*.bite(4, "3 2 1 0")
* @example
* sound("bd - bd bd*2, - sd:6 - sd:5 sd:1 - [- sd:2] -, hh [- cp:7]")
.bank("RolandTR909").speed(1.2)
.bite(4, "0 0 [1 2] <3 2> 0 0 [2 1] 3")
*/
export const bite = register(
'bite',
(npat, ipat, pat) => {
return ipat
.fmap((i) => (n) => {
const a = Fraction(i).div(n).mod(1);
const b = a.add(Fraction(1).div(n));
return pat.zoom(a, b);
})
.appLeft(npat)
.squeezeJoin();
},
false,
);
/**
* Selects the given fraction of the pattern and repeats that part to fill the remainder of the cycle.
* @param {number} fraction fraction to select
@@ -2419,6 +2492,37 @@ Pattern.prototype.tag = function (tag) {
return this.withContext((ctx) => ({ ...ctx, tags: (ctx.tags || []).concat([tag]) }));
};
/**
* Filters haps using the given function
* @name filter
* @param {Function} test function to test Hap
* @example
* 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
*/
export const filterWhen = register('filterWhen', (test, pat) => pat.filter((h) => test(h.whole.begin)));
/**
* Use within to apply a function to only a part of a pattern.
* @name within
* @param {number} start start within cycle (0 - 1)
* @param {number} end end within cycle (0 - 1). Must be > start
* @param {Function} func function to be applied to the sub-pattern
*/
export const within = register('within', (a, b, fn, pat) =>
stack(
fn(pat.filterWhen((t) => t.cyclePos() >= a && t.cyclePos() <= b)),
pat.filterWhen((t) => t.cyclePos() < a || t.cyclePos() > b),
),
);
//////////////////////////////////////////////////////////////////////
// Tactus-related functions, i.e. ones that do stepwise
// transformations
@@ -2523,7 +2627,7 @@ export function _polymeterListSteps(steps, ...args) {
* @param {any[]} patterns one or more patterns
* @example
* // the same as "{c d, e f g}%4"
* s_polymeterSteps(4, "c d", "e f g")
* s_polymeterSteps(4, "c d", "e f g").note()
*/
export function s_polymeterSteps(steps, ...args) {
if (args.length == 0) {
@@ -2541,8 +2645,8 @@ export function s_polymeterSteps(steps, ...args) {
* *EXPERIMENTAL* - Combines the given lists of patterns with the same pulse, creating polymeters when different sized sequences are used.
* @synonyms pm
* @example
* // The same as "{c eb g, c2 g2}"
* s_polymeter("c eb g", "c2 g2")
* // The same as note("{c eb g, c2 g2}")
* s_polymeter("c eb g", "c2 g2").note()
*
*/
export function s_polymeter(...args) {
@@ -2571,6 +2675,8 @@ export function s_polymeter(...args) {
/** Sequences patterns like `seq`, but each pattern has a length, relative to the whole.
* This length can either be provided as a [length, pattern] pair, or inferred from
* the pattern's 'tactus', generally inferred by the mininotation. Has the alias `timecat`.
* @name s_cat
* @synonyms timeCat, timecat
* @return {Pattern}
* @example
* s_cat([3,"e3"],[1, "g3"]).note()
@@ -2602,8 +2708,7 @@ export function s_cat(...timepats) {
}
if (timepats.length == 1) {
const result = reify(timepats[0][1]);
result.tactus = timepats[0][0];
return result;
return result.withTactus((_) => timepats[0][0]);
}
const total = timepats.map((a) => a[0]).reduce((a, b) => a.add(b), Fraction(0));
@@ -2696,6 +2801,10 @@ export const s_sub = stepRegister('s_sub', function (i, pat) {
return pat.s_add(pat.tactus.sub(i));
});
export const s_cycles = stepRegister('s_extend', function (factor, pat) {
return pat.fast(factor).s_expand(factor);
});
export const s_expand = stepRegister('s_expand', function (factor, pat) {
return pat.withTactus((t) => t.mul(Fraction(factor)));
});
@@ -2794,8 +2903,16 @@ export const s_tour = function (pat, ...many) {
export const chop = register('chop', function (n, pat) {
const slices = Array.from({ length: n }, (x, i) => i);
const slice_objects = slices.map((i) => ({ begin: i / n, end: (i + 1) / n }));
const merge = function (a, b) {
if ('begin' in a && 'end' in a && a.begin !== undefined && a.end !== undefined) {
const d = a.end - a.begin;
b = { begin: a.begin + b.begin * d, end: a.begin + b.end * d };
}
// return a;
return Object.assign({}, a, b);
};
const func = function (o) {
return sequence(slice_objects.map((slice_o) => Object.assign({}, o, slice_o)));
return sequence(slice_objects.map((slice_o) => merge(o, slice_o)));
};
return pat.squeezeBind(func).setTactus(__tactus ? Fraction(n).mulmaybe(pat.tactus) : undefined);
});
@@ -2901,7 +3018,8 @@ export const splice = register(
);
export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (factor, pat) {
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state));
const tactus = pat.tactus ? pat.tactus.div(factor) : undefined;
return new Pattern((state) => _loopAt(factor, pat, state.controls._cps).query(state), tactus);
});
/**
@@ -2915,11 +3033,14 @@ export const { loopAt, loopat } = register(['loopAt', 'loopat'], function (facto
export const fit = register('fit', (pat) =>
pat.withHaps((haps, state) =>
haps.map((hap) =>
hap.withValue((v) => ({
...v,
speed: (state.controls._cps || 1) / hap.whole.duration,
unit: 'c',
})),
hap.withValue((v) => {
const slicedur = ('end' in v ? v.end : 1) - ('begin' in v ? v.begin : 0);
return {
...v,
speed: ((state.controls._cps || 1) / hap.whole.duration) * slicedur,
unit: 'c',
};
}),
),
),
);
+13 -4
View File
@@ -10,6 +10,7 @@ export function repl({
defaultOutput,
onEvalError,
beforeEval,
beforeStart,
afterEval,
getTime,
transpiler,
@@ -19,6 +20,7 @@ export function repl({
sync = false,
setInterval,
clearInterval,
id,
}) {
const state = {
schedulerError: undefined,
@@ -32,6 +34,10 @@ export function repl({
started: false,
};
const transpilerOptions = {
id,
};
const updateState = (update) => {
Object.assign(state, update);
state.isDirty = state.code !== state.activeCode;
@@ -48,6 +54,7 @@ export function repl({
},
setInterval,
clearInterval,
beforeStart,
};
// NeoCyclist uses a shared worker to communicate between instances, which is not supported on mobile chrome
@@ -64,9 +71,10 @@ export function repl({
return silence;
};
const setPattern = (pattern, autostart = true) => {
const setPattern = async (pattern, autostart = true) => {
pattern = editPattern?.(pattern) || pattern;
scheduler.setPattern(pattern, autostart);
await scheduler.setPattern(pattern, autostart);
return pattern;
};
setTime(() => scheduler.now()); // TODO: refactor?
@@ -139,9 +147,10 @@ export function repl({
try {
updateState({ code, pending: true });
await injectPatternMethods();
setTime(() => scheduler.now()); // TODO: refactor?
await beforeEval?.({ code });
shouldHush && hush();
let { pattern, meta } = await _evaluate(code, transpiler);
let { pattern, meta } = await _evaluate(code, transpiler, transpilerOptions);
if (Object.keys(pPatterns).length) {
pattern = stack(...Object.values(pPatterns));
}
@@ -153,7 +162,7 @@ export function repl({
throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.'));
}
logger(`[eval] code updated`);
setPattern(pattern, autostart);
pattern = await setPattern(pattern, autostart);
updateState({
miniLocations: meta?.miniLocations || [],
widgets: meta?.widgets || [],
+52 -1
View File
@@ -138,7 +138,7 @@ const timeToRand = (x) => Math.abs(intSeedToRand(timeToIntSeed(x)));
const timeToRandsPrime = (seed, n) => {
const result = [];
// eslint-disable-next-line
for (let i = 0; i < n; ++n) {
for (let i = 0; i < n; ++i) {
result.push(intSeedToRand(seed));
seed = xorwise(seed);
}
@@ -159,6 +159,50 @@ const timeToRands = (t, n) => timeToRandsPrime(timeToIntSeed(t), n);
*/
export const run = (n) => saw.range(0, n).floor().segment(n);
export const randrun = (n) => {
return signal((t) => {
// Without adding 0.5, the first cycle is always 0,1,2,3,...
const rands = timeToRands(t.floor().add(0.5), n);
const nums = rands
.map((n, i) => [n, i])
.sort((a, b) => a[0] > b[0] - a[0] < b[0])
.map((x) => x[1]);
const i = t.cyclePos().mul(n).floor() % n;
return nums[i];
})._segment(n);
};
const _rearrangeWith = (ipat, n, pat) => {
const pats = [...Array(n).keys()].map((i) => pat.zoom(Fraction(i).div(n), Fraction(i + 1).div(n)));
return ipat.fmap((i) => pats[i].repeatCycles(n)._fast(n)).innerJoin();
};
/**
* @name shuffle
* Slices a pattern into the given number of parts, then plays those parts in random order.
* Each part will be played exactly once per cycle.
* @example
* note("c d e f").sound("piano").shuffle(4)
* @example
* note("c d e f".shuffle(4), "g").sound("piano")
*/
export const shuffle = register('shuffle', (n, pat) => {
return _rearrangeWith(randrun(n), n, pat);
});
/**
* @name scramble
* Slices a pattern into the given number of parts, then plays those parts at random. Similar to `shuffle`,
* but parts might be played more than once, or not at all, per cycle.
* @example
* note("c d e f").sound("piano").scramble(4)
* @example
* note("c d e f".scramble(4), "g").sound("piano")
*/
export const scramble = register('scramble', (n, pat) => {
return _rearrangeWith(_irand(n)._segment(n), n, pat);
});
/**
* A continuous pattern of random numbers, between 0 and 1.
*
@@ -326,6 +370,13 @@ export const pickRestart = register('pickRestart', function (lookup, pat) {
* @param {Pattern} pat
* @param {*} xs
* @returns {Pattern}
* @example
* "<a@2 b@2 c@2 d@2>".pickRestart({
a: n("0 1 2 0"),
b: n("2 3 4 ~"),
c: n("[4 5] [4 3] 2 0"),
d: n("0 -3 0 ~")
}).scale("C:major").s("piano")
*/
export const pickmodRestart = register('pickmodRestart', function (lookup, pat) {
return _pick(lookup, pat, true).restartJoin();
+44
View File
@@ -937,6 +937,9 @@ describe('Pattern', () => {
.firstCycle(),
);
});
it('Can chop chops', () => {
expect(pure({ s: 'bev' }).chop(2).chop(2).firstCycle()).toStrictEqual(pure({ s: 'bev' }).chop(4).firstCycle());
});
});
describe('range', () => {
it('Can be patterned', () => {
@@ -1209,4 +1212,45 @@ describe('Pattern', () => {
}
});
});
describe('s_expand', () => {
it('can expand four things in half', () => {
expect(
sameFirst(
sequence(0, 1, 2, 3).s_expand(1, 0.5),
s_cat(sequence(0, 1, 2, 3), sequence(0, 1, 2, 3).s_expand(0.5)),
),
);
});
it('can expand five things in half', () => {
expect(
sameFirst(
sequence(0, 1, 2, 3, 4).s_expand(1, 0.5),
s_cat(sequence(0, 1, 2, 3, 4), sequence(0, 1, 2, 3, 4).s_expand(0.5)),
),
);
});
});
describe('stepJoin', () => {
it('can join a pattern with a tactus of 2', () => {
expect(
sameFirst(
sequence(pure(pure('a')), pure(pure('b').setTactus(2))).stepJoin(),
s_cat(pure('a'), pure('b').setTactus(2)),
),
);
});
it('can join a pattern with a tactus of 0.5', () => {
expect(
sameFirst(
sequence(pure(pure('a')), pure(pure('b').setTactus(0.5))).stepJoin(),
s_cat(pure('a'), pure('b').setTactus(0.5)),
),
);
});
});
describe('loopAt', () => {
it('maintains tactus', () => {
expect(s('bev').chop(8).loopAt(2).tactus).toStrictEqual(Fraction(4));
});
});
});
+73
View File
@@ -91,6 +91,9 @@ export const midi2note = (n) => {
// modulo that works with negative numbers e.g. _mod(-1, 3) = 2. Works on numbers (rather than patterns of numbers, as @mod@ from pattern.mjs does)
export const _mod = (n, m) => ((n % m) + m) % m;
// average numbers in an array
export const averageArray = (arr) => arr.reduce((a, b) => a + b) / arr.length;
export function nanFallback(value, fallback = 0) {
if (isNaN(Number(value))) {
logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
@@ -360,6 +363,76 @@ export function objectMap(obj, fn) {
}
return Object.fromEntries(Object.entries(obj).map(([k, v], i) => [k, fn(v, k, i)]));
}
export function cycleToSeconds(cycle, cps) {
return cycle / cps;
}
// utility for averaging two clocks together to account for drift
export class ClockCollator {
constructor({
getTargetClockTime = getUnixTimeSeconds,
weight = 16,
offsetDelta = 0.005,
checkAfterTime = 2,
resetAfterTime = 8,
}) {
this.offsetTime;
this.timeAtPrevOffsetSample;
this.prevOffsetTimes = [];
this.getTargetClockTime = getTargetClockTime;
this.weight = weight;
this.offsetDelta = offsetDelta;
this.checkAfterTime = checkAfterTime;
this.resetAfterTime = resetAfterTime;
this.reset = () => {
this.prevOffsetTimes = [];
this.offsetTime = null;
this.timeAtPrevOffsetSample = null;
};
}
calculateOffset(currentTime) {
const targetClockTime = this.getTargetClockTime();
const diffBetweenTimeSamples = targetClockTime - this.timeAtPrevOffsetSample;
const newOffsetTime = targetClockTime - currentTime;
// recalcuate the diff from scratch if the clock has been paused for some time.
if (diffBetweenTimeSamples > this.resetAfterTime) {
this.reset();
}
if (this.offsetTime == null) {
this.offsetTime = newOffsetTime;
}
this.prevOffsetTimes.push(newOffsetTime);
if (this.prevOffsetTimes.length > this.weight) {
this.prevOffsetTimes.shift();
}
// after X time has passed, the average of the previous weight offset times is calculated and used as a stable reference
// for calculating the timestamp
if (this.timeAtPrevOffsetSample == null || diffBetweenTimeSamples > this.checkAfterTime) {
this.timeAtPrevOffsetSample = targetClockTime;
const rollingOffsetTime = averageArray(this.prevOffsetTimes);
//when the clock offsets surpass the delta, set the new reference time
if (Math.abs(rollingOffsetTime - this.offsetTime) > this.offsetDelta) {
this.offsetTime = rollingOffsetTime;
}
}
return this.offsetTime;
}
calculateTimestamp(currentTime, targetTime) {
return this.calculateOffset(currentTime) + targetTime;
}
}
export function getPerformanceTimeSeconds() {
return performance.now() * 0.001;
}
function getUnixTimeSeconds() {
return Date.now() * 0.001;
}
// Floating point versions, see Fraction for rational versions
// // greatest common divisor
+1
View File
@@ -4,6 +4,7 @@ import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [],
build: {
lib: {
+28 -35
View File
@@ -1,43 +1,36 @@
import { parseNumeral, Pattern, getEventOffsetMs } from '@strudel/core';
import { Pattern, ClockCollator } from '@strudel/core';
import { parseControlsFromHap } from 'node_modules/@strudel/osc/osc.mjs';
import { Invoke } from './utils.mjs';
Pattern.prototype.osc = function () {
return this.onTrigger(async (time, hap, currentTime, cps = 1, targetTime) => {
hap.ensureObjectValue();
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
// make sure n and note are numbers
controls.n && (controls.n = parseNumeral(controls.n));
controls.note && (controls.note = parseNumeral(controls.note));
const collator = new ClockCollator({});
const params = [];
export async function oscTriggerTauri(t_deprecate, hap, currentTime, cps = 1, targetTime) {
const controls = parseControlsFromHap(hap, cps);
const params = [];
const timestamp = collator.calculateTimestamp(currentTime, targetTime);
const timestamp = Math.round(Date.now() + getEventOffsetMs(targetTime, currentTime));
Object.keys(controls).forEach((key) => {
const val = controls[key];
const value = typeof val === 'number' ? val.toString() : val;
Object.keys(controls).forEach((key) => {
const val = controls[key];
const value = typeof val === 'number' ? val.toString() : val;
if (value == null) {
return;
}
params.push({
name: key,
value,
valueisnumber: typeof val === 'number',
});
if (value == null) {
return;
}
params.push({
name: key,
value,
valueisnumber: typeof val === 'number',
});
const messagesfromjs = [];
if (params.length) {
messagesfromjs.push({ target: '/dirt/play', timestamp, params });
}
if (messagesfromjs.length) {
setTimeout(() => {
Invoke('sendosc', { messagesfromjs });
});
}
});
if (params.length === 0) {
return;
}
const message = { target: '/dirt/play', timestamp, params };
setTimeout(() => {
Invoke('sendosc', { messagesfromjs: [message] });
});
}
Pattern.prototype.osc = function () {
return this.onTrigger(oscTriggerTauri);
};
+23 -13
View File
@@ -36,8 +36,8 @@ function stopAnimationFrame(id) {
delete animationFrames[id];
}
}
function stopAllAnimations() {
Object.keys(animationFrames).forEach((id) => stopAnimationFrame(id));
function stopAllAnimations(replID) {
Object.keys(animationFrames).forEach((id) => (!replID || id.startsWith(replID)) && stopAnimationFrame(id));
}
let memory = {};
@@ -72,18 +72,25 @@ Pattern.prototype.draw = function (fn, options) {
return this;
};
export const cleanupDraw = (clearScreen = true) => {
export const cleanupDraw = (clearScreen = true, id) => {
const ctx = getDrawContext();
clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.width);
stopAllAnimations();
if (window.strudelScheduler) {
clearInterval(window.strudelScheduler);
}
clearScreen && ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
stopAllAnimations(id);
};
Pattern.prototype.onPaint = function () {
console.warn('[draw] onPaint was not overloaded. Some drawings might not work');
return this;
Pattern.prototype.onPaint = function (painter) {
return this.withState((state) => {
if (!state.controls.painters) {
state.controls.painters = [];
}
state.controls.painters.push(painter);
});
};
Pattern.prototype.getPainters = function () {
let painters = [];
this.queryArc(0, 0, { painters });
return painters;
};
// const round = (x) => Math.round(x * 1000) / 1000;
@@ -122,6 +129,7 @@ export class Drawer {
this.visibleHaps = [];
this.lastFrame = null;
this.drawTime = drawTime;
this.painters = [];
this.framer = new Framer(
() => {
if (!this.scheduler) {
@@ -146,7 +154,7 @@ export class Drawer {
// add new haps with onset (think right edge bars scrolling in)
.concat(haps.filter((h) => h.hasOnset()));
const time = phase - lookahead;
onDraw(this.visibleHaps, time, this);
onDraw(this.visibleHaps, time, this, this.painters);
},
(err) => {
console.warn('draw error', err);
@@ -164,11 +172,13 @@ export class Drawer {
t = t ?? scheduler.now();
this.scheduler = scheduler;
let [_, lookahead] = this.drawTime;
// +0.1 = workaround for weird holes in query..
const [begin, end] = [Math.max(t, 0), t + lookahead + 0.1];
// remove all future haps
this.visibleHaps = this.visibleHaps.filter((h) => h.whole.begin < t);
this.painters = []; // will get populated by .onPaint calls attached to the pattern
// query future haps
const futureHaps = scheduler.pattern.queryArc(begin, end); // +0.1 = workaround for weird holes in query..
const futureHaps = scheduler.pattern.queryArc(begin, end, { painters: this.painters });
// append future haps
this.visibleHaps = this.visibleHaps.concat(futureHaps);
}
+8 -3
View File
@@ -67,6 +67,7 @@ Pattern.prototype.pianoroll = function (options = {}) {
* Displays a midi-style piano roll
*
* @name pianoroll
* @synonyms punchcard
* @param {Object} options Object containing all the optional following parameters as key value pairs:
* @param {integer} cycles number of cycles to be displayed at the same time - defaults to 4
* @param {number} playhead location of the active notes on the time axis - 0 to 1, defaults to 0.5
@@ -94,7 +95,11 @@ Pattern.prototype.pianoroll = function (options = {}) {
* @param {boolean} autorange automatically calculate the minMidi and maxMidi parameters - 0 by default
*
* @example
* note("C2 A2 G2").euclid(5,8).s('piano').clip(1).color('salmon').pianoroll({vertical:1, labels:1})
* note("c2 a2 eb2")
* .euclid(5,8)
* .s('sawtooth')
* .lpenv(4).lpf(300)
* ._pianoroll({ labels: 1 })
*/
export function pianoroll({
time,
@@ -113,7 +118,7 @@ export function pianoroll({
maxMidi = 90,
autorange = 0,
timeframe: timeframeProp,
fold = 0,
fold = 1,
vertical = 0,
labels = false,
fill = 1,
@@ -266,7 +271,7 @@ export function getDrawOptions(drawTime, options = {}) {
let [lookbehind, lookahead] = drawTime;
lookbehind = Math.abs(lookbehind);
const cycles = lookahead + lookbehind;
const playhead = lookbehind / cycles;
const playhead = cycles !== 0 ? lookbehind / cycles : 0;
return { fold: 1, ...options, cycles, playhead };
}
+17
View File
@@ -113,6 +113,23 @@ export function pitchwheel({
return;
}
/**
* Renders a pitch circle to visualize frequencies within one octave
* @name pitchwheel
* @param {number} hapcircles
* @param {number} circle
* @param {number} edo
* @param {string} root
* @param {number} thickness
* @param {number} hapRadius
* @param {string} mode
* @param {number} margin
* @example
* n("0 .. 12").scale("C:chromatic")
* .s("sawtooth")
* .lpf(500)
* ._pitchwheel()
*/
Pattern.prototype.pitchwheel = function (options = {}) {
let { ctx = getDrawContext(), id = 1 } = options;
return this.tag(id).onPaint((_, time, haps) =>
+27
View File
@@ -125,6 +125,33 @@ function drawSpiral(options) {
});
}
/**
* Displays a spiral visual.
*
* @name spiral
* @param {Object} options Object containing all the optional following parameters as key value pairs:
* @param {number} stretch controls the rotations per cycle ratio, where 1 = 1 cycle / 360 degrees
* @param {number} size the diameter of the spiral
* @param {number} thickness line thickness
* @param {string} cap style of line ends: butt (default), round, square
* @param {string} inset number of rotations before spiral starts (default 3)
* @param {string} playheadColor color of playhead, defaults to white
* @param {number} playheadLength length of playhead in rotations, defaults to 0.02
* @param {number} playheadThickness thickness of playheadrotations, defaults to thickness
* @param {number} padding space around spiral
* @param {number} steady steadyness of spiral vs playhead. 1 = spiral doesn't move, playhead does.
* @param {number} activeColor color of active segment. defaults to foreground of theme
* @param {number} inactiveColor color of inactive segments. defaults to gutterForeground of theme
* @param {boolean} colorizeInactive wether or not to colorize inactive segments, defaults to 0
* @param {boolean} fade wether or not past and future should fade out. defaults to 1
* @param {boolean} logSpiral wether or not the spiral should be logarithmic. defaults to 0
* @example
* note("c2 a2 eb2")
* .euclid(5,8)
* .s('sawtooth')
* .lpenv(4).lpf(300)
* ._spiral({ steady: .96 })
*/
Pattern.prototype.spiral = function (options = {}) {
return this.onPaint((ctx, time, haps, drawTime) => drawSpiral({ ctx, time, haps, drawTime, ...options }));
};
+6 -1
View File
@@ -169,13 +169,18 @@ const refs = {};
export async function midin(input) {
if (isPattern(input)) {
throw new Error(
`.midi does not accept Pattern input. Make sure to pass device name with single quotes. Example: .midi('${
`midin: does not accept Pattern as input. Make sure to pass device name with single quotes. Example: midin('${
WebMidi.outputs?.[0]?.name || 'IAC Driver Bus 1'
}')`,
);
}
const initial = await enableWebMidi(); // only returns on first init
const device = getDevice(input, WebMidi.inputs);
if (!device) {
throw new Error(
`midiin: device "${input}" not found.. connected devices: ${getMidiDeviceNamesString(WebMidi.inputs)}`,
);
}
if (initial) {
const otherInputs = WebMidi.inputs.filter((o) => o.name !== device.name);
logger(
+173 -223
View File
@@ -1,253 +1,203 @@
/*
mini.mjs - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/mini/mini.mjs>
Copyright (C) 2024 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/mini/mini.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 * as krill from './krill-parser.js';
import * as strudel from '@strudel/core';
import Fraction, { lcm } from '@strudel/core/fraction.mjs';
const randOffset = 0.0003;
const applyOptions = (parent, enter) => (pat, i) => {
const ast = parent.source_[i];
const options = ast.options_;
const ops = options?.ops;
if (ops) {
for (const op of ops) {
switch (op.type_) {
case 'stretch': {
const legalTypes = ['fast', 'slow'];
const { type, amount } = op.arguments_;
if (!legalTypes.includes(type)) {
throw new Error(`mini: stretch: type must be one of ${legalTypes.join('|')} but got ${type}`);
}
pat = strudel.reify(pat)[type](enter(amount));
break;
}
case 'replicate': {
const { amount } = op.arguments_;
pat = strudel.reify(pat);
pat = pat._repeatCycles(amount)._fast(amount);
break;
}
case 'bjorklund': {
if (op.arguments_.rotation) {
pat = pat.euclidRot(enter(op.arguments_.pulse), enter(op.arguments_.step), enter(op.arguments_.rotation));
} else {
pat = pat.euclid(enter(op.arguments_.pulse), enter(op.arguments_.step));
}
break;
}
case 'degradeBy': {
pat = strudel
.reify(pat)
._degradeByWith(strudel.rand.early(randOffset * op.arguments_.seed), op.arguments_.amount ?? 0.5);
break;
}
case 'tail': {
const friend = enter(op.arguments_.element);
pat = pat.fmap((a) => (b) => (Array.isArray(a) ? [...a, b] : [a, b])).appLeft(friend);
break;
}
case 'range': {
const friend = enter(op.arguments_.element);
pat = strudel.reify(pat);
const arrayRange = (start, stop, step = 1) =>
Array.from({ length: Math.abs(stop - start) / step + 1 }, (value, index) =>
start < stop ? start + index * step : start - index * step,
);
let range = (apat, bpat) => apat.squeezeBind((a) => bpat.bind((b) => strudel.fastcat(...arrayRange(a, b))));
pat = range(pat, friend);
break;
}
default: {
console.warn(`operator "${op.type_}" not implemented`);
}
}
}
}
return pat;
// "parser"
let token_types = {
open_polyrhythm: /^\[/,
close_polyrhythm: /^\]/,
open_polymeter: /^\{/,
close_polymeter: /^\}/,
open_slowpolymeter: /^\</,
close_slowpolymeter: /^\>/,
subpat_delimiter: /^,/,
fast: /^\*/,
slow: /^\//,
// is there a better way without back-tracking for whole numbers?
// float: /^(\d*\.)?\d+/,
plain: /^[a-zA-Z0-9\.\#]+/,
};
// expects ast from mini2ast + quoted mini string + optional callback when a node is entered
export function patternifyAST(ast, code, onEnter, offset = 0) {
onEnter?.(ast);
const enter = (node) => patternifyAST(node, code, onEnter, offset);
switch (ast.type_) {
case 'pattern': {
// resolveReplications(ast);
const children = ast.source_.map((child) => enter(child)).map(applyOptions(ast, enter));
const alignment = ast.arguments_.alignment;
const with_tactus = children.filter((child) => child.__tactus_source);
let pat;
switch (alignment) {
case 'stack': {
pat = strudel.stack(...children);
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
}
break;
}
case 'polymeter_slowcat': {
pat = strudel.stack(...children.map((child) => child._slow(child.__weight)));
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
}
break;
}
case 'polymeter': {
// polymeter
const stepsPerCycle = ast.arguments_.stepsPerCycle
? enter(ast.arguments_.stepsPerCycle).fmap((x) => strudel.Fraction(x))
: strudel.pure(strudel.Fraction(children.length > 0 ? children[0].__weight : 1));
let token_offset;
function next_token(code, global_offset) {
for (let type in token_types) {
const match = code.match(token_types[type]);
if (match) {
const leftpad = match[0].length - match[0].trimLeft().length;
const rightpad = match[0].length - match[0].trimRight().length;
const result = {
type,
value: match[0],
location: [token_offset + leftpad + global_offset, token_offset + match[0].length + global_offset - rightpad],
};
token_offset += match[0].length;
return result;
}
}
throw new Error('could not match "' + code + '"');
}
const aligned = children.map((child) => child.fast(stepsPerCycle.fmap((x) => x.div(child.__weight))));
pat = strudel.stack(...aligned);
break;
}
case 'rand': {
pat = strudel.chooseInWith(strudel.rand.early(randOffset * ast.arguments_.seed).segment(1), children);
if (with_tactus.length) {
pat.tactus = lcm(...with_tactus.map((x) => Fraction(x.tactus)));
}
break;
}
case 'feet': {
pat = strudel.fastcat(...children);
break;
}
default: {
const weightedChildren = ast.source_.some((child) => !!child.options_?.weight);
if (weightedChildren) {
const weightSum = ast.source_.reduce(
(sum, child) => sum.add(child.options_?.weight || strudel.Fraction(1)),
strudel.Fraction(0),
);
pat = strudel.timeCat(
...ast.source_.map((child, i) => [child.options_?.weight || strudel.Fraction(1), children[i]]),
);
pat.__weight = weightSum; // for polymeter
pat.tactus = weightSum;
if (with_tactus.length) {
pat.tactus = pat.tactus.mul(lcm(...with_tactus.map((x) => Fraction(x.tactus))));
}
} else {
pat = strudel.sequence(...children);
pat.tactus = children.length;
}
if (ast.arguments_.tactus) {
pat.__tactus_source = true;
}
}
}
if (with_tactus.length) {
pat.__tactus_source = true;
}
return pat;
function tokenize(code, global_offset) {
let tokens = [];
token_offset = 0;
while (code.length > 0) {
const trimmed = code.trim();
token_offset += code.length - trimmed.length;
code = trimmed;
const token = next_token(code, global_offset);
code = code.slice(token.value.length);
tokens.push(token);
}
return tokens;
}
class Parser {
parse(code, global_offset) {
this.tokens = tokenize(code, global_offset);
const subpats = this.parse_subpats();
return { type: 'polymeter', subpats };
}
consume(type) {
const token = this.tokens.shift();
this.offset += token.length;
if (token.type !== type) {
throw new Error('expected token type ' + type + ' got ' + token.type);
}
case 'element': {
1;
return enter(ast.source_);
return token;
}
parse_step() {
let next = this.tokens[0]?.type;
if (next === 'open_polyrhythm') {
return this.parse_polyrhythm();
}
case 'atom': {
if (ast.source_ === '~' || ast.source_ === '-') {
return strudel.silence;
}
if (!ast.location_) {
console.warn('no location for', ast);
return ast.source_;
}
const value = !isNaN(Number(ast.source_)) ? Number(ast.source_) : ast.source_;
if (offset === -1) {
// skip location handling (used when getting leaves to avoid confusion)
return strudel.pure(value);
}
const [from, to] = getLeafLocation(code, ast, offset);
return strudel.pure(value).withLoc(from, to);
if (next === 'open_polymeter') {
return this.parse_polymeter();
}
case 'stretch':
return enter(ast.source_).slow(enter(ast.arguments_.amount));
default:
console.warn(`node type "${ast.type_}" not implemented -> returning silence`);
return strudel.silence;
if (next === 'open_slowpolymeter') {
return this.parse_slowpolymeter();
}
if (next === 'plain') {
return this.consume('plain');
}
throw new Error('unexpected token ' + this.tokens[0]?.value + ' of type ' + this.tokens[0]?.type);
}
parse_expr() {
let result = this.parse_step();
let looking = true;
while (looking) {
let next = this.tokens[0]?.type;
if (!next) {
looking = false;
} else {
switch (next) {
case 'fast':
case 'slow':
this.consume(next);
const factor = this.parse_step();
result = { type: next, factor, step: result };
break;
default:
looking = false;
}
}
}
return result;
}
parse_seq(...close_types) {
const steps = [];
while (!close_types.includes(this.tokens[0]?.type)) {
steps.push(this.parse_expr());
}
return { type: 'seq', steps };
}
parse_subpats(close_type) {
const subpats = [];
while (true) {
subpats.push(this.parse_seq(close_type, 'subpat_delimiter'));
const next = this.tokens[0]?.type;
if (next) {
this.consume(next);
}
if (next === close_type) {
break;
}
}
return subpats;
}
parse_polymeter() {
this.consume('open_polymeter');
const subpats = this.parse_subpats('close_polymeter');
return { type: 'polymeter', subpats };
}
parse_polyrhythm() {
this.consume('open_polyrhythm');
const subpats = this.parse_subpats('close_polyrhythm');
return { type: 'polyrhythm', subpats };
}
parse_slowpolymeter() {
this.consume('open_slowpolymeter');
const subpats = this.parse_subpats('close_slowpolymeter');
return { type: 'slowpolymeter', subpats };
}
}
// takes quoted mini string + leaf node within, returns source location of node (whitespace corrected)
export const getLeafLocation = (code, leaf, globalOffset = 0) => {
// value is expected without quotes!
const { start, end } = leaf.location_;
const actual = code?.split('').slice(start.offset, end.offset).join('');
// make sure whitespaces are not part of the highlight
const [offsetStart = 0, offsetEnd = 0] = actual
? actual.split(leaf.source_).map((p) => p.split('').filter((c) => c === ' ').length)
: [];
return [start.offset + offsetStart + globalOffset, end.offset - offsetEnd + globalOffset];
};
// takes quoted mini string, returns ast
export const mini2ast = (code, start = 0, userCode = code) => {
try {
return krill.parse(code);
} catch (error) {
const region = [error.location.start.offset + start, error.location.end.offset + start];
const line = userCode.slice(0, region[0]).split('\n').length;
throw new Error(`[mini] parse error at line ${line}: ${error.message}`);
function patternifyTree(tree) {
if (tree.type === 'polyrhythm') {
const args = tree.subpats.map((arg) => patternifyTree(arg));
return strudel.polyrhythm(...args);
}
if (tree.type === 'slowpolymeter') {
const args = tree.subpats.map((arg) => patternifyTree(arg));
return strudel.s_polymeterSteps(1, ...args);
}
if (tree.type === 'polymeter') {
const args = tree.subpats.map((subpat) => patternifyTree(subpat));
return strudel.s_polymeter(...args);
}
if (tree.type === 'fast') {
const step = patternifyTree(tree.step);
const factor = patternifyTree(tree.factor);
return strudel.fast(factor, step);
}
if (tree.type === 'slow') {
const step = patternifyTree(tree.step);
const factor = patternifyTree(tree.factor);
return strudel.slow(factor, step);
}
if (tree.type === 'plain') {
return strudel.pure(tree.value).withLoc(tree.location[0], tree.location[1]);
}
if (tree.type === 'seq') {
const steps = tree.steps.map((step) => patternifyTree(step));
return strudel.fastcat(...steps);
}
}
const parser = new Parser();
export const m = (code, global_offset) => {
const tree = parser.parse(code, global_offset + 1);
const pat = patternifyTree(tree);
return strudel.reify(pat);
};
// takes quoted mini string, returns all nodes that are leaves
export const getLeaves = (code, start, userCode) => {
const ast = mini2ast(code, start, userCode);
let leaves = [];
patternifyAST(
ast,
code,
(node) => {
if (node.type_ === 'atom') {
leaves.push(node);
}
},
-1,
);
return leaves;
};
// takes quoted mini string, returns locations [fromCol,toCol] of all leaf nodes
export const getLeafLocations = (code, start = 0, userCode) => {
return getLeaves(code, start, userCode).map((l) => getLeafLocation(code, l, start));
export const getLeafLocations = (code, start) => {
code = code.replace(/^"/, '');
code = code.replace(/"$/, '');
const leaves = tokenize(code, start).filter((x) => x.type == 'plain');
return leaves.map((x) => x.location.map((y) => y + 1));
};
// mini notation only (wraps in "")
export const mini = (...strings) => {
const pats = strings.map((str) => {
const code = `"${str}"`;
const ast = mini2ast(code);
return patternifyAST(ast, code);
});
const pats = strings.map(m);
return strudel.sequence(...pats);
};
// turns str mini string (without quotes) into pattern
// offset is the position of the mini string in the JS code
// each leaf node will get .withLoc added
// this function is used by the transpiler for double quoted strings
export const m = (str, offset) => {
const code = `"${str}"`;
const ast = mini2ast(code);
return patternifyAST(ast, code, null, offset);
};
// includes haskell style (raw krill parsing)
export const h = (string) => {
const ast = mini2ast(string);
return patternifyAST(ast, string);
};
export function minify(thing) {
if (typeof thing === 'string') {
return mini(thing);
+3 -14
View File
@@ -210,25 +210,14 @@ describe('mini', () => {
});
it('supports ^ tactus marking', () => {
expect(mini('a [^b c]').tactus).toEqual(Fraction(4));
expect(mini('[^b c]!3').tactus).toEqual(Fraction(6));
expect(mini('[a b c] [d [e f]]').tactus).toEqual(Fraction(2));
expect(mini('^[a b c] [d [e f]]').tactus).toEqual(Fraction(2));
expect(mini('[a b c] [d [^e f]]').tactus).toEqual(Fraction(8));
expect(mini('[a b c] [^d [e f]]').tactus).toEqual(Fraction(4));
expect(mini('[^a b c] [^d [e f]]').tactus).toEqual(Fraction(12));
expect(mini('[^a b c] [d [^e f]]').tactus).toEqual(Fraction(24));
});
});
describe('getLeafLocation', () => {
it('gets location of leaf nodes', () => {
const code = '"bd sd"';
const ast = mini2ast(code);
const bd = ast.source_[0].source_;
expect(getLeafLocation(code, bd)).toEqual([1, 3]);
const sd = ast.source_[1].source_;
expect(getLeafLocation(code, sd)).toEqual([4, 6]);
expect(mini('[^a b c d e]').tactus).toEqual(Fraction(5));
});
});
@@ -240,9 +229,9 @@ describe('getLeafLocations', () => {
]);
expect(getLeafLocations('"bd*2 [sd cp]"')).toEqual([
[1, 3], // bd columns
[4, 5], // "2" columns
[7, 9], // sd columns
[10, 12], // cp columns
[4, 5], // "2" columns
]);
expect(getLeafLocations('"bd*<2 3>"')).toEqual([
[1, 3], // bd columns
+40 -30
View File
@@ -6,7 +6,7 @@ This program is free software: you can redistribute it and/or modify it under th
import OSC from 'osc-js';
import { logger, parseNumeral, Pattern, getEventOffsetMs, isNote, noteToMidi } from '@strudel/core';
import { logger, parseNumeral, Pattern, isNote, noteToMidi, ClockCollator } from '@strudel/core';
let connection; // Promise<OSC>
function connect() {
@@ -34,6 +34,44 @@ function connect() {
return connection;
}
export function parseControlsFromHap(hap, cps) {
hap.ensureObjectValue();
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
// make sure n and note are numbers
controls.n && (controls.n = parseNumeral(controls.n));
if (typeof controls.note !== 'undefined') {
if (isNote(controls.note)) {
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
} else {
controls.note = parseNumeral(controls.note);
}
}
controls.bank && (controls.s = controls.bank + controls.s);
controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
// speed adjustment for CPS is handled on the DSP side in superdirt and pattern side in Strudel,
// so we need to undo the adjustment before sending the message to superdirt.
controls.unit === 'c' && controls.speed != null && (controls.speed = controls.speed / cps);
const channels = controls.channels;
channels != undefined && (controls.channels = JSON.stringify(channels));
return controls;
}
const collator = new ClockCollator({});
export async function oscTrigger(t_deprecate, hap, currentTime, cps = 1, targetTime) {
const osc = await connect();
const controls = parseControlsFromHap(hap, cps);
const keyvals = Object.entries(controls).flat();
const ts = Math.round(collator.calculateTimestamp(currentTime, targetTime) * 1000);
const message = new OSC.Message('/dirt/play', ...keyvals);
const bundle = new OSC.Bundle([message], ts);
bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
osc.send(bundle);
}
/**
*
* Sends each hap as an OSC message, which can be picked up by SuperCollider or any other OSC-enabled software.
@@ -44,33 +82,5 @@ function connect() {
* @returns Pattern
*/
Pattern.prototype.osc = function () {
return this.onTrigger(async (time, hap, currentTime, cps = 1, targetTime) => {
hap.ensureObjectValue();
const osc = await connect();
const cycle = hap.wholeOrPart().begin.valueOf();
const delta = hap.duration.valueOf();
const controls = Object.assign({}, { cps, cycle, delta }, hap.value);
// make sure n and note are numbers
controls.n && (controls.n = parseNumeral(controls.n));
if (typeof controls.note !== 'undefined') {
if (isNote(controls.note)) {
controls.midinote = noteToMidi(controls.note, controls.octave || 3);
} else {
controls.note = parseNumeral(controls.note);
}
}
controls.bank && (controls.s = controls.bank + controls.s);
controls.roomsize && (controls.size = parseNumeral(controls.roomsize));
const keyvals = Object.entries(controls).flat();
// time should be audio time of onset
// currentTime should be current time of audio context (slightly before time)
const offset = getEventOffsetMs(targetTime, currentTime);
// timestamp in milliseconds used to trigger the osc bundle at a precise moment
const ts = Math.floor(Date.now() + offset);
const message = new OSC.Message('/dirt/play', ...keyvals);
const bundle = new OSC.Bundle([message], ts);
bundle.timestamp(ts); // workaround for https://github.com/adzialocha/osc-js/issues/60
osc.send(bundle);
});
return this.onTrigger(oscTrigger);
};
+10
View File
@@ -0,0 +1,10 @@
import { oscTriggerTauri } from '../desktopbridge/oscbridge.mjs';
import { isTauri } from '../desktopbridge/utils.mjs';
import { oscTrigger } from './osc.mjs';
const trigger = isTauri() ? oscTriggerTauri : oscTrigger;
export const superdirtOutput = (hap, deadline, hapDuration, cps, targetTime) => {
const currentTime = performance.now() / 1000;
return trigger(null, hap, currentTime, cps, targetTime);
};
+1
View File
@@ -6,6 +6,7 @@ import replace from '@rollup/plugin-replace';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [],
build: {
lib: {
+2 -2
View File
@@ -68,7 +68,7 @@ Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids
if (!(name in writeMessagers)) {
getWriter(name, br);
}
const onTrigger = (time, hap, currentTime) => {
const onTrigger = (t_deprecate, hap, currentTime, cps, targetTime) => {
var message = '';
var chk = 0;
if (typeof hap.value === 'object') {
@@ -105,7 +105,7 @@ Pattern.prototype.serial = function (br = 115200, sendcrc = false, singlecharids
} else {
message = hap.value;
}
const offset = (time - currentTime + latency) * 1000;
const offset = (targetTime - currentTime + latency) * 1000;
window.setTimeout(function () {
writeMessagers[name](message, chk);
+488
View File
@@ -0,0 +1,488 @@
'use strict';
// sourced from https://github.com/indutny/fft.js/
// LICENSE
// This software is licensed under the MIT License.
// Copyright Fedor Indutny, 2017.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
export default class FFT {
constructor(size) {
this.size = size | 0;
if (this.size <= 1 || (this.size & (this.size - 1)) !== 0)
throw new Error('FFT size must be a power of two and bigger than 1');
this._csize = size << 1;
// NOTE: Use of `var` is intentional for old V8 versions
var table = new Array(this.size * 2);
for (var i = 0; i < table.length; i += 2) {
const angle = (Math.PI * i) / this.size;
table[i] = Math.cos(angle);
table[i + 1] = -Math.sin(angle);
}
this.table = table;
// Find size's power of two
var power = 0;
for (var t = 1; this.size > t; t <<= 1) power++;
// Calculate initial step's width:
// * If we are full radix-4 - it is 2x smaller to give inital len=8
// * Otherwise it is the same as `power` to give len=4
this._width = power % 2 === 0 ? power - 1 : power;
// Pre-compute bit-reversal patterns
this._bitrev = new Array(1 << this._width);
for (var j = 0; j < this._bitrev.length; j++) {
this._bitrev[j] = 0;
for (var shift = 0; shift < this._width; shift += 2) {
var revShift = this._width - shift - 2;
this._bitrev[j] |= ((j >>> shift) & 3) << revShift;
}
}
this._out = null;
this._data = null;
this._inv = 0;
}
fromComplexArray(complex, storage) {
var res = storage || new Array(complex.length >>> 1);
for (var i = 0; i < complex.length; i += 2) res[i >>> 1] = complex[i];
return res;
}
createComplexArray() {
const res = new Array(this._csize);
for (var i = 0; i < res.length; i++) res[i] = 0;
return res;
}
toComplexArray(input, storage) {
var res = storage || this.createComplexArray();
for (var i = 0; i < res.length; i += 2) {
res[i] = input[i >>> 1];
res[i + 1] = 0;
}
return res;
}
completeSpectrum(spectrum) {
var size = this._csize;
var half = size >>> 1;
for (var i = 2; i < half; i += 2) {
spectrum[size - i] = spectrum[i];
spectrum[size - i + 1] = -spectrum[i + 1];
}
}
transform(out, data) {
if (out === data) throw new Error('Input and output buffers must be different');
this._out = out;
this._data = data;
this._inv = 0;
this._transform4();
this._out = null;
this._data = null;
}
realTransform(out, data) {
if (out === data) throw new Error('Input and output buffers must be different');
this._out = out;
this._data = data;
this._inv = 0;
this._realTransform4();
this._out = null;
this._data = null;
}
inverseTransform(out, data) {
if (out === data) throw new Error('Input and output buffers must be different');
this._out = out;
this._data = data;
this._inv = 1;
this._transform4();
for (var i = 0; i < out.length; i++) out[i] /= this.size;
this._out = null;
this._data = null;
}
// radix-4 implementation
//
// NOTE: Uses of `var` are intentional for older V8 version that do not
// support both `let compound assignments` and `const phi`
_transform4() {
var out = this._out;
var size = this._csize;
// Initial step (permute and transform)
var width = this._width;
var step = 1 << width;
var len = (size / step) << 1;
var outOff;
var t;
var bitrev = this._bitrev;
if (len === 4) {
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
const off = bitrev[t];
this._singleTransform2(outOff, off, step);
}
} else {
// len === 8
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
const off = bitrev[t];
this._singleTransform4(outOff, off, step);
}
}
// Loop through steps in decreasing order
var inv = this._inv ? -1 : 1;
var table = this.table;
for (step >>= 2; step >= 2; step >>= 2) {
len = (size / step) << 1;
var quarterLen = len >>> 2;
// Loop through offsets in the data
for (outOff = 0; outOff < size; outOff += len) {
// Full case
var limit = outOff + quarterLen;
for (var i = outOff, k = 0; i < limit; i += 2, k += step) {
const A = i;
const B = A + quarterLen;
const C = B + quarterLen;
const D = C + quarterLen;
// Original values
const Ar = out[A];
const Ai = out[A + 1];
const Br = out[B];
const Bi = out[B + 1];
const Cr = out[C];
const Ci = out[C + 1];
const Dr = out[D];
const Di = out[D + 1];
// Middle values
const MAr = Ar;
const MAi = Ai;
const tableBr = table[k];
const tableBi = inv * table[k + 1];
const MBr = Br * tableBr - Bi * tableBi;
const MBi = Br * tableBi + Bi * tableBr;
const tableCr = table[2 * k];
const tableCi = inv * table[2 * k + 1];
const MCr = Cr * tableCr - Ci * tableCi;
const MCi = Cr * tableCi + Ci * tableCr;
const tableDr = table[3 * k];
const tableDi = inv * table[3 * k + 1];
const MDr = Dr * tableDr - Di * tableDi;
const MDi = Dr * tableDi + Di * tableDr;
// Pre-Final values
const T0r = MAr + MCr;
const T0i = MAi + MCi;
const T1r = MAr - MCr;
const T1i = MAi - MCi;
const T2r = MBr + MDr;
const T2i = MBi + MDi;
const T3r = inv * (MBr - MDr);
const T3i = inv * (MBi - MDi);
// Final values
const FAr = T0r + T2r;
const FAi = T0i + T2i;
const FCr = T0r - T2r;
const FCi = T0i - T2i;
const FBr = T1r + T3i;
const FBi = T1i - T3r;
const FDr = T1r - T3i;
const FDi = T1i + T3r;
out[A] = FAr;
out[A + 1] = FAi;
out[B] = FBr;
out[B + 1] = FBi;
out[C] = FCr;
out[C + 1] = FCi;
out[D] = FDr;
out[D + 1] = FDi;
}
}
}
}
// radix-2 implementation
//
// NOTE: Only called for len=4
_singleTransform2(outOff, off, step) {
const out = this._out;
const data = this._data;
const evenR = data[off];
const evenI = data[off + 1];
const oddR = data[off + step];
const oddI = data[off + step + 1];
const leftR = evenR + oddR;
const leftI = evenI + oddI;
const rightR = evenR - oddR;
const rightI = evenI - oddI;
out[outOff] = leftR;
out[outOff + 1] = leftI;
out[outOff + 2] = rightR;
out[outOff + 3] = rightI;
}
// radix-4
//
// NOTE: Only called for len=8
_singleTransform4(outOff, off, step) {
const out = this._out;
const data = this._data;
const inv = this._inv ? -1 : 1;
const step2 = step * 2;
const step3 = step * 3;
// Original values
const Ar = data[off];
const Ai = data[off + 1];
const Br = data[off + step];
const Bi = data[off + step + 1];
const Cr = data[off + step2];
const Ci = data[off + step2 + 1];
const Dr = data[off + step3];
const Di = data[off + step3 + 1];
// Pre-Final values
const T0r = Ar + Cr;
const T0i = Ai + Ci;
const T1r = Ar - Cr;
const T1i = Ai - Ci;
const T2r = Br + Dr;
const T2i = Bi + Di;
const T3r = inv * (Br - Dr);
const T3i = inv * (Bi - Di);
// Final values
const FAr = T0r + T2r;
const FAi = T0i + T2i;
const FBr = T1r + T3i;
const FBi = T1i - T3r;
const FCr = T0r - T2r;
const FCi = T0i - T2i;
const FDr = T1r - T3i;
const FDi = T1i + T3r;
out[outOff] = FAr;
out[outOff + 1] = FAi;
out[outOff + 2] = FBr;
out[outOff + 3] = FBi;
out[outOff + 4] = FCr;
out[outOff + 5] = FCi;
out[outOff + 6] = FDr;
out[outOff + 7] = FDi;
}
// Real input radix-4 implementation
_realTransform4() {
var out = this._out;
var size = this._csize;
// Initial step (permute and transform)
var width = this._width;
var step = 1 << width;
var len = (size / step) << 1;
var outOff;
var t;
var bitrev = this._bitrev;
if (len === 4) {
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
const off = bitrev[t];
this._singleRealTransform2(outOff, off >>> 1, step >>> 1);
}
} else {
// len === 8
for (outOff = 0, t = 0; outOff < size; outOff += len, t++) {
const off = bitrev[t];
this._singleRealTransform4(outOff, off >>> 1, step >>> 1);
}
}
// Loop through steps in decreasing order
var inv = this._inv ? -1 : 1;
var table = this.table;
for (step >>= 2; step >= 2; step >>= 2) {
len = (size / step) << 1;
var halfLen = len >>> 1;
var quarterLen = halfLen >>> 1;
var hquarterLen = quarterLen >>> 1;
// Loop through offsets in the data
for (outOff = 0; outOff < size; outOff += len) {
for (var i = 0, k = 0; i <= hquarterLen; i += 2, k += step) {
var A = outOff + i;
var B = A + quarterLen;
var C = B + quarterLen;
var D = C + quarterLen;
// Original values
var Ar = out[A];
var Ai = out[A + 1];
var Br = out[B];
var Bi = out[B + 1];
var Cr = out[C];
var Ci = out[C + 1];
var Dr = out[D];
var Di = out[D + 1];
// Middle values
var MAr = Ar;
var MAi = Ai;
var tableBr = table[k];
var tableBi = inv * table[k + 1];
var MBr = Br * tableBr - Bi * tableBi;
var MBi = Br * tableBi + Bi * tableBr;
var tableCr = table[2 * k];
var tableCi = inv * table[2 * k + 1];
var MCr = Cr * tableCr - Ci * tableCi;
var MCi = Cr * tableCi + Ci * tableCr;
var tableDr = table[3 * k];
var tableDi = inv * table[3 * k + 1];
var MDr = Dr * tableDr - Di * tableDi;
var MDi = Dr * tableDi + Di * tableDr;
// Pre-Final values
var T0r = MAr + MCr;
var T0i = MAi + MCi;
var T1r = MAr - MCr;
var T1i = MAi - MCi;
var T2r = MBr + MDr;
var T2i = MBi + MDi;
var T3r = inv * (MBr - MDr);
var T3i = inv * (MBi - MDi);
// Final values
var FAr = T0r + T2r;
var FAi = T0i + T2i;
var FBr = T1r + T3i;
var FBi = T1i - T3r;
out[A] = FAr;
out[A + 1] = FAi;
out[B] = FBr;
out[B + 1] = FBi;
// Output final middle point
if (i === 0) {
var FCr = T0r - T2r;
var FCi = T0i - T2i;
out[C] = FCr;
out[C + 1] = FCi;
continue;
}
// Do not overwrite ourselves
if (i === hquarterLen) continue;
// In the flipped case:
// MAi = -MAi
// MBr=-MBi, MBi=-MBr
// MCr=-MCr
// MDr=MDi, MDi=MDr
var ST0r = T1r;
var ST0i = -T1i;
var ST1r = T0r;
var ST1i = -T0i;
var ST2r = -inv * T3i;
var ST2i = -inv * T3r;
var ST3r = -inv * T2i;
var ST3i = -inv * T2r;
var SFAr = ST0r + ST2r;
var SFAi = ST0i + ST2i;
var SFBr = ST1r + ST3i;
var SFBi = ST1i - ST3r;
var SA = outOff + quarterLen - i;
var SB = outOff + halfLen - i;
out[SA] = SFAr;
out[SA + 1] = SFAi;
out[SB] = SFBr;
out[SB + 1] = SFBi;
}
}
}
}
// radix-2 implementation
//
// NOTE: Only called for len=4
_singleRealTransform2(outOff, off, step) {
const out = this._out;
const data = this._data;
const evenR = data[off];
const oddR = data[off + step];
const leftR = evenR + oddR;
const rightR = evenR - oddR;
out[outOff] = leftR;
out[outOff + 1] = 0;
out[outOff + 2] = rightR;
out[outOff + 3] = 0;
}
// radix-4
//
// NOTE: Only called for len=8
_singleRealTransform4(outOff, off, step) {
const out = this._out;
const data = this._data;
const inv = this._inv ? -1 : 1;
const step2 = step * 2;
const step3 = step * 3;
// Original values
const Ar = data[off];
const Br = data[off + step];
const Cr = data[off + step2];
const Dr = data[off + step3];
// Pre-Final values
const T0r = Ar + Cr;
const T1r = Ar - Cr;
const T2r = Br + Dr;
const T3r = inv * (Br - Dr);
// Final values
const FAr = T0r + T2r;
const FBr = T1r;
const FBi = -T3r;
const FCr = T0r - T2r;
const FDr = T1r;
const FDi = T3r;
out[outOff] = FAr;
out[outOff + 1] = 0;
out[outOff + 2] = FBr;
out[outOff + 3] = FBi;
out[outOff + 4] = FCr;
out[outOff + 5] = 0;
out[outOff + 6] = FDr;
out[outOff + 7] = FDi;
}
}
+185
View File
@@ -0,0 +1,185 @@
'use strict';
// sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
const WEBAUDIO_BLOCK_SIZE = 128;
/** Overlap-Add Node */
class OLAProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this.started = false;
this.nbInputs = options.numberOfInputs;
this.nbOutputs = options.numberOfOutputs;
this.blockSize = options.processorOptions.blockSize;
// TODO for now, the only support hop size is the size of a web audio block
this.hopSize = WEBAUDIO_BLOCK_SIZE;
this.nbOverlaps = this.blockSize / this.hopSize;
// pre-allocate input buffers (will be reallocated if needed)
this.inputBuffers = new Array(this.nbInputs);
this.inputBuffersHead = new Array(this.nbInputs);
this.inputBuffersToSend = new Array(this.nbInputs);
// default to 1 channel per input until we know more
for (let i = 0; i < this.nbInputs; i++) {
this.allocateInputChannels(i, 1);
}
// pre-allocate input buffers (will be reallocated if needed)
this.outputBuffers = new Array(this.nbOutputs);
this.outputBuffersToRetrieve = new Array(this.nbOutputs);
// default to 1 channel per output until we know more
for (let i = 0; i < this.nbOutputs; i++) {
this.allocateOutputChannels(i, 1);
}
}
/** Handles dynamic reallocation of input/output channels buffer
(channel numbers may lety during lifecycle) **/
reallocateChannelsIfNeeded(inputs, outputs) {
for (let i = 0; i < this.nbInputs; i++) {
let nbChannels = inputs[i].length;
if (nbChannels != this.inputBuffers[i].length) {
this.allocateInputChannels(i, nbChannels);
}
}
for (let i = 0; i < this.nbOutputs; i++) {
let nbChannels = outputs[i].length;
if (nbChannels != this.outputBuffers[i].length) {
this.allocateOutputChannels(i, nbChannels);
}
}
}
allocateInputChannels(inputIndex, nbChannels) {
// allocate input buffers
this.inputBuffers[inputIndex] = new Array(nbChannels);
for (let i = 0; i < nbChannels; i++) {
this.inputBuffers[inputIndex][i] = new Float32Array(this.blockSize + WEBAUDIO_BLOCK_SIZE);
this.inputBuffers[inputIndex][i].fill(0);
}
// allocate input buffers to send and head pointers to copy from
// (cannot directly send a pointer/subarray because input may be modified)
this.inputBuffersHead[inputIndex] = new Array(nbChannels);
this.inputBuffersToSend[inputIndex] = new Array(nbChannels);
for (let i = 0; i < nbChannels; i++) {
this.inputBuffersHead[inputIndex][i] = this.inputBuffers[inputIndex][i].subarray(0, this.blockSize);
this.inputBuffersToSend[inputIndex][i] = new Float32Array(this.blockSize);
}
}
allocateOutputChannels(outputIndex, nbChannels) {
// allocate output buffers
this.outputBuffers[outputIndex] = new Array(nbChannels);
for (let i = 0; i < nbChannels; i++) {
this.outputBuffers[outputIndex][i] = new Float32Array(this.blockSize);
this.outputBuffers[outputIndex][i].fill(0);
}
// allocate output buffers to retrieve
// (cannot send a pointer/subarray because new output has to be add to exising output)
this.outputBuffersToRetrieve[outputIndex] = new Array(nbChannels);
for (let i = 0; i < nbChannels; i++) {
this.outputBuffersToRetrieve[outputIndex][i] = new Float32Array(this.blockSize);
this.outputBuffersToRetrieve[outputIndex][i].fill(0);
}
}
/** Read next web audio block to input buffers **/
readInputs(inputs) {
// when playback is paused, we may stop receiving new samples
if (inputs[0].length && inputs[0][0].length == 0) {
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) {
this.inputBuffers[i][j].fill(0, this.blockSize);
}
}
return;
}
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) {
let webAudioBlock = inputs[i][j];
this.inputBuffers[i][j].set(webAudioBlock, this.blockSize);
}
}
}
/** Write next web audio block from output buffers **/
writeOutputs(outputs) {
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) {
let webAudioBlock = this.outputBuffers[i][j].subarray(0, WEBAUDIO_BLOCK_SIZE);
outputs[i][j].set(webAudioBlock);
}
}
}
/** Shift left content of input buffers to receive new web audio block **/
shiftInputBuffers() {
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) {
this.inputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE);
}
}
}
/** Shift left content of output buffers to receive new web audio block **/
shiftOutputBuffers() {
for (let i = 0; i < this.nbOutputs; i++) {
for (let j = 0; j < this.outputBuffers[i].length; j++) {
this.outputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE);
this.outputBuffers[i][j].subarray(this.blockSize - WEBAUDIO_BLOCK_SIZE).fill(0);
}
}
}
/** Copy contents of input buffers to buffer actually sent to process **/
prepareInputBuffersToSend() {
for (let i = 0; i < this.nbInputs; i++) {
for (let j = 0; j < this.inputBuffers[i].length; j++) {
this.inputBuffersToSend[i][j].set(this.inputBuffersHead[i][j]);
}
}
}
/** Add contents of output buffers just processed to output buffers **/
handleOutputBuffersToRetrieve() {
for (let i = 0; i < this.nbOutputs; i++) {
for (let j = 0; j < this.outputBuffers[i].length; j++) {
for (let k = 0; k < this.blockSize; k++) {
this.outputBuffers[i][j][k] += this.outputBuffersToRetrieve[i][j][k] / this.nbOverlaps;
}
}
}
}
process(inputs, outputs, params) {
const input = inputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;
this.reallocateChannelsIfNeeded(inputs, outputs);
this.readInputs(inputs);
this.shiftInputBuffers();
this.prepareInputBuffersToSend();
this.processOLA(this.inputBuffersToSend, this.outputBuffersToRetrieve, params);
this.handleOutputBuffersToRetrieve();
this.writeOutputs(outputs);
this.shiftOutputBuffers();
return true;
}
processOLA(inputs, outputs, params) {
console.assert(false, 'Not overriden');
}
}
export default OLAProcessor;
+61 -48
View File
@@ -22,16 +22,13 @@ function humanFileSize(bytes, si) {
return bytes.toFixed(1) + ' ' + units[u];
}
export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resolveUrl) => {
let transpose = 0;
if (freq !== undefined && note !== undefined) {
logger('[sampler] hap has note and freq. ignoring note', 'warning');
}
let midi = valueToMidi({ freq, note }, 36);
transpose = midi - 36; // C3 is middle C
const ac = getAudioContext();
// deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export function getSampleInfo(hapValue, bank) {
const { s, n = 0, speed = 1.0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let sampleUrl;
let index = 0;
if (Array.isArray(bank)) {
@@ -50,19 +47,54 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol
index = getSoundIndex(n, bank[closest].length);
sampleUrl = bank[closest][index];
}
const label = `${s}:${index}`;
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, sampleUrl, index, midi, label, playbackRate };
}
// takes hapValue and returns buffer + playbackRate.
export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
let { sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
if (resolveUrl) {
sampleUrl = await resolveUrl(sampleUrl);
}
let buffer = await loadBuffer(sampleUrl, ac, s, index);
if (speed < 0) {
const ac = getAudioContext();
const buffer = await loadBuffer(sampleUrl, ac, label);
if (hapValue.unit === 'c') {
playbackRate = playbackRate * buffer.duration;
}
return { buffer, playbackRate };
};
// creates playback ready AudioBufferSourceNode from hapValue
export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
let { buffer, playbackRate } = await getSampleBuffer(hapValue, bank, resolveUrl);
if (hapValue.speed < 0) {
// should this be cached?
buffer = reverseBuffer(buffer);
}
const ac = getAudioContext();
const bufferSource = ac.createBufferSource();
bufferSource.buffer = buffer;
const playbackRate = 1.0 * Math.pow(2, transpose / 12);
bufferSource.playbackRate.value = playbackRate;
return bufferSource;
const { s, loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
// "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;
const loop = s.startsWith('wt_') ? 1 : hapValue.loop;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset;
}
const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value;
const sliceDuration = (end - begin) * bufferDuration;
return { bufferSource, offset, bufferDuration, sliceDuration };
};
export const loadBuffer = (url, ac, s, n = 0) => {
@@ -232,10 +264,10 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
const { prebake, tag } = options;
processSampleMap(
sampleMap,
(key, value) =>
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
(key, bank) =>
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
type: 'sample',
samples: value,
samples: bank,
baseUrl,
prebake,
tag,
@@ -249,38 +281,26 @@ const cutGroups = [];
export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
let {
s,
freq,
unit,
nudge = 0, // TODO: is this in seconds?
cut,
loop,
clip = undefined, // if set, samples will be cut off when the hap ends
n = 0,
note,
speed = 1, // sample playback speed
loopBegin = 0,
begin = 0,
loopEnd = 1,
end = 1,
duration,
} = value;
// load sample
if (speed === 0) {
// no playback
return;
}
loop = s.startsWith('wt_') ? 1 : value.loop;
const ac = getAudioContext();
// destructure adsr here, because the default should be different for synths and samples
let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
//const soundfont = getSoundfontKey(s);
const time = t + nudge;
const bufferSource = await getSampleBufferSource(s, n, note, speed, freq, bank, resolveUrl);
// vibrato
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl);
// asny stuff above took too long?
if (ac.currentTime > t) {
@@ -292,26 +312,19 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
logger(`[sampler] could not load "${s}:${n}"`, 'error');
return;
}
bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value;
if (unit === 'c') {
// are there other units?
bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration * 1; //cps;
}
// "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;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset;
}
// vibrato
let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t);
const time = t + nudge;
bufferSource.start(time, offset);
const envGain = ac.createGain();
const node = bufferSource.connect(envGain);
// if none of these controls is set, the duration of the sound will be set to the duration of the sample slice
if (clip == null && loop == null && value.release == null) {
const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value;
duration = (end - begin) * bufferDuration;
duration = sliceDuration;
}
let holdEnd = t + duration;
+19 -4
View File
@@ -8,7 +8,7 @@ import './feedbackdelay.mjs';
import './reverb.mjs';
import './vowel.mjs';
import { clamp, nanFallback, _mod } from './util.mjs';
import workletsUrl from './worklets.mjs?url';
import workletsUrl from './worklets.mjs?worker&url';
import { createFilter, gainNode, getCompressor, getWorklet } from './helpers.mjs';
import { map } from 'nanostores';
import { logger } from './logger.mjs';
@@ -87,6 +87,10 @@ export const getAudioContext = () => {
return audioContext;
};
export function getAudioContextCurrentTime() {
return getAudioContext().currentTime;
}
let workletsLoading;
function loadWorklets() {
if (!workletsLoading) {
@@ -309,6 +313,13 @@ export function resetGlobalEffects() {
}
export const superdough = async (value, t, hapDuration) => {
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
let { stretch } = value;
if (stretch != null) {
//account for phase vocoder latency
const latency = 0.04;
t = t - latency;
}
const ac = getAudioContext();
if (typeof value !== 'object') {
throw new Error(
@@ -320,7 +331,7 @@ export const superdough = async (value, t, hapDuration) => {
// duration is passed as value too..
value.duration = hapDuration;
// calculate absolute time
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
if (t < ac.currentTime) {
console.warn(
`[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
@@ -364,7 +375,7 @@ export const superdough = async (value, t, hapDuration) => {
bandq = getDefaultValue('bandq'),
channels = getDefaultValue('channels'),
//phaser
phaser,
phaserrate: phaser,
phaserdepth = getDefaultValue('phaserdepth'),
phasersweep,
phasercenter,
@@ -410,6 +421,7 @@ export const superdough = async (value, t, hapDuration) => {
};
if (bank && s) {
s = `${bank}_${s}`;
value.s = s;
}
// get source AudioNode
@@ -438,6 +450,7 @@ export const superdough = async (value, t, hapDuration) => {
}
const chain = []; // audio nodes that will be connected to each other sequentially
chain.push(sourceNode);
stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch }));
// gain stage
chain.push(gainNode(gain));
@@ -584,4 +597,6 @@ export const superdough = async (value, t, hapDuration) => {
toDisconnect = chain.concat([delaySend, reverbSend, analyserSend]);
};
export const superdoughTrigger = (t, hap, ct, cps) => superdough(hap, t - ct, hap.duration / cps, cps);
export const superdoughTrigger = (t, hap, ct, cps) => {
superdough(hap, t - ct, hap.duration / cps, cps);
};
+184
View File
@@ -1,6 +1,10 @@
// coarse, crush, and shape processors adapted from dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js
// LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE
// TOFIX: THIS FILE DOES NOT SUPPORT IMPORTS ON DEPOLYMENT
import OLAProcessor from './ola-processor';
import FFT from './fft.js';
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const _mod = (n, m) => ((n % m) + m) % m;
@@ -464,3 +468,183 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
}
registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);
// Phase Vocoder sourced from // sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file
const BUFFERED_BLOCK_SIZE = 2048;
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));
}
return win;
}
class PhaseVocoderProcessor extends OLAProcessor {
static get parameterDescriptors() {
return [
{
name: 'pitchFactor',
defaultValue: 1.0,
},
];
}
constructor(options) {
options.processorOptions = {
blockSize: BUFFERED_BLOCK_SIZE,
};
super(options);
this.fftSize = this.blockSize;
this.timeCursor = 0;
this.hannWindow = genHannWindow(this.blockSize);
// prepare FFT and pre-allocate buffers
this.fft = new FFT(this.fftSize);
this.freqComplexBuffer = this.fft.createComplexArray();
this.freqComplexBufferShifted = this.fft.createComplexArray();
this.timeComplexBuffer = this.fft.createComplexArray();
this.magnitudes = new Float32Array(this.fftSize / 2 + 1);
this.peakIndexes = new Int32Array(this.magnitudes.length);
this.nbPeaks = 0;
}
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];
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;
}
}
/** Compute squared magnitudes for peak finding **/
computeMagnitudes() {
var i = 0,
j = 0;
while (i < this.magnitudes.length) {
let real = this.freqComplexBuffer[j];
let imag = this.freqComplexBuffer[j + 1];
// no need to sqrt for peak finding
this.magnitudes[i] = real ** 2 + imag ** 2;
i += 1;
j += 2;
}
}
/** Find peaks in spectrum magnitudes **/
findPeaks() {
this.nbPeaks = 0;
var i = 2;
let end = this.magnitudes.length - 2;
while (i < end) {
let mag = this.magnitudes[i];
if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
i++;
continue;
}
if (this.magnitudes[i + 1] >= mag || this.magnitudes[i + 2] >= mag) {
i++;
continue;
}
this.peakIndexes[this.nbPeaks] = i;
this.nbPeaks++;
i += 2;
}
}
/** Shift peaks and regions of influence by pitchFactor into new specturm */
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);
if (peakIndexShifted > this.magnitudes.length) {
break;
}
// find region of influence
var startIndex = 0;
var endIndex = this.fftSize;
if (i > 0) {
let peakIndexBefore = this.peakIndexes[i - 1];
startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2);
}
if (i < this.nbPeaks - 1) {
let peakIndexAfter = this.peakIndexes[i + 1];
endIndex = peakIndex + Math.ceil((peakIndexAfter - 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;
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);
let indexReal = binIndex * 2;
let indexImag = indexReal + 1;
let valueReal = this.freqComplexBuffer[indexReal];
let valueImag = this.freqComplexBuffer[indexImag];
let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
let indexShiftedReal = binIndexShifted * 2;
let indexShiftedImag = indexShiftedReal + 1;
this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
}
}
}
}
registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);
+2 -1
View File
@@ -69,6 +69,7 @@ export function transpiler(input, options = {}) {
to: node.end,
index,
type,
id: options.id,
};
emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig));
@@ -162,7 +163,7 @@ export function getWidgetID(widgetConfig) {
// that means, if we use the index index of line position as id, less garbage is generated
// return `widget_${widgetConfig.to}`; // more gargabe
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
return `widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
}
function widgetWithLocation(node, widgetConfig) {
+1
View File
@@ -5,6 +5,7 @@ import replace from '@rollup/plugin-replace';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [],
build: {
lib: {
+1 -1
View File
@@ -130,7 +130,7 @@ Pattern.prototype.fscope = function (config = {}) {
* @param {number} pos y-position relative to screen height. 0 = top, 1 = bottom of screen
* @param {number} trigger amplitude value that is used to align the scope. defaults to 0.
* @example
* s("sawtooth").scope()
* s("sawtooth")._scope()
*/
let latestColor = {};
Pattern.prototype.tscope = function (config = {}) {
+6 -4
View File
@@ -6,13 +6,13 @@ use std::net::UdpSocket;
use serde::Deserialize;
use std::sync::Arc;
use std::thread::sleep;
use std::time::Duration;
use std::time::{Duration};
use tokio::sync::{mpsc, Mutex};
use crate::loggerbridge::Logger;
pub struct OscMsg {
pub msg_buf: Vec<u8>,
pub timestamp: u64,
pub timestamp: f64,
}
pub struct AsyncInputTransmit {
@@ -106,7 +106,7 @@ pub struct Param {
#[derive(Deserialize)]
pub struct MessageFromJS {
params: Vec<Param>,
timestamp: u64,
timestamp: f64,
target: String,
}
// Called from JS
@@ -127,9 +127,11 @@ pub async fn sendosc(
args.push(OscType::String(p.value));
}
}
// let start = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let time_delay = Duration::from_secs_f64(m.timestamp);
let duration_since_epoch =
Duration::from_millis(m.timestamp) + Duration::new(UNIX_OFFSET, 0);
time_delay + Duration::new(UNIX_OFFSET, 0);
let seconds = u32::try_from(duration_since_epoch.as_secs())
.map_err(|_| "bit conversion failed for osc message timetag")?;
File diff suppressed because it is too large Load Diff
+14
View File
@@ -122,6 +122,19 @@ strudel.Pattern.prototype.midi = function () {
return this;
};
strudel.Pattern.prototype._scope = function () {
return this;
};
strudel.Pattern.prototype._spiral = function () {
return this;
};
strudel.Pattern.prototype._pitchwheel = function () {
return this;
};
strudel.Pattern.prototype._pianoroll = function () {
return this;
};
const uiHelpersMocked = {
backgroundImage: id,
};
@@ -178,6 +191,7 @@ evalScope(
},
);
// TBD: use transpiler to support labeled statements
export const queryCode = async (code, cycles = 1) => {
const { pattern } = await evaluate(code);
const haps = pattern.sortHapsByPart().queryArc(0, cycles);
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { loadFeaturedPatterns, loadPublicPatterns } from '@src/user_pattern_utils.mjs';
import { MiniRepl } from '@src/docs/MiniRepl';
import { PatternLabel } from '@src/repl/panel/PatternsTab';
import { PatternLabel } from '@src/repl/components/panel/PatternsTab';
function PatternList({ patterns }) {
return (
@@ -0,0 +1,32 @@
import { useRef } from 'react';
export function UdelFrame({ onEvaluate, hash, instance }) {
const ref = useRef();
window.addEventListener('message', (message) => {
const childWindow = ref?.current?.contentWindow;
if (message == null || message.source !== childWindow) {
return; // Skip message in this event listener
}
onEvaluate(message.data);
});
const url = new URL(window.location.origin);
url.hash = hash;
url.searchParams.append('instance', instance);
const source = url.toString();
return (
<iframe
ref={ref}
style={{
display: 'flex',
flexGrow: 1,
minWidth: '50%',
boxSizing: 'border-box',
border: '0px',
}}
title="strudel embed"
src={source}
></iframe>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { code2hash } from '@strudel/core';
import { UdelFrame } from './UdelFrame';
import { useState } from 'react';
import UdelsHeader from './UdelsHeader';
const defaultHash = 'c3RhY2soCiAgCik%3D';
const getHashesFromUrl = () => {
return window.location.hash?.slice(1).split(',');
};
const updateURLHashes = (hashes) => {
const newHash = '#' + hashes.join(',');
window.location.hash = newHash;
};
export function Udels() {
const hashes = getHashesFromUrl();
const [numWindows, setNumWindows] = useState(hashes?.length ?? 1);
const numWindowsOnChange = (num) => {
setNumWindows(num);
const hashes = getHashesFromUrl();
const newHashes = [];
for (let i = 0; i < num; i++) {
newHashes[i] = hashes[i] ?? defaultHash;
}
updateURLHashes(newHashes);
};
const onEvaluate = (key, code) => {
const hashes = getHashesFromUrl();
hashes[key] = code2hash(code);
updateURLHashes(hashes);
};
return (
<div
style={{
margin: 0,
display: 'flex',
flex: 1,
height: '100vh',
width: '100%',
flexDirection: 'column',
}}
>
<UdelsHeader numWindows={numWindows} setNumWindows={numWindowsOnChange} />
<div
style={{
display: 'flex',
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{hashes.map((hash, key) => {
return (
<UdelFrame
instance={key}
onEvaluate={(code) => {
onEvaluate(key, code);
}}
hash={hash}
key={key}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import Loader from '@src/repl/components/Loader';
import { Panel } from '@src/repl/components/panel/Panel';
import { Code } from '@src/repl/components/Code';
import BigPlayButton from '@src/repl/components/BigPlayButton';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
// type Props = {
// context: replcontext,
// }
export default function UdelsEditor(Props) {
const { context } = Props;
const { containerRef, editorRef, error, init, pending, started, handleTogglePlay } = context;
return (
<div className={'h-full flex w-full flex-col relative'}>
<Loader active={pending} />
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
</div>
<UserFacingErrorMessage error={error} />
<Panel context={context} />
</div>
);
}
@@ -0,0 +1,20 @@
import NumberInput from '@src/repl/components/NumberInput';
export default function UdelsHeader(Props) {
const { numWindows, setNumWindows } = Props;
return (
<header id="header" className="flex text-white z-[100] text-lg select-none bg-neutral-900">
<div className="px-4 items-center gap-2 flex space-x-2 md:pt-0 select-none">
<h1 onClick={() => {}} className={'text-l cursor-pointer flex gap-4'}>
<div className={'mt-[1px] cursor-pointer'}>🌀</div>
<div className={'animate-pulse'}>
<span className="">strudel</span> <span className="text-sm">-UDELS</span>
</div>
</h1>
<NumberInput value={numWindows} setValue={setNumWindows} />
</div>
</header>
);
}
+1
View File
@@ -78,6 +78,7 @@ export const SIDEBAR: Sidebar = {
More: [
{ text: 'Recipes', link: 'recipes/recipes' },
{ text: 'Mini-Notation', link: 'learn/mini-notation' },
{ text: 'Visual Feedback', link: 'learn/visual-feedback' },
{ text: 'Offline', link: 'learn/pwa' },
{ text: 'Patterns', link: 'technical-manual/patterns' },
{ text: 'Music metadata', link: 'learn/metadata' },
@@ -26,7 +26,7 @@ author: froos
- fix: 💄 Enhance visualisation of the Tutorial on mobile by @puria in https://github.com/tidalcycles/strudel/pull/19
- Stateful queries and events (WIP) by @yaxu in https://github.com/tidalcycles/strudel/pull/14
- Fix resolveState by @yaxu in https://github.com/tidalcycles/strudel/pull/22
- added \_asNumber + interprete numbers as midi by @felixroos in https://github.com/tidalcycles/strudel/pull/21
- added \_asNumber + interpret numbers as midi by @felixroos in https://github.com/tidalcycles/strudel/pull/21
- Update package.json by @ChiakiUehira in https://github.com/tidalcycles/strudel/pull/23
- packaging by @felixroos in https://github.com/tidalcycles/strudel/pull/24
@@ -28,7 +28,7 @@ author: froos
- support freq in pianoroll by @felixroos in https://github.com/tidalcycles/strudel/pull/308
- ICLC2023 paper WIP by @yaxu in https://github.com/tidalcycles/strudel/pull/306
- fix: copy share link to clipboard was broken for some browers by @felixroos in https://github.com/tidalcycles/strudel/pull/311
- fix: copy share link to clipboard was broken for some browsers by @felixroos in https://github.com/tidalcycles/strudel/pull/311
- Jsdoc component by @felixroos in https://github.com/tidalcycles/strudel/pull/312
- object support for .scale by @felixroos in https://github.com/tidalcycles/strudel/pull/307
- Astro build by @felixroos in https://github.com/tidalcycles/strudel/pull/315
@@ -138,7 +138,7 @@ Plenty of things have been added to the docs, including a [showcase of what peop
- Export patterns + ui tweaks by @felixroos in https://github.com/tidalcycles/strudel/pull/855
- Pattern organization by @felixroos in https://github.com/tidalcycles/strudel/pull/858
- Sound Import from local file system by @daslyfe in https://github.com/tidalcycles/strudel/pull/839
- bugfix: suspend and close exisiting audio context when changing interface by @daslyfe in https://github.com/tidalcycles/strudel/pull/882
- bugfix: suspend and close existing audio context when changing interface by @daslyfe in https://github.com/tidalcycles/strudel/pull/882
- add root mode for voicings by @felixroos in https://github.com/tidalcycles/strudel/pull/887
- scales can now be anchored by @felixroos in https://github.com/tidalcycles/strudel/pull/888
- add dough function for raw dsp by @felixroos in https://github.com/tidalcycles/strudel/pull/707 (experimental)
@@ -195,7 +195,7 @@ Plenty of things have been added to the docs, including a [showcase of what peop
- Update tauri.yml workflow file by @vasilymilovidov in https://github.com/tidalcycles/strudel/pull/705
- vite-vanilla-repl readme fix by @felixroos in https://github.com/tidalcycles/strudel/pull/737
- completely revert config mess by @felixroos in https://github.com/tidalcycles/strudel/pull/745
- hopefully fix trainling slashes bug by @felixroos in https://github.com/tidalcycles/strudel/pull/753
- hopefully fix trailing slashes bug by @felixroos in https://github.com/tidalcycles/strudel/pull/753
- Update vite pwa by @felixroos in https://github.com/tidalcycles/strudel/pull/772
- Update to Astro 3 by @felixroos in https://github.com/tidalcycles/strudel/pull/775
- support multiple named serial connections, change default baudrate by @yaxu in https://github.com/tidalcycles/strudel/pull/551
+26 -11
View File
@@ -1,7 +1,8 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Icon } from './Icon';
import { silence, noteToMidi, _mod } from '@strudel/core';
import { getPunchcardPainter } from '@strudel/draw';
import { clearHydra } from '@strudel/hydra';
import { getDrawContext, getPunchcardPainter } from '@strudel/draw';
import { transpiler } from '@strudel/transpiler';
import { getAudioContext, webaudioOutput, initAudioOnFirstClick } from '@strudel/webaudio';
import { StrudelMirror } from '@strudel/codemirror';
@@ -28,24 +29,29 @@ export function MiniRepl({
claviature,
claviatureLabels,
maxHeight,
autodraw,
drawTime,
}) {
const code = tunes ? tunes[0] : tune;
const id = useMemo(() => s4(), []);
const canvasId = useMemo(() => `canvas-${id}`, [id]);
const shouldDraw = !!punchcard || !!claviature;
const shouldShowCanvas = !!punchcard;
const drawTime = punchcard ? [0, 4] : [0, 0];
const canvasId = shouldShowCanvas ? useMemo(() => `canvas-${id}`, [id]) : null;
autodraw = !!punchcard || !!claviature || !!autodraw;
drawTime = drawTime ?? punchcard ? [0, 4] : [-2, 2];
if (claviature) {
drawTime = [0, 0];
}
const [activeNotes, setActiveNotes] = useState([]);
const init = useCallback(({ code, shouldDraw }) => {
const drawContext = shouldDraw ? document.querySelector('#' + canvasId)?.getContext('2d') : null;
const init = useCallback(({ code, autodraw }) => {
const drawContext = canvasId ? document.querySelector('#' + canvasId)?.getContext('2d') : getDrawContext();
const editor = new StrudelMirror({
id,
defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime,
transpiler,
autodraw: !!shouldDraw,
autodraw,
root: containerRef.current,
initialCode: '// LOADING',
pattern: silence,
@@ -56,7 +62,7 @@ export function MiniRepl({
pat = pat.onTrigger(onTrigger, false);
}
if (claviature) {
editor?.painters.push((ctx, time, haps, drawTime) => {
pat = pat.onPaint((ctx, time, haps, drawTime) => {
const active = haps
.map((hap) => hap.value.note)
.filter(Boolean)
@@ -65,7 +71,7 @@ export function MiniRepl({
});
}
if (punchcard) {
editor?.painters.push(getPunchcardPainter({ labels: !!punchcardLabels }));
pat = pat.punchcard({ labels: !!punchcardLabels });
}
return pat;
},
@@ -73,7 +79,12 @@ export function MiniRepl({
onUpdateState: (state) => {
setReplState({ ...state });
},
beforeEval: () => audioReady, // not doing this in prebake to make sure viz is drawn
onToggle: (playing) => {
if (!playing) {
// clearHydra(); // TBD: doesn't work with multiple MiniRepl's on a page
}
},
beforeStart: () => audioReady,
afterEval: ({ code }) => setVersionDefaultsFrom(code),
});
// init settings
@@ -109,6 +120,7 @@ export function MiniRepl({
'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background',
started ? 'animate-pulse' : '',
)}
aria-label={started ? 'stop' : 'play'}
onClick={() => editorRef.current?.toggle()}
>
<Icon type={started ? 'stop' : 'play'} />
@@ -118,6 +130,7 @@ export function MiniRepl({
'w-16 flex items-center justify-center p-1 text-foreground border-lineHighlight bg-lineHighlight',
isDirty ? 'text-foreground hover:bg-background cursor-pointer' : 'opacity-50 cursor-not-allowed',
)}
aria-label="update"
onClick={() => editorRef.current?.evaluate()}
>
<Icon type="refresh" />
@@ -129,6 +142,7 @@ export function MiniRepl({
className={
'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background'
}
aria-label="previous example"
onClick={() => changeTune(tuneIndex - 1)}
>
<div className="rotate-180">
@@ -139,6 +153,7 @@ export function MiniRepl({
className={
'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background'
}
aria-label="next example"
onClick={() => changeTune(tuneIndex + 1)}
>
<Icon type="skip" />
@@ -152,7 +167,7 @@ export function MiniRepl({
ref={(el) => {
if (!editorRef.current) {
containerRef.current = el;
init({ code, shouldDraw });
init({ code, autodraw });
}
}}
></div>
+1 -1
View File
@@ -30,7 +30,7 @@ const githubEditUrl = `${CONFIG.GITHUB_EDIT_URL}/${currentFile}`;
</title>
</head>
<body class="h-app-height text-gray-50 bg-background">
<body class="h-app-height m-0 text-gray-50 bg-background">
<div class="w-full h-full space-y-4 flex flex-col">
<header class="max-w-full fixed top-0 w-full z-[100]">
<Header currentPage={currentPage} />
+2 -2
View File
@@ -3,12 +3,12 @@ import HeadCommon from '../components/HeadCommon.astro';
import { Repl } from '../repl/Repl';
---
<html lang="en" class="dark">
<html lang="en" class="m-0 dark">
<head>
<HeadCommon />
<title>Strudel REPL</title>
</head>
<body class="h-app-height bg-background">
<body class="h-app-height bg-background m-0">
<Repl client:only="react" />
<a rel="me" href="https://social.toplap.org/@strudel" target="_blank" class="hidden">mastodon</a>
</body>
+15 -27
View File
@@ -11,55 +11,43 @@ import { JsDoc } from '../../docs/JsDoc';
The following functions will return a pattern.
These are the equivalents used by the Mini Notation:
| function | mini |
| ------------------------------ | ---------------- |
| `cat(x, y)` | `"<x y>"` |
| `seq(x, y)` | `"x y"` |
| `stack(x, y)` | `"x,y"` |
| `timeCat([3,x],[2,y])` | `"x@3 y@2"` |
| `polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
| `polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
| `silence` | `"~"` |
| function | mini |
| -------------------------------- | ---------------- |
| `cat(x, y)` | `"<x y>"` |
| `seq(x, y)` | `"x y"` |
| `stack(x, y)` | `"x,y"` |
| `s_cat([3,x],[2,y])` | `"x@3 y@2"` |
| `s_polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
| `s_polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
| `silence` | `"~"` |
## cat
<JsDoc client:idle name="cat" h={0} />
You can also use cat as a chained function like this:
<JsDoc client:idle name="Pattern.cat" h={0} hideDescription />
## seq
<JsDoc client:idle name="seq" h={0} />
Or as a chained function:
<JsDoc client:idle name="Pattern.seq" h={0} hideDescription />
## stack
<JsDoc client:idle name="stack" h={0} />
As a chained function:
## s_cat
<JsDoc client:idle name="Pattern.stack" h={0} hideDescription />
## timeCat
<JsDoc client:idle name="timeCat" h={0} />
<JsDoc client:idle name="s_cat" h={0} />
## arrange
<JsDoc client:idle name="arrange" h={0} />
## polymeter
## s_polymeter
<JsDoc client:idle name="polymeter" h={0} />
<JsDoc client:idle name="s_polymeter" h={0} />
## polymeterSteps
## s_polymeterSteps
<JsDoc client:idle name="polymeterSteps" h={0} />
<JsDoc client:idle name="s_polymeterSteps" h={0} />
## silence
+9 -7
View File
@@ -90,11 +90,13 @@ src(s0).kaleid(H("<4 5 6>"))
.modulateScale(osc(2,-0.25,1))
.out()
//
stack(
s("bd*4,[hh:0:<.5 1>]*8,~ rim").bank("RolandTR909").speed(.9),
note("[<g1!3 <bb1 <f1 d1>>>]*3").s("sawtooth")
.room(.75).sometimes(add(note(12))).clip(.3)
.lpa(.05).lpenv(-4).lpf(2000).lpq(8).ftype('24db')
).fft(4)
.scope({pos:0,smear:.95})`}
$: s("bd*4,[hh:0:<.5 1>]*8,~ rim").bank("RolandTR909").speed(.9)
$: note("[<g1!3 <bb1 <f1 d1>>>]\*3").s("sawtooth")
.room(.75).sometimes(add(note(12))).clip(.3)
.lpa(.05).lpenv(-4).lpf(2000).lpq(8).ftype('24db')
all(x=>x.fft(4).scope({pos:0,smear:.95}))`}
/>
+6 -14
View File
@@ -20,11 +20,7 @@ Strudel also supports midi via [webmidi](https://npmjs.com/package/webmidi).
Either connect a midi device or use the IAC Driver (Mac) or Midi Through Port (Linux) for internal midi messages.
If no outputName is given, it uses the first midi output it finds.
<MiniRepl
client:idle
tune={`stack("<C^7 A7 Dm7 G7>".voicings('lefthand'), "<C3 A2 D3 G2>").note()
.midi()`}
/>
<MiniRepl client:idle tune={`chord("<C^7 A7 Dm7 G7>").voicing().midi()`} />
In the console, you will see a log of the available MIDI devices as soon as you run the code, e.g. `Midi connected! Using "Midi Through Port-0".`
@@ -45,13 +41,11 @@ But you can also control cc messages separately like this:
<MiniRepl
client:idle
tune={`stack(
note("c a f e"),
ccv(sine.segment(16).slow(4)).ccn(74)
).midi()`}
tune={`$: note("c a f e").midi()
$: ccv(sine.segment(16).slow(4)).ccn(74).midi()`}
/>
# SuperDirt API
# OSC/SuperDirt API
In mainline tidal, the actual sound is generated via [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider.
Strudel also supports using [SuperDirt](https://github.com/musikinformatik/SuperDirt/) as a backend, although it requires some developer tooling to run.
@@ -79,16 +73,14 @@ Now you're all set!
If you now hear sound, congratulations! If not, you can get help on the [#strudel channel in the TidalCycles discord](https://discord.com/invite/HGEdXmRkzT).
Note: if you have the 'Audio Engine Target' in settings set to 'OSC', you do not need to add .osc() to the end of your pattern.
### Pattern.osc
<JsDoc client:idle name="Pattern.osc" h={0} />
## SuperDirt Params
The following functions can be used with [SuperDirt](https://github.com/musikinformatik/SuperDirt/):
`s n note freq channel orbit cutoff resonance hcutoff hresonance bandf bandq djf vowel cut begin end loop fadeTime speed unitA gain amp accelerate crush coarse delay lock leslie lrate lsize pan panspan pansplay room size dry shape squiz waveloss attack decay octave detune tremolodepth`
Please refer to [Tidal Docs](https://tidalcycles.org/) for more info.
<br />
+7 -9
View File
@@ -268,11 +268,10 @@ With it, you can enter any sample name(s) to query from [freesound.org](https://
<MiniRepl
client:idle
tune={`samples('shabda:bass:4,hihat:4,rimshot:2')
stack(
n("0 1 2 3 0 1 2 3").s('bass'),
n("0 1*2 2 3*2").s('hihat'),
n("~ 0 ~ 1 ~ 0 0 1").s('rimshot')
).clip(1)`}
$: n("0 1 2 3 0 1 2 3").s('bass')
$: n("0 1*2 2 3*2").s('hihat').clip(1)
$: n("~ 0 ~ 1 ~ 0 0 1").s('rimshot')`}
/>
You can also generate artificial voice samples with any text, in multiple languages.
@@ -282,10 +281,9 @@ Note that the language code and the gender parameters are optional and default t
client:idle
tune={`samples('shabda/speech:the_drum,forever')
samples('shabda/speech/fr-FR/m:magnifique')
stack(
s("the_drum*2").chop(16).speed(rand.range(0.85,1.1)),
s("forever magnifique").slow(4).late(0.125)
)`}
$: s("the_drum*2").chop(16).speed(rand.range(0.85,1.1))
$: s("forever magnifique").slow(4).late(0.125)`}
/>
# Sampler Effects
+6 -21
View File
@@ -112,29 +112,14 @@ Also, samples are always loaded from a URL rather than from the disk, although [
## Evaluation
The Strudel REPL does not support [block based evaluation](https://github.com/tidalcycles/strudel/issues/34) yet.
You can use the following "workaround" to create multiple patterns that can be turned on and off:
You can use labeled statements and `_` to mute:
```
let a = note("c a f e")
<MiniRepl
client:visible
tune={`$: n("[0 .. 8]*8/9").scale("C:minor:pentatonic")
let b = s("bd sd")
stack(
a,
// b
)
```
Alternatively, you could write everything as one `stack` and use `.hush()` to silence a pattern:
```
stack(
note("c a f e"),
s("bd sd").hush()
)
```
Note that strudel will always use the last statement in your code as the pattern for querying
\_$: s("bd\*4").bank('RolandTR909')`}
/>
## Tempo
+12 -11
View File
@@ -18,7 +18,7 @@ The basic waveforms are `sine`, `sawtooth`, `square` and `triangle`, which can b
client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("<sawtooth square triangle sine>")
.scope()`}
._scope()`}
/>
If you don't set a `sound` but a `note` the default value for `sound` is `triangle`!
@@ -28,23 +28,23 @@ If you don't set a `sound` but a `note` the default value for `sound` is `triang
You can also use noise as a source by setting the waveform to: `white`, `pink` or `brown`. These are different
flavours of noise, here written from hard to soft.
<MiniRepl client:idle tune={`sound("<white pink brown>").scope()`} />
<MiniRepl client:idle tune={`sound("<white pink brown>")._scope()`} />
Here's a more musical example of how to use noise for hihats:
<MiniRepl
client:idle
tune={`sound("bd*2,<white pink brown>*8")
.decay(.04).sustain(0).scope()`}
.decay(.04).sustain(0)._scope()`}
/>
Some amount of pink noise can also be added to any oscillator by using the `noise` paremeter:
<MiniRepl client:idle tune={`note("c3").noise("<0.1 0.25 0.5>").scope()`} />
<MiniRepl client:idle tune={`note("c3").noise("<0.1 0.25 0.5>")._scope()`} />
You can also use the `crackle` type to play some subtle noise crackles. You can control noise amount by using the `density` parameter:
<MiniRepl client:idle tune={`s("crackle*4").density("<0.01 0.04 0.2 0.5>".slow(2)).scope()`} />
<MiniRepl client:idle tune={`s("crackle*4").density("<0.01 0.04 0.2 0.5>".slow(2))._scope()`} />
### Additive Synthesis
@@ -55,7 +55,7 @@ To tame the harsh sound of the basic waveforms, we can set the `n` control to li
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("sawtooth")
.n("<32 16 8 4>")
.scope()`}
._scope()`}
/>
When the `n` control is used on a basic waveform, it defines the number of harmonic partials the sound is getting.
@@ -65,7 +65,7 @@ You can also set `n` directly in mini notation with `sound`:
client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("sawtooth:<32 16 8 4>")
.scope()`}
._scope()`}
/>
Note for tidal users: `n` in tidal is synonymous to `note` for synths only.
@@ -119,13 +119,14 @@ Any sample preceded by the `wt_` prefix will be loaded as a wavetable. This mean
<MiniRepl
client:idle
tune={`samples('github:Bubobubobubobubo/Dough-Waveforms/main/');
tune={`samples('bubo:waveforms');
note("<[g3,b3,e4]!2 [a3,c3,e4] [b3,d3,f#4]>")
.n("<1 2 3 4 5 6 7 8 9 10>/2").room(0.5).size(0.9)
.s('wt_flute').velocity(0.25).often(n => n.ply(2))
.release(0.125).decay("<0.1 0.25 0.3 0.4>").sustain(0)
.cutoff(2000).scope({}).cutoff("<1000 2000 4000>").fast(4)`}
.cutoff(2000).cutoff("<1000 2000 4000>").fast(4)
._scope()
`}
/>
## ZZFX
@@ -159,7 +160,7 @@ It has 20 parameters in total, here is a snippet that uses all:
.tremolo(0) // 0-1 lfo volume modulation amount
//.duration(.2) // overwrite strudel event duration
//.gain(1) // change volume
.scope() // vizualise waveform (not zzfx related)
._scope() // vizualise waveform (not zzfx related)
`}
/>
-14
View File
@@ -55,20 +55,6 @@ Transposes notes inside the scale by the number of steps:
.note()`}
/>
### voicings(range?)
Turns chord symbols into voicings, using the smoothest voice leading possible:
<MiniRepl
client:only="react"
tune={`stack(
"<C^7 A7 Dm7 G7>".voicings('lefthand'),
"<C3 A2 D3 G2>"
).note()`}
/>
Note: This function might be removed, as `voicing` (without s) is a newer implementation.
### rootNotes(octave = 2)
Turns chord symbols into root notes of chords in given octave.
+100
View File
@@ -0,0 +1,100 @@
---
title: Visual Feedback
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
# Visual Feedback
There are several function that add visual feedback to your patterns.
## Mini Notation Highlighting
When you write mini notation with "double quotes" or \`backticks\`, the active parts of the mini notation will be highlighted:
<MiniRepl
client:idle
tune={`n("<0 2 1 3 2>*8")
.scale("<A1 D2>/4:minor:pentatonic")
.s("supersaw").lpf(300).lpenv("<4 3 2>\*4")`}
/>
You can change the color as well, even pattern it:
<MiniRepl
client:idle
tune={`n("<0 2 1 3 2>*8")
.scale("<A1 D2>/4:minor:pentatonic")
.s("supersaw").lpf(300).lpenv("<4 3 2>*4")
.color("cyan magenta")`}
/>
## Global vs Inline Visuals
The following functions all come with in 2 variants.
**Without prefix**: renders the visual to the background of the page:
<MiniRepl client:idle tune={`note("c a f e").color("white").punchcard()`} />
**With `_` prefix**: renders the visual inside the code. Allows for multiple visuals
<MiniRepl client:idle tune={`note("c a f e").color("white")._punchcard()`} />
Here we see the 2 variants for `punchcard`. The same goes for all others below.
To improve readability the following demos will all use the inline variant.
## Punchcard / Pianoroll
These 2 functions render a pianoroll style visual.
The only difference between the 2 is that `pianoroll` will render the pattern directly,
while `punchcard` will also take the transformations into account that occur afterwards:
<MiniRepl
client:idle
tune={`note("c a f e").color("white")
._punchcard()
.color("cyan")`}
autodraw
/>
Here, the `color` is still visible in the visual, even if it is applied after `_punchcard`.
On the contrary, the color is not visible when using `_pianoroll`:
<MiniRepl
client:idle
tune={`note("c a f e").color("white")
._pianoroll()
.color("cyan")`}
autodraw
/>
import Box from '@components/Box.astro';
<br />
<Box>
`punchcard` is less resource intensive because it uses the same data as used for the mini notation highlighting.
</Box>
The visual can be customized by passing options. Those options are the same for both functions.
What follows is the API doc of all the options you can pass:
<JsDoc client:idle name="pianoroll" h={0} />
## Spiral
<JsDoc client:idle name="spiral" h={0} />
## Scope
<JsDoc client:idle name="scope" h={0} />
## Pitchwheel
<JsDoc client:idle name="pitchwheel" h={0} />
+12
View File
@@ -0,0 +1,12 @@
---
import { Udels } from '../../components/Udels/Udels.jsx';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
---
<body class="m-0">
<Udels client:only="react" />
</body>
+35 -46
View File
@@ -60,11 +60,10 @@ We will learn how to automate with waves later...
<MiniRepl
client:visible
tune={`stack(
sound("hh*16").gain("[.25 1]*4"),
sound("bd*4,[~ sd:1]*2")
) `}
punchcard
tune={`$: sound("hh*16").gain("[.25 1]*4")
$: sound("bd*4,[~ sd:1]*2")`}
punchcard
/>
<Box>
@@ -76,31 +75,21 @@ Rhythm is all about dynamics!
</Box>
**stacks within stacks**
Let's combine all of the above into a little tune:
<MiniRepl
client:visible
tune={`stack(
stack(
sound("hh*8").gain("[.25 1]*4"),
sound("bd*4,[~ sd:1]*2")
),
note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("sawtooth").lpf("200 1000 200 1000"),
note("<[c3,g3,e4] [bb2,f3,d4] [a2,f3,c4] [bb2,g3,eb4]>")
.sound("sawtooth").vowel("<a e i o>")
)`}
tune={`$: sound("hh*8").gain("[.25 1]*4")
$: sound("bd*4,[~ sd:1]*2")
$: note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("sawtooth").lpf("200 1000 200 1000")
$: note("<[c3,g3,e4] [bb2,f3,d4] [a2,f3,c4] [bb2,g3,eb4]>")
.sound("sawtooth").vowel("<a e i o>")`}
/>
<Box>
Try to identify the individual parts of the stacks, pay attention to where the commas are.
The 3 parts (drums, bassline, chords) are exactly as earlier, just stacked together, separated by comma.
</Box>
**shape the sound with an adsr envelope**
<MiniRepl
@@ -151,11 +140,10 @@ Can you guess what they do?
<MiniRepl
client:visible
tune={`stack(
note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted"),
sound("<bd rim>").bank("RolandTR707")
).delay(".5")`}
tune={`$: note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5)
$: sound("bd rim").bank("RolandTR707").delay(".5")`}
/>
<Box>
@@ -199,31 +187,32 @@ Add a delay too!
<MiniRepl
client:visible
tune={`stack(
note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5),
sound("<bd rim>").bank("RolandTR707").delay(.5),
n("<4 [3@3 4] [<2 0> ~@16] ~>")
tune={`$: note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5)
$: sound("bd rim").bank("RolandTR707").delay(.5)
$: n("<4 [3@3 4] [<2 0> ~@16] ~>")
.scale("D4:minor").sound("gm_accordion:2")
.room(2).gain(.5)
)`}
.room(2).gain(.5)`}
/>
Let's add a bass to make this complete:
<MiniRepl
client:visible
tune={`stack(
note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5),
sound("<bd rim>").bank("RolandTR707").delay(.5),
n("<4 [3@3 4] [<2 0> ~@16] ~>")
tune={`$: note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5)
$: sound("bd rim").bank("RolandTR707").delay(.5)
$: n("<4 [3@3 4] [<2 0> ~@16] ~>")
.scale("D4:minor").sound("gm_accordion:2")
.room(2).gain(.4),
n("[0 [~ 0] 4 [3 2] [0 ~] [0 ~] <0 2> ~]/2")
.room(2).gain(.4)
$: n("[0 [~ 0] 4 [3 2] [0 ~] [0 ~] <0 2> ~]/2")
.scale("D2:minor")
.sound("sawtooth,triangle").lpf(800)
)`}
.sound("sawtooth,triangle").lpf(800)`}
/>
<Box>
@@ -266,7 +255,7 @@ By the way, inside Mini-Notation, `fast` is `*` and `slow` is `/`.
Instead of changing values stepwise, we can also control them with signals:
<MiniRepl client:visible tune={`sound("hh*32").gain(sine)`} punchcard punchcardLabels={false} />
<MiniRepl client:visible tune={`sound("hh*16").gain(sine)`} punchcard punchcardLabels={false} />
<Box>
+24 -14
View File
@@ -314,11 +314,11 @@ Let's recap what we've learned in this chapter:
New functions:
| Name | Description | Example |
| ----- | ----------------------------------- | --------------------------------------------------------------------------------- |
| note | set pitch as number or letter | <MiniRepl client:visible tune={`note("b g e c").sound("piano")`} /> |
| scale | interpret `n` as scale degree | <MiniRepl client:visible tune={`n("6 4 2 0").scale("C:minor").sound("piano")`} /> |
| stack | play patterns in parallel (read on) | <MiniRepl client:visible tune={`stack(s("bd sd"),note("c eb g"))`} /> |
| Name | Description | Example |
| ----- | ----------------------------- | --------------------------------------------------------------------------------- |
| note | set pitch as number or letter | <MiniRepl client:visible tune={`note("b g e c").sound("piano")`} /> |
| scale | interpret `n` as scale degree | <MiniRepl client:visible tune={`n("6 4 2 0").scale("C:minor").sound("piano")`} /> |
| $: | play patterns in parallel | <MiniRepl client:visible tune={'$: s("bd sd")\n$: note("c eb g")'} /> |
## Examples
@@ -356,25 +356,35 @@ New functions:
<Box>
It's called `stack` 😙
You can use `$:` 😙
</Box>
## Playing multiple patterns
If you want to play multiple patterns at the same time, make sure to write `$:` before each:
<MiniRepl
client:visible
tune={`stack(
note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("gm_synth_bass_1").lpf(800),
n(\`<
tune={`$: note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("gm_synth_bass_1").lpf(800)
$: n(\`<
[~ 0] 2 [0 2] [~ 2]
[~ 0] 1 [0 1] [~ 1]
[~ 0] 3 [0 3] [~ 3]
[~ 0] 2 [0 2] [~ 2]
>*4\`).scale("C4:minor")
.sound("gm_synth_strings_1"),
sound("bd*4, [~ <sd cp>]*2, [~ hh]*4")
.bank("RolandTR909")
)`}
.sound("gm_synth_strings_1")
$: sound("bd*4, [~ <sd cp>]*2, [~ hh]*4")
.bank("RolandTR909")`}
/>
<Box>
Try changing `$` to `_$` to mute a part!
</Box>
This is starting to sound like actual music! We have sounds, we have notes, now the last piece of the puzzle is missing: [effects](/workshop/first-effects)
+13 -21
View File
@@ -25,20 +25,16 @@ This is the same as:
<MiniRepl
client:visible
tune={`stack(
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0),
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).rev()
)`}
tune={`$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0)
$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).rev()`}
/>
Let's visualize what happens here:
<MiniRepl
client:visible
tune={`stack(
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0).color("cyan"),
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).color("magenta").rev()
)`}
tune={`$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0).color("cyan")
$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).color("magenta").rev()`}
punchcard
/>
@@ -56,11 +52,9 @@ This is like doing
<MiniRepl
client:visible
tune={`stack(
note("c2, eb3 g3 [bb3 c4]").s("piano").slow(0.5).color('cyan'),
note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1).color('magenta'),
note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1.5).color('yellow')
)`}
tune={`$: note("c2, eb3 g3 [bb3 c4]").s("piano").slow(0.5).color('cyan')
$: note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1).color('magenta')
$: note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1.5).color('yellow')`}
punchcard
/>
@@ -110,17 +104,15 @@ We can add as often as we like:
<MiniRepl
client:visible
tune={`stack(
n("0 [2 4] <3 5> [~ <4 1>]".add("<0 [0,2,4]>"))
tune={`$: n("0 [2 4] <3 5> [~ <4 1>]".add("<0 [0,2,4]>"))
.scale("C5:minor")
.sound("gm_xylophone")
.room(.4).delay(.125),
note("c2 [eb3,g3]".add("<0 <1 -1>>"))
.room(.4).delay(.125)
$: note("c2 [eb3,g3]".add("<0 <1 -1>>"))
.adsr("[.1 0]:.2:[1 0]")
.sound("gm_acoustic_bass")
.room(.5),
n("0 1 [2 3] 2").sound("jazz").jux(rev)
)`}
.room(.5)
$: n("0 1 [2 3] 2").sound("jazz").jux(rev)`}
/>
**ply**
@@ -165,7 +157,7 @@ off is also useful for modifying other sounds, and can even be nested:
client:visible
tune={`s("bd sd [rim bd] sd,[~ hh]*4").bank("CasioRZ1")
.off(2/16, x=>x.speed(1.5).gain(.25)
.off(3/16, y=>y.vowel("<a e i o>*8")))`}
.off(3/16, y=>y.vowel("<a e i o>*8")))`}
/>
| name | description | example |
+1 -1
View File
@@ -39,7 +39,7 @@ This page is just a listing of all functions covered in the workshop!
| --------- | ----------------------------- | --------------------------------------------------------------------------------- |
| note | set pitch as number or letter | <MiniRepl client:visible tune={`note("b g e c").sound("piano")`} /> |
| n + scale | set note in scale | <MiniRepl client:visible tune={`n("6 4 2 0").scale("C:minor").sound("piano")`} /> |
| stack | play patterns in parallel | <MiniRepl client:visible tune={`stack(s("bd sd"),note("c eb g"))`} /> |
| $: | play patterns in parallel | <MiniRepl client:visible tune={'$: s("bd sd")\n$: note("c eb g")'} /> |
## Audio Effects
+9 -262
View File
@@ -4,268 +4,15 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
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 { code2hash, logger, silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw';
import cx from '@src/cx.mjs';
import { transpiler } from '@strudel/transpiler';
import {
getAudioContext,
webaudioOutput,
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
} from '@strudel/webaudio';
import { defaultAudioDeviceName } from '../settings.mjs';
import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
import { clearHydra } from '@strudel/hydra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../settings.mjs';
import {
setActivePattern,
setLatestCode,
createPatternID,
userPattern,
getViewingPatternData,
setViewingPatternData,
} from '../user_pattern_utils.mjs';
import { Header } from './Header';
import Loader from './Loader';
import { Panel } from './panel/Panel';
import { useStore } from '@nanostores/react';
import { prebake } from './prebake.mjs';
import { getRandomTune, initCode, loadModules, shareCode, ReplContext } from './util.mjs';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
const { latestCode } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, isIframe, audioReady;
if (typeof window !== 'undefined') {
audioReady = initAudioOnFirstClick();
modulesLoading = loadModules();
presets = prebake();
drawContext = getDrawContext();
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
isIframe = window.location !== window.parent.location;
}
async function getModule(name) {
if (!modulesLoading) {
return;
}
const modules = await modulesLoading;
return modules.find((m) => m.packageName === name);
}
import { isIframe, isUdels } from './util.mjs';
import UdelsEditor from '@components/Udels/UdelsEditor';
import ReplEditor from './components/ReplEditor';
import EmbeddedReplEditor from './components/EmbeddedReplEditor';
import { useReplContext } from './useReplContext';
export function Repl({ embedded = false }) {
const isEmbedded = embedded || isIframe;
const { panelPosition, isZen, isSyncEnabled } = useSettings();
const init = useCallback(() => {
const drawTime = [-2, 2];
const drawContext = getDrawContext();
const editor = new StrudelMirror({
sync: isSyncEnabled,
defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime,
setInterval,
clearInterval,
transpiler,
autodraw: false,
root: containerRef.current,
initialCode: '// LOADING',
pattern: silence,
drawTime,
drawContext,
prebake: async () => Promise.all([modulesLoading, presets]),
onUpdateState: (state) => {
setReplState({ ...state });
},
onToggle: (playing) => {
if (!playing) {
clearHydra();
}
},
beforeEval: () => audioReady,
afterEval: (all) => {
const { code } = all;
setLatestCode(code);
window.location.hash = '#' + code2hash(code);
setDocumentTitle(code);
const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(code);
const data = { ...viewingPatternData, code };
let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) {
const codeHasChanged = code !== viewingPatternData.code;
if (codeHasChanged) {
// fork example
const newPattern = userPattern.duplicate(data);
id = newPattern.id;
setViewingPatternData(newPattern.data);
}
} else {
id = userPattern.isValidID(id) ? id : createPatternID();
setViewingPatternData(userPattern.update(id, data).data);
}
setActivePattern(id);
},
bgFill: false,
});
window.strudelMirror = editor;
// init settings
initCode().then(async (decoded) => {
let code, msg;
if (decoded) {
code = decoded;
msg = `I have loaded the code from the URL.`;
} else if (latestCode) {
code = latestCode;
msg = `Your last session has been loaded!`;
} else {
const { code: randomTune, name } = await getRandomTune();
code = randomTune;
msg = `A random code snippet named "${name}" has been loaded!`;
}
editor.setCode(code);
setDocumentTitle(code);
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
});
editorRef.current = editor;
}, []);
const [replState, setReplState] = useState({});
const { started, isDirty, error, activeCode, pending } = replState;
const editorRef = useRef();
const containerRef = useRef();
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
// this will be the case when the main repl is being replaced
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
useEffect(() => {
let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => {
if (_settings.hasOwnProperty(key)) {
editorSettings[key] = _settings[key];
}
});
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
// on first load, set stored audio device if possible
useEffect(() => {
const { audioDeviceName } = _settings;
if (audioDeviceName !== defaultAudioDeviceName) {
getAudioDevices().then((devices) => {
const deviceID = devices.get(audioDeviceName);
if (deviceID == null) {
return;
}
setAudioDevice(deviceID);
});
}
}, []);
//
// UI Actions
//
const setDocumentTitle = (code) => {
const meta = getMetadata(code);
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
};
const handleTogglePlay = async () => {
editorRef.current?.toggle();
};
const resetEditor = async () => {
(await getModule('@strudel/tonal'))?.resetVoicings();
resetGlobalEffects();
clearCanvas();
clearHydra();
resetLoadedSounds();
editorRef.current.repl.setCps(0.5);
await prebake(); // declare default samples
};
const handleUpdate = async (patternData, reset = false) => {
setViewingPatternData(patternData);
editorRef.current.setCode(patternData.code);
if (reset) {
await resetEditor();
handleEvaluate();
}
};
const handleEvaluate = () => {
editorRef.current.evaluate();
};
const handleShuffle = async () => {
const patternData = await getRandomTune();
const code = patternData.code;
logger(`[repl] ✨ loading random tune "${patternData.id}"`);
setActivePattern(patternData.id);
setViewingPatternData(patternData);
await resetEditor();
editorRef.current.setCode(code);
editorRef.current.repl.evaluate(code);
};
const handleShare = async () => shareCode(replState.code);
const context = {
embedded,
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
handleEvaluate,
};
return (
<ReplContext.Provider value={context}>
<div className={cx('h-full flex flex-col relative')}>
<Loader active={pending} />
<Header context={context} />
{isEmbedded && !started && (
<button
onClick={() => handleTogglePlay()}
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
>
<PlayCircleIcon className="w-6 h-6" />
<span>play</span>
</button>
)}
<div className="grow flex relative overflow-hidden">
<section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow' + (isZen ? ' px-10' : '')}
id="code"
ref={(el) => {
containerRef.current = el;
if (!editorRef.current) {
init();
}
}}
></section>
{panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
</div>
{error && (
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
)}
{panelPosition === 'bottom' && !isEmbedded && <Panel context={context} />}
</div>
</ReplContext.Provider>
);
const isEmbedded = embedded || isIframe();
const Editor = isUdels() ? UdelsEditor : isEmbedded ? EmbeddedReplEditor : ReplEditor;
const context = useReplContext();
return <Editor context={context} />;
}
@@ -0,0 +1,22 @@
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
// type Props = {
// started: boolean;
// handleTogglePlay: () => void;
// };
export default function BigPlayButton(Props) {
const { started, handleTogglePlay } = Props;
if (started) {
return;
}
return (
<button
onClick={() => handleTogglePlay()}
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
>
<PlayCircleIcon className="w-6 h-6" />
<span>play</span>
</button>
);
}
+21
View File
@@ -0,0 +1,21 @@
// type Props = {
// containerRef: React.MutableRefObject<HTMLElement | null>,
// editorRef: React.MutableRefObject<HTMLElement | null>,
// init: () => void
// }
export function Code(Props) {
const { editorRef, containerRef, init } = Props;
return (
<section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow'}
id="code"
ref={(el) => {
containerRef.current = el;
if (!editorRef.current) {
init();
}
}}
></section>
);
}
@@ -0,0 +1,25 @@
import Loader from '@src/repl/components/Loader';
import { Code } from '@src/repl/components/Code';
import BigPlayButton from '@src/repl/components/BigPlayButton';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
import { Header } from './Header';
// type Props = {
// context: replcontext,
// }
export default function EmbeddedReplEditor(Props) {
const { context } = Props;
const { pending, started, handleTogglePlay, containerRef, editorRef, error, init } = context;
return (
<div className="h-full flex flex-col relative">
<Loader active={pending} />
<Header context={context} embedded={true} />
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
</div>
<UserFacingErrorMessage error={error} />
</div>
);
}
@@ -5,24 +5,15 @@ import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon';
import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon';
import cx from '@src/cx.mjs';
import { useSettings, setIsZen } from '../settings.mjs';
// import { ReplContext } from './Repl';
import './Repl.css';
import { useSettings, setIsZen } from '../../settings.mjs';
import '../Repl.css';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function Header({ context }) {
const {
embedded,
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleEvaluate,
handleShuffle,
handleShare,
} = context;
export function Header({ context, embedded = false }) {
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare } =
context;
const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location);
const { isZen } = useSettings();
@@ -52,7 +43,7 @@ export function Header({ context }) {
)}
>
<div
className={cx('mt-[1px]', started && 'animate-spin', 'cursor-pointer')}
className={cx('mt-[1px]', started && 'animate-spin', 'cursor-pointer', isZen && 'fixed top-2 right-4')}
onClick={() => {
if (!isEmbedded) {
setIsZen(!isZen);
@@ -0,0 +1,54 @@
function Button(Props) {
const { children, onClick } = Props;
return (
<button
onClick={onClick}
type="button"
data-input-counter-increment="counter-input"
className="flex-shrink-0 bg-gray-700 hover:bg-gray-600 border-gray-600 inline-flex items-center justify-center border rounded-md h-5 w-5 focus:ring-gray-700 focus:ring-2 focus:outline-none"
>
{children}
</button>
);
}
export default function NumberInput(Props) {
const { value = 0, setValue, max, min } = Props;
return (
<div className="relative flex items-center">
<Button onClick={() => setValue(value - 1)}>
<svg
className="w-2.5 h-2.5 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 18 2"
>
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 1h16" />
</svg>
</Button>
<input
min={min}
max={max}
type="text"
data-input-counter
className="flex-shrink-0 text-white border-0 bg-transparent text-sm font-normal focus:outline-none focus:ring-0 max-w-[2.5rem] text-center"
placeholder=""
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button onClick={() => setValue(value + 1)}>
<svg
className="w-2.5 h-2.5 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 18 18"
>
<path strokeLinecap="round" stroke="currentColor" strokeLinejoin="round" strokeWidth="2" d="M9 1v16M1 9h16" />
</svg>
</Button>
</div>
);
}
@@ -0,0 +1,30 @@
import Loader from '@src/repl/components/Loader';
import { Panel } from '@src/repl/components/panel/Panel';
import { Code } from '@src/repl/components/Code';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
import { Header } from './Header';
import { useSettings } from '@src/settings.mjs';
// type Props = {
// context: replcontext,
// }
export default function ReplEditor(Props) {
const { context } = Props;
const { containerRef, editorRef, error, init, pending } = context;
const settings = useSettings();
const { panelPosition } = settings;
return (
<div className="h-full flex flex-col relative">
<Loader active={pending} />
<Header context={context} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
{panelPosition === 'right' && <Panel context={context} />}
</div>
<UserFacingErrorMessage error={error} />
{panelPosition === 'bottom' && <Panel context={context} />}
</div>
);
}
@@ -0,0 +1,8 @@
// type Props = { error: Error | null };
export default function UserFacingErrorMessage(Props) {
const { error } = Props;
if (error == null) {
return;
}
return <div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>;
}
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { getAudioDevices, setAudioDevice } from '../util.mjs';
import { getAudioDevices, setAudioDevice } from '../../util.mjs';
import { SelectInput } from './SelectInput';
const initdevices = new Map();
@@ -0,0 +1,29 @@
import React from 'react';
import { audioEngineTargets } from '../../../settings.mjs';
import { SelectInput } from './SelectInput';
// Allows the user to select an audio interface for Strudel to play through
export function AudioEngineTargetSelector({ target, onChange, isDisabled }) {
const onTargetChange = (target) => {
onChange(target);
};
const options = new Map([
[audioEngineTargets.webaudio, audioEngineTargets.webaudio],
[audioEngineTargets.osc, audioEngineTargets.osc],
]);
return (
<div className=" flex flex-col gap-1">
<SelectInput isDisabled={isDisabled} options={options} value={target} onChange={onTargetChange} />
{target === audioEngineTargets.osc && (
<div>
<p className="text-sm italic">
All events routed to OSC, audio is silenced! See{' '}
<a className="text-blue-500" href="https://strudel.cc/learn/input-output/">
Docs
</a>
</p>
</div>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import cx from '@src/cx.mjs';
export function ConsoleTab({ log }) {
return (
<div id="console-tab" className="break-all px-4 dark:text-white text-stone-900 text-sm">
<pre>{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
<pre aria-hidden="true">{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
@@ -1,6 +1,6 @@
import { Fragment, useEffect } from 'react';
import React, { useMemo, useState } from 'react';
import { isAudioFile, readDir, dir, playFile } from '../files.mjs';
import { isAudioFile, readDir, dir, playFile } from '../../files.mjs';
export function FilesTab() {
const [path, setPath] = useState([]);
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { registerSamplesFromDB, uploadSamplesToDB, userSamplesDBConfig } from '../idbutils.mjs';
import { registerSamplesFromDB, uploadSamplesToDB, userSamplesDBConfig } from '../../idbutils.mjs';
//choose a directory to locally import samples
export default function ImportSoundsButton({ onComplete }) {
@@ -33,7 +33,7 @@ export default function ImportSoundsButton({ onComplete }) {
directory=""
webkitdirectory=""
multiple
accept="audio/*, .wav, .mp3, .m4a, .flac"
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
onChange={() => {
onChange();
}}
@@ -4,7 +4,7 @@ import useEvent from '@src/useEvent.mjs';
import cx from '@src/cx.mjs';
import { nanoid } from 'nanoid';
import { useCallback, useLayoutEffect, useEffect, useRef, useState } from 'react';
import { setActiveFooter, useSettings } from '../../settings.mjs';
import { setActiveFooter, useSettings } from '../../../settings.mjs';
import { ConsoleTab } from './ConsoleTab';
import { FilesTab } from './FilesTab';
import { Reference } from './Reference';
@@ -5,13 +5,13 @@ import {
useActivePattern,
useViewingPatternData,
userPattern,
} from '../../user_pattern_utils.mjs';
} from '../../../user_pattern_utils.mjs';
import { useMemo } from 'react';
import { getMetadata } from '../../metadata_parser';
import { useExamplePatterns } from '../useExamplePatterns';
import { parseJSON } from '../util.mjs';
import { getMetadata } from '../../../metadata_parser.js';
import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { settingsMap, useSettings } from '../../settings.mjs';
import { settingsMap, useSettings } from '../../../settings.mjs';
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
@@ -99,7 +99,7 @@ export function PatternsTab({ context }) {
};
const viewingPatternID = viewingPatternData?.id;
const autoResetPatternOnChange = !window.parent?.location.pathname.includes('oodles');
const autoResetPatternOnChange = !isUdels();
return (
<div className="px-4 w-full dark:text-white text-stone-900 space-y-2 pb-4 flex flex-col overflow-hidden max-h-full">
@@ -1,5 +1,7 @@
import jsdocJson from '../../../../doc.json';
const visibleFunctions = jsdocJson.docs
import { useMemo, useState } from 'react';
import jsdocJson from '../../../../../doc.json';
const availableFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
@@ -10,9 +12,29 @@ const getInnerText = (html) => {
};
export function Reference() {
const [search, setSearch] = useState('');
const visibleFunctions = useMemo(() => {
return availableFunctions.filter((entry) => {
if (!search) {
return true;
}
return entry.name.includes(search) || (entry.synonyms?.some((s) => s.includes(search)) ?? false);
});
}, [search]);
return (
<div className="flex h-full w-full pt-2 text-foreground overflow-hidden">
<div className="w-42 flex-none h-full overflow-y-auto overflow-x-hidden pr-4">
<div class="w-full ml-2 mb-2 top-0 sticky">
<input
className="w-full p-1 bg-background rounded-md"
placeholder="Search"
value={search}
onInput={(event) => setSearch(event.target.value)}
/>
</div>
{visibleFunctions.map((entry, i) => (
<a
key={i}
@@ -1,12 +1,15 @@
import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs';
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
import { themes } from '@strudel/codemirror';
import { isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs';
function Checkbox({ label, value, onChange }) {
function Checkbox({ label, value, onChange, disabled = false }) {
return (
<label>
<input type="checkbox" checked={value} onChange={onChange} />
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
@@ -77,6 +80,8 @@ const fontFamilyOptions = {
galactico: 'galactico',
};
const RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
export function SettingsTab({ started }) {
const {
theme,
@@ -95,19 +100,41 @@ export function SettingsTab({ started }) {
fontFamily,
panelPosition,
audioDeviceName,
audioEngineTarget,
} = useSettings();
const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
return (
<div className="text-foreground p-4 space-y-4">
{AudioContext.prototype.setSinkId != null && (
{canChangeAudioDevice && (
<FormItem label="Audio Output Device">
<AudioDeviceSelector
isDisabled={started}
audioDeviceName={audioDeviceName}
onChange={(audioDeviceName) => settingsMap.setKey('audioDeviceName', audioDeviceName)}
onChange={(audioDeviceName) => {
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('audioDeviceName', audioDeviceName);
return window.location.reload();
}
});
}}
/>
</FormItem>
)}
<FormItem label="Audio Engine Target">
<AudioEngineTargetSelector
target={audioEngineTarget}
onChange={(target) => {
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('audioEngineTarget', target);
return window.location.reload();
}
});
}}
/>
</FormItem>
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
@@ -192,11 +219,15 @@ export function SettingsTab({ started }) {
<Checkbox
label="Sync across Browser Tabs / Windows"
onChange={(cbEvent) => {
if (confirm('Changing this setting requires the window to reload itself. OK?')) {
settingsMap.setKey('isSyncEnabled', cbEvent.target.checked);
window.location.reload();
}
const newVal = cbEvent.target.checked;
confirmDialog(RELOAD_MSG).then((r) => {
if (r) {
settingsMap.setKey('isSyncEnabled', newVal);
window.location.reload();
}
});
}}
disabled={shouldAlwaysSync}
value={isSyncEnabled}
/>
</FormItem>
@@ -205,9 +236,11 @@ export function SettingsTab({ started }) {
<button
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
onClick={() => {
if (confirm('Sure?')) {
settingsMap.set(defaultSettings);
}
confirmDialog('Sure?').then((r) => {
if (r) {
settingsMap.set(defaultSettings);
}
});
}}
>
restore default settings
@@ -1,8 +1,8 @@
import useEvent from '@src/useEvent.mjs';
import { useStore } from '@nanostores/react';
import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio';
import React, { useMemo, useRef } from 'react';
import { settingsMap, useSettings } from '../../settings.mjs';
import React, { useMemo, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../../../settings.mjs';
import { ButtonGroup } from './Forms.jsx';
import ImportSoundsButton from './ImportSoundsButton.jsx';
@@ -12,15 +12,20 @@ const getSamples = (samples) =>
export function SoundsTab() {
const sounds = useStore(soundMap);
const { soundsFilter } = useSettings();
const [search, setSearch] = useState('');
const soundEntries = useMemo(() => {
let filtered = Object.entries(sounds)
.filter(([key]) => !key.startsWith('_'))
.sort((a, b) => a[0].localeCompare(b[0]));
if (!sounds) {
return [];
}
let filtered = Object.entries(sounds)
.filter(([key]) => !key.startsWith('_'))
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([name]) => name.toLowerCase().includes(search.toLowerCase()));
if (soundsFilter === 'user') {
return filtered.filter(([key, { data }]) => !data.prebake);
return filtered.filter(([_, { data }]) => !data.prebake);
}
if (soundsFilter === 'drums') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
@@ -32,9 +37,11 @@ export function SoundsTab() {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
return filtered;
}, [sounds, soundsFilter]);
}, [sounds, soundsFilter, search]);
// holds mutable ref to current triggered sound
const trigRef = useRef();
// stop current sound on mouseup
useEvent('mouseup', () => {
const t = trigRef.current;
@@ -43,8 +50,17 @@ export function SoundsTab() {
ref?.stop(getAudioContext().currentTime + 0.01);
});
});
return (
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
<div className="w-full ml-2 mb-2 top-0 sticky">
<input
className="w-full p-1 bg-background rounded-md"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="pb-2 flex shrink-0 flex-wrap">
<ButtonGroup
value={soundsFilter}
+5 -4
View File
@@ -23,10 +23,10 @@ async function hasStrudelJson(subpath) {
async function loadStrudelJson(subpath) {
const contents = await readTextFile(subpath + '/strudel.json', { dir });
const sampleMap = JSON.parse(contents);
processSampleMap(sampleMap, (key, value) => {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value, fileResolver(subpath)), {
processSampleMap(sampleMap, (key, bank) => {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank, fileResolver(subpath)), {
type: 'sample',
samples: value,
samples: bank,
fileSystem: true,
tag: 'local',
});
@@ -75,7 +75,8 @@ export const walkFileTree = (node, fn) => {
}
};
export const isAudioFile = (filename) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]);
export const isAudioFile = (filename) =>
['wav', 'mp3', 'flac', 'ogg', 'm4a', 'aac'].includes(filename.split('.').slice(-1)[0]);
function uint8ArrayToDataURL(uint8Array) {
const blob = new Blob([uint8Array], { type: 'audio/*' });
+94 -58
View File
@@ -12,7 +12,7 @@ export const userSamplesDBConfig = {
};
// deletes all of the databases, useful for debugging
const clearIDB = () => {
function clearIDB() {
window.indexedDB
.databases()
.then((r) => {
@@ -21,54 +21,81 @@ const clearIDB = () => {
.then(() => {
alert('All data cleared.');
});
};
}
// queries the DB, and registers the sounds so they can be played
export const registerSamplesFromDB = (config = userSamplesDBConfig, onComplete = () => {}) => {
export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete = () => {}) {
openDB(config, (objectStore) => {
let query = objectStore.getAll();
const query = objectStore.getAll();
query.onerror = (e) => {
logger('User Samples failed to load ', 'error');
onComplete();
console.error(e?.target?.error);
};
query.onsuccess = (event) => {
const soundFiles = event.target.result;
if (!soundFiles?.length) {
return;
}
const sounds = new Map();
[...soundFiles]
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
.forEach((soundFile) => {
const title = soundFile.title;
if (!isAudioFile(title)) {
return;
}
const splitRelativePath = soundFile.id.split('/');
const parentDirectory =
//fallback to file name before period and seperator if no parent directory
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
const soundPath = soundFile.blob;
const soundPaths = sounds.get(parentDirectory) ?? new Set();
soundPaths.add(soundPath);
sounds.set(parentDirectory, soundPaths);
});
sounds.forEach((soundPaths, key) => {
const value = Array.from(soundPaths);
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
type: 'sample',
samples: value,
baseUrl: undefined,
prebake: false,
tag: undefined,
Promise.all(
[...soundFiles]
.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }))
.map((soundFile, i) => {
const title = soundFile.title;
if (!isAudioFile(title)) {
return;
}
const splitRelativePath = soundFile.id.split('/');
let parentDirectory =
//fallback to file name before period and seperator if no parent directory
splitRelativePath[splitRelativePath.length - 2] ?? soundFile.id.split(/\W+/)[0] ?? 'user';
const blob = soundFile.blob;
// Files used to be uploaded as base64 strings, After Jan 1 2025 this check can be safely deleted
if (typeof blob === 'string') {
const soundPaths = sounds.get(parentDirectory) ?? new Set();
soundPaths.add(blob);
sounds.set(parentDirectory, soundPaths);
return;
}
return blobToDataUrl(blob).then((soundPath) => {
const soundPaths = sounds.get(parentDirectory) ?? new Set();
soundPaths.add(soundPath);
sounds.set(parentDirectory, soundPaths);
return;
});
}),
)
.then(() => {
sounds.forEach((soundPaths, key) => {
const value = Array.from(soundPaths);
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
type: 'sample',
samples: value,
baseUrl: undefined,
prebake: false,
tag: undefined,
});
});
logger('imported sounds registered!', 'success');
onComplete();
})
.catch((error) => {
logger('Something went wrong while registering saved samples from the index db', 'error');
console.error(error);
});
});
logger('imported sounds registered!', 'success');
onComplete();
};
});
};
// creates a blob from a buffer that can be read
async function bufferToDataUrl(buf) {
}
async function blobToDataUrl(blob) {
return new Promise((resolve) => {
var blob = new Blob([buf], { type: 'application/octet-binary' });
var reader = new FileReader();
reader.onload = function (event) {
resolve(event.target.result);
@@ -76,8 +103,9 @@ async function bufferToDataUrl(buf) {
reader.readAsDataURL(blob);
});
}
//open db and initialize it if necessary
const openDB = (config, onOpened) => {
function openDB(config, onOpened) {
const { dbName, version, table, columns } = config;
if (typeof window === 'undefined') {
return;
@@ -102,19 +130,19 @@ const openDB = (config, onOpened) => {
dbOpen.onsuccess = () => {
const db = dbOpen.result;
const // lock store for writing
writeTransaction = db.transaction([table], 'readwrite'),
// get object store
objectStore = writeTransaction.objectStore(table);
// lock store for writing
const writeTransaction = db.transaction([table], 'readwrite');
// get object store
const objectStore = writeTransaction.objectStore(table);
onOpened(objectStore, db);
};
};
return dbOpen;
}
const processFilesForIDB = async (files) => {
return await Promise.all(
async function processFilesForIDB(files) {
return Promise.all(
Array.from(files)
.map(async (s) => {
.map((s) => {
const title = s.name;
if (!isAudioFile(title)) {
@@ -123,34 +151,42 @@ const processFilesForIDB = async (files) => {
//create obscured url to file system that can be fetched
const sUrl = URL.createObjectURL(s);
//fetch the sound and turn it into a buffer array
const buf = await fetch(sUrl).then((res) => res.arrayBuffer());
//create a url base64 containing all of the buffer data
const base64 = await bufferToDataUrl(buf);
const path = s.webkitRelativePath;
const id = path?.length ? path : title;
return {
title,
blob: base64,
id,
};
return fetch(sUrl).then((res) => {
return res.blob().then((blob) => {
const path = s.webkitRelativePath;
let id = path?.length ? path : title;
if (id == null || title == null || blob == null) {
return;
}
return {
title,
blob,
id,
};
});
});
})
.filter(Boolean),
).catch((error) => {
logger('Something went wrong while processing uploaded files', 'error');
console.error(error);
});
};
}
export const uploadSamplesToDB = async (config, files) => {
export async function uploadSamplesToDB(config, files) {
logger('procesing user samples...');
await processFilesForIDB(files).then((files) => {
logger('user samples processed... opening db');
const onOpened = (objectStore, _db) => {
logger('index db opened... writing files to db');
files.forEach((file) => {
if (file == null) {
return;
}
objectStore.put(file);
});
logger('user samples written successfully');
};
openDB(config, onOpened);
});
};
}
+239
View File
@@ -0,0 +1,239 @@
/*
Repl.jsx - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/App.js>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw';
import { transpiler } from '@strudel/transpiler';
import {
getAudioContextCurrentTime,
webaudioOutput,
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
} from '@strudel/webaudio';
import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
import { clearHydra } from '@strudel/hydra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../settings.mjs';
import {
setActivePattern,
setLatestCode,
createPatternID,
userPattern,
getViewingPatternData,
setViewingPatternData,
} from '../user_pattern_utils.mjs';
import { superdirtOutput } from '@strudel/osc/superdirtoutput';
import { audioEngineTargets, defaultAudioDeviceName } from '../settings.mjs';
import { useStore } from '@nanostores/react';
import { prebake } from './prebake.mjs';
import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
const { latestCode } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
if (typeof window !== 'undefined') {
audioReady = initAudioOnFirstClick();
modulesLoading = loadModules();
presets = prebake();
drawContext = getDrawContext();
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
}
async function getModule(name) {
if (!modulesLoading) {
return;
}
const modules = await modulesLoading;
return modules.find((m) => m.packageName === name);
}
export function useReplContext() {
const { isSyncEnabled, audioEngineTarget } = 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();
const editor = new StrudelMirror({
sync: isSyncEnabled,
defaultOutput,
getTime,
setInterval,
clearInterval,
transpiler,
autodraw: false,
root: containerRef.current,
initialCode: '// LOADING',
pattern: silence,
drawTime,
drawContext,
prebake: async () => Promise.all([modulesLoading, presets]),
onUpdateState: (state) => {
setReplState({ ...state });
},
onToggle: (playing) => {
if (!playing) {
clearHydra();
}
},
beforeEval: () => audioReady,
afterEval: (all) => {
const { code } = all;
//post to iframe parent (like Udels) if it exists...
window.parent?.postMessage(code);
setLatestCode(code);
window.location.hash = '#' + code2hash(code);
setDocumentTitle(code);
const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(code);
const data = { ...viewingPatternData, code };
let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) {
const codeHasChanged = code !== viewingPatternData.code;
if (codeHasChanged) {
// fork example
const newPattern = userPattern.duplicate(data);
id = newPattern.id;
setViewingPatternData(newPattern.data);
}
} else {
id = userPattern.isValidID(id) ? id : createPatternID();
setViewingPatternData(userPattern.update(id, data).data);
}
setActivePattern(id);
},
bgFill: false,
});
window.strudelMirror = editor;
// init settings
initCode().then(async (decoded) => {
let code, msg;
if (decoded) {
code = decoded;
msg = `I have loaded the code from the URL.`;
} else if (latestCode) {
code = latestCode;
msg = `Your last session has been loaded!`;
} else {
const { code: randomTune, name } = await getRandomTune();
code = randomTune;
msg = `A random code snippet named "${name}" has been loaded!`;
}
editor.setCode(code);
setDocumentTitle(code);
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
});
editorRef.current = editor;
}, []);
const [replState, setReplState] = useState({});
const { started, isDirty, error, activeCode, pending } = replState;
const editorRef = useRef();
const containerRef = useRef();
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
// this will be the case when the main repl is being replaced
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
useEffect(() => {
let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
editorSettings[key] = _settings[key];
}
});
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
// on first load, set stored audio device if possible
useEffect(() => {
const { audioDeviceName } = _settings;
if (audioDeviceName !== defaultAudioDeviceName) {
getAudioDevices().then((devices) => {
const deviceID = devices.get(audioDeviceName);
if (deviceID == null) {
return;
}
setAudioDevice(deviceID);
});
}
}, []);
//
// UI Actions
//
const setDocumentTitle = (code) => {
const meta = getMetadata(code);
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
};
const handleTogglePlay = async () => {
editorRef.current?.toggle();
};
const resetEditor = async () => {
(await getModule('@strudel/tonal'))?.resetVoicings();
resetGlobalEffects();
clearCanvas();
clearHydra();
resetLoadedSounds();
editorRef.current.repl.setCps(0.5);
await prebake(); // declare default samples
};
const handleUpdate = async (patternData, reset = false) => {
setViewingPatternData(patternData);
editorRef.current.setCode(patternData.code);
if (reset) {
await resetEditor();
handleEvaluate();
}
};
const handleEvaluate = () => {
editorRef.current.evaluate();
};
const handleShuffle = async () => {
const patternData = await getRandomTune();
const code = patternData.code;
logger(`[repl] ✨ loading random tune "${patternData.id}"`);
setActivePattern(patternData.id);
setViewingPatternData(patternData);
await resetEditor();
editorRef.current.setCode(code);
editorRef.current.repl.evaluate(code);
};
const handleShare = async () => shareCode(replState.code);
const context = {
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
handleEvaluate,
init,
error,
editorRef,
containerRef,
};
return context;
}
+47 -25
View File
@@ -2,14 +2,11 @@ import { evalScope, hash2code, logger } from '@strudel/core';
import { settingPatterns, defaultAudioDeviceName } from '../settings.mjs';
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext, 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/api/clipboard';
import { createContext } from 'react';
import { $featuredPatterns, loadDBPatterns } from '@src/user_pattern_utils.mjs';
// Create a single supabase client for interacting with your database
@@ -97,6 +94,16 @@ export function loadModules() {
return evalScope(settingPatterns, ...modules);
}
// confirm dialog is a promise in webkit and a boolean in other browsers... normalize it to be a promise everywhere
export function confirmDialog(msg) {
const confirmed = confirm(msg);
if (confirmed instanceof Promise) {
return confirmed;
}
return new Promise((resolve) => {
resolve(confirmed);
});
}
let lastShared;
export async function shareCode(codeToShare) {
@@ -105,34 +112,49 @@ export async function shareCode(codeToShare) {
logger(`Link already generated!`, 'error');
return;
}
const isPublic = confirm(
confirmDialog(
'Do you want your pattern to be public? If no, press cancel and you will get just a private link.',
);
// generate uuid in the browser
const hash = nanoid(12);
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
const { error } = await supabase.from('code_v1').insert([{ code: codeToShare, hash, ['public']: isPublic }]);
if (!error) {
lastShared = codeToShare;
// copy shareUrl to clipboard
if (isTauri()) {
await writeText(shareUrl);
).then(async (isPublic) => {
const hash = nanoid(12);
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
const { error } = await supabase.from('code_v1').insert([{ code: codeToShare, hash, ['public']: isPublic }]);
if (!error) {
lastShared = codeToShare;
// copy shareUrl to clipboard
if (isTauri()) {
await writeText(shareUrl);
} else {
await navigator.clipboard.writeText(shareUrl);
}
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
await navigator.clipboard.writeText(shareUrl);
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
}
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
});
}
export const isIframe = () => window.location !== window.parent.location;
function isCrossOriginFrame() {
try {
return !window.top.location.hostname;
} catch (e) {
return true;
}
}
export const ReplContext = createContext(null);
export const isUdels = () => {
if (isCrossOriginFrame()) {
return false;
}
return window.top?.location?.pathname.includes('udels');
};
export const getAudioDevices = async () => {
await navigator.mediaDevices.getUserMedia({ audio: true });
+18 -3
View File
@@ -1,9 +1,15 @@
import { persistentMap } from '@nanostores/persistent';
import { useStore } from '@nanostores/react';
import { register } from '@strudel/core';
import { isUdels } from './repl/util.mjs';
export const defaultAudioDeviceName = 'System Standard';
export const audioEngineTargets = {
webaudio: 'webaudio',
osc: 'osc',
};
export const defaultSettings = {
activeFooter: 'intro',
keybindings: 'codemirror',
@@ -27,9 +33,18 @@ export const defaultSettings = {
panelPosition: 'right',
userPatterns: '{}',
audioDeviceName: defaultAudioDeviceName,
audioEngineTarget: audioEngineTargets.webaudio,
};
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
let search = null;
if (typeof window !== 'undefined') {
search = new URLSearchParams(window.location.search);
}
// if running multiple instance in one window, it will use the settings for that instance. else default to normal
const instance = parseInt(search?.get('instance') ?? '0');
const settings_key = `strudel-settings${instance > 0 ? instance : ''}`;
export const settingsMap = persistentMap(settings_key, defaultSettings);
const parseBoolean = (booleanlike) => ([true, 'true'].includes(booleanlike) ? true : false);
@@ -54,9 +69,9 @@ export function useSettings() {
isTooltipEnabled: parseBoolean(state.isTooltipEnabled),
isLineWrappingEnabled: parseBoolean(state.isLineWrappingEnabled),
isFlashEnabled: parseBoolean(state.isFlashEnabled),
isSyncEnabled: parseBoolean(state.isSyncEnabled),
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
fontSize: Number(state.fontSize),
panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
userPatterns: userPatterns,
};
}
+13 -12
View File
@@ -1,10 +1,9 @@
import { atom } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';
import { useStore } from '@nanostores/react';
import { logger } from '@strudel/core';
import { nanoid } from 'nanoid';
import { settingsMap } from './settings.mjs';
import { parseJSON, supabase } from './repl/util.mjs';
import { confirmDialog, parseJSON, supabase } from './repl/util.mjs';
export let $publicPatterns = atom([]);
export let $featuredPatterns = atom([]);
@@ -131,17 +130,19 @@ export const userPattern = {
return this.update(newPattern.id, { ...newPattern.data, code: data.code });
},
clearAll() {
if (!confirm(`This will delete all your patterns. Are you really sure?`)) {
return;
}
const viewingPatternData = getViewingPatternData();
setUserPatterns({});
confirmDialog(`This will delete all your patterns. Are you really sure?`).then((r) => {
if (r == false) {
return;
}
const viewingPatternData = getViewingPatternData();
setUserPatterns({});
if (viewingPatternData.collection !== this.collection) {
return { id: viewingPatternData.id, data: viewingPatternData };
}
setActivePattern(null);
return this.create();
if (viewingPatternData.collection !== this.collection) {
return { id: viewingPatternData.id, data: viewingPatternData };
}
setActivePattern(null);
return this.create();
});
},
delete(id) {
const userPatterns = this.getAll();