// Hook owning the render requests. 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. Rendering goes through // the repo's instrument/ module (the same track-render interface midiboot // uses), with the full parameter surface, the edited .m sources, and the // diagnostics payload passed through the request's app-facing fields. // Cancelling is cooperative: the worker checks between render stages, and // the instrument module tears the worker down only if it never responds. import { useCallback, useEffect, useRef, useState } from 'react'; import type { BassParams } from '../params'; import type { MFile } from '../mfiles'; import { bassInstrument } from '../../../instrument/index.ts'; import type { BassExploreDiag } from '../../../instrument/core.ts'; /** The old worker response shape, reassembled from the instrument result * so the plot components are unchanged. */ export type RenderResult = BassExploreDiag & { y: Float32Array; sampleRate: number; }; export interface RenderError { message: string; file?: string; line?: number; } export interface RenderState { result: RenderResult | null; error: RenderError | null; busy: boolean; stage: string; progress: number; // 0..1 through the render elapsedS: number; } export function useRenderer(params: BassParams, mfiles: MFile[]) { const abortRef = useRef(null); const startedAt = useRef(0); const [state, setState] = useState({ result: null, error: null, busy: false, stage: '', progress: 0, elapsedS: 0, }); useEffect(() => () => abortRef.current?.abort(), []); // 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') => { if (abortRef.current) return; // a request is already in flight const ctrl = new AbortController(); abortRef.current = ctrl; startedAt.current = performance.now(); setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0, elapsedS: 0 })); const sampleRate = Number(params.sample_rate); void bassInstrument.renderTrack({ notes: [], sampleRate, lengthSamples: 1, quality, params: params as unknown as Record, mfiles, wantDiagnostics: true, signal: ctrl.signal, onProgress: (p) => setState((s) => ({ ...s, stage: p.stage, progress: p.frac })), }).then((r) => { if (abortRef.current === ctrl) abortRef.current = null; if (ctrl.signal.aborted) return; const diag = r.diag as BassExploreDiag; setState((s) => ({ ...s, result: { ...diag, y: r.audio, sampleRate }, error: null, busy: false, stage: '', progress: 0, })); }).catch((err: unknown) => { if (abortRef.current === ctrl) abortRef.current = null; if (err instanceof DOMException && err.name === 'AbortError') { setState((s) => ({ ...s, busy: false, stage: '', progress: 0 })); return; } // 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. const e = err as { message?: string; file?: string; line?: number }; setState((s) => ({ ...s, result: null, error: { message: e?.message ? String(e.message) : String(err), file: typeof e?.file === 'string' ? e.file : undefined, line: typeof e?.line === 'number' ? e.line : undefined, }, busy: false, stage: '', progress: 0, })); }); }, [params, mfiles], ); const cancel = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setState((s) => ({ ...s, busy: false, stage: '', progress: 0, elapsedS: 0 })); }, []); const renderPreview = useCallback(() => render('preview'), [render]); const renderFull = useCallback(() => render('full'), [render]); return { ...state, renderPreview, renderFull, cancel }; }