// Convert the `sys` struct produced by a numbl script into a BeadSystem. // The script uses 1-based bead indices; they become 0-based here. import type { RuntimeValue } from "numbl"; import type { BeadSystem, Hit, Method } from "./types"; type Mat = { rows: number; cols: number; data: Float64Array }; class SysError extends Error {} export const DEFAULT_T60 = 2.5; // s function asMat(v: RuntimeValue | undefined, name: string): Mat | null { if (v === undefined) return null; if (typeof v === "number") return { rows: 1, cols: 1, data: Float64Array.of(v) }; if (typeof v === "boolean") return { rows: 1, cols: 1, data: Float64Array.of(v ? 1 : 0) }; if (typeof v === "object" && v !== null && (v as { kind?: string }).kind === "tensor") { const t = v as unknown as { data: Float64Array; shape: number[]; imag?: Float64Array }; if (t.imag) throw new SysError(`${name} must be real`); const rows = t.shape[0] ?? 1; const cols = t.shape.slice(1).reduce((a, b) => a * b, 1); return { rows, cols, data: t.data }; } throw new SysError(`${name} must be numeric`); } function scalar(v: RuntimeValue | undefined, name: string, def: number): number { const m = asMat(v, name); if (!m || m.data.length === 0) return def; if (m.data.length !== 1) throw new SysError(`${name} must be a scalar`); return m.data[0]; } function vec3(v: RuntimeValue | undefined, name: string, def: [number, number, number]): [number, number, number] { const m = asMat(v, name); if (!m || m.data.length === 0) return def; if (m.data.length !== 3) throw new SysError(`${name} must have 3 elements`); return [m.data[0], m.data[1], m.data[2]]; } function str(v: RuntimeValue | undefined, name: string, def: string): string { if (v === undefined) return def; if (typeof v === "string") return v; if (typeof v === "object" && v !== null && (v as { kind?: string }).kind === "char") { return (v as unknown as { value: string }).value; } throw new SysError(`${name} must be a string`); } function perBead(v: RuntimeValue | undefined, name: string, n: number, def: number): Float64Array { const out = new Float64Array(n); const m = asMat(v, name); if (!m || m.data.length === 0) return out.fill(def); if (m.data.length === 1) return out.fill(m.data[0]); if (m.data.length !== n) throw new SysError(`${name} must be a scalar or have one entry per bead (${n})`); out.set(m.data); return out; } function indices(v: RuntimeValue | undefined, name: string, n: number): Int32Array { const m = asMat(v, name); if (!m) return new Int32Array(0); const isLogical = (v as { _isLogical?: boolean })._isLogical || typeof v === "boolean"; if (isLogical) { if (m.data.length !== n) throw new SysError(`${name}: a logical mask must have one entry per bead (${n})`); const out: number[] = []; m.data.forEach((b, i) => b && out.push(i)); return Int32Array.from(out); } const out = new Int32Array(m.data.length); m.data.forEach((b, q) => { if (!Number.isInteger(b) || b < 1 || b > n) throw new SysError(`${name}: bead index ${b} is not in 1..${n}`); out[q] = b - 1; }); return out; } function fields(v: RuntimeValue | undefined, name: string): Map[] { if (v === undefined) return []; const k = (v as { kind?: string }).kind; if (k === "struct") return [(v as unknown as { fields: Map }).fields]; if (k === "struct_array") { return (v as unknown as { elements: { fields: Map }[] }).elements.map(e => e.fields); } throw new SysError(`${name} must be a struct or struct array`); } const KNOWN = new Set([ "pos", "mass", "pinned", "springs", "bends", "dashpots", "friction", "hits", "listen", "duration", "sampleRate", "dt", "safety", "method", "frameRate", "modes", ]); export function parseSystem(sysValue: RuntimeValue | undefined): { sys: BeadSystem; warnings: string[] } { try { return parseInner(sysValue); } catch (e) { if (e instanceof SysError) throw new Error(e.message); throw e; } } function parseInner(sysValue: RuntimeValue | undefined): { sys: BeadSystem; warnings: string[] } { if (sysValue === undefined) throw new SysError("The script must define a struct named sys."); const [f] = fields(sysValue, "sys"); if (!f) throw new SysError("sys must be a single struct"); const warnings: string[] = []; for (const key of f.keys()) if (!KNOWN.has(key)) warnings.push(`sys.${key} is not used by the solver.`); const P = asMat(f.get("pos"), "sys.pos"); if (!P || P.cols !== 3 || P.rows < 1) throw new SysError("sys.pos must be an n-by-3 matrix of bead positions"); const n = P.rows; const pos0 = new Float64Array(3 * n); for (let i = 0; i < n; i++) for (let c = 0; c < 3; c++) pos0[3 * i + c] = P.data[i + c * n]; if (!f.has("mass")) throw new SysError("sys.mass is required (scalar or one per bead)"); const mass = perBead(f.get("mass"), "sys.mass", n, 0); for (let i = 0; i < n; i++) if (!(mass[i] > 0)) throw new SysError(`sys.mass must be positive (bead ${i + 1})`); const pinned = new Uint8Array(n); for (const b of indices(f.get("pinned"), "sys.pinned", n)) pinned[b] = 1; const S = asMat(f.get("springs"), "sys.springs"); const ns = S && S.data.length ? S.rows : 0; if (S && ns && S.cols !== 4 && S.cols !== 5) { throw new SysError("sys.springs must have 4 or 5 columns: [i j k L0] or [i j k L0 k3]"); } const si = new Int32Array(ns); const sj = new Int32Array(ns); const sk = new Float64Array(ns); const sL0 = new Float64Array(ns); const sk3 = new Float64Array(ns); for (let s = 0; s < ns; s++) { const col = (c: number) => S!.data[s + c * ns]; const i = col(0); const j = col(1); for (const b of [i, j]) { if (!Number.isInteger(b) || b < 1 || b > n) throw new SysError(`sys.springs row ${s + 1}: bead index ${b} is not in 1..${n}`); } if (i === j) throw new SysError(`sys.springs row ${s + 1} joins bead ${i} to itself`); si[s] = i - 1; sj[s] = j - 1; sk[s] = col(2); sL0[s] = col(3); sk3[s] = S!.cols === 5 ? col(4) : 0; if (sk[s] < 0 || sk3[s] < 0 || sL0[s] < 0) throw new SysError(`sys.springs row ${s + 1}: k, L0 and k3 must be nonnegative`); } const D = asMat(f.get("dashpots"), "sys.dashpots"); const nd = D && D.data.length ? D.rows : 0; if (D && nd && D.cols !== 3) throw new SysError("sys.dashpots must have 3 columns: [i j c]"); const di = new Int32Array(nd); const dj = new Int32Array(nd); const dc = new Float64Array(nd); for (let d = 0; d < nd; d++) { const col = (c: number) => D!.data[d + c * nd]; for (const b of [col(0), col(1)]) { if (!Number.isInteger(b) || b < 1 || b > n) throw new SysError(`sys.dashpots row ${d + 1}: bead index ${b} is not in 1..${n}`); } di[d] = col(0) - 1; dj[d] = col(1) - 1; dc[d] = col(2); if (dc[d] < 0) throw new SysError(`sys.dashpots row ${d + 1}: c must be nonnegative`); } const B = asMat(f.get("bends"), "sys.bends"); const nbend = B && B.data.length ? B.rows : 0; if (B && nbend && B.cols !== 4 && B.cols !== 5) { throw new SysError("sys.bends must have 4 or 5 columns: [i j k kb] or [i j k kb cb] (j the middle bead)"); } const bi = new Int32Array(nbend); const bj = new Int32Array(nbend); const bk = new Int32Array(nbend); const bkb = new Float64Array(nbend); const bcb = new Float64Array(nbend); for (let r = 0; r < nbend; r++) { const col = (c: number) => B!.data[r + c * nbend]; const ids = [col(0), col(1), col(2)]; for (const b of ids) { if (!Number.isInteger(b) || b < 1 || b > n) throw new SysError(`sys.bends row ${r + 1}: bead index ${b} is not in 1..${n}`); } if (new Set(ids).size !== 3) throw new SysError(`sys.bends row ${r + 1} repeats a bead`); bi[r] = ids[0] - 1; bj[r] = ids[1] - 1; bk[r] = ids[2] - 1; bkb[r] = col(3); bcb[r] = B!.cols === 5 ? col(4) : 0; if (bkb[r] < 0 || bcb[r] < 0) throw new SysError(`sys.bends row ${r + 1}: kb and cb must be nonnegative`); } let friction = perBead(f.get("friction"), "sys.friction", n, 0); if (!f.has("friction") && !f.has("dashpots") && !(B && B.cols === 5)) { // Default damping: every mode falls 60 dB in DEFAULT_T60 seconds // (amplitude rate sigma = ln(1000)/T60, from friction 2 sigma m per bead). const sigma = Math.log(1000) / DEFAULT_T60; friction = mass.map(m => 2 * sigma * m); warnings.push(`No damping given: using the default, every mode falls 60 dB in ${DEFAULT_T60} s. Set sys.friction = 0 for none.`); } const hits: Hit[] = fields(f.get("hits"), "sys.hits").map((h, q) => { const nm = `sys.hits(${q + 1})`; const beads = indices(h.get("beads"), `${nm}.beads`, n); if (beads.length === 0) throw new SysError(`${nm}.beads is required`); const duration = scalar(h.get("duration"), `${nm}.duration`, 0); const hit: Hit = { t: scalar(h.get("t"), `${nm}.t`, 0), beads, velocity: vec3(h.get("velocity"), `${nm}.velocity`, [0, 0, 0]), force: vec3(h.get("force"), `${nm}.force`, [0, 0, 0]), duration, }; // In a struct array every hit has every field, so treat empty [] as absent. const given = (k: string) => (asMat(h.get(k), `${nm}.${k}`)?.data.length ?? 0) > 0; if (duration > 0 && given("velocity")) throw new SysError(`${nm}: give velocity (impulse) or force with duration (pulse), not both`); if (duration === 0 && given("force")) throw new SysError(`${nm}.force needs a positive duration`); return hit; }); if (hits.length === 0) warnings.push("sys.hits is empty: nothing excites the system."); const [L] = fields(f.get("listen"), "sys.listen"); if (!L) throw new SysError("sys.listen is required, e.g. struct('beads', 10, 'dir', [0 0 1])"); const listenBeads = indices(L.get("beads") ?? L.get("bead"), "sys.listen.beads", n); if (listenBeads.length === 0) throw new SysError("sys.listen.beads is required"); const dir = vec3(L.get("dir"), "sys.listen.dir", [0, 0, 1]); const dn = Math.hypot(...dir); if (dn === 0) throw new SysError("sys.listen.dir must be nonzero"); const method = str(f.get("method"), "sys.method", "verlet") as Method; if (method !== "verlet" && method !== "yoshida4") throw new SysError("sys.method must be 'verlet' or 'yoshida4'"); const sys: BeadSystem = { n, pos0, mass, pinned, si, sj, sk, sL0, sk3, bi, bj, bk, bkb, bcb, di, dj, dc, friction, hits, listenBeads, listenDir: [dir[0] / dn, dir[1] / dn, dir[2] / dn], duration: scalar(f.get("duration"), "sys.duration", 2), sampleRate: scalar(f.get("sampleRate"), "sys.sampleRate", 48000), dt: scalar(f.get("dt"), "sys.dt", 0), safety: scalar(f.get("safety"), "sys.safety", 0.5), method, frameRate: scalar(f.get("frameRate"), "sys.frameRate", 500), modes: scalar(f.get("modes"), "sys.modes", 1) !== 0, }; if (!(sys.duration > 0)) throw new SysError("sys.duration must be positive"); if (!(sys.safety > 0 && sys.safety <= 1)) throw new SysError("sys.safety must be in (0, 1]"); if (ns === 0 && nbend === 0) warnings.push("sys.springs and sys.bends are empty: the beads do not interact."); return { sys, warnings }; }