diff --git a/packages/react/src/hooks/useWidgets.mjs b/packages/react/src/hooks/useWidgets.mjs
deleted file mode 100644
index e7ca136a1..000000000
--- a/packages/react/src/hooks/useWidgets.mjs
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useEffect, useState } from 'react';
-import { updateWidgets } from '@strudel/codemirror';
-
-// i know this is ugly.. in the future, repl needs to run without react
-export function useWidgets(view) {
- const [widgets, setWidgets] = useState([]);
- useEffect(() => {
- if (view) {
- updateWidgets(view, widgets);
- }
- }, [view, widgets]);
- return { widgets, setWidgets };
-}
diff --git a/packages/react/src/index.js b/packages/react/src/index.js
index 6ddb47622..8c85de9c5 100644
--- a/packages/react/src/index.js
+++ b/packages/react/src/index.js
@@ -5,6 +5,7 @@ export * from './components/MiniRepl'; // !SSR
export { default as useHighlighting } from './hooks/useHighlighting'; // !SSR
export { default as useStrudel } from './hooks/useStrudel'; // !SSR
export { default as usePostMessage } from './hooks/usePostMessage';
+export { default as usePatternFrame } from './hooks/usePatternFrame';
export { default as useEvent } from './hooks/useEvent';
export { default as strudelTheme } from './themes/strudel-theme';
export { default as teletext } from './themes/teletext';
diff --git a/website/src/docs/MiniRepl.jsx b/website/src/docs/MiniRepl.jsx
index 552e1027e..db90cb8dd 100644
--- a/website/src/docs/MiniRepl.jsx
+++ b/website/src/docs/MiniRepl.jsx
@@ -28,7 +28,7 @@ export function MiniRepl({
useEffect(() => {
// we have to load this package on the client
// because codemirror throws an error on the server
- Promise.all([import('@strudel.cycles/react'), init])
+ Promise.all([import('@strudel.cycles/react')])
.then(([res]) => setRepl(() => res.MiniRepl))
.catch((err) => console.error(err));
}, []);
diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx
index 377f91a5e..98d083eea 100644
--- a/website/src/repl/Repl.jsx
+++ b/website/src/repl/Repl.jsx
@@ -4,25 +4,25 @@ Copyright (C) 2022 Strudel contributors - see .
*/
-import { cleanupDraw, cleanupUi, getDrawContext, logger } from '@strudel.cycles/core';
-import { CodeMirror, cx, flash, useHighlighting, useStrudel } from '@strudel.cycles/react';
-import { getAudioContext, resetLoadedSounds, webaudioOutput, panic } from '@strudel.cycles/webaudio';
+import PlayCircleIcon from '@heroicons/react/20/solid/PlayCircleIcon';
+import { getDrawContext, logger } from '@strudel.cycles/core';
+import { CodeMirror, cx } from '@strudel.cycles/react';
+import { getAudioContext, resetLoadedSounds } from '@strudel.cycles/webaudio';
import { createClient } from '@supabase/supabase-js';
+import { writeText } from '@tauri-apps/api/clipboard';
import { nanoid } from 'nanoid';
-import React, { createContext, useCallback, useEffect, useState, useMemo } from 'react';
-import './Repl.css';
+import { createContext, useEffect, useMemo, useState } from 'react';
+import { settingsMap, useSettings } from '../settings.mjs';
+import { isTauri } from '../tauri.mjs';
import { Footer } from './Footer';
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 Loader from './Loader';
-import { code2hash, hash2code } from './helpers.mjs';
-import { isTauri } from '../tauri.mjs';
-import { useWidgets } from '@strudel.cycles/react/src/hooks/useWidgets.mjs';
-import { writeText } from '@tauri-apps/api/clipboard';
+import './Repl.css';
+import { hash2code, code2hash } from './helpers.mjs';
+import * as tunes from './tunes.mjs';
+import { useRepl } from './useRepl';
+import { setLatestCode } from '../settings.mjs';
+import { resetSounds } from './prebake.mjs';
const { latestCode } = settingsMap.get();
@@ -32,16 +32,12 @@ const supabase = createClient(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpZHhkc3hwaGxoempuem1pZnRoIiwicm9sZSI6ImFub24iLCJpYXQiOjE2NTYyMzA1NTYsImV4cCI6MTk3MTgwNjU1Nn0.bqlw7802fsWRnqU5BLYtmXk_k-D1VFmbkHMywWc15NM',
);
-const init = prebake();
-
-let drawContext, clearCanvas;
+let clearCanvas;
if (typeof window !== 'undefined') {
- drawContext = getDrawContext();
+ const drawContext = getDrawContext();
clearCanvas = () => drawContext.clearRect(0, 0, drawContext.canvas.height, drawContext.canvas.width);
}
-const getTime = () => getAudioContext().currentTime;
-
async function initCode() {
// load code from url hash (either short hash from database or decode long hash)
try {
@@ -85,56 +81,30 @@ export const ReplContext = createContext(null);
export function Repl({ embedded = false }) {
const isEmbedded = embedded || window.location !== window.parent.location;
- const [view, setView] = useState(); // codemirror view
const [lastShared, setLastShared] = useState();
- const [pending, setPending] = useState(true);
- const {
- theme,
- keybindings,
- fontSize,
- fontFamily,
- isLineNumbersDisplayed,
- isAutoCompletionEnabled,
- isLineWrappingEnabled,
- panelPosition,
- isZen,
- } = useSettings();
+ const { panelPosition, isZen } = useSettings();
- const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
- const { setWidgets } = useWidgets(view);
- const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } =
- useStrudel({
- initialCode: '// LOADING...',
- defaultOutput: webaudioOutput,
- getTime,
- beforeEval: async () => {
- setPending(true);
- await init;
- cleanupUi();
- cleanupDraw();
- },
- afterEval: ({ code, meta }) => {
- setMiniLocations(meta.miniLocations);
- setWidgets(meta.widgets);
- setPending(false);
- setLatestCode(code);
- window.location.hash = '#' + code2hash(code);
- },
- onEvalError: (err) => {
- setPending(false);
- },
- onToggle: (play) => {
- if (!play) {
- cleanupDraw(false);
- window.postMessage('strudel-stop');
- } else {
- window.postMessage('strudel-start');
- }
- },
- drawContext,
- // drawTime: [0, 6],
- paintOptions,
- });
+ const {
+ codemirror,
+ code,
+ setCode,
+ scheduler,
+ evaluate,
+ activateCode,
+ isDirty,
+ activeCode,
+ pattern,
+ started,
+ stop,
+ error,
+ pending,
+ setPending,
+ } = useRepl({
+ afterEval: ({ code }) => {
+ setLatestCode(code);
+ window.location.hash = '#' + code2hash(code);
+ },
+ });
// init code
useEffect(() => {
@@ -155,30 +125,10 @@ export function Repl({ embedded = false }) {
});
}, []);
- // highlighting
- const { setMiniLocations } = useHighlighting({
- view,
- pattern,
- active: started && !activeCode?.includes('strudel disable-highlighting'),
- getTime: () => scheduler.now(),
- });
-
//
// UI Actions
//
- const handleChangeCode = useCallback(
- (c) => {
- setCode(c);
- //started && logger('[edit] code changed. hit ctrl+enter to update');
- },
- [started],
- );
- const handleSelectionChange = useCallback((selection) => {
- // TODO: scroll to selected function in reference
- // console.log('selectino change', selection.ranges[0].from);
- }, []);
-
const handleTogglePlay = async () => {
await getAudioContext().resume(); // fixes no sound in ios webkit
if (!started) {
@@ -198,9 +148,8 @@ export function Repl({ embedded = false }) {
const { code, name } = getRandomTune();
logger(`[repl] ✨ loading random tune "${name}"`);
clearCanvas();
- resetLoadedSounds();
+ await resetSounds();
scheduler.setCps(1);
- await prebake(); // declare default samples
await evaluate(code, false);
};
@@ -241,16 +190,12 @@ export function Repl({ embedded = false }) {
isDirty,
lastShared,
activeCode,
- handleChangeCode,
+ handleChangeCode: codemirror.handleChangeCode,
handleTogglePlay,
handleUpdate,
handleShuffle,
handleShare,
};
- const currentTheme = useMemo(() => themes[theme] || themes.strudelTheme, [theme]);
- const handleViewChanged = useCallback((v) => {
- setView(v);
- }, []);
return (
// bg-gradient-to-t from-blue-900 to-slate-900
@@ -275,37 +220,7 @@ export function Repl({ embedded = false }) {
)}
- {
- if (getAudioContext().state !== 'running') {
- alert('please click play to initialize the audio. you can use shortcuts after that!');
- return;
- }
- flash(view);
- activateCode();
- }}
- onReEvaluate={() => {
- stop();
- panic();
- activateCode();
- }}
- onPanic={() => {
- stop();
- panic();
- }}
- onStop={() => stop()}
- />
+
{panelPosition === 'right' && !isEmbedded && }
diff --git a/website/src/repl/prebake.mjs b/website/src/repl/prebake.mjs
index ba15418af..b9fd48792 100644
--- a/website/src/repl/prebake.mjs
+++ b/website/src/repl/prebake.mjs
@@ -4,41 +4,10 @@ import './piano.mjs';
import './files.mjs';
import { isTauri } from '../tauri.mjs';
import { settingPatterns } from '../settings.mjs';
+import { soundMap } from '@strudel.cycles/webaudio';
-export async function prebake() {
- const initAudio = initAudioOnFirstClick();
- // lazy load modules
- let modules = [
- 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'),
- ];
- if (isTauri()) {
- modules = modules.concat([
- import('@strudel/desktopbridge/loggerbridge.mjs'),
- import('@strudel/desktopbridge/midibridge.mjs'),
- import('@strudel/desktopbridge/oscbridge.mjs'),
- ]);
- } else {
- modules = modules.concat([import('@strudel.cycles/midi'), import('@strudel.cycles/osc')]);
- }
- // register modules in global scope
- const modulesLoading = evalScope(
- controls, // sadly, this cannot be exported from core direclty
- settingPatterns,
- ...modules,
- );
- // register sounds and samples
+export function registerStockSounds() {
return Promise.all([
- initAudio,
- modulesLoading,
registerSynthSounds(),
registerZZFXSounds(),
//registerSoundfonts(),
@@ -145,11 +114,51 @@ export async function prebake() {
],
},
'github:tidalcycles/Dirt-Samples/master/',
+ { prebake: true },
),
]);
+}
+
+export async function prebake() {
+ const initAudio = initAudioOnFirstClick();
+ // lazy load modules
+ let modules = [
+ 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'),
+ ];
+ if (isTauri()) {
+ modules = modules.concat([
+ import('@strudel/desktopbridge/loggerbridge.mjs'),
+ import('@strudel/desktopbridge/midibridge.mjs'),
+ import('@strudel/desktopbridge/oscbridge.mjs'),
+ ]);
+ } else {
+ modules = modules.concat([import('@strudel.cycles/midi'), import('@strudel.cycles/osc')]);
+ }
+ // register modules in global scope
+ const modulesLoading = evalScope(
+ controls, // sadly, this cannot be exported from core direclty
+ settingPatterns,
+ ...modules,
+ );
+ // register sounds and samples
+ return Promise.all([initAudio, modulesLoading, registerStockSounds()]);
// await samples('github:tidalcycles/Dirt-Samples/master');
}
+export const resetSounds = () => {
+ soundMap.set({});
+ registerStockSounds();
+};
+
const maxPan = noteToMidi('C8');
const panwidth = (pan, width) => pan * width + (1 - width) / 2;
diff --git a/website/src/repl/useRepl.jsx b/website/src/repl/useRepl.jsx
new file mode 100644
index 000000000..02dea6a77
--- /dev/null
+++ b/website/src/repl/useRepl.jsx
@@ -0,0 +1,174 @@
+import { useStore } from '@nanostores/react';
+import { webrepl, $replstate, setReplState } from './webrepl.mjs';
+import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
+import { useHighlighting, flash, usePatternFrame } from '@strudel.cycles/react';
+import { getAudioContext } from '@strudel.cycles/webaudio';
+import { useSettings } from '../settings.mjs';
+import { themes } from './themes.mjs';
+import { updateWidgets } from '@strudel/codemirror';
+import { getDrawContext } from '@strudel.cycles/core';
+
+export function useRepl({
+ drawContext = getDrawContext(),
+ editPattern,
+ evalOnMount = false,
+ canvasId,
+ drawTime = [-2, 2],
+ afterEval,
+} = {}) {
+ const [view, setView] = useState(); // codemirror view
+ const handleViewChanged = useCallback((v) => setView(v), []);
+ const setCode = (code) => setReplState('code', code);
+ const setPending = (pending) => setReplState('pending', pending);
+ const handleChangeCode = useCallback((c) => setReplState('code', c), []); // [started] ?
+ // const handleSelectionChange = useCallback((selection) => {}, []);
+ // onSelectionChange={handleSelectionChange}
+
+ const repl = webrepl({ editPattern, afterEval });
+ const { evaluate, stop, scheduler } = repl;
+ const { schedulerError, evalError, code, started, pattern, miniLocations, activeCode, widgets, pending } =
+ useStore($replstate);
+ const error = schedulerError || evalError;
+ const settings = useSettings();
+ const {
+ theme,
+ keybindings,
+ fontSize,
+ fontFamily,
+ isLineNumbersDisplayed,
+ isAutoCompletionEnabled,
+ isLineWrappingEnabled,
+ panelPosition,
+ isZen,
+ } = settings;
+
+ // very ugly draw logic
+ const shouldPaint = useCallback((pat) => !!pat?.context?.onPaint, []);
+ const paintOptions = useMemo(() => ({ fontFamily }), [fontFamily]);
+ const id = useMemo(() => s4(), []);
+ canvasId = canvasId || `canvas-${id}`; // draw logic
+ const onDraw = useCallback(
+ (pattern, time, haps, drawTime) => {
+ const { onPaint } = pattern.context || {};
+ const ctx = typeof drawContext === 'function' ? drawContext(canvasId) : drawContext;
+ onPaint?.(ctx, time, haps, drawTime, paintOptions);
+ },
+ [drawContext, canvasId, paintOptions],
+ );
+ const drawFirstFrame = useCallback(
+ (pat) => {
+ if (shouldPaint(pat)) {
+ const [_, lookahead] = drawTime;
+ const haps = pat.queryArc(0, lookahead);
+ // draw at -0.001 to avoid activating haps at 0
+ onDraw(pat, -0.001, haps, drawTime);
+ }
+ },
+ [drawTime, onDraw, shouldPaint],
+ );
+ const inited = useRef();
+ useEffect(() => {
+ if (!inited.current && evalOnMount && code) {
+ inited.current = true;
+ evaluate(code, false).then((pat) => drawFirstFrame(pat));
+ }
+ }, [evalOnMount, code, evaluate, drawFirstFrame]);
+ usePatternFrame({
+ pattern,
+ started: shouldPaint(pattern) && started,
+ getTime: () => scheduler.now(),
+ drawTime,
+ onDraw,
+ });
+ //
+
+ const currentTheme = useMemo(() => themes[theme] || themes.strudelTheme, [theme]);
+ const isDirty = code !== activeCode;
+ // highlighting => should probably also just live outside of react...
+ const { setMiniLocations } = useHighlighting({
+ view,
+ pattern,
+ active: started && !activeCode?.includes('strudel disable-highlighting'),
+ getTime: () => scheduler.now(),
+ });
+
+ useEffect(() => {
+ if (view) {
+ updateWidgets(view, widgets);
+ }
+ }, [view, widgets]);
+
+ useEffect(() => {
+ setMiniLocations(miniLocations);
+ }, [miniLocations]);
+
+ const activateCode = useCallback(
+ async (autostart = true) => {
+ const res = await evaluate(code, autostart);
+ // broadcast({ type: 'start', from: id });
+ return res;
+ },
+ [evaluate, code],
+ );
+
+ // Codemirror hooks
+ const onEvaluate = () => {
+ if (getAudioContext().state !== 'running') {
+ alert('please click play to initialize the audio. you can use shortcuts after that!');
+ return;
+ }
+ flash(view);
+ activateCode();
+ };
+ const onReEvaluate = () => {
+ stop();
+ panic();
+ activateCode();
+ };
+ const onPanic = () => {
+ stop();
+ panic();
+ };
+ const onStop = () => stop();
+
+ const codemirror = {
+ theme: currentTheme,
+ value: code,
+ keybindings: keybindings,
+ isLineNumbersDisplayed: isLineNumbersDisplayed,
+ isAutoCompletionEnabled: isAutoCompletionEnabled,
+ isLineWrappingEnabled: isLineWrappingEnabled,
+ fontSize: fontSize,
+ fontFamily: fontFamily,
+ onChange: handleChangeCode,
+ onViewChanged: handleViewChanged,
+ onEvaluate: onEvaluate,
+ onReEvaluate: onReEvaluate,
+ onPanic: onPanic,
+ onStop: onStop,
+ };
+
+ return {
+ code,
+ setCode,
+ scheduler,
+ evaluate,
+ activateCode,
+ isDirty,
+ activeCode,
+ pattern,
+ started,
+ stop,
+ error,
+ widgets,
+ pending,
+ setPending,
+ codemirror,
+ };
+}
+
+function s4() {
+ return Math.floor((1 + Math.random()) * 0x10000)
+ .toString(16)
+ .substring(1);
+}
diff --git a/website/src/repl/webrepl.mjs b/website/src/repl/webrepl.mjs
new file mode 100644
index 000000000..00f1d92a6
--- /dev/null
+++ b/website/src/repl/webrepl.mjs
@@ -0,0 +1,77 @@
+import { cleanupDraw, cleanupUi, repl, getDrawContext, silence } from '@strudel.cycles/core';
+import { webaudioOutput, getAudioContext } from '@strudel.cycles/webaudio';
+import { transpiler } from '@strudel.cycles/transpiler';
+import { prebake } from './prebake.mjs';
+import { atom } from 'nanostores';
+
+export const $replstate = atom({
+ schedulerError: undefined,
+ evalError: undefined,
+ code: '// LOADING',
+ activeCode: '// LOADING',
+ pattern: silence,
+ miniLocations: [],
+ widgets: [],
+ pending: true,
+});
+export const setReplState = (key, value) => $replstate.set({ ...$replstate.get(), [key]: value });
+
+let instance;
+export function webrepl({ beforeEval, onEvalError, afterEval, editPattern } = {}) {
+ if (instance) {
+ // TODO: find way to attack hooks (beforeEval, ...) to existing instance
+ // maybe use subscriber pattern
+ return instance;
+ }
+ if (typeof window === 'undefined') {
+ throw new Error('webrepl can only be created in a browser!');
+ }
+ const init = prebake(); // load modules + samples
+
+ instance = repl({
+ // interval,
+ defaultOutput: webaudioOutput,
+ getTime: () => getAudioContext().currentTime,
+ onSchedulerError: (err) => setReplState('schedulerError', err),
+ onEvalError: (err) => {
+ setReplState('evalError', err);
+ setReplState('pending', false);
+ onEvalError?.(err);
+ },
+ drawContext: getDrawContext(),
+ transpiler,
+ editPattern,
+ beforeEval: async ({ code }) => {
+ setReplState('code', code);
+ setReplState('pending', true);
+ await init;
+ await beforeEval?.();
+ cleanupUi();
+ cleanupDraw();
+ },
+ afterEval: async (res) => {
+ const { pattern: _pattern, code, meta } = res;
+ setReplState('miniLocations', meta?.miniLocations || []);
+ setReplState('widgets', meta?.widgets || []);
+
+ setReplState('activeCode', code);
+ setReplState('pattern', _pattern);
+ setReplState('evalError', undefined);
+ setReplState('schedulerError', undefined);
+ setReplState('pending', false);
+ await afterEval?.(res);
+ },
+ onToggle: (started) => {
+ setReplState('started', started);
+ // this was so far only used in main repl...
+ // is this also relevant for mini repl?
+ if (!started) {
+ cleanupDraw(false);
+ window.postMessage('strudel-stop');
+ } else {
+ window.postMessage('strudel-start');
+ }
+ },
+ });
+ return instance;
+}