+
{soundEntries.map(([name, { data, onTrigger }]) => (
form > * + * {
+ margin-top: 10px;
+}
diff --git a/website/src/repl/vanilla/vanilla.mjs b/website/src/repl/vanilla/vanilla.mjs
new file mode 100644
index 00000000..488fcf8c
--- /dev/null
+++ b/website/src/repl/vanilla/vanilla.mjs
@@ -0,0 +1,202 @@
+import { logger, getDrawContext, silence, controls, evalScope, hash2code, code2hash } from '@strudel.cycles/core';
+import { StrudelMirror, initTheme, activateTheme } from '@strudel/codemirror';
+import { transpiler } from '@strudel.cycles/transpiler';
+import {
+ getAudioContext,
+ webaudioOutput,
+ registerSynthSounds,
+ registerZZFXSounds,
+ samples,
+} from '@strudel.cycles/webaudio';
+import './vanilla.css';
+
+let editor;
+const initialSettings = {
+ keybindings: 'codemirror',
+ isLineNumbersDisplayed: true,
+ isActiveLineHighlighted: true,
+ isAutoCompletionEnabled: false,
+ isPatternHighlightingEnabled: true,
+ isFlashEnabled: true,
+ isTooltipEnabled: false,
+ isLineWrappingEnabled: false,
+ theme: 'teletext',
+ fontFamily: 'monospace',
+ fontSize: 18,
+};
+initTheme(initialSettings.theme);
+
+async function run() {
+ const container = document.getElementById('code');
+ if (!container) {
+ console.warn('could not init: no container found');
+ return;
+ }
+
+ const drawContext = getDrawContext();
+ const drawTime = [-2, 2];
+ editor = new StrudelMirror({
+ defaultOutput: webaudioOutput,
+ getTime: () => getAudioContext().currentTime,
+ transpiler,
+ root: container,
+ initialCode: '// LOADING',
+ pattern: silence,
+ settings: initialSettings,
+ drawTime,
+ onDraw: (haps, time, frame, painters) => {
+ painters.length && drawContext.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2);
+ painters?.forEach((painter) => {
+ // ctx time haps drawTime paintOptions
+ painter(drawContext, time, haps, drawTime, { clear: false });
+ });
+ },
+ prebake: async () => {
+ // populate scope / lazy load modules
+ const modulesLoading = evalScope(
+ import('@strudel.cycles/core'),
+ import('@strudel.cycles/tonal'),
+ import('@strudel.cycles/mini'),
+ // import('@strudel.cycles/xen'),
+ import('@strudel.cycles/webaudio'),
+ import('@strudel/codemirror'),
+ /* import('@strudel/hydra'), */
+ // import('@strudel.cycles/serial'),
+ /* import('@strudel.cycles/soundfonts'), */
+ // import('@strudel.cycles/csound'),
+ /* import('@strudel.cycles/midi'), */
+ // import('@strudel.cycles/osc'),
+ controls, // sadly, this cannot be exported from core directly (yet)
+ );
+ // load samples
+ const ds = 'https://raw.githubusercontent.com/felixroos/dough-samples/main/';
+ await Promise.all([
+ modulesLoading,
+ registerSynthSounds(),
+ registerZZFXSounds(),
+ samples(`${ds}/tidal-drum-machines.json`),
+ samples(`${ds}/piano.json`),
+ samples(`${ds}/Dirt-Samples.json`),
+ samples(`${ds}/EmuSP12.json`),
+ samples(`${ds}/vcsl.json`),
+ ]);
+ },
+ afterEval: ({ code }) => {
+ window.location.hash = '#' + code2hash(code);
+ },
+ });
+
+ // init settings
+ editor.updateSettings(initialSettings);
+
+ logger(`Welcome to Strudel! Click into the editor and then hit ctrl+enter to run the code!`, 'highlight');
+ const codeParam = window.location.href.split('#')[1] || '';
+
+ const initialCode = codeParam
+ ? hash2code(codeParam)
+ : `// @date 23-11-30
+// "teigrührgerät" @by froos
+
+stack(
+ stack(
+ s("bd(<3!3 5>,6)/2").bank('RolandTR707')
+ ,
+ s("~ sd:<0 1>").bank('RolandTR707').room("<0 .5>")
+ .lastOf(8, x=>x.segment("12").end(.2).gain(isaw))
+ ,
+ s("[tb ~ tb]").bank('RolandTR707')
+ .clip(0).release(.08).room(.2)
+ ).off(-1/6, x=>x.speed(.7).gain(.2).degrade())
+ ,
+ stack(
+ note(",6) ~!2 [f1?]*2>")
+ .s("sawtooth").lpf(perlin.range(400,1000))
+ .lpa(.1).lpenv(-3).room(.2)
+ .lpq(8).noise(.2)
+ .add(note("0,.1"))
+ ,
+ chord("<~ Gm9 ~!2>")
+ .dict('ireal').voicing()
+ .s("sawtooth").vib("2:.1")
+ .lpf(1000).lpa(.1).lpenv(-4)
+ .room(.5)
+ ,
+ n(run(3)).chord("/8")
+ .dict('ireal-ext')
+ .off(1/2, add(n(4)))
+ .voicing()
+ .clip(.1).release(.05)
+ .s("sine").jux(rev)
+ .sometimesBy(sine.slow(16), add(note(12)))
+ .room(.75)
+ .lpf(sine.range(200,2000).slow(16))
+ .gain(saw.slow(4).div(2))
+ ).add(note(perlin.range(0,.5)))
+)`;
+
+ editor.setCode(initialCode); // simpler alternative to above init
+
+ // settingsMap.listen((settings, key) => editor.changeSetting(key, settings[key]));
+ onEvent('strudel-toggle-play', () => editor.toggle());
+}
+
+run();
+
+function onEvent(key, callback) {
+ const listener = (e) => {
+ if (e.data === key) {
+ callback();
+ }
+ };
+ window.addEventListener('message', listener);
+ return () => window.removeEventListener('message', listener);
+}
+
+// settings form
+function getInput(form, name) {
+ return form.querySelector(`input[name=${name}]`) || form.querySelector(`select[name=${name}]`);
+}
+function getFormValues(form, initial) {
+ const entries = Object.entries(initial).map(([key, initialValue]) => {
+ const input = getInput(form, key);
+ if (!input) {
+ return [key, initialValue]; // fallback
+ }
+ if (input.type === 'checkbox') {
+ return [key, input.checked];
+ }
+ if (input.type === 'number') {
+ return [key, Number(input.value)];
+ }
+ if (input.tagName === 'SELECT') {
+ return [key, input.value];
+ }
+ return [key, input.value];
+ });
+ return Object.fromEntries(entries);
+}
+function setFormValues(form, values) {
+ Object.entries(values).forEach(([key, value]) => {
+ const input = getInput(form, key);
+ if (!input) {
+ return;
+ }
+ if (input.type === 'checkbox') {
+ input.checked = !!value;
+ } else if (input.type === 'number') {
+ input.value = value;
+ } else if (input.tagName) {
+ input.value = value;
+ }
+ });
+}
+
+const form = document.querySelector('form[name=settings]');
+setFormValues(form, initialSettings);
+form.addEventListener('change', () => {
+ const values = getFormValues(form, initialSettings);
+ // console.log('values', values);
+ editor.updateSettings(values);
+ // TODO: only activateTheme when it changes
+ activateTheme(values.theme);
+});
diff --git a/website/src/settings.mjs b/website/src/settings.mjs
index 67d386cf..570b6446 100644
--- a/website/src/settings.mjs
+++ b/website/src/settings.mjs
@@ -1,7 +1,8 @@
-import { persistentMap } from '@nanostores/persistent';
+import { persistentMap, persistentAtom } from '@nanostores/persistent';
import { useStore } from '@nanostores/react';
import { register } from '@strudel.cycles/core';
import * as tunes from './repl/tunes.mjs';
+import { logger } from '@strudel.cycles/core';
export const defaultSettings = {
activeFooter: 'intro',
@@ -19,11 +20,28 @@ export const defaultSettings = {
soundsFilter: 'all',
panelPosition: 'bottom',
userPatterns: '{}',
- activePattern: '',
};
export const settingsMap = persistentMap('strudel-settings', defaultSettings);
+// active pattern is separate, because it shouldn't sync state across tabs
+// reason: https://github.com/tidalcycles/strudel/issues/857
+const $activePattern = persistentAtom('activePattern', '', { listen: false });
+export function setActivePattern(key) {
+ $activePattern.set(key);
+}
+export function getActivePattern() {
+ return $activePattern.get();
+}
+export function useActivePattern() {
+ return useStore($activePattern);
+}
+export function initUserCode(code) {
+ const userPatterns = getUserPatterns();
+ const match = Object.entries(userPatterns).find(([_, pat]) => pat.code === code);
+ setActivePattern(match?.[0] || '');
+}
+
export function useSettings() {
const state = useStore(settingsMap);
return {
@@ -62,14 +80,14 @@ export const fontSize = patternSetting('fontSize');
export const settingPatterns = { theme, fontFamily, fontSize };
-function getUserPatterns() {
+export function getUserPatterns() {
return JSON.parse(settingsMap.get().userPatterns);
}
function getSetting(key) {
return settingsMap.get()[key];
}
-function setUserPatterns(obj) {
+export function setUserPatterns(obj) {
settingsMap.setKey('userPatterns', JSON.stringify(obj));
}
@@ -116,13 +134,17 @@ export function getUserPattern(key) {
}
export function renameActivePattern() {
- let activePattern = getSetting('activePattern');
+ let activePattern = getActivePattern();
let userPatterns = getUserPatterns();
if (!userPatterns[activePattern]) {
alert('Cannot rename examples');
return;
}
const newName = prompt('Enter new name', activePattern);
+ if (newName === null) {
+ // canceled
+ return;
+ }
if (userPatterns[newName]) {
alert('Name already taken!');
return;
@@ -135,7 +157,7 @@ export function renameActivePattern() {
export function updateUserCode(code) {
const userPatterns = getUserPatterns();
- let activePattern = getSetting('activePattern');
+ let activePattern = getActivePattern();
// check if code is that of an example tune
const [example] = Object.entries(tunes).find(([_, tune]) => tune === code) || [];
if (example && (!activePattern || activePattern === example)) {
@@ -156,7 +178,7 @@ export function updateUserCode(code) {
}
export function deleteActivePattern() {
- let activePattern = getSetting('activePattern');
+ let activePattern = getActivePattern();
if (!activePattern) {
console.warn('cannot delete: no pattern selected');
return;
@@ -174,7 +196,7 @@ export function deleteActivePattern() {
}
export function duplicateActivePattern() {
- let activePattern = getSetting('activePattern');
+ let activePattern = getActivePattern();
let latestCode = getSetting('latestCode');
if (!activePattern) {
console.warn('cannot duplicate: no pattern selected');
@@ -186,7 +208,31 @@ export function duplicateActivePattern() {
setActivePattern(activePattern);
}
-export function setActivePattern(key) {
- console.log('set', key);
- settingsMap.setKey('activePattern', key);
+export async function importPatterns(fileList) {
+ const files = Array.from(fileList);
+ await Promise.all(
+ files.map(async (file, i) => {
+ const content = await file.text();
+ if (file.type === 'application/json') {
+ const userPatterns = getUserPatterns() || {};
+ setUserPatterns({ ...userPatterns, ...JSON.parse(content) });
+ } else if (file.type === 'text/plain') {
+ const name = file.name.replace(/\.[^/.]+$/, '');
+ addUserPattern(name, { code: content });
+ }
+ }),
+ );
+ logger(`import done!`);
+}
+
+export async function exportPatterns() {
+ const userPatterns = getUserPatterns() || {};
+ const blob = new Blob([JSON.stringify(userPatterns)], { type: 'application/json' });
+ const downloadLink = document.createElement('a');
+ downloadLink.href = window.URL.createObjectURL(blob);
+ const date = new Date().toISOString().split('T')[0];
+ downloadLink.download = `strudel_patterns_${date}.json`;
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ document.body.removeChild(downloadLink);
}