Merge branch 'main' of ssh://codeberg.org/vvolhejn/strudel into radical-new-docs
This commit is contained in:
commit
cc15e3cd36
131 changed files with 13482 additions and 7546 deletions
|
|
@ -75,5 +75,8 @@
|
|||
"sharp": "^0.33.5",
|
||||
"workbox-window": "^7.3.0",
|
||||
"vite-plugin-bundle-audioworklet": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
website/src/components/Dough/Dough.astro
Normal file
17
website/src/components/Dough/Dough.astro
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
import '../../repl/Repl.css';
|
||||
---
|
||||
|
||||
<div id="code"></div>
|
||||
|
||||
<script>
|
||||
import { DoughMirror } from './dough-mirror.mjs';
|
||||
const root = document.getElementById('code');
|
||||
const doughmirror = new DoughMirror({ root });
|
||||
globalThis.settings = doughmirror.settings.bind(doughmirror);
|
||||
</script>
|
||||
<style>
|
||||
#code {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
153
website/src/components/Dough/dough-mirror.mjs
Normal file
153
website/src/components/Dough/dough-mirror.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
initEditor,
|
||||
codemirrorSettings,
|
||||
flash,
|
||||
compartments,
|
||||
extensions,
|
||||
parseBooleans,
|
||||
activateTheme,
|
||||
updateMiniLocations,
|
||||
highlightMiniLocations,
|
||||
} from '@strudel/codemirror';
|
||||
import { evalScope, hash2code, code2hash } from '@strudel/core';
|
||||
import { Framer } from '@strudel/draw';
|
||||
import { persistentAtom } from '@nanostores/persistent';
|
||||
import { DoughRepl } from './dough-repl.mjs';
|
||||
|
||||
let initialCode = '$: note("c a f e")';
|
||||
|
||||
export const code = persistentAtom('vanilla-repl-code', initialCode, {
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const codeParam = window.location.href.split('#')[1] || '';
|
||||
if (codeParam) {
|
||||
const codeFromHash = hash2code(codeParam);
|
||||
code.set(codeFromHash);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('could not init code from url');
|
||||
}
|
||||
}
|
||||
|
||||
export class DoughMirror {
|
||||
constructor(options) {
|
||||
const { root, initialCode = code.get(), bgFill = true } = options;
|
||||
this.root = root;
|
||||
this.code = initialCode;
|
||||
this.repl = new DoughRepl();
|
||||
this.prebaked = this.prebake();
|
||||
|
||||
// init codemirror
|
||||
this.editor = initEditor({
|
||||
root,
|
||||
initialCode: this.code,
|
||||
onChange: (v) => {
|
||||
if (v.docChanged) {
|
||||
this.code = v.state.doc.toString();
|
||||
code.set(this.code);
|
||||
}
|
||||
},
|
||||
onEvaluate: this.evaluate.bind(this),
|
||||
onStop: () => this.stop(),
|
||||
mondo: false,
|
||||
});
|
||||
const settings = codemirrorSettings.get();
|
||||
this.setFontSize(settings.fontSize);
|
||||
this.setFontFamily(settings.fontFamily);
|
||||
|
||||
// init event highlighting
|
||||
this.framer = new Framer(
|
||||
(time) => {
|
||||
const frameHaps = this.repl.processHaps();
|
||||
highlightMiniLocations(this.editor, time, frameHaps);
|
||||
},
|
||||
(err) => console.log('Framer error', err),
|
||||
);
|
||||
}
|
||||
prebake() {
|
||||
const modulesLoading = evalScope(import('@strudel/core'), import('@strudel/tonal'), import('@strudel/mini'));
|
||||
return Promise.all([modulesLoading, this.repl.prebake()]);
|
||||
}
|
||||
async evaluate() {
|
||||
this.framer.start();
|
||||
this.flash();
|
||||
await this.prebaked;
|
||||
const { miniLocations } = await this.repl.evaluate(this.code);
|
||||
window.location.hash = '#' + code2hash(this.code);
|
||||
updateMiniLocations(this.editor, miniLocations);
|
||||
}
|
||||
stop() {
|
||||
this.repl.stop();
|
||||
this.framer.stop();
|
||||
highlightMiniLocations(this.editor, 0, []);
|
||||
}
|
||||
// added synonym (compared to StrudelMirror)
|
||||
settings(settings = {}) {
|
||||
this.updateSettings(settings);
|
||||
}
|
||||
|
||||
// the rest is copy pasted from StrudelMirror:
|
||||
|
||||
updateSettings(settings = {}) {
|
||||
settings.fontSize && this.setFontSize(settings.fontSize);
|
||||
settings.fontFamily && this.setFontFamily(settings.fontFamily);
|
||||
for (let key in extensions) {
|
||||
if (key in settings) {
|
||||
this.reconfigureExtension(key, settings[key]);
|
||||
}
|
||||
}
|
||||
const updated = { ...codemirrorSettings.get(), ...settings };
|
||||
// console.log(updated);
|
||||
codemirrorSettings.set(updated);
|
||||
}
|
||||
reconfigureExtension(key, value) {
|
||||
if (!extensions[key]) {
|
||||
console.warn(`extension ${key} is not known`);
|
||||
return;
|
||||
}
|
||||
value = parseBooleans(value);
|
||||
const newValue = extensions[key](value, this);
|
||||
this.editor.dispatch({
|
||||
effects: compartments[key].reconfigure(newValue),
|
||||
});
|
||||
if (key === 'theme') {
|
||||
activateTheme(value);
|
||||
}
|
||||
}
|
||||
flash(ms) {
|
||||
flash(this.editor, ms);
|
||||
}
|
||||
setFontSize(size) {
|
||||
this.root.style.fontSize = size + 'px';
|
||||
}
|
||||
setFontFamily(family) {
|
||||
this.root.style.fontFamily = family;
|
||||
const scroller = this.root.querySelector('.cm-scroller');
|
||||
if (scroller) {
|
||||
scroller.style.fontFamily = family;
|
||||
}
|
||||
}
|
||||
setCode(code, offset = 0) {
|
||||
const changes = {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length + offset,
|
||||
insert: code,
|
||||
};
|
||||
this.editor.dispatch({ changes });
|
||||
}
|
||||
getCursorLocation() {
|
||||
return this.editor.state.selection.main.head;
|
||||
}
|
||||
setCursorLocation(col) {
|
||||
return this.editor.dispatch({ selection: { anchor: col } });
|
||||
}
|
||||
appendCode(code) {
|
||||
const cursor = this.getCursorLocation();
|
||||
this.setCode(this.code + code);
|
||||
this.setCursorLocation(cursor);
|
||||
}
|
||||
}
|
||||
109
website/src/components/Dough/dough-repl.mjs
Normal file
109
website/src/components/Dough/dough-repl.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// import { Dough, doughsamples } from 'dough-synth';
|
||||
import { Dough, doughsamples } from 'https://unpkg.com/dough-synth@0.1.9/dough.js';
|
||||
import { Pattern, noteToMidi, evaluate, stack } from '@strudel/core';
|
||||
// import doughUrl from 'dough-synth?url';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
//const doughBaseUrl = doughUrl.split('/').slice(0, -1).join('/') + '/';
|
||||
const doughBaseUrl = 'https://unpkg.com/dough-synth@0.1.9/';
|
||||
|
||||
Object.assign(globalThis, { doughsamples });
|
||||
|
||||
export class DoughRepl {
|
||||
pattern;
|
||||
latency = 0.1;
|
||||
cps = 0.5;
|
||||
origin;
|
||||
t0;
|
||||
lasttime;
|
||||
strudel;
|
||||
q = [];
|
||||
constructor() {
|
||||
this.ready = this.init();
|
||||
}
|
||||
async init() {
|
||||
// init dough immediately, so that it can attach the document click event to initAudio immediately
|
||||
this.dough = new Dough({
|
||||
base: doughBaseUrl,
|
||||
//base: "../", // local dev
|
||||
onTick: ({ t0, t1 }) => {
|
||||
if (!this.pattern) {
|
||||
return;
|
||||
}
|
||||
this.origin ??= t0;
|
||||
this.t0 = t0;
|
||||
this.lasttime = performance.now();
|
||||
const a = (t0 - this.origin) * this.cps;
|
||||
const b = (t1 - this.origin) * this.cps;
|
||||
|
||||
const haps = this.pattern.queryArc(a, b).filter((hap) => hap.hasOnset());
|
||||
if (!haps.length) {
|
||||
return;
|
||||
}
|
||||
haps.forEach((hap) => {
|
||||
const time = hap.whole.begin.valueOf() / this.cps + this.origin + this.latency;
|
||||
const duration = hap.duration.valueOf() / this.cps;
|
||||
const event = {
|
||||
dough: 'play',
|
||||
...hap.value,
|
||||
time,
|
||||
duration,
|
||||
};
|
||||
if (event.note && typeof event.note === 'string') {
|
||||
event.note = noteToMidi(event.note);
|
||||
}
|
||||
//console.log("event", JSON.stringify(Object.entries(event)));
|
||||
this.dough.evaluate(event);
|
||||
this.q.push({ event, hap });
|
||||
});
|
||||
},
|
||||
});
|
||||
// miniAllStrings();
|
||||
const setcps = (cps) => (this.cps = cps);
|
||||
const setcpm = (cpm) => setcps(cpm / 60);
|
||||
const replScope = { setcps, setcpm };
|
||||
Object.assign(globalThis, replScope);
|
||||
}
|
||||
async evaluate(code) {
|
||||
await this.ready;
|
||||
let patterns = [];
|
||||
Pattern.prototype.p = function (id) {
|
||||
if (!id.startsWith('_')) {
|
||||
patterns.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
let { meta } = await evaluate(code, transpiler, { addReturn: false, wrapAsync: true, emitWidgets: false });
|
||||
const { miniLocations } = meta;
|
||||
|
||||
this.pattern = stack(...patterns);
|
||||
return { miniLocations, pattern: this.pattern };
|
||||
}
|
||||
stop() {
|
||||
this.pattern = undefined;
|
||||
this.origin = undefined;
|
||||
}
|
||||
prebake() {
|
||||
return Promise.all([
|
||||
// doughsamples('github:eddyflux/crate')
|
||||
]);
|
||||
}
|
||||
// tbd: move this to dough-synth
|
||||
get time() {
|
||||
return this.t0 + (performance.now() - this.lasttime) / 1000 + this.latency;
|
||||
}
|
||||
processHaps() {
|
||||
const currentHaps = [];
|
||||
const time = this.time;
|
||||
this.q = this.q.filter(({ event, hap }) => {
|
||||
const end = event.time + event.duration;
|
||||
const isActive = time >= event.time && time <= end;
|
||||
if (isActive) {
|
||||
currentHaps.push(hap);
|
||||
}
|
||||
return end > time; // delete old events
|
||||
// we do NOT return !isActive, because a frame might miss an event, which would cause a leak
|
||||
});
|
||||
// console.log(this.q.length); // to check for leaks
|
||||
return currentHaps;
|
||||
}
|
||||
}
|
||||
15
website/src/pages/dough/index.astro
Normal file
15
website/src/pages/dough/index.astro
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import HeadCommon from '../../components/HeadCommon.astro';
|
||||
import Dough from '../../components/Dough/Dough.astro';
|
||||
---
|
||||
|
||||
<html lang="en" class="m-0 dark">
|
||||
<head>
|
||||
<HeadCommon />
|
||||
<title>Strudel Dough REPL</title>
|
||||
</head>
|
||||
<body class="h-app-height bg-background m-0">
|
||||
<Dough />
|
||||
<a rel="me" href="https://social.toplap.org/@strudel" target="_blank" class="hidden">mastodon</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -11,9 +11,9 @@ import { JsDoc } from '../../docs/JsDoc';
|
|||
You can optionally add some music metadata in your Strudel code, by using tags in code comments:
|
||||
|
||||
```js
|
||||
// @title Hey Hoo
|
||||
// @by Sam Tagada
|
||||
// @license CC BY-NC-SA
|
||||
// @title My Cool Song
|
||||
// @by John Doe
|
||||
// @license CC-BY-SA-4.0
|
||||
```
|
||||
|
||||
Like other comments, those are ignored by Strudel, but it can be used by other tools to retrieve some information about the music.
|
||||
|
|
@ -24,22 +24,22 @@ You can also use comment blocks:
|
|||
|
||||
```js
|
||||
/*
|
||||
@title Hey Hoo
|
||||
@by Sam Tagada
|
||||
@license CC BY-NC-SA
|
||||
@title My Cool Song
|
||||
@by John Doe
|
||||
@license CC-BY-SA-4.0
|
||||
*/
|
||||
```
|
||||
|
||||
Or define multiple tags in one line:
|
||||
|
||||
```js
|
||||
// @title Hey Hoo @by Sam Tagada @license CC BY-NC-SA
|
||||
// @title My Cool Song @by John Doe @license CC-BY-SA-4.0
|
||||
```
|
||||
|
||||
The `title` tag has an alternative syntax using quotes (must be defined at the very begining):
|
||||
|
||||
```js
|
||||
// "Hey Hoo" @by Sam Tagada
|
||||
// "My Cool Song" @by John Doe
|
||||
```
|
||||
|
||||
## Tags list
|
||||
|
|
@ -48,20 +48,22 @@ Available tags are:
|
|||
|
||||
- `@title`: music title
|
||||
- `@by`: music author(s), separated by comma, eventually followed with a link in `<>` (ex: `@by John Doe <https://example.com>`)
|
||||
- `@license`: music license(s), e.g. CC BY-NC-SA. Unsure? [Choose a creative commons license here](https://creativecommons.org/choose/)
|
||||
- `@license`: music license(s), separated by comma. Each license should be specified by using the correct identifier in the [https://spdx.org/licenses/](SPDX License List). Example: CC-BY-SA-4.0. Unsure? [Choose a Creative Commons license here](https://creativecommons.org/choose/).
|
||||
- `@details`: some additional information about the music
|
||||
- `@url`: web page(s) related to the music (git repo, soundcloud link, etc.)
|
||||
- `@genre`: music genre(s) (pop, jazz, etc)
|
||||
- `@url`: web page(s) related to the music (git repository, Soundcloud link, etc.)
|
||||
- `@genre`: music genre(s) (pop, jazz, etc.)
|
||||
- `@album`: music album name
|
||||
|
||||
Note to tool authors: _Never_ trust that a song has filled those fields with syntactically correct values; make sure your software is robust enough it doesn't break if it encounters bad values
|
||||
|
||||
## Multiple values
|
||||
|
||||
Some of them accepts several values, using the comma or new line separator, or duplicating the tag:
|
||||
|
||||
```js
|
||||
/*
|
||||
@by Sam Tagada
|
||||
Jimmy
|
||||
@by John Doe
|
||||
Jane Doe
|
||||
@genre pop, jazz
|
||||
@url https://example.com
|
||||
@url https://example.org
|
||||
|
|
@ -72,11 +74,11 @@ You can also add optional prefixes and use tags where you want:
|
|||
|
||||
```js
|
||||
/*
|
||||
song @by Sam Tagada
|
||||
samples @by Jimmy
|
||||
song @by John Doe
|
||||
samples @by Jane Doe
|
||||
*/
|
||||
...
|
||||
note("a3 c#4 e4 a4") // @by Sandy
|
||||
note("a3 c#4 e4 a4") // @by Sandy Sue
|
||||
```
|
||||
|
||||
## Multiline
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@ The "~" represents a rest, and will create silence between other events:
|
|||
|
||||
<MiniRepl client:idle tune={`note("[b4 [~ c5] d5 e5]")`} punchcard />
|
||||
|
||||
Alternatively, "-" can be used instead of "~". It means the same thing.
|
||||
|
||||
## Parallel / polyphony
|
||||
|
||||
Using commas, we can play chords.
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ You can also create custom aliases for existing sounds using the `soundAlias` fu
|
|||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`soundAlias("RolandTR808_bd", "kick")
|
||||
tune={`soundAlias('RolandTR808_bd', 'kick')
|
||||
s("kick")`}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,28 +48,94 @@ You can also use the `crackle` type to play some subtle noise crackles. You can
|
|||
|
||||
### Additive Synthesis
|
||||
|
||||
To tame the harsh sound of the basic waveforms, we can set the `n` control to limit the overtones of the waveform:
|
||||
Periodic waveforms are composed of several [harmonics](https://en.wikipedia.org/wiki/Harmonic) above a fundamental frequency, lying at integer multiples. These overtones combine to give a sound its unique timbral quality.
|
||||
|
||||
For the basic waveforms, we offer you control over these harmonics with the `partials` and `phases` functions.
|
||||
|
||||
#### Partials
|
||||
|
||||
`partials` refers to the magnitude of each harmonic relative to the fundamental frequency. They can thus be used to spectrally filter these waveforms and tame some of their harshness:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
|
||||
.sound("sawtooth")
|
||||
.n("<32 16 8 4>")
|
||||
.partials([1, 1, "<1 0>", "<1 0>", "<1 0>", "<1 0>", "<1 0>"])
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
When the `n` control is used on a basic waveform, it defines the number of harmonic partials the sound is getting.
|
||||
You can also set `n` directly in mini notation with `sound`:
|
||||
`partials` can also be used to construct _new_ waveforms not present in our basic set with the 'user' sound source:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
|
||||
.sound("sawtooth:<32 16 8 4>")
|
||||
.sound("user")
|
||||
.partials([1, 0, 0.3, 0, 0.1, 0, 0, 0.3])
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
Note for tidal users: `n` in tidal is synonymous to `note` for synths only.
|
||||
In strudel, this is not the case, where `n` will always change timbre, be it though different samples or different waveforms.
|
||||
We may algorithmically construct lists of magnitudes with Javascript code like:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`const numHarmonics = 22;
|
||||
note("c2 <eb2 <g2 g1>>".fast(2))
|
||||
.sound("saw")
|
||||
.partials(new Array(numHarmonics).fill(1))
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
which acts as a spectral filter. Or:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>").fast(2)
|
||||
.sound("user")
|
||||
.partials(new Array(50).fill(0)
|
||||
.map((_, idx) => ((-1) ** (idx + 1)) / (idx + 1))
|
||||
)
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
which recovers a familiar waveform.
|
||||
|
||||
`partials` is also compatible with pattern functions designed to produce lists, like `randL` or `binaryL`:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>").fast(2)
|
||||
.sound("user")
|
||||
.partials(randL(10))
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
and with lists _of_ patterns:
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`note("c2 <eb2 <g2 g1>>".fast(4))
|
||||
.sound("user")
|
||||
.partials([1, 0, "0 1", "0 1 0.3", rand])
|
||||
._scope()`}
|
||||
/>
|
||||
|
||||
Note that the first value in the `partials` array controls the magnitude of the fundamental harmonic rather than the DC offset, which is fixed at 0.
|
||||
|
||||
#### Phases
|
||||
|
||||
Earlier, we mentioned that periodic waveforms can be broken into a set of harmonics above a fundamental frequency. Each harmonic has two defining properties: its magnitude (how loud it is) and its phase, which determines where in its cycle that sine wave starts when the waveform is built.
|
||||
|
||||
These phases too can be declared in Strudel and can give your sounds interesting depth.
|
||||
|
||||
<MiniRepl
|
||||
client:idle
|
||||
tune={`s("saw").seg(16).n(irand(12)).scale("F1:minor")
|
||||
.penv(48).panchor(0).pdec(0.05)
|
||||
.delay(0.25).room(0.25)
|
||||
.compressor(-20).vib(0.3)
|
||||
.partials(randL(200))
|
||||
.phases(randL(200))`}
|
||||
/>
|
||||
|
||||
## Vibrato
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ There are 3 quick ways to embed strudel in your website:
|
|||
|
||||
### Inside an iframe
|
||||
|
||||
Using an iframe is the most easy way to embed a studel tune.
|
||||
Using an iframe is the most easy way to embed a strudel tune.
|
||||
You can embed any pattern of your choice via an iframe and the URL of the pattern of your choice:
|
||||
|
||||
```html
|
||||
|
|
@ -133,7 +133,7 @@ If you'd rather use your own UI, you can use the `@strudel/web` package:
|
|||
</script>
|
||||
```
|
||||
|
||||
For more info on this package, see the [@strudel/web README]https://codeberg.org/uzu/strudel/src/branch/main/packages/web#strudel-web).
|
||||
For more info on this package, see the [@strudel/web README](https://codeberg.org/uzu/strudel/src/branch/main/packages/web#strudel-web).
|
||||
|
||||
## Via npm
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ note("c3 [e3 g3]*2")
|
|||
is transpiled to:
|
||||
|
||||
```strudel
|
||||
note(m('c3 [e3 g3]', 5))
|
||||
note(m('c3 [e3 g3]*2', 5))
|
||||
```
|
||||
|
||||
Here, the string is wrapped in `m`, which will create a pattern from a mini-notation string. As the second parameter, it gets passed source code location of the string, which enables highlighting active events later.
|
||||
|
|
@ -117,6 +117,10 @@ Here's an example AST for `c3 [e3 g3]`
|
|||
|
||||
which translates to `seq(c3, seq(e3, g3))`
|
||||
|
||||
## Vim Keybindings
|
||||
|
||||
See the separate page on Vim shortcuts for a quick reference: [/technical-manual/vim](/technical-manual/vim)
|
||||
|
||||
## Scheduling Events
|
||||
|
||||
After an instance of `Pattern` is obtained from the user code,
|
||||
|
|
|
|||
58
website/src/pages/technical-manual/vim.mdx
Normal file
58
website/src/pages/technical-manual/vim.mdx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
title: Vim Shortcuts
|
||||
layout: ../../layouts/MainLayout.astro
|
||||
---
|
||||
|
||||
# Vim Shortcuts in the REPL
|
||||
|
||||
When the REPL editor (CodeMirror) is configured to use Vim keybindings, the following commands are available:
|
||||
|
||||
- :w — Evaluate the current code
|
||||
|
||||
- Triggers the same evaluation as Ctrl+Enter / Alt+Enter
|
||||
- You'll see messages in the Console panel such as:
|
||||
- [vim] :w — evaluating code
|
||||
- [repl] evaluate via event
|
||||
- [eval] code updated
|
||||
|
||||
- :q — Stop/pause playback
|
||||
|
||||
- Triggers the same stop action as Alt+.
|
||||
- Useful to quickly stop scheduling without leaving Vim mode
|
||||
|
||||
- gc — Toggle line comments for the current selection(s)
|
||||
|
||||
- Works in normal and visual mode
|
||||
- If there's a selection, all selected lines are toggled
|
||||
|
||||
Notes
|
||||
|
||||
- Behavior respects the current language mode in the editor for comment syntax.
|
||||
- If multiple REPL editors are open, commands target the active editor. The implementation dispatches custom events handled by the editor.
|
||||
- If you don't see the Console panel, open the right panel in the REPL UI.
|
||||
|
||||
Troubleshooting
|
||||
|
||||
- If :w logs but evaluation doesn't apply, ensure Vim keybindings are active and try again. You can also use Ctrl+Enter as a fallback.
|
||||
- For :q / gc, ensure focus is inside the editor. If an error occurs, reload the page to reset editor state and try again.
|
||||
|
||||
## Adding custom keybindings
|
||||
|
||||
To add custom keybindings the `Vim` object can be used (either from within a pattern or in a prebake script)
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
// Map 'jk' to Escape in normal mode
|
||||
Vim.map('jk', '<Esc>', 'insert');
|
||||
// Map 'U' to :w (Evaluate the current code)
|
||||
Vim.map('U', ':w<CR>', 'normal');
|
||||
// Map 'Q' to :q — Stop/pause playback
|
||||
Vim.map('Q', ':q<CR>', 'normal');
|
||||
// Map 'J' to find next '$' (jump to next label)
|
||||
Vim.map('J', '/\\$<CR>', 'normal');
|
||||
// Map 'K' to find previous '$' (jump to previous label)
|
||||
Vim.map('K', '?\\$<CR>', 'normal');
|
||||
```
|
||||
|
||||
For more information on how to use the `Vim` object see [CodeMirror Vim](https://github.com/replit/codemirror-vim)
|
||||
|
|
@ -377,4 +377,4 @@ insect [crow metal] - -,
|
|||
punchcard
|
||||
/>
|
||||
|
||||
Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes)
|
||||
Now that we know the basics of how to make beats, let's look at how we can play [notes](/workshop/first-notes).
|
||||
|
|
|
|||
651
website/src/repl/audiograph.mjs
Normal file
651
website/src/repl/audiograph.mjs
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
/*
|
||||
audiograph.mjs - show a svg view of the web audio API graph built during a playback
|
||||
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/website/src/repl/audiograph.mjs>
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// main entry point is `debugAudiograph`
|
||||
import { logger } from '@strudel/core';
|
||||
import { getAudioContext, getSuperdoughAudioController, webaudioOutput } from '@strudel/webaudio';
|
||||
|
||||
let mermaid = null;
|
||||
let svgPanZoom = null;
|
||||
|
||||
let running = false;
|
||||
let hap_count = 0;
|
||||
|
||||
let cache = new Map();
|
||||
const initCache = JSON.stringify({
|
||||
connect: [],
|
||||
where: [],
|
||||
disconnectAll: 0,
|
||||
disconnectOne: 0,
|
||||
hasStop: false,
|
||||
stopCount: 0,
|
||||
ac: null,
|
||||
creation: null,
|
||||
});
|
||||
|
||||
let toggleOrig;
|
||||
|
||||
function stackTrace() {
|
||||
var err = new Error();
|
||||
const stacktrace = err.stack;
|
||||
const lines = stacktrace.split('\n');
|
||||
let lineIndex = lines.findIndex((line) => line !== 'Error' && !line.includes('audiograph.mjs'));
|
||||
if (lines[lineIndex].includes('gainNode')) lineIndex++;
|
||||
if (lines[lineIndex].includes('getWorklet')) lineIndex++;
|
||||
const line = lines[lineIndex].replace(/\s*at\s/, '').replace('http', '@');
|
||||
let match;
|
||||
match = line.match(/([^@]*)@.*packages(\/[^:]+:\d+:\d+)/);
|
||||
if (match) {
|
||||
return match[1].replace(/[^.:/a-zA-Z0-9]/g, '') + '@' + match[2].replace(/[^.:/a-zA-Z0-9]/g, '');
|
||||
}
|
||||
return '@';
|
||||
}
|
||||
|
||||
// This captures all AudioNodes lazily
|
||||
// when an `.audioid` property is called
|
||||
// no solution was found to hook
|
||||
// AudioNode's constructor directly
|
||||
let audioid = 0;
|
||||
const lazyRegister = (o) => {
|
||||
Object.defineProperty(o.prototype, 'audioid', {
|
||||
get: function () {
|
||||
if (!this._audioid) {
|
||||
this._audioid = ++audioid;
|
||||
const s = JSON.parse(initCache);
|
||||
s.type = this.constructor.name === 'AudiographNode' ? this.constructor._parentClassName : this.constructor.name;
|
||||
// special case for subclassed AudioNodes
|
||||
// they are implemented in superdough but hard to get a reference on here
|
||||
// they are not AudioScheduledSourceNodes anyway
|
||||
if (['FeedbackDelayNode', 'VowelNode'].indexOf(s.type) === -1) {
|
||||
s.hasStop = window[s.type].prototype instanceof AudioScheduledSourceNode;
|
||||
}
|
||||
s.ac = this.context?.constructor.name || 'AudioParam';
|
||||
s.creation = s.creation || stackTrace();
|
||||
cache.set(this._audioid, s);
|
||||
}
|
||||
return this._audioid;
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// extend a specific AudioNode's constructor
|
||||
// necessary when creation is done direclty by
|
||||
// calling the constructor
|
||||
// eg: new GainNode(...)
|
||||
const audioNodeHook = (node) => {
|
||||
const name = node.prototype.constructor.name;
|
||||
const PatchedNode = class AudiographNode extends node {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
// trigger the lazy register
|
||||
this._audioid = this.audioid;
|
||||
}
|
||||
};
|
||||
PatchedNode._parentClassName = name;
|
||||
window[name] = PatchedNode;
|
||||
};
|
||||
|
||||
const drawMessage = async function (message) {
|
||||
const element = document.querySelector('.strudel-mermaid');
|
||||
let gd = '';
|
||||
gd += '---\n';
|
||||
gd += 'config:\n';
|
||||
gd += ' flowchart:\n';
|
||||
gd += ' wrappingWidth: 600\n';
|
||||
gd += '---\n';
|
||||
gd += 'flowchart LR\n';
|
||||
gd += 'id[' + message.replaceAll(' ', ' ') + ']\n';
|
||||
|
||||
let { svg } = await mermaid.render('strudelSvgId', gd);
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
svg = svg.replaceAll('&nbsp;', ' ');
|
||||
element.innerHTML = svg;
|
||||
};
|
||||
|
||||
const drawDiagram = async function () {
|
||||
const element = document.querySelector('.strudel-mermaid');
|
||||
let code = window.strudelMirror.code;
|
||||
code = code.replace(/^await debugAudiograph.*\n?/gm, '');
|
||||
code = '// date: ' + new Date().toISOString() + '\n\n' + code;
|
||||
code = '// host: ' + document.location.hostname + '\n' + code;
|
||||
const codeLines = code.split(/(?:\n|\r\n?)/);
|
||||
const maxLineLength = codeLines.reduce((memo, line) => Math.max(memo, line.length), 0);
|
||||
|
||||
// https://mermaid.js.org/syntax/flowchart.html
|
||||
let gd = '';
|
||||
gd += '---\n';
|
||||
gd += 'config:\n';
|
||||
gd += ' flowchart:\n';
|
||||
gd += ' wrappingWidth: ' + 14 * maxLineLength + '\n';
|
||||
gd += '---\n';
|
||||
gd += 'flowchart TB\n';
|
||||
gd += '\tsubgraph AG[STRUDEL AUDIOGRAPH]\n';
|
||||
|
||||
// seed graph builder with all
|
||||
// unconnected nodes
|
||||
let lookup = [];
|
||||
cache.forEach((v, k) => {
|
||||
if (v.connect.length === 0) lookup.push(k);
|
||||
});
|
||||
const relations = [];
|
||||
let curRelations;
|
||||
const zombieCount = 0;
|
||||
const sourceLoc = (stack) => {
|
||||
if (stack === '@') return stack;
|
||||
return stack.replace('@', '\n').replace('/superdough/', '/');
|
||||
};
|
||||
const label = (s) => {
|
||||
const source = s.creation ? '\n' + sourceLoc(s.creation) : '';
|
||||
let lb = '[' + '**' + s.type + '**' + source + ']';
|
||||
if (s.ac === 'OfflineAudioContext') lb = '[' + lb + ']';
|
||||
return lb;
|
||||
};
|
||||
const isConnectLeak = (s) => {
|
||||
return (
|
||||
['AudioDestinationNode', 'AudioParam'].indexOf(s.type) === -1 &&
|
||||
s.disconnectAll === 0 &&
|
||||
s.connect.length > s.disconnectOne
|
||||
);
|
||||
};
|
||||
const isStopLeak = (s) => {
|
||||
return s.hasStop && s.stopCount === 0;
|
||||
};
|
||||
do {
|
||||
curRelations = relations.length;
|
||||
lookup.slice().forEach((n) => {
|
||||
cache.forEach((v, k) => {
|
||||
if (v.connect.indexOf(n) !== -1) {
|
||||
if (lookup.indexOf(k) === -1) lookup.push(k);
|
||||
gd += v.connect
|
||||
.map((i) => {
|
||||
if (lookup.indexOf(i) === -1) lookup.push(i);
|
||||
if (relations.indexOf(k + '-' + i) === -1) {
|
||||
relations.push(k + '-' + i);
|
||||
return (
|
||||
'\t\tnode' +
|
||||
k +
|
||||
label(v) +
|
||||
' -- ' +
|
||||
sourceLoc(v.where[0]) +
|
||||
' --> node' +
|
||||
i +
|
||||
label(cache.get(i)) +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
if (k === n) {
|
||||
gd += v.connect
|
||||
.map((i) => {
|
||||
if (lookup.indexOf(i) === -1) lookup.push(i);
|
||||
if (relations.indexOf(k + '-' + i) === -1) {
|
||||
relations.push(k + '-' + i);
|
||||
return (
|
||||
'\t\tnode' +
|
||||
k +
|
||||
label(v) +
|
||||
' -- ' +
|
||||
sourceLoc(v.where[0]) +
|
||||
' --> node' +
|
||||
i +
|
||||
label(cache.get(i)) +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
});
|
||||
});
|
||||
} while (relations.length > curRelations /*&& lookup.length < 100*/);
|
||||
// add orphan nodes
|
||||
const inRelation = '-' + relations.join('-') + '-';
|
||||
cache.forEach((v, k) => {
|
||||
if (!inRelation.includes('-' + k + '-')) {
|
||||
gd += '\t\tnode' + k + label(v) + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
const codePlaceholder = 'm'.repeat(maxLineLength);
|
||||
gd += '\tsubgraph LEGEND\n';
|
||||
gd += '\t\tlegend1[in AudioContext]\n';
|
||||
gd += '\t\tlegend2[[in OfflineAudioContext]]\n';
|
||||
gd += '\t\tlegend3[not disconnected]\n';
|
||||
gd += '\t\tlegend4[AudioParam]\n';
|
||||
gd += '\t\tlegend5[AudioDestinationNode]\n';
|
||||
gd += '\t\tlegend6[not stopped]\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tsubgraph CODE[Strudel Code]\n';
|
||||
// we use a codePlaceholder to
|
||||
// - avoid problems with special chars
|
||||
// - stop mermaid to split lines on space with multiple tspans
|
||||
// - force mermaid to prepare a sufficiently sized zone
|
||||
gd += '\ncode[' + (codePlaceholder + '<br>').repeat(codeLines.length) + ']\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tend\n';
|
||||
gd += '\tclassDef audioparam fill:#6f6;\n';
|
||||
gd += '\tclassDef destination fill:#99f;\n';
|
||||
gd += '\tclassDef connectleak fill:#f96,stroke:#f00,stroke-width:2px;\n';
|
||||
gd += '\tclassDef stopleak fill:#f55,stroke:#f00,stroke-width:2px;\n';
|
||||
gd += '\tclass legend3 connectleak;\n';
|
||||
gd += '\tclass legend4 audioparam;\n';
|
||||
gd += '\tclass legend5 destination;\n';
|
||||
gd += '\tclass legend6 stopleak;\n';
|
||||
cache.forEach((v, k) => {
|
||||
if (isConnectLeak(v)) {
|
||||
gd += '\tclass node' + k + ' connectleak;\n';
|
||||
} else if (isStopLeak(v)) {
|
||||
gd += '\tclass node' + k + ' stopleak;\n';
|
||||
}
|
||||
if (v.type === 'AudioParam') {
|
||||
gd += '\tclass node' + k + ' audioparam;\n';
|
||||
}
|
||||
if (v.type === 'AudioDestinationNode') {
|
||||
gd += '\tclass node' + k + ' destination;\n';
|
||||
}
|
||||
});
|
||||
let { svg } = await mermaid.render('strudelSvgId', gd);
|
||||
|
||||
// put real code in code zone
|
||||
let idx = 0;
|
||||
const escapeHtml = (unsafe) => {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
svg = svg.replaceAll(codePlaceholder, () => escapeHtml(codeLines[idx++]));
|
||||
|
||||
// improve sizing on web page
|
||||
svg = svg.replace(/max-width:\s[0-9.]*px;/i, 'height: 100%');
|
||||
element.innerHTML = svg;
|
||||
|
||||
// align the code lines
|
||||
let svgText = document.querySelector('[id^=flowchart-code] text');
|
||||
svgText.setAttributeNS(null, 'style', 'text-anchor: start;');
|
||||
const svgElement = document.querySelector('svg');
|
||||
const svgLabel = document.querySelector('svg [id^=flowchart-code] .label');
|
||||
const transformList = svgLabel.transform.baseVal;
|
||||
const svgTransform = svgElement.createSVGTransform();
|
||||
const tspans = Array.from(document.querySelectorAll('[id^=flowchart-code] tspan.text-inner-tspan'));
|
||||
let tspansMaxLength = tspans.reduce((memo, tspan) => Math.max(memo, tspan.getComputedTextLength()), 0);
|
||||
svgTransform.setTranslate(-tspansMaxLength / 2, 0);
|
||||
transformList.appendItem(svgTransform);
|
||||
|
||||
let doPan = false;
|
||||
let eventsHandler;
|
||||
let panZoom;
|
||||
let mousepos;
|
||||
|
||||
eventsHandler = {
|
||||
haltEventListeners: ['mousedown', 'mousemove', 'mouseup'],
|
||||
mouseDownHandler: function (ev) {
|
||||
if (event.target.className == '[object SVGAnimatedString]') {
|
||||
doPan = true;
|
||||
mousepos = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
}
|
||||
},
|
||||
mouseMoveHandler: function (ev) {
|
||||
if (doPan) {
|
||||
panZoom.panBy({
|
||||
x: ev.clientX - mousepos.x,
|
||||
y: ev.clientY - mousepos.y,
|
||||
});
|
||||
mousepos = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
},
|
||||
mouseUpHandler: function (ev) {
|
||||
doPan = false;
|
||||
},
|
||||
init: function (options) {
|
||||
options.svgElement.addEventListener('mousedown', this.mouseDownHandler, false);
|
||||
options.svgElement.addEventListener('mousemove', this.mouseMoveHandler, false);
|
||||
options.svgElement.addEventListener('mouseup', this.mouseUpHandler, false);
|
||||
},
|
||||
destroy: function (options) {
|
||||
options.svgElement.removeEventListener('mousedown', this.mouseDownHandler, false);
|
||||
options.svgElement.removeEventListener('mousemove', this.mouseMoveHandler, false);
|
||||
options.svgElement.removeEventListener('mouseup', this.mouseUpHandler, false);
|
||||
},
|
||||
};
|
||||
panZoom = svgPanZoom('#strudelSvgId', {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: true,
|
||||
fit: 1,
|
||||
center: 1,
|
||||
zoomScaleSensitivity: 0.4,
|
||||
customEventsHandler: eventsHandler,
|
||||
});
|
||||
};
|
||||
|
||||
const svgExport = async () => {
|
||||
const a = document.createElement('a');
|
||||
document.body.appendChild(a);
|
||||
a.style = 'display: none';
|
||||
|
||||
const selector = '.strudel-mermaid';
|
||||
const bbox = document.querySelector('svg g').getBBox();
|
||||
let transform, style;
|
||||
// clean pan-zoom viewport
|
||||
const pzViewport = document.querySelector('.svg-pan-zoom_viewport');
|
||||
if (pzViewport) {
|
||||
transform = pzViewport.transform;
|
||||
style = pzViewport.style;
|
||||
pzViewport.setAttribute('transform', '');
|
||||
pzViewport.style = '';
|
||||
}
|
||||
|
||||
const spzMin = await fetch('https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.2/dist/svg-pan-zoom.min.js').then((res) =>
|
||||
res.text(),
|
||||
);
|
||||
|
||||
const scriptContent = '<![CDATA[' + spzMin + ';svgPanZoom("svg");]]>';
|
||||
// prepare svg
|
||||
const content = document
|
||||
.querySelector(selector)
|
||||
.innerHTML.replaceAll('<br>', '<br/>')
|
||||
// remove useless tags
|
||||
.replace(/<g id="svg-pan-zoom.*<\/g>/, '<script>' + scriptContent + '</script>')
|
||||
.replace(/<defs>.*<\/defs>/, '')
|
||||
// give inkscape true sizes
|
||||
.replace('width="100%"', 'width="' + bbox.width + '" height="' + bbox.height + '"');
|
||||
|
||||
// restore pan-zoom viewport
|
||||
if (pzViewport) {
|
||||
pzViewport.setAttribute('transform', transform);
|
||||
pzViewport.style = style;
|
||||
}
|
||||
|
||||
// trigger download
|
||||
var blob = new Blob([content], { type: 'image/svg+xml' }),
|
||||
url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = 'audiograph.svg';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const resetAudioOutput = function (audioid) {
|
||||
// calling reset on SuperdoughAudioController
|
||||
// will discard output nodes AND recreate them
|
||||
// so we keep the same `cache` to handle the
|
||||
// `disconnects` knowing that new nodes will be
|
||||
// stricly after the current audioid.
|
||||
// then we purge the old nodes from the `cache`
|
||||
// to have a clean state
|
||||
|
||||
// make sure destination will be recreated in the
|
||||
// cache
|
||||
const destination = getAudioContext().destination;
|
||||
if (destination._audioid) delete destination._audioid;
|
||||
|
||||
const sac = getSuperdoughAudioController();
|
||||
sac.reset();
|
||||
Array.from(cache.keys()).map((k) => {
|
||||
if (k <= audioid) cache.delete(k);
|
||||
});
|
||||
};
|
||||
|
||||
const postProcessing = async function () {
|
||||
hap_count = 0;
|
||||
await drawDiagram();
|
||||
|
||||
resetAudioOutput(audioid);
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
StopAfterHapCount: 10,
|
||||
hapsBatch: 0,
|
||||
maxEdges: 10000,
|
||||
maxTextSize: 200000,
|
||||
audioAPIBreathingRoomSec: 5,
|
||||
};
|
||||
|
||||
// `StopAfterHapCount` :
|
||||
// The player will auto-stop after hap count have
|
||||
// been played. when StopAfterHapCount = 0, it will
|
||||
// continue playing until 'stop' is clicked
|
||||
// `audioAPIBreathingRoomSec` :
|
||||
// how much time should we wait after 'stop' to let
|
||||
// the audioAPI finish its tail of ondended calls
|
||||
// `hapsBatch` :
|
||||
// the AudioGraph will be displayed every hapsBatch haps
|
||||
// when hapsBatch = 0, AudioGraph will only be displayed
|
||||
// after and auto-stop or after 'stop' is clicked
|
||||
// In hapsBatch mode you will probably see a trailing of
|
||||
// non disconnected notes on the graph because the audio
|
||||
// API may have some lag disconnecting them
|
||||
// cf also audioAPIBreathingRoomSec
|
||||
// `maxEdges`
|
||||
// This is a mermaid.js config that forces a hard limit
|
||||
// on the maximum number of Edges of a graph
|
||||
// needs a reload to be taken into account
|
||||
// `maxTextSize`
|
||||
// This is a mermaid.js config that forces a hard limit
|
||||
// on the maximum text size of a graph definition
|
||||
// needs a reload to be taken into account
|
||||
|
||||
export const debugAudiograph = async (argOptions = {}) => {
|
||||
const options = Object.assign({}, defaultOptions, argOptions);
|
||||
const { StopAfterHapCount, hapsBatch, maxEdges, maxTextSize, audioAPIBreathingRoomSec } = options;
|
||||
const sm = window.strudelMirror;
|
||||
const code = sm.code;
|
||||
if (!code.match(/await\s+debugAudiograph/)) {
|
||||
throw new Error('you need to call `await debugAudiograph()` for audiograph to work');
|
||||
}
|
||||
const emptyOptions = /await\s+debugAudiograph\(\)/.exec(code);
|
||||
if (emptyOptions) {
|
||||
const cutCode = emptyOptions.index + emptyOptions[0].length - 1;
|
||||
const codeOptions = JSON.stringify({ StopAfterHapCount: StopAfterHapCount }).replaceAll('"', '');
|
||||
sm.setCode(code.slice(0, cutCode) + codeOptions + code.slice(cutCode));
|
||||
}
|
||||
|
||||
if (window.audiograph === undefined) {
|
||||
const ag = (window.audiograph = {});
|
||||
|
||||
toggleOrig = sm.toggle;
|
||||
|
||||
////////////////////////////////////////
|
||||
// step 1: web audio api instrumentation
|
||||
////////////////////////////////////////
|
||||
|
||||
// path AudioNode & AudioParam
|
||||
// to give them lazy ids
|
||||
// this captures both `ac.createGain`
|
||||
// and `new GainNode(..)` patterns
|
||||
lazyRegister(AudioNode);
|
||||
lazyRegister(AudioParam);
|
||||
lazyRegister(PeriodicWave);
|
||||
|
||||
const audioNodes = [
|
||||
AudioBufferSourceNode,
|
||||
AudioWorkletNode,
|
||||
AnalyserNode,
|
||||
BiquadFilterNode,
|
||||
ChannelMergerNode,
|
||||
ChannelSplitterNode,
|
||||
ConstantSourceNode,
|
||||
ConvolverNode,
|
||||
DelayNode,
|
||||
DynamicsCompressorNode,
|
||||
GainNode,
|
||||
IIRFilterNode,
|
||||
OscillatorNode,
|
||||
PannerNode,
|
||||
StereoPannerNode,
|
||||
WaveShaperNode,
|
||||
];
|
||||
audioNodes.map((n) => {
|
||||
if (n.prototype instanceof AudioScheduledSourceNode) {
|
||||
const stopOrig = n.prototype.stop;
|
||||
n.prototype.stop = function (...args) {
|
||||
// stop called
|
||||
const result = stopOrig.call(this, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
s.stopCount++;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
audioNodeHook(n);
|
||||
});
|
||||
|
||||
// patch BaseAudioContext factory methods
|
||||
// to capture the source reference
|
||||
Object.getOwnPropertyNames(BaseAudioContext.prototype)
|
||||
.filter((n) => n.startsWith('create') && ['createBuffer'].indexOf(n) === -1)
|
||||
.map((name) => {
|
||||
const orig = BaseAudioContext.prototype[name];
|
||||
BaseAudioContext.prototype[name] = function (...args) {
|
||||
const result = orig.call(this, ...args);
|
||||
const s = cache.get(result.audioid);
|
||||
s.creation = stackTrace();
|
||||
return result;
|
||||
};
|
||||
});
|
||||
|
||||
const connectOrig = AudioNode.prototype.connect;
|
||||
AudioNode.prototype.connect = function (destination, ...args) {
|
||||
const result = connectOrig.call(this, destination, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
s.connect.push(destination.audioid);
|
||||
s.where.push(stackTrace());
|
||||
return result;
|
||||
};
|
||||
|
||||
const disconnectOrig = AudioNode.prototype.disconnect;
|
||||
AudioNode.prototype.disconnect = function (destination, ...args) {
|
||||
const result = disconnectOrig.call(this, destination, ...args);
|
||||
const s = cache.get(this.audioid);
|
||||
if (s.connect.length) {
|
||||
if (destination) {
|
||||
s.disconnectOne++;
|
||||
} else {
|
||||
s.disconnectAll++;
|
||||
}
|
||||
} else {
|
||||
logger('WEIRD: node ' + this.audioid + 'called disconnect before any call to connect !');
|
||||
//logger(new Error().stack);
|
||||
console.log(cache);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// call reset 2 times to handle reload + 'play'
|
||||
// the first reset's disconnect adds audioid tags on previous outputs
|
||||
// that were not tagged (wrong cutoff)
|
||||
resetAudioOutput(audioid);
|
||||
// the second reset has the correct audioid cutoff
|
||||
resetAudioOutput(audioid);
|
||||
|
||||
////////////////////////////////////////
|
||||
// step 2: Load external modules
|
||||
////////////////////////////////////////
|
||||
|
||||
const { default: mermaidModule } = await import(
|
||||
'https://cdn.jsdelivr.net/npm/mermaid@11.12.1/dist/mermaid.esm.mjs'
|
||||
);
|
||||
mermaid = mermaidModule;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
themeCSS: '.flowchart { height: 100%; }',
|
||||
maxEdges: maxEdges,
|
||||
maxTextSize: maxTextSize,
|
||||
htmlLabels: false,
|
||||
flowchart: {
|
||||
htmlLabels: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { default: svgPanZoomModule } = await import('https://esm.sh/svg-pan-zoom');
|
||||
svgPanZoom = svgPanZoomModule;
|
||||
|
||||
//////////////////////////////////////////
|
||||
// step 3: UI modifications
|
||||
//////////////////////////////////////////
|
||||
|
||||
// add audiograph panel
|
||||
if (!document.querySelector('.strudel-mermaid')) {
|
||||
const mermaidDiv = document.createElement('div');
|
||||
mermaidDiv.className = 'strudel-mermaid';
|
||||
mermaidDiv.style = 'min-height: 600px; width: 60%';
|
||||
const referenceNode = document.querySelector('#code');
|
||||
referenceNode.parentNode.insertBefore(mermaidDiv, referenceNode.nextSibling);
|
||||
}
|
||||
|
||||
// add svg export button
|
||||
if (!document.querySelector('button[title=svg]')) {
|
||||
const exportButton = document.createElement('button');
|
||||
exportButton.innerHTML = '<span>ExportDiagram</span>';
|
||||
exportButton.title = 'svg';
|
||||
exportButton.onclick = svgExport;
|
||||
const updateButton = document.querySelector('button[title=update]');
|
||||
updateButton.parentNode.insertBefore(exportButton, updateButton);
|
||||
}
|
||||
}
|
||||
|
||||
if (!running) {
|
||||
running = true;
|
||||
}
|
||||
|
||||
if (hapsBatch === 0 || hap_count < hapsBatch) {
|
||||
let msg = '';
|
||||
msg += 'Recording activity...';
|
||||
msg += '\npress stop to build diagram';
|
||||
if (StopAfterHapCount) {
|
||||
msg += '\nwill stop automatically in ' + Math.max(StopAfterHapCount - hap_count, 0) + ' haps';
|
||||
}
|
||||
await drawMessage(msg);
|
||||
}
|
||||
|
||||
sm.toggle = async () => {
|
||||
running = false;
|
||||
sm.toggle = toggleOrig;
|
||||
// schedule `toggle` on the js main loop
|
||||
// to avoid interfering with any on-flight onTick
|
||||
// not doing this can lead to a phase > 0 which will
|
||||
// break the next start
|
||||
setTimeout(sm.toggle.bind(sm), 0);
|
||||
await drawMessage('please wait ' + audioAPIBreathingRoomSec + ' seconds\n' + 'the audio API is finishing its work');
|
||||
setTimeout(postProcessing, audioAPIBreathingRoomSec * 1000);
|
||||
};
|
||||
|
||||
/*global all*/
|
||||
all((pat) =>
|
||||
pat.onTrigger(async (hap, duration, cps, t) => {
|
||||
hap_count++;
|
||||
const key = Object.entries(hap.value)
|
||||
.map((param) => param.join('/'))
|
||||
.join('/');
|
||||
|
||||
// if we reached StopAfterHapCount, click 'stop'
|
||||
if (StopAfterHapCount && hap_count > StopAfterHapCount) {
|
||||
if (running) {
|
||||
await sm.toggle();
|
||||
}
|
||||
// stop sending haps to superdough(...)
|
||||
return;
|
||||
}
|
||||
|
||||
await webaudioOutput(hap, t, hap.duration / cps, cps, t);
|
||||
|
||||
if (hapsBatch && hap_count % hapsBatch === 0) drawDiagram();
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -8,3 +8,34 @@ export function ActionButton({ children, label, labelIsHidden, className, ...but
|
|||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialActionButton(props) {
|
||||
const { className, ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
{...buttonProps}
|
||||
className={cx('bg-background p-2 max-w-[300px] rounded-md hover:opacity-50', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionInput({ label, className, ...props }) {
|
||||
return (
|
||||
<label className={cx('inline-flex items-center cursor-pointer', className)}>
|
||||
<input {...props} className="sr-only peer" />
|
||||
|
||||
<span className="inline-flex items-center peer-hover:opacity-50">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecialActionInput({ className, ...props }) {
|
||||
return (
|
||||
<ActionInput
|
||||
{...props}
|
||||
className={className}
|
||||
label={<span className="bg-background p-2 max-w-[300px] rounded-md">{props.label}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
197
website/src/repl/components/panel/ExportTab.jsx
Normal file
197
website/src/repl/components/panel/ExportTab.jsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
|
||||
import cx from '@src/cx.mjs';
|
||||
import NumberInput from '@src/repl/components/NumberInput';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Textbox } from '../textbox/Textbox';
|
||||
import { getAudioContext } from '@strudel/webaudio';
|
||||
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
||||
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
<label className={cx(disabled && 'opacity-50')}>
|
||||
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
|
||||
{' ' + label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormItem({ label, children, disabled }) {
|
||||
return (
|
||||
<div className="grid gap-2 w-full">
|
||||
<label className={cx(disabled && 'opacity-50')}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportTab(Props) {
|
||||
const { handleExport } = Props;
|
||||
|
||||
const [downloadName, setDownloadName] = useState('');
|
||||
const [startCycle, setStartCycle] = useState(0);
|
||||
const [endCycle, setEndCycle] = useState(1);
|
||||
const [sampleRate, setSampleRate] = useState(48000);
|
||||
const [multiChannelOrbits, setMultiChannelOrbits] = useState(true);
|
||||
const [maxPolyphony, setMaxPolyphony] = useState(1024);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [length, setLength] = useState(1);
|
||||
|
||||
const refreshProgress = () => {
|
||||
const audioContext = getAudioContext();
|
||||
if (audioContext instanceof OfflineAudioContext) {
|
||||
setProgress(audioContext.currentTime);
|
||||
setLength(audioContext.length / sampleRate);
|
||||
setTimeout(refreshProgress, 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-foreground w-full p-4 space-y-4">
|
||||
<FormItem label="File name" disabled={exporting}>
|
||||
<Textbox
|
||||
onBlur={(e) => {
|
||||
setDownloadName(e.target.value);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setDownloadName(v);
|
||||
}}
|
||||
disabled={exporting}
|
||||
placeholder="Leave empty to use current date"
|
||||
className={cx('placeholder:opacity-50', exporting && 'opacity-50 border-opacity-50')}
|
||||
value={downloadName ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<div className="flex flex-row gap-4 w-full">
|
||||
<FormItem label="Start cycle" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? 0 : Math.max(0, v);
|
||||
setStartCycle(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setStartCycle(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
|
||||
value={startCycle ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="End cycle" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? Math.max(startCycle + 1, parseInt(v)) : v;
|
||||
setEndCycle(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setEndCycle(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50', 'w-full')}
|
||||
value={endCycle ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormItem label="Sample rate" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? 1 : Math.max(1, v);
|
||||
setSampleRate(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = parseInt(v);
|
||||
setSampleRate(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50')}
|
||||
value={sampleRate ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Maximum polyphony" disabled={exporting}>
|
||||
<Textbox
|
||||
min={1}
|
||||
max={Infinity}
|
||||
onBlur={(e) => {
|
||||
let v = parseInt(e.target.value);
|
||||
v = isNaN(v) ? Math.max(1, parseInt(v)) : v;
|
||||
setMaxPolyphony(v);
|
||||
}}
|
||||
onChange={(v) => {
|
||||
v = Math.max(1, parseInt(v));
|
||||
setMaxPolyphony(v);
|
||||
}}
|
||||
type="number"
|
||||
placeholder=""
|
||||
disabled={exporting}
|
||||
className={cx(exporting && 'opacity-50 border-opacity-50')}
|
||||
value={maxPolyphony ?? ''}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Multi Channel Orbits"
|
||||
onChange={(cbEvent) => {
|
||||
const val = cbEvent.target.checked;
|
||||
setMultiChannelOrbits(val);
|
||||
}}
|
||||
disabled={exporting}
|
||||
value={multiChannelOrbits}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={cx('bg-background p-2 w-full rounded-md hover:opacity-75 relative', exporting && 'opacity-50')}
|
||||
disabled={exporting}
|
||||
onClick={async () => {
|
||||
setExporting(true);
|
||||
setTimeout(refreshProgress, 2000);
|
||||
const modal = document.getElementById('exportProgressModal');
|
||||
modal.showModal();
|
||||
await handleExport(startCycle, endCycle, sampleRate, maxPolyphony, multiChannelOrbits, downloadName)
|
||||
.then(() => {
|
||||
const modal = document.getElementById('exportProgressModal');
|
||||
modal.close();
|
||||
})
|
||||
.finally(() => {
|
||||
setExporting(false);
|
||||
setProgress(0);
|
||||
setLength(1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 backdrop-invert"
|
||||
style={{
|
||||
width: `${(exporting ? 1 : 0) + (progress / length) * 99}%`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-foreground">{exporting ? 'Exporting...' : 'Export to WAV'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<dialog
|
||||
closedby={exporting ? 'none' : 'closerequest'}
|
||||
id="exportProgressModal"
|
||||
className="text-md bg-background text-foreground rounded-lg backdrop:bg-background backdrop:opacity-25"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { errorLogger } from '@strudel/core';
|
||||
import { useSettings, storePrebakeScript } from '../../../settings.mjs';
|
||||
import { SpecialActionInput } from '../button/action-button';
|
||||
|
||||
async function importScript(script) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(script);
|
||||
|
||||
reader.onload = () => {
|
||||
const text = reader.result;
|
||||
storePrebakeScript(text);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
errorLogger(new Error('failed to import prebake script'), 'importScript');
|
||||
};
|
||||
}
|
||||
export function ImportPrebakeScriptButton() {
|
||||
const settings = useSettings();
|
||||
|
||||
return (
|
||||
<SpecialActionInput
|
||||
type="file"
|
||||
label="import prebake script"
|
||||
accept=".strudel"
|
||||
onChange={(e) => importScript(e.target.files[0])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { useLogger } from '../useLogger';
|
|||
import { WelcomeTab } from './WelcomeTab';
|
||||
import { PatternsTab } from './PatternsTab';
|
||||
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
|
||||
import ExportTab from './ExportTab';
|
||||
|
||||
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ const tabNames = {
|
|||
patterns: 'patterns',
|
||||
sounds: 'sounds',
|
||||
reference: 'reference',
|
||||
export: 'export',
|
||||
console: 'console',
|
||||
settings: 'settings',
|
||||
};
|
||||
|
|
@ -126,6 +128,8 @@ function PanelContent({ context, tab }) {
|
|||
return <SoundsTab />;
|
||||
case tabNames.reference:
|
||||
return <Reference />;
|
||||
case tabNames.export:
|
||||
return <ExportTab handleExport={context.handleExport} />;
|
||||
case tabNames.settings:
|
||||
return <SettingsTab started={context.started} />;
|
||||
case tabNames.files:
|
||||
|
|
|
|||
|
|
@ -79,13 +79,11 @@ const updateCodeWindow = (context, patternData, reset = false) => {
|
|||
context.handleUpdate(patternData, reset);
|
||||
};
|
||||
|
||||
const autoResetPatternOnChange = !isUdels();
|
||||
|
||||
function UserPatterns({ context }) {
|
||||
const activePattern = useActivePattern();
|
||||
const viewingPatternStore = useViewingPatternData();
|
||||
const viewingPatternData = parseJSON(viewingPatternStore);
|
||||
const { userPatterns, patternFilter } = useSettings();
|
||||
const { userPatterns, patternFilter, patternAutoStart } = useSettings();
|
||||
const viewingPatternID = viewingPatternData?.id;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 flex-grow overflow-hidden h-full pb-2 ">
|
||||
|
|
@ -135,13 +133,13 @@ function UserPatterns({ context }) {
|
|||
<div className="overflow-auto h-full bg-background p-2 rounded-md">
|
||||
{/* {patternFilter === patternFilterName.user && ( */}
|
||||
<PatternButtons
|
||||
onClick={(id) =>
|
||||
updateCodeWindow(
|
||||
context,
|
||||
{ ...userPatterns[id], collection: userPattern.collection },
|
||||
autoResetPatternOnChange,
|
||||
)
|
||||
}
|
||||
onClick={(id) => {
|
||||
updateCodeWindow(context, { ...userPatterns[id], collection: userPattern.collection }, patternAutoStart);
|
||||
|
||||
if (context.started && activePattern === id) {
|
||||
context.handleEvaluate();
|
||||
}
|
||||
}}
|
||||
patterns={userPatterns}
|
||||
started={context.started}
|
||||
activePattern={activePattern}
|
||||
|
|
@ -188,17 +186,14 @@ function FeaturedPatterns({ context }) {
|
|||
const examplePatterns = useExamplePatterns();
|
||||
const collections = examplePatterns.collections;
|
||||
const patterns = collections.get(patternFilterName.featured);
|
||||
const { patternAutoStart } = useSettings();
|
||||
return (
|
||||
<PatternPageWithPagination
|
||||
patterns={patterns}
|
||||
context={context}
|
||||
initialPage={featuredPageNum}
|
||||
patternOnClick={(id) => {
|
||||
updateCodeWindow(
|
||||
context,
|
||||
{ ...patterns[id], collection: patternFilterName.featured },
|
||||
autoResetPatternOnChange,
|
||||
);
|
||||
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.featured }, patternAutoStart);
|
||||
}}
|
||||
paginationOnChange={async (pageNum) => {
|
||||
await loadAndSetFeaturedPatterns(pageNum - 1);
|
||||
|
|
@ -213,13 +208,14 @@ function LatestPatterns({ context }) {
|
|||
const examplePatterns = useExamplePatterns();
|
||||
const collections = examplePatterns.collections;
|
||||
const patterns = collections.get(patternFilterName.public);
|
||||
const { patternAutoStart } = useSettings();
|
||||
return (
|
||||
<PatternPageWithPagination
|
||||
patterns={patterns}
|
||||
context={context}
|
||||
initialPage={latestPageNum}
|
||||
patternOnClick={(id) => {
|
||||
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, autoResetPatternOnChange);
|
||||
updateCodeWindow(context, { ...patterns[id], collection: patternFilterName.public }, patternAutoStart);
|
||||
}}
|
||||
paginationOnChange={async (pageNum) => {
|
||||
await loadAndSetPublicPatterns(pageNum - 1);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
|
|||
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
|
||||
import { confirmDialog } from '../../util.mjs';
|
||||
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony, setMultiChannelOrbits } from '@strudel/webaudio';
|
||||
import { ActionButton, SpecialActionButton } from '../button/action-button.jsx';
|
||||
import { ImportPrebakeScriptButton } from './ImportPrebakeScriptButton.jsx';
|
||||
|
||||
function Checkbox({ label, value, onChange, disabled = false }) {
|
||||
return (
|
||||
|
|
@ -112,6 +114,8 @@ export function SettingsTab({ started }) {
|
|||
multiChannelOrbits,
|
||||
isTabIndentationEnabled,
|
||||
isMultiCursorEnabled,
|
||||
patternAutoStart,
|
||||
includePrebakeScriptInShare,
|
||||
} = useSettings();
|
||||
const shouldAlwaysSync = isUdels();
|
||||
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
|
||||
|
|
@ -203,6 +207,15 @@ export function SettingsTab({ started }) {
|
|||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="Prebake">
|
||||
<ImportPrebakeScriptButton />
|
||||
<Checkbox
|
||||
label="Include prebake script in share"
|
||||
onChange={(cbEvent) => settingsMap.setKey('includePrebakeScriptInShare', cbEvent.target.checked)}
|
||||
value={includePrebakeScriptInShare}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="Keybindings">
|
||||
<ButtonGroup
|
||||
value={keybindings}
|
||||
|
|
@ -304,11 +317,15 @@ export function SettingsTab({ started }) {
|
|||
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
|
||||
value={isCSSAnimationDisabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Auto-start pattern on pattern change"
|
||||
onChange={(cbEvent) => settingsMap.setKey('patternAutoStart', cbEvent.target.checked)}
|
||||
value={patternAutoStart}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Zen Mode">Try clicking the logo in the top left!</FormItem>
|
||||
<FormItem label="Reset Settings">
|
||||
<button
|
||||
className="bg-background p-2 max-w-[300px] rounded-md hover:opacity-50"
|
||||
<SpecialActionButton
|
||||
onClick={() => {
|
||||
confirmDialog('Sure?').then((r) => {
|
||||
if (r) {
|
||||
|
|
@ -319,7 +336,7 @@ export function SettingsTab({ started }) {
|
|||
}}
|
||||
>
|
||||
restore default settings
|
||||
</button>
|
||||
</SpecialActionButton>
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { prebake } from '@src/repl/prebake.mjs';
|
|||
const getSamples = (samples) =>
|
||||
Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function SoundsTab() {
|
||||
const sounds = useStore(soundMap);
|
||||
|
||||
|
|
@ -56,17 +60,21 @@ export function SoundsTab() {
|
|||
|
||||
// holds mutable ref to current triggered sound
|
||||
const trigRef = useRef();
|
||||
|
||||
// Used to cycle through sound previews on banks with multiple sounds
|
||||
let soundPreviewIdx = 0;
|
||||
const numRef = useRef(0);
|
||||
|
||||
// stop current sound on mouseup
|
||||
useEvent('mouseup', () => {
|
||||
const t = trigRef.current;
|
||||
const ref = trigRef.current;
|
||||
trigRef.current = undefined;
|
||||
t?.then((ref) => {
|
||||
ref?.stop(getAudioContext().currentTime + 0.01);
|
||||
});
|
||||
ref?.stop?.(getAudioContext().currentTime + 0.01);
|
||||
});
|
||||
useEvent('keydown', (e) => {
|
||||
if (!isNaN(Number(e.key))) {
|
||||
numRef.current = Number(e.key);
|
||||
}
|
||||
});
|
||||
useEvent('keyup', (e) => {
|
||||
numRef.current = 0;
|
||||
});
|
||||
return (
|
||||
<div id="sounds-tab" className="px-4 flex gap-2 flex-col w-full h-full text-foreground">
|
||||
|
|
@ -117,19 +125,35 @@ export function SoundsTab() {
|
|||
const params = {
|
||||
note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
|
||||
s: name,
|
||||
n: soundPreviewIdx,
|
||||
n: numRef.current,
|
||||
clip: 1,
|
||||
release: 0.5,
|
||||
sustain: 1,
|
||||
duration: 0.5,
|
||||
};
|
||||
soundPreviewIdx++;
|
||||
const time = ctx.currentTime + 0.05;
|
||||
const onended = () => trigRef.current?.node?.disconnect();
|
||||
trigRef.current = Promise.resolve(onTrigger(time, params, onended));
|
||||
trigRef.current.then((ref) => {
|
||||
connectToDestination(ref?.node);
|
||||
});
|
||||
// Attempt to play the sample and retry every 200ms until 10 attempts have been reached
|
||||
let errMsg;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
// Pre-load the sample by calling onTrigger with a future time
|
||||
// This triggers the loading but schedules playback for later
|
||||
const time = ctx.currentTime + 0.05; // Give 50ms for loading
|
||||
const ref = await onTrigger(time, params, onended);
|
||||
trigRef.current = ref;
|
||||
if (ref?.node) {
|
||||
connectToDestination(ref.node);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
errMsg = err;
|
||||
}
|
||||
if (attempt == 9) {
|
||||
console.warn('Failed to trigger sound after 10 attempts' + (errMsg ? `: ${errMsg}` : ''));
|
||||
} else {
|
||||
await wait(200);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
|
|
|
|||
|
|
@ -92,11 +92,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
|
|||
|
||||
async function blobToDataUrl(blob) {
|
||||
return new Promise((resolve) => {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
resolve(URL.createObjectURL(blob));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { aliasBank, registerSynthSounds, registerZZFXSounds, samples } from '@st
|
|||
import { registerSamplesFromDB } from './idbutils.mjs';
|
||||
import './piano.mjs';
|
||||
import './files.mjs';
|
||||
import { settingsMap } from '@src/settings.mjs';
|
||||
import { evaluate } from '@strudel/transpiler';
|
||||
|
||||
const { BASE_URL } = import.meta.env;
|
||||
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
|
||||
|
|
|
|||
|
|
@ -393,6 +393,8 @@ samples({
|
|||
bass: { d2: 'https://cdn.freesound.org/previews/608/608286_13074022-lq.mp3' }
|
||||
})
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// bells
|
||||
n("0").euclidLegato(3,8)
|
||||
|
|
@ -430,6 +432,7 @@ export const festivalOfFingers3 = `// "Festival of fingers 3"
|
|||
// @by Felix Roos
|
||||
|
||||
setcps(1)
|
||||
useRNG('legacy')
|
||||
|
||||
n("[-7*3],0,2,6,[8 7]")
|
||||
.echoWith(
|
||||
|
|
@ -454,6 +457,8 @@ export const meltingsubmarine = `// "Melting submarine"
|
|||
// @by Felix Roos
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
s("bd:5,[~ <sd:1!3 sd:1(3,4,3)>],hh27(3,4,1)") // drums
|
||||
.speed(perlin.range(.7,.9)) // random sample speed variation
|
||||
|
|
@ -602,6 +607,9 @@ export const belldub = `// "Belldub"
|
|||
|
||||
samples({ bell: {b4:'https://cdn.freesound.org/previews/339/339809_5121236-lq.mp3'}})
|
||||
// "Hand Bells, B, Single.wav" by InspectorJ (www.jshaw.co.uk) of Freesound.org
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// bass
|
||||
note("[0 ~] [2 [0 2]] [4 4*2] [[4 ~] [2 ~] 0@2]".scale('g1 dorian').superimpose(x=>x.add(.02)))
|
||||
|
|
@ -638,6 +646,7 @@ export const dinofunk = `// "Dinofunk"
|
|||
// @by Felix Roos
|
||||
|
||||
setcps(1)
|
||||
useRNG('legacy')
|
||||
|
||||
samples({bass:'https://cdn.freesound.org/previews/614/614637_2434927-hq.mp3',
|
||||
dino:{b4:'https://cdn.freesound.org/previews/316/316403_5123851-hq.mp3'}})
|
||||
|
|
@ -666,6 +675,8 @@ export const sampleDemo = `// "Sample demo"
|
|||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// percussion
|
||||
s("[woodblock:1 woodblock:2*2] snare_rim:0,gong/8,brakedrum:1(3,8),~@3 cowbell:3")
|
||||
|
|
@ -684,6 +695,8 @@ export const holyflute = `// "Holy flute"
|
|||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
"c3 eb3(3,8) c4/2 g3*2"
|
||||
.superimpose(
|
||||
x=>x.slow(2).add(12),
|
||||
|
|
@ -699,6 +712,8 @@ export const flatrave = `// "Flatrave"
|
|||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
s("bd*2,~ [cp,sd]").bank('RolandTR909'),
|
||||
|
||||
|
|
@ -727,6 +742,8 @@ export const amensister = `// "Amensister"
|
|||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
stack(
|
||||
// amen
|
||||
n("0 1 2 3 4 5 6 7")
|
||||
|
|
@ -834,6 +851,8 @@ export const arpoon = `// "Arpoon"
|
|||
// @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||
// @by Felix Roos
|
||||
|
||||
useRNG('legacy')
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
|
||||
n("[0,3] 2 [1,3] 2".fast(3).lastOf(4, fast(2))).clip(2)
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ This program is free software: you can redistribute it and/or modify it under th
|
|||
|
||||
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
|
||||
import { getDrawContext } from '@strudel/draw';
|
||||
import { transpiler } from '@strudel/transpiler';
|
||||
import { evaluate, transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContextCurrentTime,
|
||||
renderPatternAudio,
|
||||
webaudioOutput,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
resetDefaults,
|
||||
initAudio,
|
||||
} from '@strudel/webaudio';
|
||||
import { setVersionDefaultsFrom } from './util.mjs';
|
||||
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
|
||||
|
|
@ -36,6 +38,7 @@ import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
|
|||
import './Repl.css';
|
||||
import { setInterval, clearInterval } from 'worker-timers';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { debugAudiograph } from './audiograph';
|
||||
|
||||
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
|
||||
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
|
||||
|
|
@ -63,11 +66,10 @@ async function getModule(name) {
|
|||
const initialCode = `// LOADING`;
|
||||
|
||||
export function useReplContext() {
|
||||
const { isSyncEnabled, audioEngineTarget } = useSettings();
|
||||
const { isSyncEnabled, audioEngineTarget, prebakeScript, includePrebakeScriptInShare } = useSettings();
|
||||
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
|
||||
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
|
||||
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
|
||||
|
||||
const init = useCallback(() => {
|
||||
const drawTime = [-2, 2];
|
||||
const drawContext = getDrawContext();
|
||||
|
|
@ -84,7 +86,12 @@ export function useReplContext() {
|
|||
pattern: silence,
|
||||
drawTime,
|
||||
drawContext,
|
||||
prebake: async () => Promise.all([modulesLoading, presets]),
|
||||
prebake: async () =>
|
||||
Promise.all([modulesLoading, presets]).then(() => {
|
||||
if (prebakeScript?.length) {
|
||||
return evaluate(prebakeScript ?? '');
|
||||
}
|
||||
}),
|
||||
onUpdateState: (state) => {
|
||||
setReplState({ ...state });
|
||||
},
|
||||
|
|
@ -125,6 +132,7 @@ export function useReplContext() {
|
|||
bgFill: false,
|
||||
});
|
||||
window.strudelMirror = editor;
|
||||
window.debugAudiograph = debugAudiograph;
|
||||
|
||||
// init settings
|
||||
initCode().then(async (decoded) => {
|
||||
|
|
@ -203,6 +211,30 @@ export function useReplContext() {
|
|||
const handleEvaluate = () => {
|
||||
editorRef.current.evaluate();
|
||||
};
|
||||
|
||||
const handleExport = async (begin, end, sampleRate, maxPolyphony, multiChannelOrbits, downloadName = undefined) => {
|
||||
await editorRef.current.evaluate(false);
|
||||
editorRef.current.repl.scheduler.stop();
|
||||
await renderPatternAudio(
|
||||
editorRef.current.repl.state.pattern,
|
||||
editorRef.current.repl.scheduler.cps,
|
||||
begin,
|
||||
end,
|
||||
sampleRate,
|
||||
maxPolyphony,
|
||||
multiChannelOrbits,
|
||||
downloadName,
|
||||
).finally(async () => {
|
||||
const { latestCode, maxPolyphony, audioDeviceName, multiChannelOrbits } = settingsMap.get();
|
||||
await initAudio({
|
||||
latestCode,
|
||||
maxPolyphony,
|
||||
audioDeviceName,
|
||||
multiChannelOrbits,
|
||||
});
|
||||
editorRef.current.repl.scheduler.stop();
|
||||
});
|
||||
};
|
||||
const handleShuffle = async () => {
|
||||
const patternData = await getRandomTune();
|
||||
const code = patternData.code;
|
||||
|
|
@ -214,7 +246,13 @@ export function useReplContext() {
|
|||
editorRef.current.repl.evaluate(code);
|
||||
};
|
||||
|
||||
const handleShare = async () => shareCode(replState.code);
|
||||
const handleShare = async () => {
|
||||
let code = replState.code;
|
||||
if (includePrebakeScriptInShare) {
|
||||
code = prebakeScript + '\n' + code;
|
||||
}
|
||||
shareCode(code);
|
||||
};
|
||||
const context = {
|
||||
started,
|
||||
pending,
|
||||
|
|
@ -225,6 +263,7 @@ export function useReplContext() {
|
|||
handleShuffle,
|
||||
handleShare,
|
||||
handleEvaluate,
|
||||
handleExport,
|
||||
init,
|
||||
error,
|
||||
editorRef,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { code2hash, evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { settingPatterns } from '../settings.mjs';
|
||||
import { setVersionDefaults } from '@strudel/webaudio';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
import { isTauri } from '../tauri.mjs';
|
||||
import './Repl.css';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { $featuredPatterns /* , loadDBPatterns */ } from '@src/user_pattern_utils.mjs';
|
||||
|
||||
|
|
@ -109,9 +108,8 @@ export function confirmDialog(msg) {
|
|||
});
|
||||
}
|
||||
|
||||
let lastShared;
|
||||
|
||||
//RIP due to SPAM
|
||||
// let lastShared;
|
||||
// export async function shareCode(codeToShare) {
|
||||
// // const codeToShare = activeCode || code;
|
||||
// if (lastShared === codeToShare) {
|
||||
|
|
@ -146,9 +144,10 @@ let lastShared;
|
|||
// });
|
||||
// }
|
||||
|
||||
export async function shareCode() {
|
||||
export async function shareCode(codeToShare) {
|
||||
try {
|
||||
const shareUrl = window.location.href;
|
||||
const hash = '#' + code2hash(codeToShare);
|
||||
const shareUrl = window.location.origin + window.location.pathname + hash;
|
||||
if (isTauri()) {
|
||||
await writeText(shareUrl);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -45,11 +45,13 @@ export const defaultSettings = {
|
|||
isPanelOpen: true,
|
||||
togglePanelTrigger: 'click', //click | hover
|
||||
userPatterns: '{}',
|
||||
prebakeScript: '',
|
||||
audioEngineTarget: audioEngineTargets.webaudio,
|
||||
isButtonRowHidden: false,
|
||||
isCSSAnimationDisabled: false,
|
||||
maxPolyphony: 128,
|
||||
multiChannelOrbits: false,
|
||||
includePrebakeScriptInShare: true,
|
||||
};
|
||||
|
||||
let search = null;
|
||||
|
|
@ -96,6 +98,12 @@ export function useSettings() {
|
|||
isPanelOpen: parseBoolean(state.isPanelOpen),
|
||||
userPatterns: userPatterns,
|
||||
multiChannelOrbits: parseBoolean(state.multiChannelOrbits),
|
||||
includePrebakeScriptInShare: parseBoolean(state.includePrebakeScriptInShare),
|
||||
patternAutoStart: isUdels()
|
||||
? false
|
||||
: state.patternAutoStart === undefined
|
||||
? true
|
||||
: parseBoolean(state.patternAutoStart),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +111,8 @@ export const setActiveFooter = (tab) => settingsMap.setKey('activeFooter', tab);
|
|||
export const setPanelPinned = (bool) => settingsMap.setKey('isPanelPinned', bool);
|
||||
export const setIsPanelOpened = (bool) => settingsMap.setKey('isPanelOpen', bool);
|
||||
|
||||
export const storePrebakeScript = (script) => settingsMap.setKey('prebakeScript', script);
|
||||
|
||||
export const setIsZen = (active) => settingsMap.setKey('isZen', !!active);
|
||||
|
||||
const patternSetting = (key) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue