diff --git a/repl/src/App.tsx b/repl/src/App.tsx index 0eee1143..ec5ec941 100644 --- a/repl/src/App.tsx +++ b/repl/src/App.tsx @@ -1,15 +1,11 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import logo from './logo.svg'; -import cx from './cx'; +import React, { useLayoutEffect, useRef } from 'react'; import * as Tone from 'tone'; -import useCycle from './useCycle'; -import type { Pattern } from './types'; -import * as tunes from './tunes'; -import { evaluate } from './evaluate'; import CodeMirror from './CodeMirror'; -import hot from '../public/hot'; -import { isNote } from 'tone'; -import { useWebMidi } from './midi'; +import cx from './cx'; +import { evaluate } from './evaluate'; +import logo from './logo.svg'; +import * as tunes from './tunes'; +import useRepl from './useRepl'; // TODO: use https://www.npmjs.com/package/@monaco-editor/react @@ -38,108 +34,16 @@ function getRandomTune() { const randomTune = getRandomTune(); function App() { - const [code, setCode] = useState(decoded || randomTune); - const [activeCode, setActiveCode] = useState(); - const [log, setLog] = useState(''); - const logBox = useRef(); - const [error, setError] = useState(); - const [pattern, setPattern] = useState(); - const dirty = code !== activeCode; - const activateCode = (_code = code) => { - !cycle.started && cycle.start(); - if (activeCode && !dirty) { - setError(undefined); - return; - } - try { - const parsed = evaluate(_code); - setPattern(() => parsed.pattern); - window.location.hash = '#' + encodeURIComponent(btoa(code)); - setError(undefined); - setActiveCode(_code); - } catch (err: any) { - setError(err); - } - }; - const pushLog = (message: string) => setLog((log) => log + `${log ? '\n\n' : ''}${message}`); - // logs events of cycle - const logCycle = (_events: any, cycle: any) => { - if (_events.length) { - pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n')); - } - }; - // cycle hook to control scheduling - const cycle = useCycle({ - onEvent: useCallback((time, event) => { - try { - if (!event.value?.onTrigger) { - const note = event.value?.value || event.value; - if (!isNote(note)) { - throw new Error('not a note: ' + note); - } - defaultSynth.triggerAttackRelease(note, event.duration, time); - /* console.warn('no instrument chosen', event); - throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */ - } else { - const { onTrigger } = event.value; - onTrigger(time, event); - } - } catch (err: any) { - console.warn(err); - err.message = 'unplayable event: ' + err?.message; - pushLog(err.message); // not with setError, because then we would have to setError(undefined) on next playable event - } - }, []), - onQuery: useCallback( - (span) => { - try { - return pattern?.query(span) || []; - } catch (err: any) { - setError(err); - return []; - } - }, - [pattern] - ), - onSchedule: useCallback((_events, cycle) => logCycle(_events, cycle), [pattern]), - ready: !!pattern, + const { setCode, setPattern, error, code, cycle, dirty, log, togglePlay } = useRepl({ + tune: decoded || randomTune, + defaultSynth, }); - - // set active pattern on ctrl+enter - useLayoutEffect(() => { - const handleKeyPress = (e: any) => { - if (e.ctrlKey || e.altKey) { - switch (e.code) { - case 'Enter': - activateCode(); - !cycle.started && cycle.start(); - break; - case 'Period': - cycle.stop(); - } - } - }; - document.addEventListener('keypress', handleKeyPress); - return () => document.removeEventListener('keypress', handleKeyPress); - }, [pattern, code]); - + const logBox = useRef(); // scroll log box to bottom when log changes useLayoutEffect(() => { logBox.current.scrollTop = logBox.current?.scrollHeight; }, [log]); - useWebMidi({ - ready: useCallback(({ outputs }) => { - pushLog(`WebMidi ready! Just add .midi(${outputs.map((o) => `"${o.name}"`).join(' | ')}) to the pattern. `); - }, []), - connected: useCallback(({ outputs }) => { - pushLog(`Midi device connected! Available: ${outputs.map((o) => `"${o.name}"`).join(', ')}`); - }, []), - disconnected: useCallback(({ outputs }) => { - pushLog(`Midi device disconnected! Available: ${outputs.map((o) => `"${o.name}"`).join(', ')}`); - }, []), - }); - return (
@@ -175,11 +79,7 @@ function App() { onChange={(_: any, __: any, value: any) => setCode(value)} /> - {!cycle.started - ? `press ctrl+enter to play\n` - : code !== activeCode - ? `ctrl+enter to update\n` - : 'no changes\n'} + {!cycle.started ? `press ctrl+enter to play\n` : dirty ? `ctrl+enter to update\n` : 'no changes\n'}
{error && ( @@ -188,16 +88,7 @@ function App() { diff --git a/repl/src/useRepl.ts b/repl/src/useRepl.ts new file mode 100644 index 00000000..5e29d4a1 --- /dev/null +++ b/repl/src/useRepl.ts @@ -0,0 +1,120 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { isNote } from 'tone'; +import { evaluate } from './evaluate'; +import { useWebMidi } from './midi'; +import type { Pattern } from './types'; +import useCycle from './useCycle'; + +function useRepl({ tune, defaultSynth }) { + const [code, setCode] = useState(tune); + const [activeCode, setActiveCode] = useState(); + const [log, setLog] = useState(''); + const [error, setError] = useState(); + const [pattern, setPattern] = useState(); + const dirty = code !== activeCode; + const activateCode = (_code = code) => { + !cycle.started && cycle.start(); + if (activeCode && !dirty) { + setError(undefined); + return; + } + try { + const parsed = evaluate(_code); + setPattern(() => parsed.pattern); + window.location.hash = '#' + encodeURIComponent(btoa(code)); + setError(undefined); + setActiveCode(_code); + } catch (err: any) { + setError(err); + } + }; + const pushLog = (message: string) => setLog((log) => log + `${log ? '\n\n' : ''}${message}`); + // logs events of cycle + const logCycle = (_events: any, cycle: any) => { + if (_events.length) { + pushLog(`# cycle ${cycle}\n` + _events.map((e: any) => e.show()).join('\n')); + } + }; + // cycle hook to control scheduling + const cycle = useCycle({ + onEvent: useCallback((time, event) => { + try { + if (!event.value?.onTrigger) { + const note = event.value?.value || event.value; + if (!isNote(note)) { + throw new Error('not a note: ' + note); + } + if (defaultSynth) { + defaultSynth.triggerAttackRelease(note, event.duration, time); + } else { + throw new Error('no defaultSynth passed to useRepl.'); + } + /* console.warn('no instrument chosen', event); + throw new Error(`no instrument chosen for ${JSON.stringify(event)}`); */ + } else { + const { onTrigger } = event.value; + onTrigger(time, event); + } + } catch (err: any) { + console.warn(err); + err.message = 'unplayable event: ' + err?.message; + pushLog(err.message); // not with setError, because then we would have to setError(undefined) on next playable event + } + }, []), + onQuery: useCallback( + (span) => { + try { + return pattern?.query(span) || []; + } catch (err: any) { + setError(err); + return []; + } + }, + [pattern] + ), + onSchedule: useCallback((_events, cycle) => logCycle(_events, cycle), [pattern]), + ready: !!pattern, + }); + + // set active pattern on ctrl+enter + useLayoutEffect(() => { + const handleKeyPress = (e: any) => { + if (e.ctrlKey || e.altKey) { + switch (e.code) { + case 'Enter': + activateCode(); + !cycle.started && cycle.start(); + break; + case 'Period': + cycle.stop(); + } + } + }; + document.addEventListener('keypress', handleKeyPress); + return () => document.removeEventListener('keypress', handleKeyPress); + }, [pattern, code]); + + useWebMidi({ + ready: useCallback(({ outputs }) => { + pushLog(`WebMidi ready! Just add .midi(${outputs.map((o) => `"${o.name}"`).join(' | ')}) to the pattern. `); + }, []), + connected: useCallback(({ outputs }) => { + pushLog(`Midi device connected! Available: ${outputs.map((o) => `"${o.name}"`).join(', ')}`); + }, []), + disconnected: useCallback(({ outputs }) => { + pushLog(`Midi device disconnected! Available: ${outputs.map((o) => `"${o.name}"`).join(', ')}`); + }, []), + }); + + const togglePlay = () => { + if (!cycle.started) { + activateCode(); + } else { + cycle.stop(); + } + }; + + return { code, setCode, pattern, error, cycle, setPattern, dirty, log, togglePlay }; +} + +export default useRepl;