import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { DEFAULTS, PRESETS, applyPreset, matchPresetId, loadParams, saveParams, type Cymbal2Params } from './params'; import { DEFAULT_MFILES, type MFile } from './mfiles'; import { useRenderer } from './hooks/useRenderer'; import { encodeWav } from './lib/wav'; import ParamPanel from './components/ParamPanel'; import CodePanel from './components/CodePanel'; import StrikePicker from './components/StrikePicker'; import DiagnosticsPanel from './components/DiagnosticsPanel'; import WaveformPlot from './components/plots/WaveformPlot'; import SpectrumPlot from './components/plots/SpectrumPlot'; import SpectrogramPlot from './components/plots/SpectrogramPlot'; import ContactPlot from './components/plots/ContactPlot'; // The tutorial carries KaTeX and its own figures; load it only when opened. const Tutorial = lazy(() => import('./tutorial/Tutorial')); export default function App() { const [params, setParams] = useState(() => loadParams()); // Editable copy of the MATLAB implementation. Deliberately not persisted: // a reload restores the shipped code. const [mfiles, setMfiles] = useState(() => DEFAULT_MFILES.map((f) => ({ ...f }))); const [codeOpen, setCodeOpen] = useState(false); // The tutorial is linkable: #how-it-works opens it (and its internal // anchors keep it open across a reload). const [tutorialOpen, setTutorialOpenRaw] = useState( () => window.location.hash === '#how-it-works' || window.location.hash.startsWith('#tut-'), ); const setTutorialOpen = useCallback((open: boolean) => { setTutorialOpenRaw(open); try { const url = open ? '#how-it-works' : window.location.pathname + window.location.search; window.history.replaceState(null, '', url); } catch { // sandboxed iframe or similar; the hash is a convenience only } }, []); const { result, error, busy, stage, elapsedS, renderPreview, renderFull, cancel } = useRenderer( params, mfiles, ); const audioCtxRef = useRef(null); useEffect(() => saveParams(params), [params]); const onChange = useCallback((key: keyof Cymbal2Params, value: number | boolean) => { setParams((p) => ({ ...p, [key]: value })); }, []); const reset = useCallback(() => setParams({ ...DEFAULTS }), []); const presetId = matchPresetId(params); const preset = PRESETS.find((p) => p.id === presetId) ?? null; const onPreset = useCallback((id: string) => { const p = PRESETS.find((q) => q.id === id); if (p) setParams(applyPreset(p)); }, []); const onChangeFile = useCallback((name: string, source: string) => { setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f))); }, []); const onResetFile = useCallback((name: string) => { setMfiles((fs) => fs.map((f) => f.name === name ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source } : f, ), ); }, []); const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []); const codeEdited = mfiles.some( (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source, ); const [looping, setLooping] = useState(false); const loopSrcRef = useRef(null); // A failed render clears the result, so a loop left running has nothing to // play; drop out of loop mode rather than leaving the button lit. useEffect(() => { if (!result) setLooping(false); }, [result]); const getAudioCtx = useCallback((sampleRate: number) => { let ctx = audioCtxRef.current; if (!ctx || ctx.state === 'closed') { ctx = new AudioContext({ sampleRate }); audioCtxRef.current = ctx; } void ctx.resume(); return ctx; }, []); const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable) => { const buf = ctx.createBuffer(1, r.y.length, r.sampleRate); buf.copyToChannel(new Float32Array(r.y), 0); return buf; }, []); const play = useCallback(() => { if (!result) return; const ctx = getAudioCtx(result.sampleRate); const src = ctx.createBufferSource(); src.buffer = makeBuffer(ctx, result); src.connect(ctx.destination); src.start(); }, [result, getAudioCtx, makeBuffer]); useEffect(() => { if (!looping || !result) return; const ctx = getAudioCtx(result.sampleRate); const src = ctx.createBufferSource(); src.buffer = makeBuffer(ctx, result); src.loop = true; src.connect(ctx.destination); src.start(); loopSrcRef.current = src; return () => { loopSrcRef.current = null; try { src.stop(); } catch { // already stopped } src.disconnect(); }; }, [looping, result, getAudioCtx, makeBuffer]); const download = useCallback(() => { if (!result) return; const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = result.quality === 'full' ? 'cymbal2.wav' : 'cymbal2_preview.wav'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); }, [result, params.bit_depth]); return (

Cymbal2 Simulator

{busy && ( )}
{error && code error} {result && !busy && ( {result.quality === 'full' ? 'full quality' : 'preview quality'} )} {busy ? ( {stage}… {elapsedS.toFixed(0)} s ) : ( ready )}
{tutorialOpen && ( Loading the tutorial…
}> setTutorialOpen(false)} /> )}
{preset &&
{preset.note}
}
Physical model: T. Samejima, “Nonlinear physical modeling sound synthesis of cymbals involving dynamics of washers and sticks/mallets,”{' '} Acoust. Sci. & Tech. 42(6), 314–325 (2021).{' '} doi:10.1250/ast.42.314
{error && (

MATLAB error

{error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'} {' '} {error.message}
Nothing is playable until a render succeeds. Open Code to fix the source, or revert it.
)}

Cross-section — drag the strike and pickup points

{result && ( <>

Diagnostics

Contact force

Waveform

Spectrum

Spectrogram — watch the crash build after the attack

)} {!result && !busy && !error && (

No render yet

Press Render preview for a cheaper pass at the same duration, or Render full for the real thing. Unlike the sibling modal-cymbal app, every render here is a nonlinear time-domain integration of the shell, so nothing renders automatically.
)}
{codeOpen && ( setCodeOpen(false)} onRenderFull={renderFull} error={error} busy={busy} /> )} ); }