/ proof-of-concept / cymmodes
Sign in
proof-of-concept / cymmodes
cymmodes / src / modes.ts
204 lines · 7.8 KBCodeBlameHistory
8354482cymmodes: linear modes of a cymsim cymbal driven in the full nonlinear model, with the energy of every mode over timeJeremy Magland 1/** The modal data file: what the precompute produces and the player consumes.
2 *
3 * Layout: magic "CYMB", uint32 LE header length, UTF-8 JSON header, raw little-endian arrays at
4 * the byte offsets the header lists. Arrays are copied out of the file buffer so that every typed
5 * array is correctly aligned regardless of the offsets.
6 *
7 * The file describes the shell's *shape*, not its material or its listener. Frequencies are
8 * stored for the reference Young's modulus and density in the header; the damping is stored as
9 * the quadratic form q_m of the sigma1 loss term rather than as a decay rate; the gains are
10 * stored for the listener that was current when the file was made but can be recomputed. This is
11 * what lets the design panel change damping, material and listener without a new eigensolve.
12 * See the notes in precompute/pipeline.ts for the scaling relations.
13 */
15import type { Exciter } from "./config/schema.ts";
17export const FORMAT_VERSION = 2;
19export interface ModesMeta {
20 format_version: number;
21 n_nodes: number;
22 n_elements: number;
23 n_modes: number;
24 fmax: number;
25 radius: number;
26 mass: number;
27 /** Young's modulus and density the frequencies and mode shapes were computed at. */
28 E0: number;
29 rho0: number;
30 config: Record<string, unknown>;
31 playback_element_size: number;
32 exciters: { stick: Exciter; mallet: Exciter };
33 notes: string;
36export interface ModesData {
37 meta: ModesMeta;
38 nNodes: number;
39 nElements: number;
40 nModes: number;
41 radius: number;
42 xy: Float32Array; // (N, 2)
43 tri: Int32Array; // (E, 3)
44 z0: Float32Array; // (N,)
45 h: Float32Array; // (N,)
46 freq: Float64Array; // (Nm,) at E0, rho0
47 dampQ: Float64Array; // (Nm,) sigma_m = sigma0 + sigma1 * dampQ[m]
48 gainFront: Float32Array; // (Nm, 2) re, im, for the stored listener
49 gainBack: Float32Array; // (Nm, 2)
50 phiScale: Float32Array; // (Nm,)
51 phiQ: Int16Array; // (N, Nm) node-major
54interface ArrayEntry {
55 dtype: string;
56 shape: number[];
57 offset: number;
58 nbytes: number;
61const CTORS: Record<string, new (b: ArrayBuffer) => ArrayBufferView> = {
62 float32: Float32Array,
63 float64: Float64Array,
64 int16: Int16Array,
65 int32: Int32Array,
66};
68export async function loadModes(url: string, onProgress?: (loaded: number, total: number) => void): Promise<ModesData> {
69 const resp = await fetch(url);
70 if (!resp.ok) throw new Error(`could not fetch ${url}: ${resp.status}`);
71 const total = Number(resp.headers.get("content-length") ?? 0);
72 let buffer: ArrayBuffer;
73 if (resp.body && onProgress) {
74 const reader = resp.body.getReader();
75 const chunks: Uint8Array[] = [];
76 let loaded = 0;
77 for (;;) {
78 const { done, value } = await reader.read();
79 if (done) break;
80 chunks.push(value);
81 loaded += value.length;
82 onProgress(loaded, total);
83 }
84 const out = new Uint8Array(loaded);
85 let pos = 0;
86 for (const c of chunks) {
87 out.set(c, pos);
88 pos += c.length;
89 }
90 buffer = out.buffer;
91 } else {
92 buffer = await resp.arrayBuffer();
93 }
94 return parseModes(buffer);
97export function parseModes(buffer: ArrayBuffer): ModesData {
98 const bytes = new Uint8Array(buffer);
99 const magic = String.fromCharCode(...bytes.subarray(0, 4));
100 if (magic !== "CYMB") throw new Error("not a cymplay modes file");
101 const hlen = new DataView(buffer).getUint32(4, true);
102 const header = JSON.parse(new TextDecoder().decode(bytes.subarray(8, 8 + hlen)));
103 const base = 8 + hlen;
104 const arrays: Record<string, ArrayEntry> = header.arrays;
105 delete header.arrays;
106 const meta = header as ModesMeta;
107 if (meta.format_version !== FORMAT_VERSION) {
108 throw new Error(`modes file is version ${meta.format_version}, this build reads version ${FORMAT_VERSION}; recompute it`);
109 }
110 const take = (name: string) => {
111 const e = arrays[name];
112 if (!e) throw new Error(`modes file lacks array ${name}`);
113 const ctor = CTORS[e.dtype];
114 if (!ctor) throw new Error(`unsupported dtype ${e.dtype} for ${name}`);
115 return new ctor(buffer.slice(base + e.offset, base + e.offset + e.nbytes));
116 };
117 return {
118 meta,
119 nNodes: meta.n_nodes,
120 nElements: meta.n_elements,
121 nModes: meta.n_modes,
122 radius: meta.radius,
123 xy: take("xy") as Float32Array,
124 tri: take("tri") as Int32Array,
125 z0: take("z0") as Float32Array,
126 h: take("h") as Float32Array,
127 freq: take("freq") as Float64Array,
128 dampQ: take("damp_q") as Float64Array,
129 gainFront: take("gain_front") as Float32Array,
130 gainBack: take("gain_back") as Float32Array,
131 phiScale: take("phi_scale") as Float32Array,
132 phiQ: take("phi_q") as Int16Array,
133 };
136/** Write a ModesData back into the file format (the inverse of parseModes). */
137export function serializeModes(data: ModesData): ArrayBuffer {
138 const entries: [string, ArrayBufferView, string, number[]][] = [
139 ["xy", data.xy, "float32", [data.nNodes, 2]],
140 ["tri", data.tri, "int32", [data.nElements, 3]],
141 ["z0", data.z0, "float32", [data.nNodes]],
142 ["h", data.h, "float32", [data.nNodes]],
143 ["freq", data.freq, "float64", [data.nModes]],
144 ["damp_q", data.dampQ, "float64", [data.nModes]],
145 ["gain_front", data.gainFront, "float32", [data.nModes, 2]],
146 ["gain_back", data.gainBack, "float32", [data.nModes, 2]],
147 ["phi_scale", data.phiScale, "float32", [data.nModes]],
148 ["phi_q", data.phiQ, "int16", [data.nNodes, data.nModes]],
149 ];
150 const table: Record<string, ArrayEntry> = {};
151 let offset = 0;
152 for (const [name, arr, dtype, shape] of entries) {
153 table[name] = { dtype, shape, offset, nbytes: arr.byteLength };
154 offset += arr.byteLength;
155 }
156 const header = new TextEncoder().encode(JSON.stringify({ ...data.meta, arrays: table }));
157 const out = new Uint8Array(8 + header.length + offset);
158 out.set([0x43, 0x59, 0x4d, 0x42], 0); // "CYMB"
159 new DataView(out.buffer).setUint32(4, header.length, true);
160 out.set(header, 8);
161 const base = 8 + header.length;
162 for (const [name, arr] of entries) {
163 out.set(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength), base + table[name].offset);
164 }
165 return out.buffer;
168/** Containing triangle and barycentric weights of a point of the projected disk. */
169export function locate(data: ModesData, px: number, py: number): { nodes: [number, number, number]; weights: [number, number, number] } | null {
170 const { xy, tri, nElements } = data;
171 for (let e = 0; e < nElements; e++) {
172 const i0 = tri[3 * e], i1 = tri[3 * e + 1], i2 = tri[3 * e + 2];
173 const x0 = xy[2 * i0], y0 = xy[2 * i0 + 1];
174 const x1 = xy[2 * i1], y1 = xy[2 * i1 + 1];
175 const x2 = xy[2 * i2], y2 = xy[2 * i2 + 1];
176 const det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
177 const l1 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / det;
178 const l2 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / det;
179 const l3 = 1 - l1 - l2;
180 if (Math.min(l1, l2, l3) >= -1e-9) return { nodes: [i0, i1, i2], weights: [l1, l2, l3] };
181 }
182 // outside the mesh (e.g. between the rim polygon and the circle): not a valid strike point
183 return null;
186/** Mode shape values at a point: phi_m(p) = sum_i lambda_i phi_m(node_i), for all modes. */
187export function modeValuesAt(data: ModesData, nodes: number[], weights: number[], out: Float32Array, offset: number,
188 factor = 1): void {
189 const { nModes, phiQ, phiScale } = data;
190 for (let m = 0; m < nModes; m++) out[offset + m] = 0;
191 for (let i = 0; i < nodes.length; i++) {
192 const row = nodes[i] * nModes;
193 const w = weights[i];
194 for (let m = 0; m < nModes; m++) out[offset + m] += w * phiQ[row + m];
195 }
196 for (let m = 0; m < nModes; m++) out[offset + m] *= phiScale[m] * factor;
199/** Linear interpolation of a nodal field at a point. */
200export function interpolateNodal(field: Float32Array, nodes: number[], weights: number[]): number {
201 let s = 0;
202 for (let i = 0; i < nodes.length; i++) s += weights[i] * field[nodes[i]];
203 return s;
↑↓ move↵openescclose