diff --git a/packages/react/src/hooks/useStrudel.mjs b/packages/react/src/hooks/useStrudel.mjs
index a10998e7..dbf2269e 100644
--- a/packages/react/src/hooks/useStrudel.mjs
+++ b/packages/react/src/hooks/useStrudel.mjs
@@ -74,8 +74,11 @@ function useStrudel({
}
});
const activateCode = useCallback(
- async (autostart = true) => {
- const res = await evaluate(code, autostart);
+ async (newCode, autostart = true) => {
+ if (newCode) {
+ setCode(code);
+ }
+ const res = await evaluate(newCode || code, autostart);
broadcast({ type: 'start', from: id });
return res;
},
diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs
index b8f10d5d..6df5a6b6 100644
--- a/packages/superdough/sampler.mjs
+++ b/packages/superdough/sampler.mjs
@@ -1,4 +1,4 @@
-import { noteToMidi, valueToMidi } from './util.mjs';
+import { noteToMidi, valueToMidi, nanFallback } from './util.mjs';
import { getAudioContext, registerSound } from './index.mjs';
import { getEnvelope } from './helpers.mjs';
import { logger } from './logger.mjs';
@@ -33,6 +33,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol
const ac = getAudioContext();
let sampleUrl;
if (Array.isArray(bank)) {
+ n = nanFallback(n, 0);
sampleUrl = bank[n % bank.length];
} else {
const midiDiff = (noteA) => noteToMidi(noteA) - midi;
diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs
index 2305ab44..3be97615 100644
--- a/packages/superdough/superdough.mjs
+++ b/packages/superdough/superdough.mjs
@@ -7,7 +7,7 @@ This program is free software: you can redistribute it and/or modify it under th
import './feedbackdelay.mjs';
import './reverb.mjs';
import './vowel.mjs';
-import { clamp } from './util.mjs';
+import { clamp, nanFallback } from './util.mjs';
import workletsUrl from './worklets.mjs?url';
import { createFilter, gainNode, getCompressor } from './helpers.mjs';
import { map } from 'nanostores';
@@ -322,6 +322,7 @@ export const superdough = async (value, deadline, hapDuration) => {
compressorAttack,
compressorRelease,
} = value;
+ gain = nanFallback(gain, 1);
//music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior
channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs
index db056376..d49ffd6b 100644
--- a/packages/superdough/util.mjs
+++ b/packages/superdough/util.mjs
@@ -1,3 +1,5 @@
+import { logger } from './logger.mjs';
+
// currently duplicate with core util.mjs to skip dependency
// TODO: add separate util module?
@@ -51,3 +53,11 @@ export const valueToMidi = (value, fallbackValue) => {
}
return fallbackValue;
};
+
+export function nanFallback(value, fallback) {
+ if (isNaN(Number(value))) {
+ logger(`"${value}" is not a number, falling back to ${fallback}`, 'warning');
+ return fallback;
+ }
+ return value;
+}
diff --git a/website/src/repl/Footer.jsx b/website/src/repl/Footer.jsx
deleted file mode 100644
index cbf0b63b..00000000
--- a/website/src/repl/Footer.jsx
+++ /dev/null
@@ -1,496 +0,0 @@
-import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
-import { logger } from '@strudel.cycles/core';
-import { useEvent, cx } from '@strudel.cycles/react';
-// import { cx } from '@strudel.cycles/react';
-import { nanoid } from 'nanoid';
-import React, { useMemo, useCallback, useLayoutEffect, useRef, useState } from 'react';
-import { Reference } from './Reference';
-import { themes } from './themes.mjs';
-import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs';
-import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio';
-import { useStore } from '@nanostores/react';
-import { FilesTab } from './FilesTab';
-
-const TAURI = window.__TAURI__;
-
-const { BASE_URL } = import.meta.env;
-const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
-
-export function Footer({ context }) {
- const footerContent = useRef();
- const [log, setLog] = useState([]);
- const { activeFooter, isZen, panelPosition } = useSettings();
-
- useLayoutEffect(() => {
- if (footerContent.current && activeFooter === 'console') {
- // scroll log box to bottom when log changes
- footerContent.current.scrollTop = footerContent.current?.scrollHeight;
- }
- }, [log, activeFooter]);
- useLayoutEffect(() => {
- 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 FooterTab = ({ children, name, label }) => (
- <>
-
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}
-
- {activeFooter === name && <>{children}>}
- >
- );
- 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]' : ''),
- };
- return (
-
-
-
-
-
-
-
-
- {TAURI && }
-
- {activeFooter !== '' && (
-
setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
-
-
- )}
-
- {activeFooter !== '' && (
-
-
- {activeFooter === 'intro' && }
- {activeFooter === 'console' && }
- {activeFooter === 'sounds' && }
- {activeFooter === 'reference' && }
- {activeFooter === 'settings' && }
- {activeFooter === 'files' && }
-
-
- )}
-
- );
-}
-
-function useLogger(onTrigger) {
- useEvent(logger.key, onTrigger);
-}
-
-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, '$1 ');
-
- //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$2 ',
- );
-
- //Change email addresses to mailto:: links.
- replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
- replacedText = replacedText.replace(replacePattern3, '$1 ');
-
- return replacedText;
-}
-
-function WelcomeTab() {
- return (
-
-
- 🌀 welcome
-
-
- You have found strudel , 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:
-
-
- 1. hit play - 2. change something -{' '}
- 3. hit update
-
- If you don't like what you hear, try shuffle !
-
-
- To learn more about what this all means, check out the{' '}
-
- interactive tutorial
-
- . Also feel free to join the{' '}
-
- tidalcycles discord channel
- {' '}
- to ask any questions, give feedback or just say hello.
-
-
about
-
- strudel is a JavaScript version of{' '}
-
- tidalcycles
-
- , which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
-
- github
-
- . Please consider to{' '}
-
- support this project
- {' '}
- to ensure ongoing development 💖
-
-
- );
-}
-
-function ConsoleTab({ log }) {
- return (
-
-
{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
-██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔══██╗██╔════╝██║
-███████╗ ██║ ██████╔╝██║ ██║██║ ██║█████╗ ██║
-╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║██╔══╝ ██║
-███████║ ██║ ██║ ██║╚██████╔╝██████╔╝███████╗███████╗
-╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝`}
- {log.map((l, i) => {
- const message = linkify(l.message);
- return (
-
-
- {l.count ? ` (${l.count})` : ''}
-
- );
- })}
-
- );
-}
-
-const getSamples = (samples) =>
- Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
-
-function SoundsTab() {
- const sounds = useStore(soundMap);
- const { soundsFilter } = useSettings();
- const soundEntries = useMemo(() => {
- let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
- if (!sounds) {
- return [];
- }
- if (soundsFilter === 'user') {
- return filtered.filter(([key, { data }]) => !data.prebake);
- }
- if (soundsFilter === 'drums') {
- return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
- }
- if (soundsFilter === 'samples') {
- return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
- }
- if (soundsFilter === 'synths') {
- return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
- }
- return filtered;
- }, [sounds, soundsFilter]);
- // holds mutable ref to current triggered sound
- const trigRef = useRef();
- // stop current sound on mouseup
- useEvent('mouseup', () => {
- const t = trigRef.current;
- trigRef.current = undefined;
- t?.then((ref) => {
- ref?.stop(getAudioContext().currentTime + 0.01);
- });
- });
- return (
-
-
- settingsMap.setKey('soundsFilter', value)}
- items={{
- samples: 'samples',
- drums: 'drum-machines',
- synths: 'Synths',
- user: 'User',
- }}
- >
-
-
- {soundEntries.map(([name, { data, onTrigger }]) => (
- {
- const ctx = getAudioContext();
- const params = {
- note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
- s: name,
- clip: 1,
- release: 0.5,
- };
- const time = ctx.currentTime + 0.05;
- const onended = () => trigRef.current?.node?.disconnect();
- trigRef.current = Promise.resolve(onTrigger(time, params, onended));
- trigRef.current.then((ref) => {
- connectToDestination(ref?.node);
- });
- }}
- >
- {' '}
- {name}
- {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
- {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
-
- ))}
- {!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
-
-
- );
-}
-
-function Checkbox({ label, value, onChange }) {
- return (
-
-
- {' ' + label}
-
- );
-}
-
-function ButtonGroup({ value, onChange, items }) {
- return (
-
- {Object.entries(items).map(([key, label], i, arr) => (
- onChange(key)}
- className={cx(
- 'px-2 border-b h-8',
- // i === 0 && 'rounded-l-md',
- // i === arr.length - 1 && 'rounded-r-md',
- // value === key ? 'bg-background' : 'bg-lineHighlight',
- value === key ? 'border-foreground' : 'border-transparent',
- )}
- >
- {label.toLowerCase()}
-
- ))}
-
- );
-}
-
-function SelectInput({ value, options, onChange }) {
- return (
- onChange(e.target.value)}
- >
- {Object.entries(options).map(([k, label]) => (
-
- {label}
-
- ))}
-
- );
-}
-
-function NumberSlider({ value, onChange, step = 1, ...rest }) {
- return (
-
- onChange(Number(e.target.value))}
- {...rest}
- />
- onChange(Number(e.target.value))}
- />
-
- );
-}
-
-function FormItem({ label, children }) {
- return (
-
- {label}
- {children}
-
- );
-}
-
-const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
-const fontFamilyOptions = {
- monospace: 'monospace',
- BigBlueTerminal: 'BigBlueTerminal',
- x3270: 'x3270',
- PressStart: 'PressStart2P',
- galactico: 'galactico',
- 'we-come-in-peace': 'we-come-in-peace',
- FiraCode: 'FiraCode',
- 'FiraCode-SemiBold': 'FiraCode SemiBold',
- teletext: 'teletext',
- mode7: 'mode7',
-};
-
-function SettingsTab({ scheduler }) {
- const {
- theme,
- keybindings,
- isLineNumbersDisplayed,
- isActiveLineHighlighted,
- isAutoCompletionEnabled,
- isTooltipEnabled,
- isLineWrappingEnabled,
- fontSize,
- fontFamily,
- panelPosition,
- } = useSettings();
-
- return (
-
- {/*
-
- {
- scheduler.setCps(scheduler.cps - 0.1);
- }}
- >
- slower
-
- {
- scheduler.setCps(scheduler.cps + 0.1);
- }}
- >
- faster
-
-
- */}
-
- settingsMap.setKey('theme', theme)} />
-
-
-
- settingsMap.setKey('fontFamily', fontFamily)}
- />
-
-
- settingsMap.setKey('fontSize', fontSize)}
- min={10}
- max={40}
- step={2}
- />
-
-
-
- settingsMap.setKey('keybindings', keybindings)}
- items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
- >
-
-
- settingsMap.setKey('panelPosition', value)}
- items={{ bottom: 'Bottom', right: 'Right' }}
- >
-
-
- settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
- value={isLineNumbersDisplayed}
- />
- settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
- value={isActiveLineHighlighted}
- />
- settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
- value={isAutoCompletionEnabled}
- />
- settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
- value={isTooltipEnabled}
- />
- settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
- value={isLineWrappingEnabled}
- />
-
-
Try clicking the logo in the top left!
-
- {
- if (confirm('Sure?')) {
- settingsMap.set(defaultSettings);
- }
- }}
- >
- restore default settings
-
-
-
- );
-}
diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx
index 2700bcf9..3d3117ae 100644
--- a/website/src/repl/Repl.jsx
+++ b/website/src/repl/Repl.jsx
@@ -11,13 +11,13 @@ import { createClient } from '@supabase/supabase-js';
import { nanoid } from 'nanoid';
import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react';
import './Repl.css';
-import { Footer } from './Footer';
+import { Panel } from './panel/Panel';
import { Header } from './Header';
import { prebake } from './prebake.mjs';
import * as tunes from './tunes.mjs';
import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
import { themes } from './themes.mjs';
-import { settingsMap, useSettings, setLatestCode } from '../settings.mjs';
+import { settingsMap, useSettings, setLatestCode, updateUserCode } from '../settings.mjs';
import Loader from './Loader';
import { settingPatterns } from '../settings.mjs';
import { code2hash, hash2code } from './helpers.mjs';
@@ -131,6 +131,7 @@ export function Repl({ embedded = false }) {
isLineWrappingEnabled,
panelPosition,
isZen,
+ activePattern,
} = useSettings();
const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
@@ -147,6 +148,7 @@ export function Repl({ embedded = false }) {
cleanupDraw();
},
afterEval: ({ code, meta }) => {
+ updateUserCode(code);
setMiniLocations(meta.miniLocations);
setWidgets(meta.widgets);
setPending(false);
@@ -226,7 +228,7 @@ export function Repl({ embedded = false }) {
const handleChangeCode = useCallback(
(c) => {
setCode(c);
- //started && logger('[edit] code changed. hit ctrl+enter to update');
+ // started && logger('[edit] code changed. hit ctrl+enter to update');
},
[started],
);
@@ -245,8 +247,8 @@ export function Repl({ embedded = false }) {
stop();
}
};
- const handleUpdate = () => {
- isDirty && activateCode();
+ const handleUpdate = (newCode) => {
+ (newCode || isDirty) && activateCode(newCode);
logger('[repl] code updated! tip: you can also update the code by pressing ctrl+enter', 'highlight');
};
@@ -347,12 +349,12 @@ export function Repl({ embedded = false }) {
onSelectionChange={handleSelectionChange}
/>
- {panelPosition === 'right' && !isEmbedded && }
+ {panelPosition === 'right' && !isEmbedded && }
{error && (
{error.message || 'Unknown Error :-/'}
)}
- {panelPosition === 'bottom' && !isEmbedded && }
+ {panelPosition === 'bottom' && !isEmbedded && }
);
diff --git a/website/src/repl/panel/ConsoleTab.jsx b/website/src/repl/panel/ConsoleTab.jsx
new file mode 100644
index 00000000..4472fd4c
--- /dev/null
+++ b/website/src/repl/panel/ConsoleTab.jsx
@@ -0,0 +1,45 @@
+import { cx } from '@strudel.cycles/react';
+import React from 'react';
+
+export function ConsoleTab({ log }) {
+ return (
+
+
{`███████╗████████╗██████╗ ██╗ ██╗██████╗ ███████╗██╗
+██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔══██╗██╔════╝██║
+███████╗ ██║ ██████╔╝██║ ██║██║ ██║█████╗ ██║
+╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║██╔══╝ ██║
+███████║ ██║ ██║ ██║╚██████╔╝██████╔╝███████╗███████╗
+╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝`}
+ {log.map((l, i) => {
+ const message = linkify(l.message);
+ return (
+
+
+ {l.count ? ` (${l.count})` : ''}
+
+ );
+ })}
+
+ );
+}
+
+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, '$1 ');
+
+ //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$2 ',
+ );
+
+ //Change email addresses to mailto:: links.
+ replacePattern3 = /(([a-zA-Z0-9\-\_\.])+@[a-zA-Z\_]+?(\.[a-zA-Z]{2,6})+)/gim;
+ replacedText = replacedText.replace(replacePattern3, '$1 ');
+
+ return replacedText;
+}
diff --git a/website/src/repl/FilesTab.jsx b/website/src/repl/panel/FilesTab.jsx
similarity index 97%
rename from website/src/repl/FilesTab.jsx
rename to website/src/repl/panel/FilesTab.jsx
index e04086b6..d78ec1ec 100644
--- a/website/src/repl/FilesTab.jsx
+++ b/website/src/repl/panel/FilesTab.jsx
@@ -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([]);
diff --git a/website/src/repl/panel/Forms.jsx b/website/src/repl/panel/Forms.jsx
new file mode 100644
index 00000000..e45305e5
--- /dev/null
+++ b/website/src/repl/panel/Forms.jsx
@@ -0,0 +1,24 @@
+import { cx } from '@strudel.cycles/react';
+import React from 'react';
+
+export function ButtonGroup({ value, onChange, items }) {
+ return (
+
+ {Object.entries(items).map(([key, label], i, arr) => (
+ onChange(key)}
+ className={cx(
+ 'px-2 border-b h-8',
+ // i === 0 && 'rounded-l-md',
+ // i === arr.length - 1 && 'rounded-r-md',
+ // value === key ? 'bg-background' : 'bg-lineHighlight',
+ value === key ? 'border-foreground' : 'border-transparent',
+ )}
+ >
+ {label.toLowerCase()}
+
+ ))}
+
+ );
+}
diff --git a/website/src/repl/panel/Panel.jsx b/website/src/repl/panel/Panel.jsx
new file mode 100644
index 00000000..b815770c
--- /dev/null
+++ b/website/src/repl/panel/Panel.jsx
@@ -0,0 +1,119 @@
+import XMarkIcon from '@heroicons/react/20/solid/XMarkIcon';
+import { logger } from '@strudel.cycles/core';
+import { cx, useEvent } from '@strudel.cycles/react';
+import { nanoid } from 'nanoid';
+import React, { useCallback, useLayoutEffect, 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';
+
+const TAURI = window.__TAURI__;
+
+export function Panel({ context }) {
+ const footerContent = useRef();
+ const [log, setLog] = useState([]);
+ const { activeFooter, isZen, panelPosition } = useSettings();
+
+ useLayoutEffect(() => {
+ if (footerContent.current && activeFooter === 'console') {
+ // scroll log box to bottom when log changes
+ footerContent.current.scrollTop = footerContent.current?.scrollHeight;
+ }
+ }, [log, activeFooter]);
+ useLayoutEffect(() => {
+ 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 }) => (
+ <>
+ 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}
+
+ {activeFooter === name && <>{children}>}
+ >
+ );
+ 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]' : ''),
+ };
+ return (
+
+
+
+
+
+
+
+
+
+ {TAURI &&
}
+
+ {activeFooter !== '' && (
+
setActiveFooter('')} className="text-foreground px-2" aria-label="Close Panel">
+
+
+ )}
+
+ {activeFooter !== '' && (
+
+
+ {activeFooter === 'intro' &&
}
+ {activeFooter === 'patterns' &&
}
+ {activeFooter === 'console' &&
}
+ {activeFooter === 'sounds' &&
}
+ {activeFooter === 'reference' &&
}
+ {activeFooter === 'settings' &&
}
+ {activeFooter === 'files' &&
}
+
+
+ )}
+
+ );
+}
+
+function useLogger(onTrigger) {
+ useEvent(logger.key, onTrigger);
+}
diff --git a/website/src/repl/panel/PatternsTab.jsx b/website/src/repl/panel/PatternsTab.jsx
new file mode 100644
index 00000000..1b19c8e0
--- /dev/null
+++ b/website/src/repl/panel/PatternsTab.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import * as tunes from '../tunes.mjs';
+import {
+ useSettings,
+ clearUserPatterns,
+ newUserPattern,
+ setActivePattern,
+ deleteActivePattern,
+ duplicateActivePattern,
+ getUserPattern,
+ renameActivePattern,
+} from '../../settings.mjs';
+
+function classNames(...classes) {
+ return classes.filter(Boolean).join(' ');
+}
+
+export function PatternsTab({ context }) {
+ const { userPatterns, activePattern } = useSettings();
+ return (
+
+ );
+}
+
+/*
+selectable examples
+if example selected
+ type character -> create new user pattern with exampleName_n
+ even if
+clicking (+) opens the "new" example with same behavior as above
+*/
diff --git a/website/src/repl/Reference.jsx b/website/src/repl/panel/Reference.jsx
similarity index 98%
rename from website/src/repl/Reference.jsx
rename to website/src/repl/panel/Reference.jsx
index b43f365e..9483e9c5 100644
--- a/website/src/repl/Reference.jsx
+++ b/website/src/repl/panel/Reference.jsx
@@ -1,4 +1,4 @@
-import jsdocJson from '../../../doc.json';
+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));
diff --git a/website/src/repl/panel/SettingsTab.jsx b/website/src/repl/panel/SettingsTab.jsx
new file mode 100644
index 00000000..cee5b286
--- /dev/null
+++ b/website/src/repl/panel/SettingsTab.jsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import { defaultSettings, settingsMap, useSettings } from '../../settings.mjs';
+import { themes } from '../themes.mjs';
+import { ButtonGroup } from './Forms.jsx';
+
+function Checkbox({ label, value, onChange }) {
+ return (
+
+
+ {' ' + label}
+
+ );
+}
+
+function SelectInput({ value, options, onChange }) {
+ return (
+ onChange(e.target.value)}
+ >
+ {Object.entries(options).map(([k, label]) => (
+
+ {label}
+
+ ))}
+
+ );
+}
+
+function NumberSlider({ value, onChange, step = 1, ...rest }) {
+ return (
+
+ onChange(Number(e.target.value))}
+ {...rest}
+ />
+ onChange(Number(e.target.value))}
+ />
+
+ );
+}
+
+function FormItem({ label, children }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+const themeOptions = Object.fromEntries(Object.keys(themes).map((k) => [k, k]));
+const fontFamilyOptions = {
+ monospace: 'monospace',
+ BigBlueTerminal: 'BigBlueTerminal',
+ x3270: 'x3270',
+ PressStart: 'PressStart2P',
+ galactico: 'galactico',
+ 'we-come-in-peace': 'we-come-in-peace',
+ FiraCode: 'FiraCode',
+ 'FiraCode-SemiBold': 'FiraCode SemiBold',
+ teletext: 'teletext',
+ mode7: 'mode7',
+};
+
+export function SettingsTab() {
+ const {
+ theme,
+ keybindings,
+ isLineNumbersDisplayed,
+ isActiveLineHighlighted,
+ isAutoCompletionEnabled,
+ isTooltipEnabled,
+ isLineWrappingEnabled,
+ fontSize,
+ fontFamily,
+ panelPosition,
+ } = useSettings();
+
+ return (
+
+ {/*
+
+ {
+ scheduler.setCps(scheduler.cps - 0.1);
+ }}
+ >
+ slower
+
+ {
+ scheduler.setCps(scheduler.cps + 0.1);
+ }}
+ >
+ faster
+
+
+ */}
+
+ settingsMap.setKey('theme', theme)} />
+
+
+
+ settingsMap.setKey('fontFamily', fontFamily)}
+ />
+
+
+ settingsMap.setKey('fontSize', fontSize)}
+ min={10}
+ max={40}
+ step={2}
+ />
+
+
+
+ settingsMap.setKey('keybindings', keybindings)}
+ items={{ codemirror: 'Codemirror', vim: 'Vim', emacs: 'Emacs', vscode: 'VSCode' }}
+ >
+
+
+ settingsMap.setKey('panelPosition', value)}
+ items={{ bottom: 'Bottom', right: 'Right' }}
+ >
+
+
+ settingsMap.setKey('isLineNumbersDisplayed', cbEvent.target.checked)}
+ value={isLineNumbersDisplayed}
+ />
+ settingsMap.setKey('isActiveLineHighlighted', cbEvent.target.checked)}
+ value={isActiveLineHighlighted}
+ />
+ settingsMap.setKey('isAutoCompletionEnabled', cbEvent.target.checked)}
+ value={isAutoCompletionEnabled}
+ />
+ settingsMap.setKey('isTooltipEnabled', cbEvent.target.checked)}
+ value={isTooltipEnabled}
+ />
+ settingsMap.setKey('isLineWrappingEnabled', cbEvent.target.checked)}
+ value={isLineWrappingEnabled}
+ />
+
+
Try clicking the logo in the top left!
+
+ {
+ if (confirm('Sure?')) {
+ settingsMap.set(defaultSettings);
+ }
+ }}
+ >
+ restore default settings
+
+
+
+ );
+}
diff --git a/website/src/repl/panel/SoundsTab.jsx b/website/src/repl/panel/SoundsTab.jsx
new file mode 100644
index 00000000..aeda4f6d
--- /dev/null
+++ b/website/src/repl/panel/SoundsTab.jsx
@@ -0,0 +1,89 @@
+import { useEvent } from '@strudel.cycles/react';
+// import { cx } from '@strudel.cycles/react';
+import { useStore } from '@nanostores/react';
+import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio';
+import React, { useMemo, useRef } from 'react';
+import { settingsMap, useSettings } from '../../settings.mjs';
+import { ButtonGroup } from './Forms.jsx';
+
+const getSamples = (samples) =>
+ Array.isArray(samples) ? samples.length : typeof samples === 'object' ? Object.values(samples).length : 1;
+
+export function SoundsTab() {
+ const sounds = useStore(soundMap);
+ const { soundsFilter } = useSettings();
+ const soundEntries = useMemo(() => {
+ let filtered = Object.entries(sounds).filter(([key]) => !key.startsWith('_'));
+ if (!sounds) {
+ return [];
+ }
+ if (soundsFilter === 'user') {
+ return filtered.filter(([key, { data }]) => !data.prebake);
+ }
+ if (soundsFilter === 'drums') {
+ return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag === 'drum-machines');
+ }
+ if (soundsFilter === 'samples') {
+ return filtered.filter(([_, { data }]) => data.type === 'sample' && data.tag !== 'drum-machines');
+ }
+ if (soundsFilter === 'synths') {
+ return filtered.filter(([_, { data }]) => ['synth', 'soundfont'].includes(data.type));
+ }
+ return filtered;
+ }, [sounds, soundsFilter]);
+ // holds mutable ref to current triggered sound
+ const trigRef = useRef();
+ // stop current sound on mouseup
+ useEvent('mouseup', () => {
+ const t = trigRef.current;
+ trigRef.current = undefined;
+ t?.then((ref) => {
+ ref?.stop(getAudioContext().currentTime + 0.01);
+ });
+ });
+ return (
+
+
+ settingsMap.setKey('soundsFilter', value)}
+ items={{
+ samples: 'samples',
+ drums: 'drum-machines',
+ synths: 'Synths',
+ user: 'User',
+ }}
+ >
+
+
+ {soundEntries.map(([name, { data, onTrigger }]) => (
+ {
+ const ctx = getAudioContext();
+ const params = {
+ note: ['synth', 'soundfont'].includes(data.type) ? 'a3' : undefined,
+ s: name,
+ clip: 1,
+ release: 0.5,
+ };
+ const time = ctx.currentTime + 0.05;
+ const onended = () => trigRef.current?.node?.disconnect();
+ trigRef.current = Promise.resolve(onTrigger(time, params, onended));
+ trigRef.current.then((ref) => {
+ connectToDestination(ref?.node);
+ });
+ }}
+ >
+ {' '}
+ {name}
+ {data?.type === 'sample' ? `(${getSamples(data.samples)})` : ''}
+ {data?.type === 'soundfont' ? `(${data.fonts.length})` : ''}
+
+ ))}
+ {!soundEntries.length ? 'No custom sounds loaded in this pattern (yet).' : ''}
+
+
+ );
+}
diff --git a/website/src/repl/panel/WelcomeTab.jsx b/website/src/repl/panel/WelcomeTab.jsx
new file mode 100644
index 00000000..feb0a583
--- /dev/null
+++ b/website/src/repl/panel/WelcomeTab.jsx
@@ -0,0 +1,53 @@
+import { cx } from '@strudel.cycles/react';
+import React from 'react';
+import * as tunes from '../tunes.mjs';
+
+const { BASE_URL } = import.meta.env;
+const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL;
+
+export function WelcomeTab({ context }) {
+ return (
+
+
+ 🌀 welcome
+
+
+ You have found strudel , 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:
+
+
+ 1. hit play - 2. change something -{' '}
+ 3. hit update
+
+ If you don't like what you hear, try shuffle !
+
+
+ To learn more about what this all means, check out the{' '}
+
+ interactive tutorial
+
+ . Also feel free to join the{' '}
+
+ tidalcycles discord channel
+ {' '}
+ to ask any questions, give feedback or just say hello.
+
+
about
+
+ strudel is a JavaScript version of{' '}
+
+ tidalcycles
+
+ , which is a popular live coding language for music, written in Haskell. You can find the source code at{' '}
+
+ github
+
+ . Please consider to{' '}
+
+ support this project
+ {' '}
+ to ensure ongoing development 💖
+
+
+ );
+}
diff --git a/website/src/settings.mjs b/website/src/settings.mjs
index 9f6d2d90..24269200 100644
--- a/website/src/settings.mjs
+++ b/website/src/settings.mjs
@@ -1,6 +1,7 @@
import { persistentMap } from '@nanostores/persistent';
import { useStore } from '@nanostores/react';
import { register } from '@strudel.cycles/core';
+import * as tunes from './repl/tunes.mjs';
export const defaultSettings = {
activeFooter: 'intro',
@@ -17,6 +18,8 @@ export const defaultSettings = {
isZen: false,
soundsFilter: 'all',
panelPosition: 'bottom',
+ userPatterns: '{}',
+ activePattern: '',
};
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
@@ -33,6 +36,7 @@ export function useSettings() {
isLineWrappingEnabled: [true, 'true'].includes(state.isLineWrappingEnabled) ? true : false,
fontSize: Number(state.fontSize),
panelPosition: state.activeFooter !== '' ? state.panelPosition : 'bottom',
+ userPatterns: JSON.parse(state.userPatterns),
};
}
@@ -57,3 +61,131 @@ export const fontFamily = patternSetting('fontFamily');
export const fontSize = patternSetting('fontSize');
export const settingPatterns = { theme, fontFamily, fontSize };
+
+function getUserPatterns() {
+ return JSON.parse(settingsMap.get().userPatterns);
+}
+function getSetting(key) {
+ return settingsMap.get()[key];
+}
+
+function setUserPatterns(obj) {
+ settingsMap.setKey('userPatterns', JSON.stringify(obj));
+}
+
+export function addUserPattern(name, config) {
+ if (typeof config !== 'object') {
+ throw new Error('addUserPattern expected object as second param');
+ }
+ if (!config.code) {
+ throw new Error('addUserPattern expected code as property of second param');
+ }
+ const userPatterns = getUserPatterns();
+ setUserPatterns({ ...userPatterns, [name]: config });
+}
+
+export function newUserPattern() {
+ const userPatterns = getUserPatterns();
+ const date = new Date().toISOString().split('T')[0];
+ const todays = Object.entries(userPatterns).filter(([name]) => name.startsWith(date));
+ const num = String(todays.length + 1).padStart(3, '0');
+ const defaultNewPattern = 's("hh")';
+ const name = date + '_' + num;
+ addUserPattern(name, { code: defaultNewPattern });
+ setActivePattern(name);
+ return name;
+}
+
+export function clearUserPatterns() {
+ if (!confirm(`This will delete all your patterns. Are you really sure?`)) {
+ return;
+ }
+ setUserPatterns({});
+}
+
+export function getNextCloneName(key) {
+ const userPatterns = getUserPatterns();
+ const clones = Object.entries(userPatterns).filter(([name]) => name.startsWith(key));
+ const num = String(clones.length + 1).padStart(3, '0');
+ return key + '_' + num;
+}
+
+export function getUserPattern(key) {
+ const userPatterns = getUserPatterns();
+ return userPatterns[key];
+}
+
+export function renameActivePattern() {
+ let activePattern = getSetting('activePattern');
+ let userPatterns = getUserPatterns();
+ if (!userPatterns[activePattern]) {
+ alert('Cannot rename examples');
+ return;
+ }
+ const newName = prompt('Enter new name', activePattern);
+ if (userPatterns[newName]) {
+ alert('Name already taken!');
+ return;
+ }
+ userPatterns[newName] = userPatterns[activePattern]; // copy code
+ delete userPatterns[activePattern];
+ setUserPatterns({ ...userPatterns });
+ setActivePattern(newName);
+}
+
+export function updateUserCode(code) {
+ const userPatterns = getUserPatterns();
+ let activePattern = getSetting('activePattern');
+ // check if code is that of an example tune
+ const [example] = Object.entries(tunes).find(([_, tune]) => tune === code) || [];
+ if (example) {
+ // select example
+ setActivePattern(example);
+ return;
+ }
+ if (!activePattern) {
+ // create new user pattern
+ activePattern = newUserPattern();
+ setActivePattern(activePattern);
+ } else if (!!tunes[activePattern] && code !== tunes[activePattern]) {
+ // fork example
+ activePattern = getNextCloneName(activePattern);
+ setActivePattern(activePattern);
+ }
+ setUserPatterns({ ...userPatterns, [activePattern]: { code } });
+}
+
+export function deleteActivePattern() {
+ let activePattern = getSetting('activePattern');
+ if (!activePattern) {
+ console.warn('cannot delete: no pattern selected');
+ return;
+ }
+ const userPatterns = getUserPatterns();
+ if (!userPatterns[activePattern]) {
+ alert('Cannot delete examples');
+ return;
+ }
+ if (!confirm(`Really delete the selected pattern "${activePattern}"?`)) {
+ return;
+ }
+ setUserPatterns(Object.fromEntries(Object.entries(userPatterns).filter(([key]) => key !== activePattern)));
+ setActivePattern('');
+}
+
+export function duplicateActivePattern() {
+ let activePattern = getSetting('activePattern');
+ let latestCode = getSetting('latestCode');
+ if (!activePattern) {
+ console.warn('cannot duplicate: no pattern selected');
+ return;
+ }
+ const userPatterns = getUserPatterns();
+ activePattern = getNextCloneName(activePattern);
+ setUserPatterns({ ...userPatterns, [activePattern]: { code: latestCode } });
+ setActivePattern(activePattern);
+}
+
+export function setActivePattern(key) {
+ settingsMap.setKey('activePattern', key);
+}