Merge branch 'main' into voicings-doc

This commit is contained in:
Felix Roos 2024-12-23 23:07:46 +01:00
commit 991d8e4717
180 changed files with 19272 additions and 18871 deletions

View file

@ -26,15 +26,15 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
title="Top Navigation"
>
<div class="flex overflow-visible items-center grow" style="overflow:visible">
<a href={`${baseNoTrailing}/`} class="flex items-center text-2xl space-x-2">
<div class="flex items-center text-2xl space-x-2">
<h1 class="font-bold flex space-x-2 items-baseline text-xl">
<span>🌀</span>
<div class="flex space-x-1 items-baseline">
<span class="block rotate-90 text-blue-500">꩜</span>
<div class="flex space-x-2 items-baseline">
<span class="">strudel</span>
<span class="text-sm">DOCS</span>
<span class="text-sm font-medium">DOCS</span>
</div>
</h1>
</a>
</div>
</div>
{/* KNOWN_LANGUAGE_CODES.length > 1 && <LanguageSelect lang={lang} client:idle /> */}
<div class="search-item h-10">

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { loadFeaturedPatterns, loadPublicPatterns } from '@src/user_pattern_utils.mjs';
import { MiniRepl } from '@src/docs/MiniRepl';
import { PatternLabel } from '@src/repl/panel/PatternsTab';
import { PatternLabel } from '@src/repl/components/panel/PatternsTab';
function PatternList({ patterns }) {
return (

View file

@ -0,0 +1,32 @@
import { useRef } from 'react';
export function UdelFrame({ onEvaluate, hash, instance }) {
const ref = useRef();
window.addEventListener('message', (message) => {
const childWindow = ref?.current?.contentWindow;
if (message == null || message.source !== childWindow) {
return; // Skip message in this event listener
}
onEvaluate(message.data);
});
const url = new URL(window.location.origin);
url.hash = hash;
url.searchParams.append('instance', instance);
const source = url.toString();
return (
<iframe
ref={ref}
style={{
display: 'flex',
flexGrow: 1,
minWidth: '50%',
boxSizing: 'border-box',
border: '0px',
}}
title="strudel embed"
src={source}
></iframe>
);
}

View file

@ -0,0 +1,72 @@
import { code2hash } from '@strudel/core';
import { UdelFrame } from './UdelFrame';
import { useState } from 'react';
import UdelsHeader from './UdelsHeader';
const defaultHash = 'c3RhY2soCiAgCik%3D';
const getHashesFromUrl = () => {
return window.location.hash?.slice(1).split(',');
};
const updateURLHashes = (hashes) => {
const newHash = '#' + hashes.join(',');
window.location.hash = newHash;
};
export function Udels() {
const hashes = getHashesFromUrl();
const [numWindows, setNumWindows] = useState(hashes?.length ?? 1);
const numWindowsOnChange = (num) => {
setNumWindows(num);
const hashes = getHashesFromUrl();
const newHashes = [];
for (let i = 0; i < num; i++) {
newHashes[i] = hashes[i] ?? defaultHash;
}
updateURLHashes(newHashes);
};
const onEvaluate = (key, code) => {
const hashes = getHashesFromUrl();
hashes[key] = code2hash(code);
updateURLHashes(hashes);
};
return (
<div
style={{
margin: 0,
display: 'flex',
flex: 1,
height: '100vh',
width: '100%',
flexDirection: 'column',
}}
>
<UdelsHeader numWindows={numWindows} setNumWindows={numWindowsOnChange} />
<div
style={{
display: 'flex',
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{hashes.map((hash, key) => {
return (
<UdelFrame
instance={key}
onEvaluate={(code) => {
onEvaluate(key, code);
}}
hash={hash}
key={key}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,26 @@
import Loader from '@src/repl/components/Loader';
import { HorizontalPanel } from '@src/repl/components/panel/Panel';
import { Code } from '@src/repl/components/Code';
import BigPlayButton from '@src/repl/components/BigPlayButton';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
// type Props = {
// context: replcontext,
// }
export default function UdelsEditor(Props) {
const { context } = Props;
const { containerRef, editorRef, error, init, pending, started, handleTogglePlay } = context;
return (
<div className={'h-full flex w-full flex-col relative'}>
<Loader active={pending} />
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
</div>
<UserFacingErrorMessage error={error} />
<HorizontalPanel context={context} />
</div>
);
}

View file

@ -0,0 +1,20 @@
import NumberInput from '@src/repl/components/NumberInput';
export default function UdelsHeader(Props) {
const { numWindows, setNumWindows } = Props;
return (
<header id="header" className="flex text-white z-[100] text-lg select-none bg-neutral-900">
<div className="px-4 items-center gap-2 flex space-x-2 md:pt-0 select-none">
<h1 onClick={() => {}} className={'text-l cursor-pointer flex gap-4'}>
<div className={'mt-[1px] cursor-pointer'}>🌀</div>
<div className={'animate-pulse'}>
<span className="">strudel</span> <span className="text-sm">-UDELS</span>
</div>
</h1>
<NumberInput value={numWindows} setValue={setNumWindows} />
</div>
</header>
);
}

View file

@ -78,6 +78,7 @@ export const SIDEBAR: Sidebar = {
More: [
{ text: 'Recipes', link: 'recipes/recipes' },
{ text: 'Mini-Notation', link: 'learn/mini-notation' },
{ text: 'Visual Feedback', link: 'learn/visual-feedback' },
{ text: 'Offline', link: 'learn/pwa' },
{ text: 'Patterns', link: 'technical-manual/patterns' },
{ text: 'Music metadata', link: 'learn/metadata' },

View file

@ -26,7 +26,7 @@ author: froos
- fix: 💄 Enhance visualisation of the Tutorial on mobile by @puria in https://github.com/tidalcycles/strudel/pull/19
- Stateful queries and events (WIP) by @yaxu in https://github.com/tidalcycles/strudel/pull/14
- Fix resolveState by @yaxu in https://github.com/tidalcycles/strudel/pull/22
- added \_asNumber + interprete numbers as midi by @felixroos in https://github.com/tidalcycles/strudel/pull/21
- added \_asNumber + interpret numbers as midi by @felixroos in https://github.com/tidalcycles/strudel/pull/21
- Update package.json by @ChiakiUehira in https://github.com/tidalcycles/strudel/pull/23
- packaging by @felixroos in https://github.com/tidalcycles/strudel/pull/24

View file

@ -28,7 +28,7 @@ author: froos
- support freq in pianoroll by @felixroos in https://github.com/tidalcycles/strudel/pull/308
- ICLC2023 paper WIP by @yaxu in https://github.com/tidalcycles/strudel/pull/306
- fix: copy share link to clipboard was broken for some browers by @felixroos in https://github.com/tidalcycles/strudel/pull/311
- fix: copy share link to clipboard was broken for some browsers by @felixroos in https://github.com/tidalcycles/strudel/pull/311
- Jsdoc component by @felixroos in https://github.com/tidalcycles/strudel/pull/312
- object support for .scale by @felixroos in https://github.com/tidalcycles/strudel/pull/307
- Astro build by @felixroos in https://github.com/tidalcycles/strudel/pull/315

View file

@ -138,7 +138,7 @@ Plenty of things have been added to the docs, including a [showcase of what peop
- Export patterns + ui tweaks by @felixroos in https://github.com/tidalcycles/strudel/pull/855
- Pattern organization by @felixroos in https://github.com/tidalcycles/strudel/pull/858
- Sound Import from local file system by @daslyfe in https://github.com/tidalcycles/strudel/pull/839
- bugfix: suspend and close exisiting audio context when changing interface by @daslyfe in https://github.com/tidalcycles/strudel/pull/882
- bugfix: suspend and close existing audio context when changing interface by @daslyfe in https://github.com/tidalcycles/strudel/pull/882
- add root mode for voicings by @felixroos in https://github.com/tidalcycles/strudel/pull/887
- scales can now be anchored by @felixroos in https://github.com/tidalcycles/strudel/pull/888
- add dough function for raw dsp by @felixroos in https://github.com/tidalcycles/strudel/pull/707 (experimental)
@ -195,7 +195,7 @@ Plenty of things have been added to the docs, including a [showcase of what peop
- Update tauri.yml workflow file by @vasilymilovidov in https://github.com/tidalcycles/strudel/pull/705
- vite-vanilla-repl readme fix by @felixroos in https://github.com/tidalcycles/strudel/pull/737
- completely revert config mess by @felixroos in https://github.com/tidalcycles/strudel/pull/745
- hopefully fix trainling slashes bug by @felixroos in https://github.com/tidalcycles/strudel/pull/753
- hopefully fix trailing slashes bug by @felixroos in https://github.com/tidalcycles/strudel/pull/753
- Update vite pwa by @felixroos in https://github.com/tidalcycles/strudel/pull/772
- Update to Astro 3 by @felixroos in https://github.com/tidalcycles/strudel/pull/775
- support multiple named serial connections, change default baudrate by @yaxu in https://github.com/tidalcycles/strudel/pull/551

View file

@ -1,20 +1,20 @@
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 { 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 +28,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 +61,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 +70,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);
@ -107,6 +119,7 @@ export function MiniRepl({
'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background',
started ? 'animate-pulse' : '',
)}
aria-label={started ? 'stop' : 'play'}
onClick={() => editorRef.current?.toggle()}
>
<Icon type={started ? 'stop' : 'play'} />
@ -116,6 +129,7 @@ export function MiniRepl({
'w-16 flex items-center justify-center p-1 text-foreground border-lineHighlight bg-lineHighlight',
isDirty ? 'text-foreground hover:bg-background cursor-pointer' : 'opacity-50 cursor-not-allowed',
)}
aria-label="update"
onClick={() => editorRef.current?.evaluate()}
>
<Icon type="refresh" />
@ -127,6 +141,7 @@ export function MiniRepl({
className={
'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background'
}
aria-label="previous example"
onClick={() => changeTune(tuneIndex - 1)}
>
<div className="rotate-180">
@ -137,6 +152,7 @@ export function MiniRepl({
className={
'cursor-pointer w-16 flex items-center justify-center p-1 border-r border-lineHighlight text-foreground bg-lineHighlight hover:bg-background'
}
aria-label="next example"
onClick={() => changeTune(tuneIndex + 1)}
>
<Icon type="skip" />
@ -150,7 +166,7 @@ export function MiniRepl({
ref={(el) => {
if (!editorRef.current) {
containerRef.current = el;
init({ code, shouldDraw });
init({ code, autodraw });
}
}}
></div>

View file

@ -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)")

View file

@ -30,7 +30,7 @@ const githubEditUrl = `${CONFIG.GITHUB_EDIT_URL}/${currentFile}`;
</title>
</head>
<body class="h-app-height text-gray-50 bg-background">
<body class="h-app-height m-0 text-gray-50 bg-background">
<div class="w-full h-full space-y-4 flex flex-col">
<header class="max-w-full fixed top-0 w-full z-[100]">
<Header currentPage={currentPage} />

View file

@ -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
/>

View file

@ -3,12 +3,12 @@ import HeadCommon from '../components/HeadCommon.astro';
import { Repl } from '../repl/Repl';
---
<html lang="en" class="dark">
<html lang="en" class="m-0 dark">
<head>
<HeadCommon />
<title>Strudel REPL</title>
</head>
<body class="h-app-height bg-background">
<body class="h-app-height bg-background m-0">
<Repl client:only="react" />
<a rel="me" href="https://social.toplap.org/@strudel" target="_blank" class="hidden">mastodon</a>
</body>

View file

@ -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`:

View file

@ -11,55 +11,43 @@ import { JsDoc } from '../../docs/JsDoc';
The following functions will return a pattern.
These are the equivalents used by the Mini Notation:
| function | mini |
| ------------------------------ | ---------------- |
| `cat(x, y)` | `"<x y>"` |
| `seq(x, y)` | `"x y"` |
| `stack(x, y)` | `"x,y"` |
| `timeCat([3,x],[2,y])` | `"x@3 y@2"` |
| `polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
| `polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
| `silence` | `"~"` |
| function | mini |
| -------------------------------- | ---------------- |
| `cat(x, y)` | `"<x y>"` |
| `seq(x, y)` | `"x y"` |
| `stack(x, y)` | `"x,y"` |
| `s_cat([3,x],[2,y])` | `"x@3 y@2"` |
| `s_polymeter([a, b, c], [x, y])` | `"{a b c, x y}"` |
| `s_polymeterSteps(2, x, y, z)` | `"{x y z}%2"` |
| `silence` | `"~"` |
## cat
<JsDoc client:idle name="cat" h={0} />
You can also use cat as a chained function like this:
<JsDoc client:idle name="Pattern.cat" h={0} hideDescription />
## seq
<JsDoc client:idle name="seq" h={0} />
Or as a chained function:
<JsDoc client:idle name="Pattern.seq" h={0} hideDescription />
## stack
<JsDoc client:idle name="stack" h={0} />
As a chained function:
## s_cat
<JsDoc client:idle name="Pattern.stack" h={0} hideDescription />
## timeCat
<JsDoc client:idle name="timeCat" h={0} />
<JsDoc client:idle name="s_cat" h={0} />
## arrange
<JsDoc client:idle name="arrange" h={0} />
## polymeter
## s_polymeter
<JsDoc client:idle name="polymeter" h={0} />
<JsDoc client:idle name="s_polymeter" h={0} />
## polymeterSteps
## s_polymeterSteps
<JsDoc client:idle name="polymeterSteps" h={0} />
<JsDoc client:idle name="s_polymeterSteps" h={0} />
## silence

View file

@ -90,11 +90,13 @@ src(s0).kaleid(H("<4 5 6>"))
.modulateScale(osc(2,-0.25,1))
.out()
//
stack(
s("bd*4,[hh:0:<.5 1>]*8,~ rim").bank("RolandTR909").speed(.9),
note("[<g1!3 <bb1 <f1 d1>>>]*3").s("sawtooth")
.room(.75).sometimes(add(note(12))).clip(.3)
.lpa(.05).lpenv(-4).lpf(2000).lpq(8).ftype('24db')
).fft(4)
.scope({pos:0,smear:.95})`}
$: s("bd*4,[hh:0:<.5 1>]*8,~ rim").bank("RolandTR909").speed(.9)
$: note("[<g1!3 <bb1 <f1 d1>>>]\*3").s("sawtooth")
.room(.75).sometimes(add(note(12))).clip(.3)
.lpa(.05).lpenv(-4).lpf(2000).lpq(8).ftype('24db')
all(x=>x.fft(4).scope({pos:0,smear:.95}))`}
/>

View file

@ -1,30 +1,27 @@
---
title: MIDI & OSC
title: MIDI, OSC & MQTT
layout: ../../layouts/MainLayout.astro
---
import { MiniRepl } from '../../docs/MiniRepl';
import { JsDoc } from '../../docs/JsDoc';
# MIDI and OSC
# MIDI, OSC and MQTT
The default audio output of Strudel uses the [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API).
It is also possible to use Strudel with MIDI and OSC / [SuperDirt](https://github.com/musikinformatik/SuperDirt/) instead.
Normally, Strudel is used to pattern sound, using its own '[web audio](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)'-based synthesiser called [SuperDough](https://github.com/tidalcycles/strudel/tree/main/packages/superdough).
# MIDI API
It is also possible to pattern other things with Strudel, such as software and hardware synthesisers with MIDI, other software using Open Sound Control/OSC (including the [SuperDirt](https://github.com/musikinformatik/SuperDirt/) synthesiser commonly used with Strudel's sibling [TidalCycles](https://tidalcycles.org/)), or the MQTT 'internet of things' protocol.
Strudel also supports midi via [webmidi](https://npmjs.com/package/webmidi).
# MIDI
Strudel supports MIDI without any additional software (thanks to [webmidi](https://npmjs.com/package/webmidi)), just by adding methods to your pattern:
## midi(outputName?)
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,26 +42,26 @@ But you can also control cc messages separately like this:
<MiniRepl
client:idle
tune={`stack(
note("c a f e"),
ccv(sine.segment(16).slow(4)).ccn(74)
).midi()`}
tune={`$: note("c a f e").midi()
$: ccv(sine.segment(16).slow(4)).ccn(74).midi()`}
/>
# SuperDirt API
# OSC/SuperDirt/StrudelDirt
In mainline tidal, the actual sound is generated via [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider.
Strudel also supports using [SuperDirt](https://github.com/musikinformatik/SuperDirt/) as a backend, although it requires some developer tooling to run.
In TidalCycles, sound is usually generated using [SuperDirt](https://github.com/musikinformatik/SuperDirt/), which runs inside SuperCollider. Strudel also supports using SuperDirt, although it requires installing some additional software.
There is also [StrudelDirt](https://github.com/daslyfe/StrudelDirt) which is SuperDirt with some optimisations for working with Strudel. (A longer term aim is to merge these optimisations back into mainline SuperDirt)
## Prequisites
Getting [SuperDirt](https://github.com/musikinformatik/SuperDirt/) to work with Strudel, you need to
To get SuperDirt to work with Strudel, you need to
1. install SuperCollider + sc3 plugins, see [Tidal Docs](https://tidalcycles.org/docs/) (Install Tidal) for more info.
2. install [node.js](https://nodejs.org/en/)
3. download [Strudel Repo](https://github.com/tidalcycles/strudel/) (or git clone, if you have git installed)
4. run `pnpm i` in the strudel directory
5. run `pnpm run osc` to start the osc server, which forwards OSC messages from Strudel REPL to SuperCollider
2. install SuperDirt, or the [StrudelDirt](https://github.com/daslyfe/StrudelDirt) fork which is optimised for use with Strudel
3. install [node.js](https://nodejs.org/en/)
4. download [Strudel Repo](https://github.com/tidalcycles/strudel/) (or git clone, if you have git installed)
5. run `pnpm i` in the strudel directory
6. run `pnpm run osc` to start the osc server, which forwards OSC messages from Strudel REPL to SuperCollider
Now you're all set!
@ -79,18 +76,80 @@ Now you're all set!
If you now hear sound, congratulations! If not, you can get help on the [#strudel channel in the TidalCycles discord](https://discord.com/invite/HGEdXmRkzT).
Note: if you have the 'Audio Engine Target' in settings set to 'OSC', you do not need to add .osc() to the end of your pattern.
### Pattern.osc
<JsDoc client:idle name="Pattern.osc" h={0} />
## SuperDirt Params
The following functions can be used with [SuperDirt](https://github.com/musikinformatik/SuperDirt/):
`s n note freq channel orbit cutoff resonance hcutoff hresonance bandf bandq djf vowel cut begin end loop fadeTime speed unitA gain amp accelerate crush coarse delay lock leslie lrate lsize pan panspan pansplay room size dry shape squiz waveloss attack decay octave detune tremolodepth`
Please refer to [Tidal Docs](https://tidalcycles.org/) for more info.
<br />
But can we use Strudel [offline](/learn/pwa)?
# MQTT
MQTT is a lightweight network protocol, designed for 'internet of things' devices. For use with strudel, you will
need access to an MQTT server known as a 'broker' configured to accept secure 'websocket' connections. You could
run one yourself (e.g. by running [mosquitto](https://mosquitto.org/)), although getting an SSL certificate that
your web browser will trust might be a bit tricky for those without systems administration experience.
Alternatively, you can use [a public broker](https://www.hivemq.com/mqtt/public-mqtt-broker/).
Strudel does not yet support receiving messages over MQTT, only sending them.
## Usage
The following example shows how to send a pattern to an MQTT broker:
<MiniRepl
client:only="react"
tune={`"hello world"
.mqtt(undefined, // username (undefined for open/public servers)
undefined, // password
'/strudel-pattern', // mqtt 'topic'
'wss://mqtt.eclipseprojects.io:443/mqtt', // MQTT server address
'mystrudel', // MQTT client id - randomly generated if not supplied
0 // latency / delay before sending messages (0 = no delay)
)`}
/>
Other software can then receive the messages. For example using the [mosquitto](https://mosquitto.org/) commandline client tools:
```
> mosquitto_sub -h mqtt.eclipseprojects.io -p 1883 -t "/strudel-pattern"
hello
world
hello
world
...
```
Control patterns will be encoded as JSON, for example:
<MiniRepl
client:only="react"
tune={`sound("sax(3,8)").speed("2 3")
.mqtt(undefined, // username (undefined for open/public servers)
undefined, // password
'/strudel-pattern', // mqtt 'topic'
'wss://mqtt.eclipseprojects.io:443/mqtt', // MQTT server address
'mystrudel', // MQTT client id - randomly generated if not supplied
0 // latency / delay before sending messages (0 = no delay)
)`}
/>
Will send messages like the following:
```
{"s":"sax","speed":2}
{"s":"sax","speed":2}
{"s":"sax","speed":3}
{"s":"sax","speed":2}
...
```
Libraries for receiving MQTT are available for many programming languages.

View file

@ -268,11 +268,10 @@ With it, you can enter any sample name(s) to query from [freesound.org](https://
<MiniRepl
client:idle
tune={`samples('shabda:bass:4,hihat:4,rimshot:2')
stack(
n("0 1 2 3 0 1 2 3").s('bass'),
n("0 1*2 2 3*2").s('hihat'),
n("~ 0 ~ 1 ~ 0 0 1").s('rimshot')
).clip(1)`}
$: n("0 1 2 3 0 1 2 3").s('bass')
$: n("0 1*2 2 3*2").s('hihat').clip(1)
$: n("~ 0 ~ 1 ~ 0 0 1").s('rimshot')`}
/>
You can also generate artificial voice samples with any text, in multiple languages.
@ -282,10 +281,9 @@ Note that the language code and the gender parameters are optional and default t
client:idle
tune={`samples('shabda/speech:the_drum,forever')
samples('shabda/speech/fr-FR/m:magnifique')
stack(
s("the_drum*2").chop(16).speed(rand.range(0.85,1.1)),
s("forever magnifique").slow(4).late(0.125)
)`}
$: s("the_drum*2").chop(16).speed(rand.range(0.85,1.1))
$: s("forever magnifique").slow(4).late(0.125)`}
/>
# Sampler Effects

View file

@ -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)

View file

@ -112,29 +112,14 @@ Also, samples are always loaded from a URL rather than from the disk, although [
## Evaluation
The Strudel REPL does not support [block based evaluation](https://github.com/tidalcycles/strudel/issues/34) yet.
You can use the following "workaround" to create multiple patterns that can be turned on and off:
You can use labeled statements and `_` to mute:
```
let a = note("c a f e")
<MiniRepl
client:visible
tune={`$: n("[0 .. 8]*8/9").scale("C:minor:pentatonic")
let b = s("bd sd")
stack(
a,
// b
)
```
Alternatively, you could write everything as one `stack` and use `.hush()` to silence a pattern:
```
stack(
note("c a f e"),
s("bd sd").hush()
)
```
Note that strudel will always use the last statement in your code as the pattern for querying
\_$: s("bd\*4").bank('RolandTR909')`}
/>
## Tempo

View file

@ -18,7 +18,7 @@ The basic waveforms are `sine`, `sawtooth`, `square` and `triangle`, which can b
client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("<sawtooth square triangle sine>")
.scope()`}
._scope()`}
/>
If you don't set a `sound` but a `note` the default value for `sound` is `triangle`!
@ -28,23 +28,23 @@ If you don't set a `sound` but a `note` the default value for `sound` is `triang
You can also use noise as a source by setting the waveform to: `white`, `pink` or `brown`. These are different
flavours of noise, here written from hard to soft.
<MiniRepl client:idle tune={`sound("<white pink brown>").scope()`} />
<MiniRepl client:idle tune={`sound("<white pink brown>")._scope()`} />
Here's a more musical example of how to use noise for hihats:
<MiniRepl
client:idle
tune={`sound("bd*2,<white pink brown>*8")
.decay(.04).sustain(0).scope()`}
.decay(.04).sustain(0)._scope()`}
/>
Some amount of pink noise can also be added to any oscillator by using the `noise` paremeter:
<MiniRepl client:idle tune={`note("c3").noise("<0.1 0.25 0.5>").scope()`} />
<MiniRepl client:idle tune={`note("c3").noise("<0.1 0.25 0.5>")._scope()`} />
You can also use the `crackle` type to play some subtle noise crackles. You can control noise amount by using the `density` parameter:
<MiniRepl client:idle tune={`s("crackle*4").density("<0.01 0.04 0.2 0.5>".slow(2)).scope()`} />
<MiniRepl client:idle tune={`s("crackle*4").density("<0.01 0.04 0.2 0.5>".slow(2))._scope()`} />
### Additive Synthesis
@ -55,7 +55,7 @@ To tame the harsh sound of the basic waveforms, we can set the `n` control to li
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("sawtooth")
.n("<32 16 8 4>")
.scope()`}
._scope()`}
/>
When the `n` control is used on a basic waveform, it defines the number of harmonic partials the sound is getting.
@ -65,7 +65,7 @@ You can also set `n` directly in mini notation with `sound`:
client:idle
tune={`note("c2 <eb2 <g2 g1>>".fast(2))
.sound("sawtooth:<32 16 8 4>")
.scope()`}
._scope()`}
/>
Note for tidal users: `n` in tidal is synonymous to `note` for synths only.
@ -119,13 +119,14 @@ Any sample preceded by the `wt_` prefix will be loaded as a wavetable. This mean
<MiniRepl
client:idle
tune={`samples('github:Bubobubobubobubo/Dough-Waveforms/main/');
tune={`samples('bubo:waveforms');
note("<[g3,b3,e4]!2 [a3,c3,e4] [b3,d3,f#4]>")
.n("<1 2 3 4 5 6 7 8 9 10>/2").room(0.5).size(0.9)
.s('wt_flute').velocity(0.25).often(n => n.ply(2))
.release(0.125).decay("<0.1 0.25 0.3 0.4>").sustain(0)
.cutoff(2000).scope({}).cutoff("<1000 2000 4000>").fast(4)`}
.cutoff(2000).cutoff("<1000 2000 4000>").fast(4)
._scope()
`}
/>
## ZZFX
@ -159,7 +160,7 @@ It has 20 parameters in total, here is a snippet that uses all:
.tremolo(0) // 0-1 lfo volume modulation amount
//.duration(.2) // overwrite strudel event duration
//.gain(1) // change volume
.scope() // vizualise waveform (not zzfx related)
._scope() // vizualise waveform (not zzfx related)
`}
/>

View file

@ -55,20 +55,6 @@ Transposes notes inside the scale by the number of steps:
.note()`}
/>
### voicings(range?)
Turns chord symbols into voicings, using the smoothest voice leading possible:
<MiniRepl
client:only="react"
tune={`stack(
"<C^7 A7 Dm7 G7>".voicings('lefthand'),
"<C3 A2 D3 G2>"
).note()`}
/>
Note: This function might be removed, as `voicing` (without s) is a newer implementation.
### rootNotes(octave = 2)
Turns chord symbols into root notes of chords in given octave.

View file

@ -0,0 +1,108 @@
---
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} />
## Spectrum
<JsDoc client:idle name="spectrum" h={0} />
## markcss
<JsDoc client:idle name="markcss" h={0} />

View file

@ -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)`}
/>

View file

@ -0,0 +1,12 @@
---
import { Udels } from '../../components/Udels/Udels.jsx';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
---
<body class="m-0">
<Udels client:only="react" />
</body>

View file

@ -60,11 +60,10 @@ We will learn how to automate with waves later...
<MiniRepl
client:visible
tune={`stack(
sound("hh*16").gain("[.25 1]*4"),
sound("bd*4,[~ sd:1]*2")
) `}
punchcard
tune={`$: sound("hh*16").gain("[.25 1]*4")
$: sound("bd*4,[~ sd:1]*2")`}
punchcard
/>
<Box>
@ -76,31 +75,21 @@ Rhythm is all about dynamics!
</Box>
**stacks within stacks**
Let's combine all of the above into a little tune:
<MiniRepl
client:visible
tune={`stack(
stack(
sound("hh*8").gain("[.25 1]*4"),
sound("bd*4,[~ sd:1]*2")
),
note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("sawtooth").lpf("200 1000 200 1000"),
note("<[c3,g3,e4] [bb2,f3,d4] [a2,f3,c4] [bb2,g3,eb4]>")
.sound("sawtooth").vowel("<a e i o>")
)`}
tune={`$: sound("hh*8").gain("[.25 1]*4")
$: sound("bd*4,[~ sd:1]*2")
$: note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("sawtooth").lpf("200 1000 200 1000")
$: note("<[c3,g3,e4] [bb2,f3,d4] [a2,f3,c4] [bb2,g3,eb4]>")
.sound("sawtooth").vowel("<a e i o>")`}
/>
<Box>
Try to identify the individual parts of the stacks, pay attention to where the commas are.
The 3 parts (drums, bassline, chords) are exactly as earlier, just stacked together, separated by comma.
</Box>
**shape the sound with an adsr envelope**
<MiniRepl
@ -151,11 +140,10 @@ Can you guess what they do?
<MiniRepl
client:visible
tune={`stack(
note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted"),
sound("<bd rim>").bank("RolandTR707")
).delay(".5")`}
tune={`$: note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5)
$: sound("bd rim").bank("RolandTR707").delay(".5")`}
/>
<Box>
@ -199,31 +187,32 @@ Add a delay too!
<MiniRepl
client:visible
tune={`stack(
note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5),
sound("<bd rim>").bank("RolandTR707").delay(.5),
n("<4 [3@3 4] [<2 0> ~@16] ~>")
tune={`$: note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5)
$: sound("bd rim").bank("RolandTR707").delay(.5)
$: n("<4 [3@3 4] [<2 0> ~@16] ~>")
.scale("D4:minor").sound("gm_accordion:2")
room(2).gain(.5)
)`}
.room(2).gain(.5)`}
/>
Let's add a bass to make this complete:
<MiniRepl
client:visible
tune={`stack(
note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5),
sound("<bd rim>").bank("RolandTR707").delay(.5),
n("<4 [3@3 4] [<2 0> ~@16] ~>")
tune={`$: note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
.sound("gm_electric_guitar_muted").delay(.5)
$: sound("bd rim").bank("RolandTR707").delay(.5)
$: n("<4 [3@3 4] [<2 0> ~@16] ~>")
.scale("D4:minor").sound("gm_accordion:2")
.room(2).gain(.4),
n("[0 [~ 0] 4 [3 2] [0 ~] [0 ~] <0 2> ~]/2")
.room(2).gain(.4)
$: n("[0 [~ 0] 4 [3 2] [0 ~] [0 ~] <0 2> ~]/2")
.scale("D2:minor")
.sound("sawtooth,triangle").lpf(800)
)`}
.sound("sawtooth,triangle").lpf(800)`}
/>
<Box>
@ -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,7 +279,7 @@ 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
@ -301,7 +290,7 @@ We can change the automation speed with slow / fast:
<Box>
The whole automation will now take 8 cycles to repeat.
The whole modulation will now take 8 cycles to repeat.
</Box>

View file

@ -314,11 +314,11 @@ Let's recap what we've learned in this chapter:
New functions:
| Name | Description | Example |
| ----- | ----------------------------------- | --------------------------------------------------------------------------------- |
| note | set pitch as number or letter | <MiniRepl client:visible tune={`note("b g e c").sound("piano")`} /> |
| scale | interpret `n` as scale degree | <MiniRepl client:visible tune={`n("6 4 2 0").scale("C:minor").sound("piano")`} /> |
| stack | play patterns in parallel (read on) | <MiniRepl client:visible tune={`stack(s("bd sd"),note("c eb g"))`} /> |
| Name | Description | Example |
| ----- | ----------------------------- | --------------------------------------------------------------------------------- |
| note | set pitch as number or letter | <MiniRepl client:visible tune={`note("b g e c").sound("piano")`} /> |
| scale | interpret `n` as scale degree | <MiniRepl client:visible tune={`n("6 4 2 0").scale("C:minor").sound("piano")`} /> |
| $: | play patterns in parallel | <MiniRepl client:visible tune={'$: s("bd sd")\n$: note("c eb g")'} /> |
## Examples
@ -356,25 +356,35 @@ New functions:
<Box>
It's called `stack` 😙
You can use `$:` 😙
</Box>
## Playing multiple patterns
If you want to play multiple patterns at the same time, make sure to write `$:` before each:
<MiniRepl
client:visible
tune={`stack(
note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("gm_synth_bass_1").lpf(800),
n(\`<
tune={`$: note("<[c2 c3]*4 [bb1 bb2]*4 [f2 f3]*4 [eb2 eb3]*4>")
.sound("gm_synth_bass_1").lpf(800)
$: n(\`<
[~ 0] 2 [0 2] [~ 2]
[~ 0] 1 [0 1] [~ 1]
[~ 0] 3 [0 3] [~ 3]
[~ 0] 2 [0 2] [~ 2]
>*4\`).scale("C4:minor")
.sound("gm_synth_strings_1"),
sound("bd*4, [~ <sd cp>]*2, [~ hh]*4")
.bank("RolandTR909")
)`}
.sound("gm_synth_strings_1")
$: sound("bd*4, [~ <sd cp>]*2, [~ hh]*4")
.bank("RolandTR909")`}
/>
<Box>
Try changing `$` to `_$` to mute a part!
</Box>
This is starting to sound like actual music! We have sounds, we have notes, now the last piece of the puzzle is missing: [effects](/workshop/first-effects)

View file

@ -25,20 +25,16 @@ This is the same as:
<MiniRepl
client:visible
tune={`stack(
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0),
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).rev()
)`}
tune={`$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0)
$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).rev()`}
/>
Let's visualize what happens here:
<MiniRepl
client:visible
tune={`stack(
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0).color("cyan"),
n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).color("magenta").rev()
)`}
tune={`$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(0).color("cyan")
$: n("0 1 [4 3] 2 0 2 [~ 3] 4").sound("jazz").pan(1).color("magenta").rev()`}
punchcard
/>
@ -56,11 +52,9 @@ This is like doing
<MiniRepl
client:visible
tune={`stack(
note("c2, eb3 g3 [bb3 c4]").s("piano").slow(0.5).color('cyan'),
note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1).color('magenta'),
note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1.5).color('yellow')
)`}
tune={`$: note("c2, eb3 g3 [bb3 c4]").s("piano").slow(0.5).color('cyan')
$: note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1).color('magenta')
$: note("c2, eb3 g3 [bb3 c4]").s("piano").slow(1.5).color('yellow')`}
punchcard
/>
@ -110,17 +104,15 @@ We can add as often as we like:
<MiniRepl
client:visible
tune={`stack(
n("0 [2 4] <3 5> [~ <4 1>]".add("<0 [0,2,4]>"))
tune={`$: n("0 [2 4] <3 5> [~ <4 1>]".add("<0 [0,2,4]>"))
.scale("C5:minor")
.sound("gm_xylophone")
.room(.4).delay(.125),
note("c2 [eb3,g3]".add("<0 <1 -1>>"))
.room(.4).delay(.125)
$: note("c2 [eb3,g3]".add("<0 <1 -1>>"))
.adsr("[.1 0]:.2:[1 0]")
.sound("gm_acoustic_bass")
.room(.5),
n("0 1 [2 3] 2").sound("jazz").jux(rev)
)`}
.room(.5)
$: n("0 1 [2 3] 2").sound("jazz").jux(rev)`}
/>
**ply**
@ -145,13 +137,17 @@ 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>
@ -161,7 +157,7 @@ off is also useful for modifying other sounds, and can even be nested:
client:visible
tune={`s("bd sd [rim bd] sd,[~ hh]*4").bank("CasioRZ1")
.off(2/16, x=>x.speed(1.5).gain(.25)
.off(3/16, y=>y.vowel("<a e i o>*8")))`}
.off(3/16, y=>y.vowel("<a e i o>*8")))`}
/>
| name | description | example |

View file

@ -39,7 +39,7 @@ This page is just a listing of all functions covered in the workshop!
| --------- | ----------------------------- | --------------------------------------------------------------------------------- |
| note | set pitch as number or letter | <MiniRepl client:visible tune={`note("b g e c").sound("piano")`} /> |
| n + scale | set note in scale | <MiniRepl client:visible tune={`n("6 4 2 0").scale("C:minor").sound("piano")`} /> |
| stack | play patterns in parallel | <MiniRepl client:visible tune={`stack(s("bd sd"),note("c eb g"))`} /> |
| $: | play patterns in parallel | <MiniRepl client:visible tune={'$: s("bd sd")\n$: note("c eb g")'} /> |
## Audio Effects

View file

@ -4,264 +4,15 @@ Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/st
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { code2hash, logger, silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw';
import cx from '@src/cx.mjs';
import { transpiler } from '@strudel/transpiler';
import {
getAudioContext,
initAudioOnFirstClick,
webaudioOutput,
resetGlobalEffects,
resetLoadedSounds,
} from '@strudel/webaudio';
import { defaultAudioDeviceName } from '../settings.mjs';
import { getAudioDevices, setAudioDevice } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
import { clearHydra } from '@strudel/hydra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../settings.mjs';
import {
setActivePattern,
setLatestCode,
createPatternID,
userPattern,
getViewingPatternData,
setViewingPatternData,
} from '../user_pattern_utils.mjs';
import { Header } from './Header';
import Loader from './Loader';
import { Panel } from './panel/Panel';
import { useStore } from '@nanostores/react';
import { prebake } from './prebake.mjs';
import { getRandomTune, initCode, loadModules, shareCode, ReplContext } from './util.mjs';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
const { latestCode } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, isIframe;
if (typeof window !== 'undefined') {
initAudioOnFirstClick();
modulesLoading = loadModules();
presets = prebake();
drawContext = getDrawContext();
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
isIframe = window.location !== window.parent.location;
}
async function getModule(name) {
if (!modulesLoading) {
return;
}
const modules = await modulesLoading;
return modules.find((m) => m.packageName === name);
}
import { isIframe, isUdels } from './util.mjs';
import UdelsEditor from '@components/Udels/UdelsEditor';
import ReplEditor from './components/ReplEditor';
import EmbeddedReplEditor from './components/EmbeddedReplEditor';
import { useReplContext } from './useReplContext';
export function Repl({ embedded = false }) {
const isEmbedded = embedded || isIframe;
const { panelPosition, isZen, isSyncEnabled } = useSettings();
const init = useCallback(() => {
const drawTime = [-2, 2];
const drawContext = getDrawContext();
const editor = new StrudelMirror({
sync: isSyncEnabled,
defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime,
setInterval,
clearInterval,
transpiler,
autodraw: false,
root: containerRef.current,
initialCode: '// LOADING',
pattern: silence,
drawTime,
drawContext,
prebake: async () => Promise.all([modulesLoading, presets]),
onUpdateState: (state) => {
setReplState({ ...state });
},
onToggle: (playing) => {
if (!playing) {
clearHydra();
}
},
afterEval: (all) => {
const { code } = all;
setLatestCode(code);
window.location.hash = '#' + code2hash(code);
setDocumentTitle(code);
const viewingPatternData = getViewingPatternData();
const data = { ...viewingPatternData, code };
let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) {
const codeHasChanged = code !== viewingPatternData.code;
if (codeHasChanged) {
// fork example
const newPattern = userPattern.duplicate(data);
id = newPattern.id;
setViewingPatternData(newPattern.data);
}
} else {
id = userPattern.isValidID(id) ? id : createPatternID();
setViewingPatternData(userPattern.update(id, data).data);
}
setActivePattern(id);
},
bgFill: false,
});
// init settings
initCode().then(async (decoded) => {
let code, msg;
if (decoded) {
code = decoded;
msg = `I have loaded the code from the URL.`;
} else if (latestCode) {
code = latestCode;
msg = `Your last session has been loaded!`;
} else {
const { code: randomTune, name } = await getRandomTune();
code = randomTune;
msg = `A random code snippet named "${name}" has been loaded!`;
}
editor.setCode(code);
setDocumentTitle(code);
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
});
editorRef.current = editor;
}, []);
const [replState, setReplState] = useState({});
const { started, isDirty, error, activeCode, pending } = replState;
const editorRef = useRef();
const containerRef = useRef();
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
// this will be the case when the main repl is being replaced
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
useEffect(() => {
let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => {
if (_settings.hasOwnProperty(key)) {
editorSettings[key] = _settings[key];
}
});
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
// on first load, set stored audio device if possible
useEffect(() => {
const { audioDeviceName } = _settings;
if (audioDeviceName !== defaultAudioDeviceName) {
getAudioDevices().then((devices) => {
const deviceID = devices.get(audioDeviceName);
if (deviceID == null) {
return;
}
setAudioDevice(deviceID);
});
}
}, []);
//
// UI Actions
//
const setDocumentTitle = (code) => {
const meta = getMetadata(code);
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
};
const handleTogglePlay = async () => {
editorRef.current?.toggle();
};
const resetEditor = async () => {
(await getModule('@strudel/tonal'))?.resetVoicings();
resetGlobalEffects();
clearCanvas();
clearHydra();
resetLoadedSounds();
editorRef.current.repl.setCps(0.5);
await prebake(); // declare default samples
};
const handleUpdate = async (patternData, reset = false) => {
setViewingPatternData(patternData);
editorRef.current.setCode(patternData.code);
if (reset) {
await resetEditor();
handleEvaluate();
}
};
const handleEvaluate = () => {
editorRef.current.evaluate();
};
const handleShuffle = async () => {
const patternData = await getRandomTune();
const code = patternData.code;
logger(`[repl] ✨ loading random tune "${patternData.id}"`);
setActivePattern(patternData.id);
setViewingPatternData(patternData);
await resetEditor();
editorRef.current.setCode(code);
editorRef.current.repl.evaluate(code);
};
const handleShare = async () => shareCode(replState.code);
const context = {
embedded,
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
handleEvaluate,
};
return (
<ReplContext.Provider value={context}>
<div className={cx('h-full flex flex-col relative')}>
<Loader active={pending} />
<Header context={context} />
{isEmbedded && !started && (
<button
onClick={() => handleTogglePlay()}
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
>
<PlayCircleIcon className="w-6 h-6" />
<span>play</span>
</button>
)}
<div className="grow flex relative overflow-hidden">
<section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow' + (isZen ? ' px-10' : '')}
id="code"
ref={(el) => {
containerRef.current = el;
if (!editorRef.current) {
init();
}
}}
></section>
{panelPosition === 'right' && !isEmbedded && <Panel context={context} />}
</div>
{error && (
<div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>
)}
{panelPosition === 'bottom' && !isEmbedded && <Panel context={context} />}
</div>
</ReplContext.Provider>
);
const isEmbedded = embedded || isIframe();
const Editor = isUdels() ? UdelsEditor : isEmbedded ? EmbeddedReplEditor : ReplEditor;
const context = useReplContext();
return <Editor context={context} />;
}

View file

@ -0,0 +1,22 @@
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
// type Props = {
// started: boolean;
// handleTogglePlay: () => void;
// };
export default function BigPlayButton(Props) {
const { started, handleTogglePlay } = Props;
if (started) {
return;
}
return (
<button
onClick={() => handleTogglePlay()}
className="text-white text-2xl fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-[1000] m-auto p-4 bg-black rounded-md flex items-center space-x-2"
>
<PlayCircleIcon className="w-6 h-6" />
<span>play</span>
</button>
);
}

View file

@ -0,0 +1,21 @@
// type Props = {
// containerRef: React.MutableRefObject<HTMLElement | null>,
// editorRef: React.MutableRefObject<HTMLElement | null>,
// init: () => void
// }
export function Code(Props) {
const { editorRef, containerRef, init } = Props;
return (
<section
className={'text-gray-100 cursor-text pb-0 overflow-auto grow'}
id="code"
ref={(el) => {
containerRef.current = el;
if (!editorRef.current) {
init();
}
}}
></section>
);
}

View file

@ -0,0 +1,25 @@
import Loader from '@src/repl/components/Loader';
import { Code } from '@src/repl/components/Code';
import BigPlayButton from '@src/repl/components/BigPlayButton';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
import { Header } from './Header';
// type Props = {
// context: replcontext,
// }
export default function EmbeddedReplEditor(Props) {
const { context } = Props;
const { pending, started, handleTogglePlay, containerRef, editorRef, error, init } = context;
return (
<div className="h-full flex flex-col relative">
<Loader active={pending} />
<Header context={context} embedded={true} />
<BigPlayButton started={started} handleTogglePlay={handleTogglePlay} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
</div>
<UserFacingErrorMessage error={error} />
</div>
);
}

View file

@ -1,47 +1,29 @@
import AcademicCapIcon from '@heroicons/react/20/solid/AcademicCapIcon';
import ArrowPathIcon from '@heroicons/react/20/solid/ArrowPathIcon';
import LinkIcon from '@heroicons/react/20/solid/LinkIcon';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import SparklesIcon from '@heroicons/react/20/solid/SparklesIcon';
import StopCircleIcon from '@heroicons/react/20/solid/StopCircleIcon';
import cx from '@src/cx.mjs';
import { useSettings, setIsZen } from '../settings.mjs';
// import { ReplContext } from './Repl';
import './Repl.css';
import { useSettings, setIsZen } from '../../settings.mjs';
import '../Repl.css';
const { BASE_URL } = import.meta.env;
const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
export function Header({ context }) {
const {
embedded,
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleEvaluate,
handleShuffle,
handleShare,
} = context;
export function Header({ context, embedded = false }) {
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShuffle, handleShare } =
context;
const isEmbedded = typeof window !== 'undefined' && (embedded || window.location !== window.parent.location);
const { isZen } = useSettings();
const { isZen, isButtonRowHidden, isCSSAnimationDisabled } = useSettings();
return (
<header
id="header"
className={cx(
'flex-none text-black z-[100] text-lg select-none',
'flex-none text-black z-[100] text-lg select-none h-20 md:h-14',
!isZen && !isEmbedded && 'bg-lineHighlight',
isZen ? 'h-12 w-8 fixed top-0 left-0' : 'sticky top-0 w-full py-1 justify-between',
isEmbedded ? 'flex' : 'md:flex',
)}
>
<div className="px-4 flex space-x-2 md:pt-0 select-none">
{/* <img
src={logo}
className={cx('Tidal-logo', isEmbedded ? 'w-8 h-8' : 'w-10 h-10', started && 'animate-pulse')} // 'bg-[#ffffff80] rounded-full'
alt="logo"
/> */}
<h1
onClick={() => {
if (isEmbedded) window.open(window.location.href.replace('embed', ''));
@ -52,31 +34,46 @@ export function Header({ context }) {
)}
>
<div
className={cx('mt-[1px]', started && 'animate-spin', 'cursor-pointer')}
className={cx(
'mt-[1px]',
started && !isCSSAnimationDisabled && 'animate-spin',
'cursor-pointer text-blue-500',
isZen && 'fixed top-2 right-4',
)}
onClick={() => {
if (!isEmbedded) {
setIsZen(!isZen);
}
}}
>
🌀
<span className="block rotate-90"></span>
</div>
{!isZen && (
<div className={cx(started && 'animate-pulse')}>
<span className="">strudel</span> <span className="text-sm">REPL</span>
<div className="space-x-2">
<span className="">strudel</span>
<span className="text-sm font-medium">REPL</span>
{!isEmbedded && isButtonRowHidden && (
<a href={`${baseNoTrailing}/learn`} className="text-sm opacity-25 font-medium">
DOCS
</a>
)}
</div>
)}
</h1>
</div>
{!isZen && (
<div className="flex max-w-full overflow-auto text-foreground">
{!isZen && !isButtonRowHidden && (
<div className="flex max-w-full overflow-auto text-foreground px-1 md:px-2">
<button
onClick={handleTogglePlay}
title={started ? 'stop' : 'play'}
className={cx(!isEmbedded ? 'p-2' : 'px-2', 'hover:opacity-50', !started && 'animate-pulse')}
className={cx(
!isEmbedded ? 'p-2' : 'px-2',
'hover:opacity-50',
!started && !isCSSAnimationDisabled && 'animate-pulse',
)}
>
{!pending ? (
<span className={cx('flex items-center space-x-1', isEmbedded ? '' : 'w-16')}>
<span className={cx('flex items-center space-x-2')}>
{started ? <StopCircleIcon className="w-6 h-6" /> : <PlayCircleIcon className="w-6 h-6" />}
{!isEmbedded && <span>{started ? 'stop' : 'play'}</span>}
</span>
@ -93,8 +90,6 @@ export function Header({ context }) {
!isDirty || !activeCode ? 'opacity-50' : 'hover:opacity-50',
)}
>
{/* <CommandLineIcon className="w-6 h-6" /> */}
<ArrowPathIcon className="w-6 h-6" />
{!isEmbedded && <span>update</span>}
</button>
{!isEmbedded && (
@ -103,7 +98,6 @@ export function Header({ context }) {
className="hover:opacity-50 p-2 flex items-center space-x-1"
onClick={handleShuffle}
>
<SparklesIcon className="w-6 h-6" />
<span> shuffle</span>
</button>
)}
@ -116,7 +110,6 @@ export function Header({ context }) {
)}
onClick={handleShare}
>
<LinkIcon className="w-6 h-6" />
<span>share</span>
</button>
)}
@ -126,7 +119,6 @@ export function Header({ context }) {
href={`${baseNoTrailing}/workshop/getting-started/`}
className={cx('hover:opacity-50 flex items-center space-x-1', !isEmbedded ? 'p-2' : 'px-2')}
>
<AcademicCapIcon className="w-6 h-6" />
<span>learn</span>
</a>
)}

View file

@ -0,0 +1,54 @@
function Button(Props) {
const { children, onClick } = Props;
return (
<button
onClick={onClick}
type="button"
data-input-counter-increment="counter-input"
className="flex-shrink-0 bg-gray-700 hover:bg-gray-600 border-gray-600 inline-flex items-center justify-center border rounded-md h-5 w-5 focus:ring-gray-700 focus:ring-2 focus:outline-none"
>
{children}
</button>
);
}
export default function NumberInput(Props) {
const { value = 0, setValue, max, min } = Props;
return (
<div className="relative flex items-center">
<Button onClick={() => setValue(value - 1)}>
<svg
className="w-2.5 h-2.5 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 18 2"
>
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1 1h16" />
</svg>
</Button>
<input
min={min}
max={max}
type="text"
data-input-counter
className="flex-shrink-0 text-white border-0 bg-transparent text-sm font-normal focus:outline-none focus:ring-0 max-w-[2.5rem] text-center"
placeholder=""
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button onClick={() => setValue(value + 1)}>
<svg
className="w-2.5 h-2.5 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 18 18"
>
<path strokeLinecap="round" stroke="currentColor" strokeLinejoin="round" strokeWidth="2" d="M9 1v16M1 9h16" />
</svg>
</Button>
</div>
);
}

View file

@ -0,0 +1,30 @@
import Loader from '@src/repl/components/Loader';
import { HorizontalPanel, VerticalPanel } from '@src/repl/components/panel/Panel';
import { Code } from '@src/repl/components/Code';
import UserFacingErrorMessage from '@src/repl/components/UserFacingErrorMessage';
import { Header } from './Header';
import { useSettings } from '@src/settings.mjs';
// type Props = {
// context: replcontext,
// }
export default function ReplEditor(Props) {
const { context } = Props;
const { containerRef, editorRef, error, init, pending } = context;
const settings = useSettings();
const { panelPosition, isZen } = settings;
return (
<div className="h-full flex flex-col relative">
<Loader active={pending} />
<Header context={context} />
<div className="grow flex relative overflow-hidden">
<Code containerRef={containerRef} editorRef={editorRef} init={init} />
{!isZen && panelPosition === 'right' && <VerticalPanel context={context} />}
</div>
<UserFacingErrorMessage error={error} />
{!isZen && panelPosition === 'bottom' && <HorizontalPanel context={context} />}
</div>
);
}

View file

@ -0,0 +1,8 @@
// type Props = { error: Error | null };
export default function UserFacingErrorMessage(Props) {
const { error } = Props;
if (error == null) {
return;
}
return <div className="text-red-500 p-4 bg-lineHighlight animate-pulse">{error.message || 'Unknown Error :-/'}</div>;
}

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { getAudioDevices, setAudioDevice } from '../util.mjs';
import { getAudioDevices, setAudioDevice } from '../../util.mjs';
import { SelectInput } from './SelectInput';
const initdevices = new Map();

View file

@ -0,0 +1,29 @@
import React from 'react';
import { audioEngineTargets } from '../../../settings.mjs';
import { SelectInput } from './SelectInput';
// Allows the user to select an audio interface for Strudel to play through
export function AudioEngineTargetSelector({ target, onChange, isDisabled }) {
const onTargetChange = (target) => {
onChange(target);
};
const options = new Map([
[audioEngineTargets.webaudio, audioEngineTargets.webaudio],
[audioEngineTargets.osc, audioEngineTargets.osc],
]);
return (
<div className=" flex flex-col gap-1">
<SelectInput isDisabled={isDisabled} options={options} value={target} onChange={onTargetChange} />
{target === audioEngineTargets.osc && (
<div>
<p className="text-sm italic">
All events routed to OSC, audio is silenced! See{' '}
<a className="text-blue-500" href="https://strudel.cc/learn/input-output/">
Docs
</a>
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,78 @@
import { logger } from '@strudel/core';
import useEvent from '@src/useEvent.mjs';
import cx from '@src/cx.mjs';
import { nanoid } from 'nanoid';
import { useCallback, useState } from 'react';
import { useSettings } from '../../../settings.mjs';
export function ConsoleTab() {
const [log, setLog] = useState([]);
const { fontFamily, fontSize } = useSettings();
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
return (
<div
id="console-tab"
className="break-all px-4 dark:text-white text-stone-900 text-sm py-2 space-y-1"
style={{ fontFamily, fontSize }}
>
{log.map((l, i) => {
const message = linkify(l.message);
const color = l.data?.hap?.value?.color;
return (
<div
key={l.id}
className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}
style={color ? { color } : {}}
>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1<a class="underline" href="http://$2" target="_blank">$2</a>',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
return replacedText;
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}

View file

@ -1,6 +1,6 @@
import { Fragment, useEffect } from 'react';
import React, { useMemo, useState } from 'react';
import { isAudioFile, readDir, dir, playFile } from '../files.mjs';
import { isAudioFile, readDir, dir, playFile } from '../../files.mjs';
export function FilesTab() {
const [path, setPath] = useState([]);

View file

@ -6,6 +6,7 @@ export function ButtonGroup({ value, onChange, items }) {
{Object.entries(items).map(([key, label], i, arr) => (
<button
key={key}
id={key}
onClick={() => onChange(key)}
className={cx(
'px-2 border-b h-8 whitespace-nowrap',

View file

@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { registerSamplesFromDB, uploadSamplesToDB, userSamplesDBConfig } from '../idbutils.mjs';
import { registerSamplesFromDB, uploadSamplesToDB, userSamplesDBConfig } from '../../idbutils.mjs';
//choose a directory to locally import samples
export default function ImportSoundsButton({ onComplete }) {
@ -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();
}}

View file

@ -0,0 +1,212 @@
import cx from '@src/cx.mjs';
import { setPanelPinned, setActiveFooter as setTab, setIsPanelOpened, useSettings } from '../../../settings.mjs';
import { ConsoleTab } from './ConsoleTab';
import { FilesTab } from './FilesTab';
import { Reference } from './Reference';
import { SettingsTab } from './SettingsTab';
import { SoundsTab } from './SoundsTab';
import { WelcomeTab } from './WelcomeTab';
import { PatternsTab } from './PatternsTab';
import { ChevronLeftIcon, XMarkIcon } from '@heroicons/react/16/solid';
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
export function HorizontalPanel({ context }) {
const settings = useSettings();
const { isPanelOpen, activeFooter: tab } = settings;
return (
<PanelNav
settings={settings}
className={cx(isPanelOpen ? `min-h-[360px] max-h-[360px]` : 'min-h-12 max-h-12', 'overflow-hidden flex flex-col')}
>
{isPanelOpen && (
<div className="flex h-full overflow-auto pr-10 ">
<PanelContent context={context} tab={tab} />
</div>
)}
<div className="absolute right-4 pt-4">
<PanelActionButton settings={settings} />
</div>
<div className="flex justify-between min-h-12 max-h-12 grid-cols-2 items-center">
<Tabs setTab={setTab} tab={tab} />
</div>
</PanelNav>
);
}
export function VerticalPanel({ context }) {
const settings = useSettings();
const { activeFooter: tab, isPanelOpen } = settings;
return (
<PanelNav
settings={settings}
className={cx(isPanelOpen ? `min-w-[min(600px,80vw)] max-w-[min(600px,80vw)]` : 'min-w-12 max-w-12')}
>
{isPanelOpen ? (
<div className={cx('flex flex-col h-full')}>
<div className="flex justify-between w-full ">
<Tabs setTab={setTab} tab={tab} />
<PanelActionButton settings={settings} />
</div>
<div className="overflow-auto h-full">
<PanelContent context={context} tab={tab} />
</div>
</div>
) : (
<button
onClick={(e) => {
setIsPanelOpened(true);
}}
aria-label="open menu panel"
className={cx(
'flex flex-col hover:bg-lineBackground items-center cursor-pointer justify-center w-full h-full',
)}
>
<ChevronLeftIcon className="text-foreground opacity-50 w-6 h-6" />
</button>
)}
</PanelNav>
);
}
const tabNames = {
welcome: 'intro',
patterns: 'patterns',
sounds: 'sounds',
reference: 'reference',
console: 'console',
settings: 'settings',
};
if (TAURI) {
tabNames.files = 'files';
}
function PanelNav({ children, className, settings, ...props }) {
const isHoverBehavior = settings.togglePanelTrigger === 'hover';
return (
<nav
onClick={() => {
if (!settings.isPanelOpen) {
setIsPanelOpened(true);
}
}}
onMouseEnter={() => {
if (isHoverBehavior && !settings.isPanelOpen) {
setIsPanelOpened(true);
}
}}
onMouseLeave={() => {
if (isHoverBehavior && !settings.isPanelPinned) {
setIsPanelOpened(false);
}
}}
aria-label="Menu Panel"
className={cx('bg-lineHighlight group overflow-x-auto', className)}
{...props}
>
{children}
</nav>
);
}
function PanelContent({ context, tab }) {
switch (tab) {
case tabNames.patterns:
return <PatternsTab context={context} />;
case tabNames.console:
return <ConsoleTab />;
case tabNames.sounds:
return <SoundsTab />;
case tabNames.reference:
return <Reference />;
case tabNames.settings:
return <SettingsTab started={context.started} />;
case tabNames.files:
return <FilesTab />;
default:
return <WelcomeTab context={context} />;
}
}
function PanelTab({ label, isSelected, onClick }) {
return (
<>
<button
onClick={onClick}
className={cx(
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
isSelected ? 'border-foreground' : 'border-transparent',
)}
>
{label}
</button>
</>
);
}
function Tabs({ setTab, tab, className }) {
return (
<div className={cx('flex select-none max-w-full overflow-auto pb-2', className)}>
{Object.keys(tabNames).map((key) => {
const val = tabNames[key];
return <PanelTab key={key} isSelected={tab === val} label={key} onClick={() => setTab(val)} />;
})}
</div>
);
}
function PanelActionButton({ settings }) {
const { togglePanelTrigger, isPanelPinned, isPanelOpen } = settings;
const isHoverBehavior = togglePanelTrigger === 'hover';
if (!isPanelOpen) {
return;
}
if (isHoverBehavior) {
return <PinButton pinned={isPanelPinned} />;
}
return <CloseButton onClick={() => setIsPanelOpened(false)} />;
}
function PinButton({ pinned }) {
return (
<button
onClick={() => setPanelPinned(!pinned)}
className={cx(
'text-foreground max-h-8 min-h-8 max-w-8 min-w-8 items-center justify-center p-1.5 group-hover:flex',
pinned ? 'flex' : 'hidden',
)}
aria-label="Pin Menu Panel"
>
<svg
stroke="currentColor"
fill={'currentColor'}
strokeWidth="0"
className="w-full h-full"
opacity={pinned ? 1 : '.3'}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.828.722a.5.5 0 0 1 .354.146l4.95 4.95a.5.5 0 0 1 0 .707c-.48.48-1.072.588-1.503.588-.177 0-.335-.018-.46-.039l-3.134 3.134a6 6 0 0 1 .16 1.013c.046.702-.032 1.687-.72 2.375a.5.5 0 0 1-.707 0l-2.829-2.828-3.182 3.182c-.195.195-1.219.902-1.414.707s.512-1.22.707-1.414l3.182-3.182-2.828-2.829a.5.5 0 0 1 0-.707c.688-.688 1.673-.767 2.375-.72a6 6 0 0 1 1.013.16l3.134-3.133a3 3 0 0 1-.04-.461c0-.43.108-1.022.589-1.503a.5.5 0 0 1 .353-.146"></path>
</svg>
</button>
);
}
function CloseButton({ onClick }) {
return (
<button
onClick={onClick}
className={cx(
'text-foreground max-h-8 min-h-8 max-w-8 min-w-8 items-center justify-center p-1.5 group-hover:flex',
)}
aria-label="Close Menu"
>
<XMarkIcon />
</button>
);
}

View file

@ -5,13 +5,13 @@ import {
useActivePattern,
useViewingPatternData,
userPattern,
} from '../../user_pattern_utils.mjs';
} from '../../../user_pattern_utils.mjs';
import { useMemo } from 'react';
import { getMetadata } from '../../metadata_parser';
import { useExamplePatterns } from '../useExamplePatterns';
import { parseJSON } from '../util.mjs';
import { getMetadata } from '../../../metadata_parser.js';
import { useExamplePatterns } from '../../useExamplePatterns.jsx';
import { parseJSON, isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { settingsMap, useSettings } from '../../settings.mjs';
import { settingsMap, useSettings } from '../../../settings.mjs';
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
@ -77,7 +77,7 @@ function PatternButtons({ patterns, activePattern, onClick, started }) {
function ActionButton({ children, onClick, label, labelIsHidden }) {
return (
<button className="hover:opacity-50" onClick={onClick} title={label}>
<button className="hover:opacity-50 text-nowrap" onClick={onClick} title={label}>
{labelIsHidden !== true && label}
{children}
</button>
@ -99,10 +99,10 @@ export function PatternsTab({ context }) {
};
const viewingPatternID = viewingPatternData?.id;
const autoResetPatternOnChange = !window.parent?.location.pathname.includes('oodles');
const autoResetPatternOnChange = !isUdels();
return (
<div className="px-4 w-full dark:text-white text-stone-900 space-y-2 pb-4 flex flex-col overflow-hidden max-h-full">
<div className="px-4 w-full dark:text-white text-stone-900 space-y-2 flex flex-col overflow-hidden max-h-full h-full">
<ButtonGroup
value={patternFilter}
onChange={(value) => settingsMap.setKey('patternFilter', value)}
@ -155,7 +155,7 @@ export function PatternsTab({ context }) {
</div>
)}
<section className="flex overflow-y-scroll max-h-full flex-col">
<section className="flex overflow-y-auto max-h-full flex-grow flex-col">
{patternFilter === patternFilterName.user && (
<PatternButtons
onClick={(id) =>

View file

@ -0,0 +1,90 @@
import { useMemo, useState } from 'react';
import jsdocJson from '../../../../../doc.json';
const availableFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
const getInnerText = (html) => {
var div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
};
export function Reference() {
const [search, setSearch] = useState('');
const visibleFunctions = useMemo(() => {
return availableFunctions.filter((entry) => {
if (!search) {
return true;
}
return entry.name.includes(search) || (entry.synonyms?.some((s) => s.includes(search)) ?? false);
});
}, [search]);
return (
<div className="flex h-full w-full p-2 text-foreground overflow-hidden">
<div className="h-full flex flex-col gap-2 w-1/3 max-w-72 ">
<div class="w-full flex">
<input
className="w-full p-1 bg-background rounded-md border-none"
placeholder="Search"
value={search}
onInput={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex flex-col h-full overflow-y-auto gap-1.5 bg-background bg-opacity-50 rounded-md">
{visibleFunctions.map((entry, i) => (
<a
key={i}
className="cursor-pointer flex-none hover:bg-lineHighlight overflow-x-hidden px-1 text-ellipsis"
onClick={() => {
const el = document.getElementById(`doc-${i}`);
const container = document.getElementById('reference-container');
container.scrollTo(0, el.offsetTop);
}}
>
{entry.name} {/* <span className="text-gray-600">{entry.meta.filename}</span> */}
</a>
))}
</div>
</div>
<div
className="break-normal flex-grow flex-col overflow-y-auto overflow-x-hidden px-2 flex relative"
id="reference-container"
>
<div className="prose dark:prose-invert min-w-full px-1 ">
<h2>API Reference</h2>
<p>
This is the long list functions you can use! Remember that you don't need to remember all of those and that
you can already make music with a small set of functions!
</p>
{visibleFunctions.map((entry, i) => (
<section key={i}>
<h3 id={`doc-${i}`}>{entry.name}</h3>
{!!entry.synonyms_text && (
<p>
Synonyms: <code>{entry.synonyms_text}</code>
</p>
)}
{/* <small>{entry.meta.filename}</small> */}
<p dangerouslySetInnerHTML={{ __html: entry.description }}></p>
<ul>
{entry.params?.map(({ name, type, description }, i) => (
<li key={i}>
{name} : {type.names?.join(' | ')} {description ? <> - {getInnerText(description)}</> : ''}
</li>
))}
</ul>
{entry.examples?.map((example, j) => (
<pre key={j}>{example}</pre>
))}
</section>
))}
</div>
</div>
</div>
);
}

View file

@ -1,12 +1,15 @@
import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs';
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
import { themes } from '@strudel/codemirror';
import { isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx';
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs';
function Checkbox({ label, value, onChange }) {
function Checkbox({ label, value, onChange, disabled = false }) {
return (
<label>
<input type="checkbox" checked={value} onChange={onChange} />
<input disabled={disabled} type="checkbox" checked={value} onChange={onChange} />
{' ' + label}
</label>
);
@ -77,6 +80,8 @@ const fontFamilyOptions = {
galactico: 'galactico',
};
const RELOAD_MSG = 'Changing this setting requires the window to reload itself. OK?';
export function SettingsTab({ started }) {
const {
theme,
@ -89,25 +94,50 @@ export function SettingsTab({ started }) {
isAutoCompletionEnabled,
isTooltipEnabled,
isFlashEnabled,
isButtonRowHidden,
isCSSAnimationDisabled,
isSyncEnabled,
isLineWrappingEnabled,
fontSize,
fontFamily,
panelPosition,
audioDeviceName,
audioEngineTarget,
togglePanelTrigger,
} = useSettings();
const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
return (
<div className="text-foreground p-4 space-y-4">
{AudioContext.prototype.setSinkId != null && (
<div className="text-foreground p-4 space-y-4 w-full">
{canChangeAudioDevice && (
<FormItem label="Audio Output Device">
<AudioDeviceSelector
isDisabled={started}
audioDeviceName={audioDeviceName}
onChange={(audioDeviceName) => settingsMap.setKey('audioDeviceName', audioDeviceName)}
onChange={(audioDeviceName) => {
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('audioDeviceName', audioDeviceName);
return window.location.reload();
}
});
}}
/>
</FormItem>
)}
<FormItem label="Audio Engine Target">
<AudioEngineTargetSelector
target={audioEngineTarget}
onChange={(target) => {
confirmDialog(RELOAD_MSG).then((r) => {
if (r == true) {
settingsMap.setKey('audioEngineTarget', target);
return window.location.reload();
}
});
}}
/>
</FormItem>
<FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem>
@ -143,7 +173,32 @@ export function SettingsTab({ started }) {
items={{ bottom: 'Bottom', right: 'Right' }}
></ButtonGroup>
</FormItem>
<FormItem label="Code Settings">
<FormItem label="Open Panel on: ">
<ButtonGroup
value={togglePanelTrigger}
onChange={(value) => settingsMap.setKey('togglePanelTrigger', value)}
items={{ click: 'Click', hover: 'Hover' }}
></ButtonGroup>
{/* <Checkbox
label="Click"
onChange={(cbEvent) => {
if (cbEvent.target.checked) {
settingsMap.setKey('togglePanelTrigger', 'click');
}
}}
value={togglePanelTrigger != 'hover'}
/>
<Checkbox
label="Hover"
onChange={(cbEvent) => {
if (cbEvent.target.checked) {
settingsMap.setKey('togglePanelTrigger', 'hover');
}
}}
value={togglePanelTrigger == 'hover'}
/> */}
</FormItem>
<FormItem label="More Settings">
<Checkbox
label="Enable bracket matching"
onChange={(cbEvent) => settingsMap.setKey('isBracketMatchingEnabled', cbEvent.target.checked)}
@ -192,22 +247,38 @@ export function SettingsTab({ started }) {
<Checkbox
label="Sync across Browser Tabs / Windows"
onChange={(cbEvent) => {
if (confirm('Changing this setting requires the window to reload itself. OK?')) {
settingsMap.setKey('isSyncEnabled', cbEvent.target.checked);
window.location.reload();
}
const newVal = cbEvent.target.checked;
confirmDialog(RELOAD_MSG).then((r) => {
if (r) {
settingsMap.setKey('isSyncEnabled', newVal);
window.location.reload();
}
});
}}
disabled={shouldAlwaysSync}
value={isSyncEnabled}
/>
<Checkbox
label="Hide top buttons"
onChange={(cbEvent) => settingsMap.setKey('isButtonRowHidden', cbEvent.target.checked)}
value={isButtonRowHidden}
/>
<Checkbox
label="Disable CSS Animations"
onChange={(cbEvent) => settingsMap.setKey('isCSSAnimationDisabled', cbEvent.target.checked)}
value={isCSSAnimationDisabled}
/>
</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"
onClick={() => {
if (confirm('Sure?')) {
settingsMap.set(defaultSettings);
}
confirmDialog('Sure?').then((r) => {
if (r) {
settingsMap.set(defaultSettings);
}
});
}}
>
restore default settings

View file

@ -1,8 +1,8 @@
import useEvent from '@src/useEvent.mjs';
import { useStore } from '@nanostores/react';
import { getAudioContext, soundMap, connectToDestination } from '@strudel/webaudio';
import React, { useMemo, useRef } from 'react';
import { settingsMap, useSettings } from '../../settings.mjs';
import { useMemo, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../../../settings.mjs';
import { ButtonGroup } from './Forms.jsx';
import ImportSoundsButton from './ImportSoundsButton.jsx';
@ -12,13 +12,20 @@ const getSamples = (samples) =>
export function SoundsTab() {
const sounds = useStore(soundMap);
const { soundsFilter } = useSettings();
const [search, setSearch] = useState('');
const soundEntries = useMemo(() => {
let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
if (!sounds) {
return [];
}
let filtered = Object.entries(sounds)
.filter(([key]) => !key.startsWith('_'))
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([name]) => name.toLowerCase().includes(search.toLowerCase()));
if (soundsFilter === 'user') {
return filtered.filter(([key, { data }]) => !data.prebake);
return filtered.filter(([_, { data }]) => !data.prebake);
}
if (soundsFilter === 'drums') {
return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
@ -30,9 +37,11 @@ export function SoundsTab() {
return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
}
return filtered;
}, [sounds, soundsFilter]);
}, [sounds, soundsFilter, search]);
// holds mutable ref to current triggered sound
const trigRef = useRef();
// stop current sound on mouseup
useEvent('mouseup', () => {
const t = trigRef.current;
@ -41,9 +50,17 @@ export function SoundsTab() {
ref?.stop(getAudioContext().currentTime + 0.01);
});
});
return (
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
<div className="pb-2 flex shrink-0 overflow-auto">
<div id="sounds-tab" className="px-4 flex flex-col w-full h-full dark:text-white text-stone-900">
<input
className="w-full p-1 bg-background rounded-md my-2"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="pb-2 flex shrink-0 flex-wrap">
<ButtonGroup
value={soundsFilter}
onChange={(value) => settingsMap.setKey('soundsFilter', value)}
@ -56,7 +73,8 @@ export function SoundsTab() {
></ButtonGroup>
<ImportSoundsButton onComplete={() => settingsMap.setKey('soundsFilter', 'user')} />
</div>
<div className="min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal">
<div className="min-h-0 max-h-full grow overflow-auto font-mono text-sm break-normal pb-2">
{soundEntries.map(([name, { data, onTrigger }]) => {
return (
<span

View file

@ -5,10 +5,8 @@ const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
export function WelcomeTab({ context }) {
return (
<div className="prose dark:prose-invert max-w-[600px] pt-2 font-sans pb-8 px-4">
<h3>
<span className={cx('animate-spin inline-block select-none')}>🌀</span> welcome
</h3>
<div className="prose dark:prose-invert min-w-full pt-2 font-sans pb-8 px-4 ">
<h3> welcome</h3>
<p>
You have found <span className="underline">strudel</span>, a new live coding platform to write dynamic music
pieces in the browser! It is free and open-source and made for beginners and experts alike. To get started:
@ -30,7 +28,7 @@ export function WelcomeTab({ context }) {
</a>{' '}
to ask any questions, give feedback or just say hello.
</p>
<h3>about</h3>
<h3> about</h3>
<p>
strudel is a JavaScript version of{' '}
<a href="https://tidalcycles.org/" target="_blank">

View file

@ -23,10 +23,10 @@ async function hasStrudelJson(subpath) {
async function loadStrudelJson(subpath) {
const contents = await readTextFile(subpath + '/strudel.json', { dir });
const sampleMap = JSON.parse(contents);
processSampleMap(sampleMap, (key, value) => {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value, fileResolver(subpath)), {
processSampleMap(sampleMap, (key, bank) => {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank, fileResolver(subpath)), {
type: 'sample',
samples: value,
samples: bank,
fileSystem: true,
tag: 'local',
});
@ -75,7 +75,8 @@ export const walkFileTree = (node, fn) => {
}
};
export const isAudioFile = (filename) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]);
export const isAudioFile = (filename) =>
['wav', 'mp3', 'flac', 'ogg', 'm4a', 'aac'].includes(filename.split('.').slice(-1)[0]);
function uint8ArrayToDataURL(uint8Array) {
const blob = new Blob([uint8Array], { type: 'audio/*' });

View file

@ -12,7 +12,7 @@ export const userSamplesDBConfig = {
};
// deletes all of the databases, useful for debugging
const clearIDB = () => {
function clearIDB() {
window.indexedDB
.databases()
.then((r) => {
@ -21,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);
});
};
}

View file

@ -1,44 +0,0 @@
import cx from '@src/cx.mjs';
export function ConsoleTab({ log }) {
return (
<div id="console-tab" className="break-all px-4 dark:text-white text-stone-900 text-sm">
<pre>{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
`}</pre>
{log.map((l, i) => {
const message = linkify(l.message);
return (
<div key={l.id} className={cx(l.type === 'error' && 'text-red-500', l.type === 'highlight' && 'underline')}>
<span dangerouslySetInnerHTML={{ __html: message }} />
{l.count ? ` (${l.count})` : ''}
</div>
);
})}
</div>
);
}
function linkify(inputText) {
var replacedText, replacePattern1, replacePattern2, replacePattern3;
//URLs starting with http://, https://, or ftp://
replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
replacedText = inputText.replace(replacePattern1, '<a class="underline" href="$1" target="_blank">$1</a>');
//URLs starting with "www." (without // before it, or it'd re-link the ones done above).
replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
replacedText = replacedText.replace(
replacePattern2,
'$1<a class="underline" href="http://$2" target="_blank">$2</a>',
);
//Change email addresses to mailto:: links.
replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
replacedText = replacedText.replace(replacePattern3, '<a class="underline" href="mailto:$1">$1</a>');
return replacedText;
}

View file

@ -1,128 +0,0 @@
import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
import { logger } from '@strudel/core';
import useEvent from '@src/useEvent.mjs';
import cx from '@src/cx.mjs';
import { nanoid } from 'nanoid';
import { useCallback, useLayoutEffect, useEffect, useRef, useState } from 'react';
import { setActiveFooter, useSettings } from '../../settings.mjs';
import { ConsoleTab } from './ConsoleTab';
import { FilesTab } from './FilesTab';
import { Reference } from './Reference';
import { SettingsTab } from './SettingsTab';
import { SoundsTab } from './SoundsTab';
import { WelcomeTab } from './WelcomeTab';
import { PatternsTab } from './PatternsTab';
import useClient from '@src/useClient.mjs';
// https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
const TAURI = typeof window !== 'undefined' && window.__TAURI__;
export function Panel({ context }) {
const footerContent = useRef();
const [log, setLog] = useState([]);
const { activeFooter, isZen, panelPosition } = useSettings();
useIsomorphicLayoutEffect(() => {
if (footerContent.current && activeFooter === 'console') {
// scroll log box to bottom when log changes
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
}
}, [log, activeFooter]);
useIsomorphicLayoutEffect(() => {
if (!footerContent.current) {
} else if (activeFooter === 'console') {
footerContent.current.scrollTop = footerContent.current?.scrollHeight;
} else {
footerContent.current.scrollTop = 0;
}
}, [activeFooter]);
useLogger(
useCallback((e) => {
const { message, type, data } = e.detail;
setLog((l) => {
const lastLog = l.length ? l[l.length - 1] : undefined;
const id = nanoid(12);
// if (type === 'loaded-sample' && lastLog.type === 'load-sample' && lastLog.url === data.url) {
if (type === 'loaded-sample') {
// const loadIndex = l.length - 1;
const loadIndex = l.findIndex(({ data: { url }, type }) => type === 'load-sample' && url === data.url);
l[loadIndex] = { message, type, id, data };
} else if (lastLog && lastLog.message === message) {
l = l.slice(0, -1).concat([{ message, type, count: (lastLog.count ?? 1) + 1, id, data }]);
} else {
l = l.concat([{ message, type, id, data }]);
}
return l.slice(-20);
});
}, []),
);
const PanelTab = ({ children, name, label }) => (
<>
<div
onClick={() => setActiveFooter(name)}
className={cx(
'h-8 px-2 text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b',
activeFooter === name ? 'border-foreground' : 'border-transparent',
)}
>
{label || name}
</div>
{activeFooter === name && <>{children}</>}
</>
);
const client = useClient();
if (isZen) {
return null;
}
const isActive = activeFooter !== '';
let positions = {
right: cx('max-w-full flex-grow-0 flex-none overflow-hidden', isActive ? 'w-[600px] h-full' : 'absolute right-0'),
bottom: cx('relative', isActive ? 'h-[360px] min-h-[360px]' : ''),
};
if (!client) {
return null;
}
return (
<nav className={cx('bg-lineHighlight z-[10] flex flex-col', positions[panelPosition])}>
<div className="flex justify-between px-2">
<div className={cx('flex select-none max-w-full overflow-auto', activeFooter && 'pb-2')}>
<PanelTab name="intro" label="welcome" />
<PanelTab name="patterns" />
<PanelTab name="sounds" />
<PanelTab name="console" />
<PanelTab name="reference" />
<PanelTab name="settings" />
{TAURI && <PanelTab name="files" />}
</div>
{activeFooter !== '' && (
<button onClick={() => setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
{activeFooter !== '' && (
<div className="relative overflow-hidden">
<div className="text-white overflow-auto h-full max-w-full" ref={footerContent}>
{activeFooter === 'intro' && <WelcomeTab context={context} />}
{activeFooter === 'patterns' && <PatternsTab context={context} />}
{activeFooter === 'console' && <ConsoleTab log={log} />}
{activeFooter === 'sounds' && <SoundsTab />}
{activeFooter === 'reference' && <Reference />}
{activeFooter === 'settings' && <SettingsTab started={context.started} />}
{activeFooter === 'files' && <FilesTab />}
</div>
</div>
)}
</nav>
);
}
function useLogger(onTrigger) {
useEvent(logger.key, onTrigger);
}

View file

@ -1,63 +0,0 @@
import jsdocJson from '../../../../doc.json';
const visibleFunctions = jsdocJson.docs
.filter(({ name, description }) => name && !name.startsWith('_') && !!description)
.sort((a, b) => /* a.meta.filename.localeCompare(b.meta.filename) + */ a.name.localeCompare(b.name));
const getInnerText = (html) => {
var div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
};
export function Reference() {
return (
<div className="flex h-full w-full pt-2 text-foreground overflow-hidden">
<div className="w-42 flex-none h-full overflow-y-auto overflow-x-hidden pr-4">
{visibleFunctions.map((entry, i) => (
<a
key={i}
className="cursor-pointer block hover:bg-lineHighlight py-1 px-4"
onClick={() => {
const el = document.getElementById(`doc-${i}`);
const container = document.getElementById('reference-container');
container.scrollTo(0, el.offsetTop);
}}
>
{entry.name} {/* <span className="text-gray-600">{entry.meta.filename}</span> */}
</a>
))}
</div>
<div className="break-normal w-full h-full overflow-auto pl-4 flex relative" id="reference-container">
<div className="prose dark:prose-invert max-w-full pr-4">
<h2>API Reference</h2>
<p>
This is the long list functions you can use! Remember that you don't need to remember all of those and that
you can already make music with a small set of functions!
</p>
{visibleFunctions.map((entry, i) => (
<section key={i}>
<h3 id={`doc-${i}`}>{entry.name}</h3>
{!!entry.synonyms_text && (
<p>
Synonyms: <code>{entry.synonyms_text}</code>
</p>
)}
{/* <small>{entry.meta.filename}</small> */}
<p dangerouslySetInnerHTML={{ __html: entry.description }}></p>
<ul>
{entry.params?.map(({ name, type, description }, i) => (
<li key={i}>
{name} : {type.names?.join(' | ')} {description ? <> - {getInnerText(description)}</> : ''}
</li>
))}
</ul>
{entry.examples?.map((example, j) => (
<pre key={j}>{example}</pre>
))}
</section>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,239 @@
/*
Repl.jsx - <short description TODO>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/App.js>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { code2hash, getPerformanceTimeSeconds, logger, silence } from '@strudel/core';
import { getDrawContext } from '@strudel/draw';
import { transpiler } from '@strudel/transpiler';
import {
getAudioContextCurrentTime,
webaudioOutput,
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
} from '@strudel/webaudio';
import { getAudioDevices, setAudioDevice, setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
import { clearHydra } from '@strudel/hydra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { settingsMap, useSettings } from '../settings.mjs';
import {
setActivePattern,
setLatestCode,
createPatternID,
userPattern,
getViewingPatternData,
setViewingPatternData,
} from '../user_pattern_utils.mjs';
import { superdirtOutput } from '@strudel/osc/superdirtoutput';
import { audioEngineTargets, defaultAudioDeviceName } from '../settings.mjs';
import { useStore } from '@nanostores/react';
import { prebake } from './prebake.mjs';
import { getRandomTune, initCode, loadModules, shareCode } from './util.mjs';
import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser';
const { latestCode } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady;
if (typeof window !== 'undefined') {
audioReady = initAudioOnFirstClick();
modulesLoading = loadModules();
presets = prebake();
drawContext = getDrawContext();
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
}
async function getModule(name) {
if (!modulesLoading) {
return;
}
const modules = await modulesLoading;
return modules.find((m) => m.packageName === name);
}
export function useReplContext() {
const { isSyncEnabled, audioEngineTarget } = useSettings();
const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc;
const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput;
const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds;
const init = useCallback(() => {
const drawTime = [-2, 2];
const drawContext = getDrawContext();
const editor = new StrudelMirror({
sync: isSyncEnabled,
defaultOutput,
getTime,
setInterval,
clearInterval,
transpiler,
autodraw: false,
root: containerRef.current,
initialCode: '// LOADING',
pattern: silence,
drawTime,
drawContext,
prebake: async () => Promise.all([modulesLoading, presets]),
onUpdateState: (state) => {
setReplState({ ...state });
},
onToggle: (playing) => {
if (!playing) {
clearHydra();
}
},
beforeEval: () => audioReady,
afterEval: (all) => {
const { code } = all;
//post to iframe parent (like Udels) if it exists...
window.parent?.postMessage(code);
setLatestCode(code);
window.location.hash = '#' + code2hash(code);
setDocumentTitle(code);
const viewingPatternData = getViewingPatternData();
setVersionDefaultsFrom(code);
const data = { ...viewingPatternData, code };
let id = data.id;
const isExamplePattern = viewingPatternData.collection !== userPattern.collection;
if (isExamplePattern) {
const codeHasChanged = code !== viewingPatternData.code;
if (codeHasChanged) {
// fork example
const newPattern = userPattern.duplicate(data);
id = newPattern.id;
setViewingPatternData(newPattern.data);
}
} else {
id = userPattern.isValidID(id) ? id : createPatternID();
setViewingPatternData(userPattern.update(id, data).data);
}
setActivePattern(id);
},
bgFill: false,
});
window.strudelMirror = editor;
// init settings
initCode().then(async (decoded) => {
let code, msg;
if (decoded) {
code = decoded;
msg = `I have loaded the code from the URL.`;
} else if (latestCode) {
code = latestCode;
msg = `Your last session has been loaded!`;
} else {
const { code: randomTune, name } = await getRandomTune();
code = randomTune;
msg = `A random code snippet named "${name}" has been loaded!`;
}
editor.setCode(code);
setDocumentTitle(code);
logger(`Welcome to Strudel! ${msg} Press play or hit ctrl+enter to run it!`, 'highlight');
});
editorRef.current = editor;
}, []);
const [replState, setReplState] = useState({});
const { started, isDirty, error, activeCode, pending } = replState;
const editorRef = useRef();
const containerRef = useRef();
// this can be simplified once SettingsTab has been refactored to change codemirrorSettings directly!
// this will be the case when the main repl is being replaced
const _settings = useStore(settingsMap, { keys: Object.keys(defaultSettings) });
useEffect(() => {
let editorSettings = {};
Object.keys(defaultSettings).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(_settings, key)) {
editorSettings[key] = _settings[key];
}
});
editorRef.current?.updateSettings(editorSettings);
}, [_settings]);
// on first load, set stored audio device if possible
useEffect(() => {
const { audioDeviceName } = _settings;
if (audioDeviceName !== defaultAudioDeviceName) {
getAudioDevices().then((devices) => {
const deviceID = devices.get(audioDeviceName);
if (deviceID == null) {
return;
}
setAudioDevice(deviceID);
});
}
}, []);
//
// UI Actions
//
const setDocumentTitle = (code) => {
const meta = getMetadata(code);
document.title = (meta.title ? `${meta.title} - ` : '') + 'Strudel REPL';
};
const handleTogglePlay = async () => {
editorRef.current?.toggle();
};
const resetEditor = async () => {
(await getModule('@strudel/tonal'))?.resetVoicings();
resetGlobalEffects();
clearCanvas();
clearHydra();
resetLoadedSounds();
editorRef.current.repl.setCps(0.5);
await prebake(); // declare default samples
};
const handleUpdate = async (patternData, reset = false) => {
setViewingPatternData(patternData);
editorRef.current.setCode(patternData.code);
if (reset) {
await resetEditor();
handleEvaluate();
}
};
const handleEvaluate = () => {
editorRef.current.evaluate();
};
const handleShuffle = async () => {
const patternData = await getRandomTune();
const code = patternData.code;
logger(`[repl] ✨ loading random tune "${patternData.id}"`);
setActivePattern(patternData.id);
setViewingPatternData(patternData);
await resetEditor();
editorRef.current.setCode(code);
editorRef.current.repl.evaluate(code);
};
const handleShare = async () => shareCode(replState.code);
const context = {
started,
pending,
isDirty,
activeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
handleEvaluate,
init,
error,
editorRef,
containerRef,
};
return context;
}

View file

@ -1,14 +1,12 @@
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';
import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid';
import { writeText } from '@tauri-apps/api/clipboard';
import { createContext } from 'react';
import { $featuredPatterns, loadDBPatterns } from '@src/user_pattern_utils.mjs';
// Create a single supabase client for interacting with your database
@ -82,6 +80,8 @@ export function loadModules() {
import('@strudel/serial'),
import('@strudel/soundfonts'),
import('@strudel/csound'),
import('@strudel/tidal'),
import('@strudel/mqtt'),
];
if (isTauri()) {
modules = modules.concat([
@ -95,6 +95,16 @@ export function loadModules() {
return evalScope(settingPatterns, ...modules);
}
// confirm dialog is a promise in webkit and a boolean in other browsers... normalize it to be a promise everywhere
export function confirmDialog(msg) {
const confirmed = confirm(msg);
if (confirmed instanceof Promise) {
return confirmed;
}
return new Promise((resolve) => {
resolve(confirmed);
});
}
let lastShared;
export async function shareCode(codeToShare) {
@ -103,34 +113,49 @@ export async function shareCode(codeToShare) {
logger(`Link already generated!`, 'error');
return;
}
const isPublic = confirm(
confirmDialog(
'Do you want your pattern to be public? If no, press cancel and you will get just a private link.',
);
// generate uuid in the browser
const hash = nanoid(12);
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
const { error } = await supabase.from('code_v1').insert([{ code: codeToShare, hash, ['public']: isPublic }]);
if (!error) {
lastShared = codeToShare;
// copy shareUrl to clipboard
if (isTauri()) {
await writeText(shareUrl);
).then(async (isPublic) => {
const hash = nanoid(12);
const shareUrl = window.location.origin + window.location.pathname + '?' + hash;
const { error } = await supabase.from('code_v1').insert([{ code: codeToShare, hash, ['public']: isPublic }]);
if (!error) {
lastShared = codeToShare;
// copy shareUrl to clipboard
if (isTauri()) {
await writeText(shareUrl);
} else {
await navigator.clipboard.writeText(shareUrl);
}
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
await navigator.clipboard.writeText(shareUrl);
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
}
const message = `Link copied to clipboard: ${shareUrl}`;
alert(message);
// alert(message);
logger(message, 'highlight');
} else {
console.log('error', error);
const message = `Error: ${error.message}`;
// alert(message);
logger(message);
});
}
export const isIframe = () => window.location !== window.parent.location;
function isCrossOriginFrame() {
try {
return !window.top.location.hostname;
} catch (e) {
return true;
}
}
export const ReplContext = createContext(null);
export const isUdels = () => {
if (isCrossOriginFrame()) {
return false;
}
return window.top?.location?.pathname.includes('udels');
};
export const getAudioDevices = async () => {
await navigator.mediaDevices.getUserMedia({ audio: true });
@ -163,3 +188,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);
}
}

View file

@ -1,9 +1,15 @@
import { persistentMap } from '@nanostores/persistent';
import { useStore } from '@nanostores/react';
import { register } from '@strudel/core';
import { isUdels } from './repl/util.mjs';
export const defaultAudioDeviceName = 'System Standard';
export const audioEngineTargets = {
webaudio: 'webaudio',
osc: 'osc',
};
export const defaultSettings = {
activeFooter: 'intro',
keybindings: 'codemirror',
@ -24,12 +30,27 @@ export const defaultSettings = {
isZen: false,
soundsFilter: 'all',
patternFilter: 'community',
// panelPosition: window.innerWidth > 1000 ? 'right' : 'bottom', //FIX: does not work on astro
panelPosition: 'right',
isPanelPinned: false,
isPanelOpen: true,
togglePanelTrigger: 'click', //click | hover
userPatterns: '{}',
audioDeviceName: defaultAudioDeviceName,
audioEngineTarget: audioEngineTargets.webaudio,
isButtonRowHidden: false,
isCSSAnimationDisabled: false,
};
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
let search = null;
if (typeof window !== 'undefined') {
search = new URLSearchParams(window.location.search);
}
// if running multiple instance in one window, it will use the settings for that instance. else default to normal
const instance = parseInt(search?.get('instance') ?? '0');
const settings_key = `strudel-settings${instance > 0 ? instance : ''}`;
export const settingsMap = persistentMap(settings_key, defaultSettings);
const parseBoolean = (booleanlike) => ([true, 'true'].includes(booleanlike) ? true : false);
@ -51,17 +72,23 @@ export function useSettings() {
isActiveLineHighlighted: parseBoolean(state.isActiveLineHighlighted),
isAutoCompletionEnabled: parseBoolean(state.isAutoCompletionEnabled),
isPatternHighlightingEnabled: parseBoolean(state.isPatternHighlightingEnabled),
isButtonRowHidden: parseBoolean(state.isButtonRowHidden),
isCSSAnimationDisabled: parseBoolean(state.isCSSAnimationDisabled),
isTooltipEnabled: parseBoolean(state.isTooltipEnabled),
isLineWrappingEnabled: parseBoolean(state.isLineWrappingEnabled),
isFlashEnabled: parseBoolean(state.isFlashEnabled),
isSyncEnabled: parseBoolean(state.isSyncEnabled),
isSyncEnabled: isUdels() ? true : parseBoolean(state.isSyncEnabled),
fontSize: Number(state.fontSize),
panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
panelPosition: state.activeFooter !== '' && !isUdels() ? state.panelPosition : 'bottom', // <-- keep this 'bottom' where it is!
isPanelPinned: parseBoolean(state.isPanelPinned),
isPanelOpen: parseBoolean(state.isPanelOpen),
userPatterns: userPatterns,
};
}
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 setIsZen = (active) => settingsMap.setKey('isZen', !!active);

View file

@ -52,6 +52,7 @@
}
:root {
--app-height: 100vh;
--app-width: 100vw;
}
#console-tab {

View file

@ -1,10 +1,9 @@
import { atom } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';
import { useStore } from '@nanostores/react';
import { logger } from '@strudel/core';
import { nanoid } from 'nanoid';
import { settingsMap } from './settings.mjs';
import { parseJSON, supabase } from './repl/util.mjs';
import { confirmDialog, parseJSON, supabase } from './repl/util.mjs';
export let $publicPatterns = atom([]);
export let $featuredPatterns = atom([]);
@ -131,17 +130,19 @@ export const userPattern = {
return this.update(newPattern.id, { ...newPattern.data, code: data.code });
},
clearAll() {
if (!confirm(`This will delete all your patterns. Are you really sure?`)) {
return;
}
const viewingPatternData = getViewingPatternData();
setUserPatterns({});
confirmDialog(`This will delete all your patterns. Are you really sure?`).then((r) => {
if (r == false) {
return;
}
const viewingPatternData = getViewingPatternData();
setUserPatterns({});
if (viewingPatternData.collection !== this.collection) {
return { id: viewingPatternData.id, data: viewingPatternData };
}
setActivePattern(null);
return this.create();
if (viewingPatternData.collection !== this.collection) {
return { id: viewingPatternData.id, data: viewingPatternData };
}
setActivePattern(null);
return this.create();
});
},
delete(id) {
const userPatterns = this.getAll();