From 07d6bb3c44fab9f51e417c2d727bc91751de24fc Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 2 Nov 2023 14:37:50 +0100 Subject: [PATCH] - encapsulate repl state from react - might still be buggy! - smarter way to flush sounds --- packages/react/src/hooks/useWidgets.mjs | 13 -- packages/react/src/index.js | 1 + website/src/docs/MiniRepl.jsx | 2 +- website/src/repl/Repl.jsx | 167 ++++++----------------- website/src/repl/prebake.mjs | 75 +++++----- website/src/repl/useRepl.jsx | 174 ++++++++++++++++++++++++ website/src/repl/webrepl.mjs | 77 +++++++++++ 7 files changed, 336 insertions(+), 173 deletions(-) delete mode 100644 packages/react/src/hooks/useWidgets.mjs create mode 100644 website/src/repl/useRepl.jsx create mode 100644 website/src/repl/webrepl.mjs 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; +}