// Hook owning the render worker. Renders are explicit: nothing starts until // Render preview or Render full is pressed, so changing a parameter or a .m // source never kicks off work on its own. A request in flight can be // cancelled, which tears the worker down and builds a fresh one. import { useCallback, useEffect, useRef, useState } from 'react'; import type { TriangleParams } from '../params'; import type { MFile } from '../mfiles'; import type { ErrorMessage, ProgressMessage, RenderResponse } from '../worker/renderWorker'; export interface RenderError { message: string; file?: string; line?: number; } export interface RenderState { result: RenderResponse | null; error: RenderError | null; busy: boolean; stage: string; progress: number; // 0..1 through the render elapsedS: number; } export function useRenderer(params: TriangleParams, mfiles: MFile[]) { const workerRef = useRef(null); const nextId = useRef(1); const inFlight = useRef(null); const startedAt = useRef(0); const [state, setState] = useState({ result: null, error: null, busy: false, stage: '', progress: 0, elapsedS: 0, }); const attachHandlers = useCallback((worker: Worker) => { worker.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.type === 'progress') { setState((s) => ({ ...s, stage: msg.stage, progress: msg.frac })); return; } inFlight.current = null; if (msg.type === 'error') { // A failed render leaves nothing playable: the previous result is // dropped, so Play and Download cannot hand back audio that no // longer corresponds to the code or parameters on screen. setState((s) => ({ ...s, result: null, error: { message: msg.message, file: msg.file, line: msg.line }, busy: false, stage: '', progress: 0, })); return; } setState((s) => ({ ...s, result: msg, error: null, busy: false, stage: '', progress: 0 })); }; }, []); const makeWorker = useCallback(() => { const worker = new Worker(new URL('../worker/renderWorker.ts', import.meta.url), { type: 'module', }); attachHandlers(worker); workerRef.current = worker; return worker; }, [attachHandlers]); useEffect(() => { const worker = makeWorker(); return () => { worker.terminate(); workerRef.current = null; inFlight.current = null; }; }, [makeWorker]); // elapsed-time ticker while a render is in flight useEffect(() => { if (!state.busy) return; const t = setInterval( () => setState((s) => ({ ...s, elapsedS: (performance.now() - startedAt.current) / 1000 })), 250, ); return () => clearInterval(t); }, [state.busy]); const render = useCallback( (quality: 'preview' | 'full') => { const worker = workerRef.current; if (!worker || inFlight.current !== null) return; const id = nextId.current++; inFlight.current = id; startedAt.current = performance.now(); setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0, elapsedS: 0 })); worker.postMessage({ type: 'render', id, params, mfiles, quality }); }, [params, mfiles], ); // Cancelling terminates the worker outright: the .m interpreter runs a tight // loop that never yields, so there is nothing to poll a flag from. The // replacement worker starts with empty stage caches. const cancel = useCallback(() => { const worker = workerRef.current; if (worker) worker.terminate(); inFlight.current = null; makeWorker(); setState((s) => ({ ...s, busy: false, stage: '', progress: 0, elapsedS: 0 })); }, [makeWorker]); const renderPreview = useCallback(() => render('preview'), [render]); const renderFull = useCallback(() => render('full'), [render]); return { ...state, renderPreview, renderFull, cancel }; }