Merge branch 'tidalcycles:main' into udels
This commit is contained in:
commit
d23038d386
126 changed files with 4976 additions and 8255 deletions
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
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';
|
||||
import { prebake } from '../repl/prebake.mjs';
|
||||
import { loadModules } from '../repl/util.mjs';
|
||||
import { loadModules, setVersionDefaultsFrom } from '../repl/util.mjs';
|
||||
import Claviature from '@components/Claviature';
|
||||
import useClient from '@src/useClient.mjs';
|
||||
|
||||
let prebaked, modulesLoading, audioLoading;
|
||||
let prebaked, modulesLoading, audioReady;
|
||||
if (typeof window !== 'undefined') {
|
||||
prebaked = prebake();
|
||||
modulesLoading = loadModules();
|
||||
audioLoading = initAudioOnFirstClick();
|
||||
audioReady = initAudioOnFirstClick();
|
||||
}
|
||||
|
||||
export function MiniRepl({
|
||||
|
|
@ -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,14 +71,21 @@ export function MiniRepl({
|
|||
});
|
||||
}
|
||||
if (punchcard) {
|
||||
editor?.painters.push(getPunchcardPainter({ labels: !!punchcardLabels }));
|
||||
pat = pat.punchcard({ labels: !!punchcardLabels });
|
||||
}
|
||||
return pat;
|
||||
},
|
||||
prebake: async () => Promise.all([modulesLoading, prebaked, audioLoading]),
|
||||
prebake: async () => Promise.all([modulesLoading, prebaked]),
|
||||
onUpdateState: (state) => {
|
||||
setReplState({ ...state });
|
||||
},
|
||||
onToggle: (playing) => {
|
||||
if (!playing) {
|
||||
// clearHydra(); // TBD: doesn't work with multiple MiniRepl's on a page
|
||||
}
|
||||
},
|
||||
beforeStart: () => audioReady,
|
||||
afterEval: ({ code }) => setVersionDefaultsFrom(code),
|
||||
});
|
||||
// init settings
|
||||
editor.setCode(code);
|
||||
|
|
@ -150,7 +163,7 @@ export function MiniRepl({
|
|||
ref={(el) => {
|
||||
if (!editorRef.current) {
|
||||
containerRef.current = el;
|
||||
init({ code, shouldDraw });
|
||||
init({ code, autodraw });
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const examples = [
|
||||
`// "coastline" @by eddyflux
|
||||
// @version 1.0
|
||||
samples('github:eddyflux/crate')
|
||||
setcps(.75)
|
||||
let chords = chord("<Bbm9 Fm9>/4").dict('ireal')
|
||||
|
|
@ -18,7 +19,7 @@ stack(
|
|||
n("<0!3 1*2>").set(chords).mode("root:g2")
|
||||
.voicing().s("gm_acoustic_bass"),
|
||||
chords.n("[0 <4 3 <2 5>>*2](<3 5>,8)")
|
||||
.set(x).anchor("D5").voicing()
|
||||
.anchor("D5").voicing()
|
||||
.segment(4).clip(rand.range(.4,.8))
|
||||
.room(.75).shape(.3).delay(.25)
|
||||
.fm(sine.range(3,8).slow(8))
|
||||
|
|
@ -29,6 +30,7 @@ stack(
|
|||
)
|
||||
.late("[0 .01]*4").late("[0 .01]*2").size(4)`,
|
||||
`// "broken cut 1" @by froos
|
||||
// @version 1.0
|
||||
|
||||
samples('github:tidalcycles/dirt-samples')
|
||||
samples({
|
||||
|
|
@ -56,6 +58,7 @@ note("[c2 ~](3,8)*2,eb,g,bb,d").s("sawtooth")
|
|||
s("<whirl attack>?").delay(".8:.1:.8").room(2).slow(8).cut(2),
|
||||
).reset("<x@30 [x*[8 [8 [16 32]]]]@2>".late(2))`,
|
||||
`// "acidic tooth" @by eddyflux
|
||||
// @version 1.0
|
||||
setcps(1)
|
||||
stack(
|
||||
note("[<g1 f1>/8](<3 5>,8)")
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ Probier `ply` mit einem pattern zu automatisieren, z.b. `"<1 2 1 3>"`
|
|||
.off(1/8, x=>x.add(4))
|
||||
//.off(1/4, x=>x.add(7))
|
||||
).scale("<C5:minor Db5:mixolydian>/4")
|
||||
.s("triangle").room(.5).ds(".1:0").delay(.5)`}
|
||||
.s("triangle").room(.5).dec(.1).delay(.5)`}
|
||||
punchcard
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ Each filter can receive an additional filter envelope controlling the cutoff val
|
|||
client:idle
|
||||
tune={`note("[c eb g <f bb>](3,8,<0 1>)".sub(12))
|
||||
.s("<sawtooth>/64")
|
||||
.lpf(sine.range(500,3000).slow(16))
|
||||
.lpf(sine.range(300,2000).slow(16))
|
||||
.lpa(0.005)
|
||||
.lpd(perlin.range(.02,.2))
|
||||
.lps(perlin.range(0,.5).slow(3))
|
||||
|
|
@ -106,7 +106,7 @@ Each filter can receive an additional filter envelope controlling the cutoff val
|
|||
.juxBy(.5,rev)
|
||||
.sometimes(add(note(12)))
|
||||
.stack(s("bd*2").bank('RolandTR909'))
|
||||
.gain(.5)`}
|
||||
.gain(.5).fast(2)`}
|
||||
/>
|
||||
|
||||
There is one filter envelope for each filter type and thus one set of envelope filter parameters preceded either by `lp`, `hp` or `bp`:
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ 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
|
||||
|
||||
|
|
@ -45,21 +45,21 @@ As a chained function:
|
|||
|
||||
<JsDoc client:idle name="Pattern.stack" h={0} hideDescription />
|
||||
|
||||
## timeCat
|
||||
## s_cat
|
||||
|
||||
<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}))`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,10 +41,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -55,4 +55,12 @@ There is also `saw2`, `sine2`, `cosine2`, `tri2`, `square2` and `rand2` which ha
|
|||
|
||||
<JsDoc client:idle name="brandBy" h={0} />
|
||||
|
||||
## mouseX
|
||||
|
||||
<JsDoc client:idle name="mousex" h={0} />
|
||||
|
||||
## mouseY
|
||||
|
||||
<JsDoc client:idle name="mousey" h={0} />
|
||||
|
||||
Next up: [Random Modifiers](/learn/random-modifiers)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
`}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -106,4 +106,12 @@ Some of these have equivalent operators in the Mini Notation:
|
|||
|
||||
<JsDoc client:idle name="ribbon" h={0} />
|
||||
|
||||
## swingBy
|
||||
|
||||
<JsDoc client:idle name="swingBy" h={0} />
|
||||
|
||||
## swing
|
||||
|
||||
<JsDoc client:idle name="swing" h={0} />
|
||||
|
||||
Apart from modifying time, there are ways to [Control Parameters](/functions/value-modifiers/).
|
||||
|
|
|
|||
|
|
@ -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
website/src/pages/learn/visual-feedback.mdx
Normal file
100
website/src/pages/learn/visual-feedback.mdx
Normal 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} />
|
||||
|
|
@ -308,7 +308,7 @@ Running through different wavetables can also give interesting variations:
|
|||
<MiniRepl
|
||||
client:visible
|
||||
tune={`samples('github:bubobubobubobubo/dough-waveforms')
|
||||
note("c2*8").s("wt_dbass").n(run(8))`}
|
||||
note("c2*8").s("wt_dbass").n(run(8)).fast(2)`}
|
||||
/>
|
||||
|
||||
...adding a filter envelope + reverb:
|
||||
|
|
@ -317,6 +317,6 @@ note("c2*8").s("wt_dbass").n(run(8))`}
|
|||
client:visible
|
||||
tune={`samples('github:bubobubobubobubo/dough-waveforms')
|
||||
note("c2*8").s("wt_dbass").n(run(8))
|
||||
.lpf(perlin.range(200,2000).slow(8))
|
||||
.lpenv(-3).lpa(.1).room(.5)`}
|
||||
.lpf(perlin.range(100,1000).slow(8))
|
||||
.lpenv(-3).lpa(.1).room(.5).fast(2)`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ following example:
|
|||
|
||||
```js
|
||||
'0 1 2'.add('10 20');
|
||||
('10 [11 21] 20');
|
||||
('10 [11 21] 22');
|
||||
```
|
||||
|
||||
They are similar to the previous example in that the number `1` is split in two, with its two halves added to `10` and `20` respectively. However, the `11` 'remembers' that it is a fragment of that original `1` event, and so is treated as having a duration of a third of a cycle, despite only being active for a sixth of a cycle. Likewise, the `21` is also a fragment of that original `1` event, but a fragment of its second half. Because the start of its event is missing, it wouldn't actually trigger a sound (unless it underwent further pattern transformations/combinations).
|
||||
|
|
@ -41,7 +41,7 @@ This makes way for other ways to align the pattern, and several are already defi
|
|||
- `mix` - structures from both patterns are combined, so that the new events are not fragments but are created at intersections of events from both sides.
|
||||
- `squeeze` - cycles from the pattern on the right are squeezed into events on the left. So that e.g. `"0 1 2".add.squeeze("10 20")` is equivalent to `"[10 20] [11 21] [12 22]"`.
|
||||
- `squeezeout` - as with `squeeze`, but cycles from the left are squeezed into events on the right. So, `"0 1 2".add.squeezeout("10 20")` is equivalent to `[10 11 12] [20 21 22]`.
|
||||
- `reset` is similar to `squeezeout` in that cycles from the right are aligned with events on the left. However those cycles are not 'squeezed', rather they are truncated to fit the event. So `"0 1 2 3 4 5 6 7".add.trig("10 [20 30]")` would be equivalent to `10 11 12 13 20 21 30 31`. In effect, events on the right 'trigger' cycles on the left.
|
||||
- `reset` is similar to `squeezeout` in that cycles from the right are aligned with events on the left. However those cycles are not 'squeezed', rather they are truncated to fit the event. So `"0 1 2 3 4 5 6 7".add.reset("10 [20 30]")` would be equivalent to `10 11 12 13 20 21 30 31`. In effect, events on the right 'reset' cycles on the left.
|
||||
- `restart` is similar to `reset`, but the pattern is 'restarted' from its very first cycle, rather than from the current cycle. `reset` and `restart` therefore only give different results where the leftmost pattern differs from one cycle to the next.
|
||||
|
||||
We will save going deeper into the background, design and practicalities of these alignment functions for future publications. However in the next section, we take them as a case study for looking at the different design affordances offered by Haskell to Tidal, and JavaScript to Strudel.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -168,7 +156,7 @@ What happens if you use `.delay(".8:.06:.8")` ? Can you guess what the third num
|
|||
|
||||
</Box>
|
||||
|
||||
<QA q="Lösung anzeigen" client:visible>
|
||||
<QA q="Click to see solution" client:visible>
|
||||
|
||||
`delay("a:b:c")`:
|
||||
|
||||
|
|
@ -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] ~>")
|
||||
.scale("D4:minor").sound("gm_accordion:2")
|
||||
.room(2).gain(.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)
|
||||
|
||||
$: n("<4 [3@3 4] [<2 0> ~@16] ~>")
|
||||
.scale("D4:minor").sound("gm_accordion:2")
|
||||
.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] ~>")
|
||||
.scale("D4:minor").sound("gm_accordion:2")
|
||||
.room(2).gain(.4),
|
||||
n("[0 [~ 0] 4 [3 2] [0 ~] [0 ~] <0 2> ~]/2")
|
||||
.scale("D2:minor")
|
||||
.sound("sawtooth,triangle").lpf(800)
|
||||
)`}
|
||||
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")
|
||||
.scale("D2:minor")
|
||||
.sound("sawtooth,triangle").lpf(800)`}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
|
|
@ -237,7 +226,7 @@ Try adding `.hush()` at the end of one of the patterns in the stack...
|
|||
<MiniRepl
|
||||
client:visible
|
||||
tune={`sound("numbers:1 numbers:2 numbers:3 numbers:4")
|
||||
.pan("0 0.3 .6 1")`}
|
||||
.pan("0 0.3 .6 1")`}
|
||||
/>
|
||||
|
||||
**speed**
|
||||
|
|
@ -262,11 +251,11 @@ By the way, inside Mini-Notation, `fast` is `*` and `slow` is `/`.
|
|||
|
||||
<MiniRepl client:visible tune={`sound("[bd*4,~ rim ~ cp]*<1 [2 4]>")`} />
|
||||
|
||||
## automation with signals
|
||||
## modulation with signals
|
||||
|
||||
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>
|
||||
|
||||
|
|
@ -290,32 +279,33 @@ What happens if you flip the range values?
|
|||
|
||||
</Box>
|
||||
|
||||
We can change the automation speed with slow / fast:
|
||||
We can change the modulation speed with slow / fast:
|
||||
|
||||
<MiniRepl
|
||||
client:visible
|
||||
tune={`note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
|
||||
.sound("sawtooth")
|
||||
.lpf(sine.range(100, 2000).slow(4))`}
|
||||
.sound("sawtooth")
|
||||
.lpf(sine.range(100, 2000).slow(4))`}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
|
||||
The whole automation will now take 8 cycles to repeat.
|
||||
The whole modulation will now take 8 cycles to repeat.
|
||||
|
||||
</Box>
|
||||
|
||||
## Recap
|
||||
|
||||
| name | example |
|
||||
| ----- | ---------------------------------------------------------------------------------------- |
|
||||
| lpf | <MiniRepl client:visible tune={`note("c2 c3 c2 c3").s("sawtooth").lpf("<400 2000>")`} /> |
|
||||
| vowel | <MiniRepl client:visible tune={`note("c3 eb3 g3").s("sawtooth").vowel("<a e i o>")`} /> |
|
||||
| gain | <MiniRepl client:visible tune={`s("hh*16").gain("[.25 1]*2")`} /> |
|
||||
| delay | <MiniRepl client:visible tune={`s("bd rim bd cp").delay(.5)`} /> |
|
||||
| room | <MiniRepl client:visible tune={`s("bd rim bd cp").room(.5)`} /> |
|
||||
| pan | <MiniRepl client:visible tune={`s("bd rim bd cp").pan("0 1")`} /> |
|
||||
| speed | <MiniRepl client:visible tune={`s("bd rim bd cp").speed("<1 2 -1 -2>")`} /> |
|
||||
| range | <MiniRepl client:visible tune={`s("hh*16").lpf(saw.range(200,4000))`} /> |
|
||||
| name | example |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| lpf | <MiniRepl client:visible tune={`note("c2 c3 c2 c3").s("sawtooth").lpf("<400 2000>")`} /> |
|
||||
| vowel | <MiniRepl client:visible tune={`note("c3 eb3 g3").s("sawtooth").vowel("<a e i o>")`} /> |
|
||||
| gain | <MiniRepl client:visible tune={`s("hh*16").gain("[.25 1]*2")`} /> |
|
||||
| delay | <MiniRepl client:visible tune={`s("bd rim bd cp").delay(.5)`} /> |
|
||||
| room | <MiniRepl client:visible tune={`s("bd rim bd cp").room(.5)`} /> |
|
||||
| pan | <MiniRepl client:visible tune={`s("bd rim bd cp").pan("0 1")`} /> |
|
||||
| speed | <MiniRepl client:visible tune={`s("bd rim bd cp").speed("<1 2 -1 -2>")`} /> |
|
||||
| signals | `sine`, `saw`, `square`, `tri`, `rand`, `perlin`<br/><MiniRepl client:visible tune={`s("hh*16").gain (saw)`} /> |
|
||||
| range | <MiniRepl client:visible tune={`s("hh*16").lpf(saw.range(200,4000))`} /> |
|
||||
|
||||
Let us now take a look at some of Tidal's typical [pattern effects](/workshop/pattern-effects).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ These letter combinations stand for different parts of a drum set:
|
|||
- `mt` = **m**iddle tom
|
||||
- `ht` = **h**igh tom
|
||||
- `rd` = **r**i**d**e cymbal
|
||||
- `rd` = **cr**ash cymbal
|
||||
- `cr` = **cr**ash cymbal
|
||||
|
||||
Try out different drum sounds!
|
||||
|
||||
|
|
@ -268,13 +268,14 @@ This is shorter and more readable than:
|
|||
## Recap
|
||||
|
||||
Now we've learned the basics of the so called Mini-Notation, the rhythm language of Tidal.
|
||||
This is what we've leared so far:
|
||||
This is what we've learned so far:
|
||||
|
||||
| Concept | Syntax | Example |
|
||||
| ----------------- | -------- | ----------------------------------------------------------------------- |
|
||||
| Sequence | space | <MiniRepl client:visible tune={`sound("bd bd sd hh")`} /> |
|
||||
| Sample Number | :x | <MiniRepl client:visible tune={`sound("hh:0 hh:1 hh:2 hh:3")`} /> |
|
||||
| Rests | - or ~ | <MiniRepl client:visible tune={`sound("metal - jazz jazz:1")`} /> |
|
||||
| Alternate | \<\> | <MiniRepl client:visible tune={`sound("<bd hh rim oh bd rim>")`} /> |
|
||||
| Sub-Sequences | \[\] | <MiniRepl client:visible tune={`sound("bd wind [metal jazz] hh")`} /> |
|
||||
| Sub-Sub-Sequences | \[\[\]\] | <MiniRepl client:visible tune={`sound("bd [metal [jazz [sd cp]]]")`} /> |
|
||||
| Speed up | \* | <MiniRepl client:visible tune={`sound("bd sd*2 cp*3")`} /> |
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
@ -145,22 +137,27 @@ Try patterning the `ply` function, for example using `"<1 2 1 3>"`
|
|||
.off(1/16, x=>x.add(4))
|
||||
//.off(1/8, x=>x.add(7))
|
||||
).scale("<C5:minor Db5:mixolydian>/2")
|
||||
.s("triangle").room(.5).ds(".1:0").delay(.5)`}
|
||||
.s("triangle").room(.5).dec(.1)`}
|
||||
punchcard
|
||||
/>
|
||||
|
||||
<Box>
|
||||
|
||||
In the notation `x=>x.`, the `x` is the shifted pattern, which where modifying.
|
||||
In the notation `.off(1/16, x=>x.add(4))`, says:
|
||||
|
||||
- take the original pattern named as `x`
|
||||
- modify `x` with `.add(4)`, and
|
||||
- play it offset to the original pattern by `1/16` of a cycle.
|
||||
|
||||
</Box>
|
||||
|
||||
off is also useful for sounds:
|
||||
off is also useful for modifying other sounds, and can even be nested:
|
||||
|
||||
<MiniRepl
|
||||
client:visible
|
||||
tune={`s("bd sd [rim bd] sd,[~ hh]*4").bank("CasioRZ1")
|
||||
.off(1/16, x=>x.speed(1.5).gain(.25))`}
|
||||
.off(2/16, x=>x.speed(1.5).gain(.25)
|
||||
.off(3/16, y=>y.vowel("<a e i o>*8")))`}
|
||||
/>
|
||||
|
||||
| name | description | example |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ import cx from '@src/cx.mjs';
|
|||
import { transpiler } from '@strudel/transpiler';
|
||||
import {
|
||||
getAudioContext,
|
||||
initAudioOnFirstClick,
|
||||
webaudioOutput,
|
||||
resetGlobalEffects,
|
||||
resetLoadedSounds,
|
||||
initAudioOnFirstClick,
|
||||
} from '@strudel/webaudio';
|
||||
import { defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { getAudioDevices, setAudioDevice } from './util.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';
|
||||
|
|
@ -38,12 +38,14 @@ import { getRandomTune, initCode, loadModules, shareCode, ReplContext, isUdels }
|
|||
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;
|
||||
let modulesLoading, presets, drawContext, clearCanvas, isIframe, audioReady;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
initAudioOnFirstClick();
|
||||
audioReady = initAudioOnFirstClick();
|
||||
modulesLoading = loadModules();
|
||||
presets = prebake();
|
||||
drawContext = getDrawContext();
|
||||
|
|
@ -87,11 +89,14 @@ export function Repl({ embedded = false }) {
|
|||
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;
|
||||
|
|
@ -112,22 +117,25 @@ export function Repl({ embedded = false }) {
|
|||
},
|
||||
bgFill: false,
|
||||
});
|
||||
window.strudelMirror = editor;
|
||||
|
||||
// init settings
|
||||
|
||||
initCode().then(async (decoded) => {
|
||||
let msg;
|
||||
let code, msg;
|
||||
if (decoded) {
|
||||
editor.setCode(decoded);
|
||||
code = decoded;
|
||||
msg = `I have loaded the code from the URL.`;
|
||||
} else if (latestCode) {
|
||||
editor.setCode(latestCode);
|
||||
code = latestCode;
|
||||
msg = `Your last session has been loaded!`;
|
||||
} else {
|
||||
const { code: randomTune, name } = await getRandomTune();
|
||||
editor.setCode(randomTune);
|
||||
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');
|
||||
});
|
||||
|
||||
|
|
@ -170,6 +178,11 @@ export function Repl({ embedded = false }) {
|
|||
// UI Actions
|
||||
//
|
||||
|
||||
const setDocumentTitle = (code) => {
|
||||
const meta = getMetadata(code);
|
||||
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
|
||||
};
|
||||
|
||||
const handleTogglePlay = async () => {
|
||||
editorRef.current?.toggle();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/*' });
|
||||
|
|
|
|||
|
|
@ -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,52 +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 = splitRelativePath[splitRelativePath.length - 2];
|
||||
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);
|
||||
|
|
@ -74,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;
|
||||
|
|
@ -100,52 +130,63 @@ 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)) {
|
||||
return;
|
||||
}
|
||||
//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 blob containing all of the buffer data
|
||||
const base64 = await bufferToDataUrl(buf);
|
||||
return {
|
||||
title,
|
||||
blob: base64,
|
||||
id: s.webkitRelativePath,
|
||||
};
|
||||
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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default function ImportSoundsButton({ onComplete }) {
|
|||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
|
||||
await uploadSamplesToDB(userSamplesDBConfig, fileUploadRef.current.files).then(() => {
|
||||
registerSamplesFromDB(userSamplesDBConfig, () => {
|
||||
onComplete();
|
||||
|
|
@ -32,7 +33,7 @@ export default function ImportSoundsButton({ onComplete }) {
|
|||
directory=""
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
accept="audio/*"
|
||||
accept="audio/*, .wav, .mp3, .m4a, .flac, .aac, .ogg"
|
||||
onChange={() => {
|
||||
onChange();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ export function SoundsTab() {
|
|||
const sounds = useStore(soundMap);
|
||||
const { soundsFilter } = useSettings();
|
||||
const soundEntries = useMemo(() => {
|
||||
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
|
||||
let filtered = Object.entries(sounds)
|
||||
.filter(([key]) => !key.startsWith('_'))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
if (!sounds) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -43,7 +45,7 @@ export function SoundsTab() {
|
|||
});
|
||||
return (
|
||||
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
|
||||
<div className="pb-2 flex shrink-0 overflow-auto">
|
||||
<div className="pb-2 flex shrink-0 flex-wrap">
|
||||
<ButtonGroup
|
||||
value={soundsFilter}
|
||||
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ export function WelcomeTab({ context }) {
|
|||
<a href="https://tidalcycles.org/" target="_blank">
|
||||
tidalcycles
|
||||
</a>
|
||||
, which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
|
||||
, which is a popular live coding language for music, written in Haskell. Strudel is free/open source software:
|
||||
you can redistribute and/or modify it under the terms of the{' '}
|
||||
<a href="https://github.com/tidalcycles/strudel/blob/main/LICENSE" target="_blank">
|
||||
GNU Affero General Public License
|
||||
</a>
|
||||
. You can find the source code at{' '}
|
||||
<a href="https://github.com/tidalcycles/strudel" target="_blank">
|
||||
github
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { evalScope, hash2code, logger } from '@strudel/core';
|
||||
import { settingPatterns, defaultAudioDeviceName } from '../settings.mjs';
|
||||
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext } from '@strudel/webaudio';
|
||||
import { getAudioContext, initializeAudioOutput, setDefaultAudioContext, setVersionDefaults } from '@strudel/webaudio';
|
||||
import { getMetadata } from '../metadata_parser';
|
||||
|
||||
import { isTauri } from '../tauri.mjs';
|
||||
import './Repl.css';
|
||||
|
|
@ -26,13 +27,13 @@ export async function initCode() {
|
|||
// load code from url hash (either short hash from database or decode long hash)
|
||||
try {
|
||||
const initialUrl = window.location.href;
|
||||
const hash = initialUrl.split('?')[1]?.split('#')?.[0];
|
||||
const hash = initialUrl.split('?')[1]?.split('#')?.[0]?.split('&')[0];
|
||||
const codeParam = window.location.href.split('#')[1] || '';
|
||||
// looking like https://strudel.cc/?J01s5i1J0200 (fixed hash length)
|
||||
if (codeParam) {
|
||||
// looking like https://strudel.cc/#ImMzIGUzIg%3D%3D (hash length depends on code length)
|
||||
return hash2code(codeParam);
|
||||
} else if (hash) {
|
||||
// looking like https://strudel.cc/?J01s5i1J0200 (fixed hash length)
|
||||
return supabase
|
||||
.from('code_v1')
|
||||
.select('code')
|
||||
|
|
@ -82,6 +83,7 @@ export function loadModules() {
|
|||
import('@strudel/serial'),
|
||||
import('@strudel/soundfonts'),
|
||||
import('@strudel/csound'),
|
||||
import('@strudel/tidal'),
|
||||
];
|
||||
if (isTauri()) {
|
||||
modules = modules.concat([
|
||||
|
|
@ -167,3 +169,13 @@ export const setAudioDevice = async (id) => {
|
|||
}
|
||||
initializeAudioOutput();
|
||||
};
|
||||
|
||||
export function setVersionDefaultsFrom(code) {
|
||||
try {
|
||||
const metadata = getMetadata(code);
|
||||
setVersionDefaults(metadata.version);
|
||||
} catch (err) {
|
||||
console.error('Error parsing metadata..');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,16 +21,25 @@ export const patternFilterName = {
|
|||
user: 'user',
|
||||
};
|
||||
|
||||
export let $viewingPatternData = persistentAtom(
|
||||
'viewingPatternData',
|
||||
{
|
||||
id: '',
|
||||
code: '',
|
||||
collection: collectionName.user,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
{ listen: false },
|
||||
);
|
||||
const sessionAtom = (name, initial = undefined) => {
|
||||
const storage = typeof sessionStorage !== 'undefined' ? sessionStorage : {};
|
||||
const store = atom(typeof storage[name] !== 'undefined' ? storage[name] : initial);
|
||||
store.listen((newValue) => {
|
||||
if (typeof newValue === 'undefined') {
|
||||
delete storage[name];
|
||||
} else {
|
||||
storage[name] = newValue;
|
||||
}
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
export let $viewingPatternData = sessionAtom('viewingPatternData', {
|
||||
id: '',
|
||||
code: '',
|
||||
collection: collectionName.user,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
export const getViewingPatternData = () => {
|
||||
return parseJSON($viewingPatternData.get());
|
||||
|
|
@ -68,7 +77,7 @@ export async function loadDBPatterns() {
|
|||
}
|
||||
|
||||
// reason: https://github.com/tidalcycles/strudel/issues/857
|
||||
const $activePattern = persistentAtom('activePattern', '', { listen: false });
|
||||
const $activePattern = sessionAtom('activePattern', '');
|
||||
|
||||
export function setActivePattern(key) {
|
||||
$activePattern.set(key);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue