import { useCallback, useEffect, useRef, useState } from "react"; import { wavBlob } from "./sim/analysis"; import { BeadView } from "./ui/BeadView"; import { Editor } from "./ui/Editor"; import { Energy, ModeEnergy, Spectrogram, Spectrum, Waveform } from "./ui/Plots"; import type { FromWorker, RunPayload, RunRequest } from "./worker"; const exampleSources = import.meta.glob("../matlab/examples/*.m", { query: "?raw", import: "default", eager: true }) as Record; const helperSources = import.meta.glob("../matlab/helpers/*.m", { query: "?raw", import: "default", eager: true }) as Record; const examples = Object.entries(exampleSources) .map(([path, source]) => ({ name: path.split("/").pop()!.replace(/\.m$/, ""), source })) .sort((a, b) => (a.name === "drum" ? -1 : b.name === "drum" ? 1 : a.name.localeCompare(b.name))); const helpers = Object.entries(helperSources).map(([path, source]) => ({ name: path.split("/").pop()!, source })); const STORE_KEY = "bead-percussion:script"; const load = () => { try { return localStorage.getItem(STORE_KEY); } catch { return null; } }; const save = (s: string) => { try { localStorage.setItem(STORE_KEY, s); } catch { /* storage unavailable */ } }; export default function App() { const [script, setScript] = useState(() => load() ?? examples[0].source); const [running, setRunning] = useState(false); const [stage, setStage] = useState(""); const [progress, setProgress] = useState(null); const [log, setLog] = useState(""); const [error, setError] = useState(null); const [payload, setPayload] = useState(null); const [fmax, setFmax] = useState(10000); const [cursor, setCursor] = useState(null); const [viewTime, setViewTime] = useState(null); const [dtCheck, setDtCheck] = useState(false); const [linearRef, setLinearRef] = useState(false); const [playing, setPlaying] = useState<"main" | "linear" | null>(null); const [runId, setRunId] = useState(0); // remounts panels with per-run state const worker = useRef(null); const audioCtx = useRef(null); const source = useRef(null); const startedAt = useRef(0); useEffect(() => save(script), [script]); const stopAudio = useCallback(() => { source.current?.stop(); source.current = null; setCursor(null); setPlaying(null); }, []); const run = useCallback( () => { worker.current?.terminate(); stopAudio(); const w = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); worker.current = w; setRunning(true); setError(null); setLog(""); setProgress(null); setStage("Starting"); w.onmessage = (ev: MessageEvent) => { const m = ev.data; if (m.type === "log") setLog(l => l + m.text); else if (m.type === "stage") { setStage(m.text); setProgress(null); } else if (m.type === "progress") setProgress(m.t / m.duration); else if (m.type === "error") { setError(m.message); setRunning(false); w.terminate(); } else if (m.type === "result") { setPayload(m.payload); setRunId(i => i + 1); const fsHalf = m.payload.result.sampleRate / 2; const top = m.payload.modeFreqs ? m.payload.modeFreqs[m.payload.modeFreqs.length - 1] * 1.15 : 10000; setFmax([1000, 2000, 5000, 10000, 20000].find(f => f >= top && f <= fsHalf) ?? Math.min(20000, fsHalf)); setRunning(false); w.terminate(); } }; w.onerror = e => { setError(e.message || "Worker error"); setRunning(false); }; const req: RunRequest = { type: "run", script, dtCheck, linear: linearRef }; w.postMessage(req); }, [script, stopAudio, dtCheck, linearRef] ); const stop = () => { worker.current?.terminate(); worker.current = null; setRunning(false); setStage("Stopped"); }; const play = (which: "main" | "linear") => { if (!payload) return; stopAudio(); const { sampleRate } = payload.result; const audio = which === "linear" && payload.linear ? payload.linear.audio : payload.result.audio; const ctx = audioCtx.current ?? new AudioContext(); audioCtx.current = ctx; // both signals share the main run's gain, so the A/B comparison keeps levels let peak = 0; for (const v of payload.result.audio) peak = Math.max(peak, Math.abs(v)); const buf = ctx.createBuffer(1, audio.length, sampleRate); const ch = buf.getChannelData(0); const g = peak > 0 ? 0.9 / peak : 1; for (let i = 0; i < audio.length; i++) ch[i] = Math.max(-1, Math.min(1, g * audio[i])); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); src.onended = () => { if (source.current === src) { source.current = null; setCursor(null); setPlaying(null); } }; src.start(); source.current = src; setPlaying(which); startedAt.current = ctx.currentTime; const T = audio.length / sampleRate; const tick = () => { if (source.current !== src) return; const t = ctx.currentTime - startedAt.current; setCursor(Math.min(t, T)); if (t < T) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }; const download = () => { if (!payload) return; const url = URL.createObjectURL(wavBlob(payload.result.audio, payload.result.sampleRate)); const a = document.createElement("a"); a.href = url; a.download = "bead-percussion.wav"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const r = payload?.result; return (

bead-percussion

beads and springs, set up in numbl, heard through one bead
{running ? ( ) : ( )}
!running && run()} /> {running && (
{stage} {progress !== null && ` ${(100 * progress).toFixed(0)}%`}
)} {error &&
{error}
} {log &&
{log}
} {payload && }
Helper functions (bp_*.m) {helpers.map(h => (

{h.name}

{h.source}
))}
sys fields
{SYS_DOC}
{!payload &&
Run a script to hear and see the result.
} {payload && r && ( <>
{payload.linear && ( )}

Listener signal

Spectrogram

Spectrum, two windows (detuning shows as peaks shifting)

Energy by linear mode (the cascade)

Energy

Beads

)}
); } function Report({ p }: { p: RunPayload }) { const r = p.result; const nPinned = p.sys.pinned.reduce((a, b) => a + b, 0); const rows: [string, string][] = [ ["beads", `${p.sys.n} (${nPinned} pinned)`], ["springs, dashpots", `${p.sys.si.length}, ${p.sys.nDashpots}`], ["method", p.sys.method], ["dt", `${r.dt.toExponential(3)} s (${r.substeps} per audio sample)`], ["highest linear frequency", `${(r.omegaMax / (2 * Math.PI)).toFixed(0)} Hz`], ["frequency error", r.freqErrors.map(e => `${e.cents.toFixed(e.cents < 0.1 ? 3 : 2)}¢ at ${e.hz / 1000} kHz`).join(", ")], ["energy drift after excitation", r.energyDrift.toExponential(2)], ["run time", `${(r.elapsedMs / 1000).toFixed(1)} s (${(r.elapsedMs / 1000 / p.sys.duration).toFixed(1)}x real time)`], ]; if (p.modeFreqs) rows.push(["lowest modes", Array.from(p.modeFreqs.slice(0, 5)).map(f => f.toFixed(1)).join(", ") + " Hz"]); return (
{r.aborted &&
{r.aborted}
} {[...p.notes, ...r.warnings].map((w, i) => (
{w}
))} {rows.map(([k, v]) => ( ))}
{k} {v}
{p.linear && ( <>
Listener signal vs the small-strike reference (how nonlinear the run is): relative RMS difference over [0, t]
{p.linear.windows.map(w => ( ))}
t = {w.t} s {w.rel.toExponential(2)}
)} {p.check && ( <>
Listener signal, dt vs dt/2 ({p.check.dt.toExponential(2)} s): relative RMS difference over [0, t]
{p.check.windows.map(w => ( ))}
t = {w.t} s {w.rel.toExponential(2)}
)}
); } const SYS_DOC = `Required sys.pos n-by-3 bead positions (m) sys.mass scalar or n-by-1 (kg) sys.listen struct: beads (index or indices, averaged), dir (default [0 0 1]) Elements sys.springs rows [i j k L0] or [i j k L0 k3]; force k*e + k3*e^3, e = length - L0 sys.dashpots rows [i j c]; force c*(v_j - v_i) on bead i sys.friction scalar or n-by-1; force -c0*v sys.pinned bead indices (or logical mask) held fixed Excitation sys.hits struct or struct array, each with beads, t (default 0), and either velocity [vx vy vz]: added instantly at t, or force [fx fy fz] + duration: raised-cosine pulse per bead Solver (optional) sys.duration seconds (default 2) sys.method 'verlet' (default) or 'yoshida4' sys.safety fraction of the stability limit for automatic dt (default 0.5) sys.dt fixed step (rounded to divide the audio sample interval) sys.sampleRate audio rate (default 48000) sys.frameRate energy/mode/animation records per second (default 500) sys.modes compute linear transverse modes (default true; flat systems) Bead indices are 1-based.`;