- encapsulate repl state from react

- might still be buggy!
- smarter way to flush sounds
This commit is contained in:
Felix Roos
2023-11-02 14:37:50 +01:00
parent 3f2e2554be
commit 07d6bb3c44
7 changed files with 336 additions and 173 deletions
+1 -1
View File
@@ -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));
}, []);
+41 -126
View File
@@ -4,25 +4,25 @@ 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 { 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 }) {
)}
<div className="grow flex relative overflow-hidden">
<section className={'text-gray-100 cursor-text pb-0 overflow-auto grow' + (isZen ? ' px-10' : '')} id="code">
<CodeMirror
theme={currentTheme}
value={code}
keybindings={keybindings}
isLineNumbersDisplayed={isLineNumbersDisplayed}
isAutoCompletionEnabled={isAutoCompletionEnabled}
isLineWrappingEnabled={isLineWrappingEnabled}
fontSize={fontSize}
fontFamily={fontFamily}
onChange={handleChangeCode}
onViewChanged={handleViewChanged}
onSelectionChange={handleSelectionChange}
onEvaluate={() => {
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()}
/>
<CodeMirror {...codemirror} />
</section>
{panelPosition === 'right' && !isEmbedded && <Footer context={context} />}
</div>
+42 -33
View File
@@ -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;
+174
View File
@@ -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);
}
+77
View File
@@ -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;
}