/** The modal data file: what the precompute produces and the player consumes. * * Layout: magic "CYMB", uint32 LE header length, UTF-8 JSON header, raw little-endian arrays at * the byte offsets the header lists. Arrays are copied out of the file buffer so that every typed * array is correctly aligned regardless of the offsets. * * The file describes the shell's *shape*, not its material or its listener. Frequencies are * stored for the reference Young's modulus and density in the header; the damping is stored as * the quadratic form q_m of the sigma1 loss term rather than as a decay rate; the gains are * stored for the listener that was current when the file was made but can be recomputed. This is * what lets the design panel change damping, material and listener without a new eigensolve. * See the notes in precompute/pipeline.ts for the scaling relations. */ import type { Exciter } from "./config/schema.ts"; export const FORMAT_VERSION = 2; export interface ModesMeta { format_version: number; n_nodes: number; n_elements: number; n_modes: number; fmax: number; radius: number; mass: number; /** Young's modulus and density the frequencies and mode shapes were computed at. */ E0: number; rho0: number; config: Record; playback_element_size: number; exciters: { stick: Exciter; mallet: Exciter }; notes: string; } export interface ModesData { meta: ModesMeta; nNodes: number; nElements: number; nModes: number; radius: number; xy: Float32Array; // (N, 2) tri: Int32Array; // (E, 3) z0: Float32Array; // (N,) h: Float32Array; // (N,) freq: Float64Array; // (Nm,) at E0, rho0 dampQ: Float64Array; // (Nm,) sigma_m = sigma0 + sigma1 * dampQ[m] gainFront: Float32Array; // (Nm, 2) re, im, for the stored listener gainBack: Float32Array; // (Nm, 2) phiScale: Float32Array; // (Nm,) phiQ: Int16Array; // (N, Nm) node-major } interface ArrayEntry { dtype: string; shape: number[]; offset: number; nbytes: number; } const CTORS: Record ArrayBufferView> = { float32: Float32Array, float64: Float64Array, int16: Int16Array, int32: Int32Array, }; export async function loadModes(url: string, onProgress?: (loaded: number, total: number) => void): Promise { const resp = await fetch(url); if (!resp.ok) throw new Error(`could not fetch ${url}: ${resp.status}`); const total = Number(resp.headers.get("content-length") ?? 0); let buffer: ArrayBuffer; if (resp.body && onProgress) { const reader = resp.body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; onProgress(loaded, total); } const out = new Uint8Array(loaded); let pos = 0; for (const c of chunks) { out.set(c, pos); pos += c.length; } buffer = out.buffer; } else { buffer = await resp.arrayBuffer(); } return parseModes(buffer); } export function parseModes(buffer: ArrayBuffer): ModesData { const bytes = new Uint8Array(buffer); const magic = String.fromCharCode(...bytes.subarray(0, 4)); if (magic !== "CYMB") throw new Error("not a cymplay modes file"); const hlen = new DataView(buffer).getUint32(4, true); const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + hlen))); const base = 8 + hlen; const arrays: Record = header.arrays; delete header.arrays; const meta = header as ModesMeta; if (meta.format_version !== FORMAT_VERSION) { throw new Error(`modes file is version ${meta.format_version}, this build reads version ${FORMAT_VERSION}; recompute it`); } const take = (name: string) => { const e = arrays[name]; if (!e) throw new Error(`modes file lacks array ${name}`); const ctor = CTORS[e.dtype]; if (!ctor) throw new Error(`unsupported dtype ${e.dtype} for ${name}`); return new ctor(buffer.slice(base + e.offset, base + e.offset + e.nbytes)); }; return { meta, nNodes: meta.n_nodes, nElements: meta.n_elements, nModes: meta.n_modes, radius: meta.radius, xy: take("xy") as Float32Array, tri: take("tri") as Int32Array, z0: take("z0") as Float32Array, h: take("h") as Float32Array, freq: take("freq") as Float64Array, dampQ: take("damp_q") as Float64Array, gainFront: take("gain_front") as Float32Array, gainBack: take("gain_back") as Float32Array, phiScale: take("phi_scale") as Float32Array, phiQ: take("phi_q") as Int16Array, }; } /** Write a ModesData back into the file format (the inverse of parseModes). */ export function serializeModes(data: ModesData): ArrayBuffer { const entries: [string, ArrayBufferView, string, number[]][] = [ ["xy", data.xy, "float32", [data.nNodes, 2]], ["tri", data.tri, "int32", [data.nElements, 3]], ["z0", data.z0, "float32", [data.nNodes]], ["h", data.h, "float32", [data.nNodes]], ["freq", data.freq, "float64", [data.nModes]], ["damp_q", data.dampQ, "float64", [data.nModes]], ["gain_front", data.gainFront, "float32", [data.nModes, 2]], ["gain_back", data.gainBack, "float32", [data.nModes, 2]], ["phi_scale", data.phiScale, "float32", [data.nModes]], ["phi_q", data.phiQ, "int16", [data.nNodes, data.nModes]], ]; const table: Record = {}; let offset = 0; for (const [name, arr, dtype, shape] of entries) { table[name] = { dtype, shape, offset, nbytes: arr.byteLength }; offset += arr.byteLength; } const header = new TextEncoder().encode(JSON.stringify({ ...data.meta, arrays: table })); const out = new Uint8Array(8 + header.length + offset); out.set([0x43, 0x59, 0x4d, 0x42], 0); // "CYMB" new DataView(out.buffer).setUint32(4, header.length, true); out.set(header, 8); const base = 8 + header.length; for (const [name, arr] of entries) { out.set(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength), base + table[name].offset); } return out.buffer; } /** Containing triangle and barycentric weights of a point of the projected disk. */ export function locate(data: ModesData, px: number, py: number): { nodes: [number, number, number]; weights: [number, number, number] } | null { const { xy, tri, nElements } = data; for (let e = 0; e < nElements; e++) { const i0 = tri[3 * e], i1 = tri[3 * e + 1], i2 = tri[3 * e + 2]; const x0 = xy[2 * i0], y0 = xy[2 * i0 + 1]; const x1 = xy[2 * i1], y1 = xy[2 * i1 + 1]; const x2 = xy[2 * i2], y2 = xy[2 * i2 + 1]; const det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); const l1 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / det; const l2 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / det; const l3 = 1 - l1 - l2; if (Math.min(l1, l2, l3) >= -1e-9) return { nodes: [i0, i1, i2], weights: [l1, l2, l3] }; } // outside the mesh (e.g. between the rim polygon and the circle): not a valid strike point return null; } /** Mode shape values at a point: phi_m(p) = sum_i lambda_i phi_m(node_i), for all modes. */ export function modeValuesAt(data: ModesData, nodes: number[], weights: number[], out: Float32Array, offset: number, factor = 1): void { const { nModes, phiQ, phiScale } = data; for (let m = 0; m < nModes; m++) out[offset + m] = 0; for (let i = 0; i < nodes.length; i++) { const row = nodes[i] * nModes; const w = weights[i]; for (let m = 0; m < nModes; m++) out[offset + m] += w * phiQ[row + m]; } for (let m = 0; m < nModes; m++) out[offset + m] *= phiScale[m] * factor; } /** Linear interpolation of a nodal field at a point. */ export function interpolateNodal(field: Float32Array, nodes: number[], weights: number[]): number { let s = 0; for (let i = 0; i < nodes.length; i++) s += weights[i] * field[nodes[i]]; return s; }