/** * The three kinds of vibrating object, as data: their parameters and slider * ranges, how their grids are sized, and where their partials should land. * * The physics each type contributes to the generated model .m lives in * src/studio/codegen.ts; this file is the registry around it. The split is * the same one dulcimer draws between its .m files and its registries: what * an object *is* (its scheme) is MATLAB the Lab view shows, and everything * around it (defaults, ranges, hints, sizing) is host data. * * Grid sizing uses each type's stability condition at the sliders' worst * case, so moving a parameter never forces a recompile — see src/grids.ts. * * - A **string** is the stiff damped string of the dulcimer project: * u_tt = cs^2 u_xx - kap^2 u_xxxx - 2 sig0 u_t + 2 sig1 (u_xx)_t, pinned * ends, plucked by a triangular initial displacement. It couples to the * air two ways: radiating along its own line, and pulling on a bridge * patch at its x = +L/2 end. * - A **bar** is the cs = 0 case with the stiffness doing all the work — a * glockenspiel bar, loosely — struck by a mallet-shaped initial velocity. * Pinned ends (a real bar is free-free; see the README for what that * changes). Its partials go as n^2 f1, which is the bell-like sound. * - A **membrane** is the 2D wave equation with tension and damping, pinned * at its rim, struck like the bar. Its partials are the inharmonic * f0 * sqrt((m/L1)^2 + (n/L2)^2) / sqrt(1/L1^2 + 1/L2^2) set, which is * why a drum goes "thud" rather than sounding a chord. */ import { makeLineGrid, makeRectGrid, type ObjectGrid } from '../grids.ts'; import type { ObjectSpec, ObjectType, ParamSpec } from './types.ts'; export interface ObjectTemplate { type: ObjectType; label: string; /** What exciting this object is called ('Pluck', 'Strike'). */ verb: string; blurb: string; /** Baked extents and their ranges; editing one rebuilds. */ sizeSpecs: ParamSpec[]; /** Model parameters (compiled-side, free to move). */ paramSpecs: ParamSpec[]; /** Placement parameters (scene-side, cheap to move). */ placeSpecs: ParamSpec[]; /** Build this object's grid at timestep dt, stable for the sliders' worst * case. */ makeGrid(size: { L1: number; L2?: number }, dt: number): ObjectGrid; /** Where this object's partials should land, for the spectrum overlay. */ partials(spec: ObjectSpec, count: number): number[]; } const max = (specs: ParamSpec[], key: string): number => specs.find((p) => p.key === key)?.max ?? 0; const stringTemplate: ObjectTemplate = { type: 'string', label: 'String', verb: 'Pluck', blurb: 'A stiff, damped string, plucked; radiates along its line and drives a bridge patch at its far end.', sizeSpecs: [ { key: 'L1', label: 'length (m)', value: 0.6, min: 0.3, max: 0.9, step: 0.05, musical: true, hint: 'The speaking length. Changing it recompiles (the grid is sized from it).', }, ], paramSpecs: [ { key: 'f0', label: 'pitch (Hz)', value: 294, min: 80, max: 880, step: 1, musical: true, hint: 'The fundamental the string is tuned to. The wave speed follows: cs = 2·L·f0.', }, { key: 'B', label: 'inharmonicity', value: 0.0001, min: 0, max: 0.002, step: 0.00002, hint: 'Bending stiffness, as the inharmonicity coefficient B: partial n sounds near n·f0·sqrt(1 + B·n²). Zero is an ideal string; ~1e-4 a light steel string.', }, { key: 't60', label: 'decay t60 (s)', value: 4, min: 0.2, max: 8, step: 0.1, musical: true, hint: 'Seconds for the string to decay 60 dB.', }, { key: 'sig1', label: 'brightness decay', value: 0.005, min: 0, max: 0.05, step: 0.001, musical: true, hint: 'Frequency-dependent damping (m²/s). High partials die faster than low, so the note starts bright and mellows; zero keeps it buzzing.', }, { key: 'pluckpos', label: 'pluck position', value: 0.22, min: 0.08, max: 0.92, step: 0.01, musical: true, hint: 'Where along the string it is plucked, as a fraction of length. Takes effect on the next excite.', }, { key: 'amp', label: 'pluck height (m)', value: 0.002, min: 0.0002, max: 0.005, step: 0.0002, hint: 'How far the string is pulled before release. Linear equations: this scales everything and changes nothing else.', }, { key: 'gair', label: 'line radiation', value: 0.3, min: 0, max: 2, step: 0.05, hint: 'Gain on the string radiating directly along its length (idealized; a thin string barely does this in reality). 0 switches the route off.', }, { key: 'gbridge', label: 'bridge drive', value: 1, min: 0, max: 2, step: 0.05, hint: 'Gain on the string’s pull at its far end driving a patch of air above the plate — the real instrument’s main route. 0 switches it off.', }, ], placeSpecs: [ { key: 'oy', label: 'across (m)', value: 0, min: -0.15, max: 0.15, step: 0.005, hint: 'Sideways offset of the string’s line.' }, { key: 'oz', label: 'height (m)', value: 0.02, min: 0.005, max: 0.15, step: 0.005, hint: 'Height of the string above z = 0 (the top of a body, if there is one).' }, ], makeGrid(size, dt) { const cs = 2 * size.L1 * max(this.paramSpecs, 'f0'); const kap2 = (max(this.paramSpecs, 'B') * cs * cs * size.L1 * size.L1) / (Math.PI * Math.PI); return makeLineGrid(size.L1, dt, { cs, kap2, sig1: max(this.paramSpecs, 'sig1') }); }, partials(spec, count) { const { f0 = 0, B = 0 } = spec.params; return Array.from({ length: count }, (_, i) => { const n = i + 1; return n * f0 * Math.sqrt(1 + B * n * n); }); }, }; const barTemplate: ObjectTemplate = { type: 'bar', label: 'Bar', verb: 'Strike', blurb: 'A stiff bar with no tension — a chime; struck with a mallet-shaped velocity, partials at n²·f1.', sizeSpecs: [ { key: 'L1', label: 'length (m)', value: 0.3, min: 0.15, max: 0.5, step: 0.05, musical: true, hint: 'Changing it recompiles (the grid is sized from it).', }, ], paramSpecs: [ { key: 'f1', label: 'pitch (Hz)', value: 780, min: 200, max: 2000, step: 5, musical: true, hint: 'The first partial. The stiffness follows: kap = 2·f1·L²/π; higher partials land at n²·f1 (4·f1, 9·f1, ...), which is the chime sound.', }, { key: 't60', label: 'decay t60 (s)', value: 6, min: 0.5, max: 12, step: 0.1, musical: true, hint: 'Seconds to decay 60 dB. Metal rings long.', }, { key: 'sig1', label: 'brightness decay', value: 0.001, min: 0, max: 0.02, step: 0.0005, musical: true, hint: 'Frequency-dependent damping (m²/s): the strike’s clang fades to a pure ring.', }, { key: 'strikepos', label: 'strike position', value: 0.3, min: 0.1, max: 0.9, step: 0.01, musical: true, hint: 'Where the mallet lands, as a fraction of length. Takes effect on the next excite.', }, { key: 'striker', label: 'mallet width (m)', value: 0.025, min: 0.01, max: 0.08, step: 0.005, hint: 'Width of the struck patch. A narrow, hard mallet excites more of the high partials.', }, { key: 'vel', label: 'strike speed (m/s)', value: 1, min: 0.1, max: 5, step: 0.1, hint: 'Initial velocity the mallet imparts. Linear equations: sets scale only.', }, { key: 'gair', label: 'radiation', value: 1, min: 0, max: 2, step: 0.05, hint: 'Gain on the bar radiating along its line. 0 mutes it.', }, ], placeSpecs: [ { key: 'oy', label: 'across (m)', value: 0, min: -0.15, max: 0.15, step: 0.005 }, { key: 'oz', label: 'height (m)', value: 0.05, min: 0.005, max: 0.15, step: 0.005 }, ], makeGrid(size, dt) { const kap = (2 * max(this.paramSpecs, 'f1') * size.L1 * size.L1) / Math.PI; return makeLineGrid(size.L1, dt, { cs: 0, kap2: kap * kap, sig1: max(this.paramSpecs, 'sig1'), }); }, partials(spec, count) { const { f1 = 0 } = spec.params; return Array.from({ length: count }, (_, i) => (i + 1) * (i + 1) * f1); }, }; const membraneTemplate: ObjectTemplate = { type: 'membrane', label: 'Membrane', verb: 'Strike', blurb: 'A 2D membrane pinned at its rim — a drum head; struck, with the inharmonic mode set that makes a drum thud rather than chord.', sizeSpecs: [ { key: 'L1', label: 'width (m)', value: 0.32, min: 0.15, max: 0.5, step: 0.01, musical: true, hint: 'Extent along x. Changing it recompiles.', }, { key: 'L2', label: 'depth (m)', value: 0.32, min: 0.15, max: 0.5, step: 0.01, musical: true, hint: 'Extent along y. Changing it recompiles.', }, ], paramSpecs: [ { key: 'f0', label: 'pitch (Hz)', value: 110, min: 60, max: 300, step: 1, musical: true, hint: 'The (1,1) fundamental the head is tuned to; the tension’s wave speed follows from it and the size.', }, { key: 't60', label: 'decay t60 (s)', value: 1.5, min: 0.2, max: 6, step: 0.1, musical: true, hint: 'Seconds to decay 60 dB. Drums are short.', }, { key: 'sig1', label: 'brightness decay', value: 0.0005, min: 0, max: 0.01, step: 0.0002, musical: true, hint: 'Frequency-dependent damping (m²/s): the attack’s slap fades first.', }, { key: 'strikex', label: 'strike x', value: 0.35, min: 0.1, max: 0.9, step: 0.01, musical: true, hint: 'Where the stick lands along x, as a fraction. Off-centre excites more modes. Takes effect on the next excite.', }, { key: 'strikey', label: 'strike y', value: 0.5, min: 0.1, max: 0.9, step: 0.01, hint: 'Where the stick lands along y, as a fraction.', }, { key: 'striker', label: 'stick width (m)', value: 0.04, min: 0.01, max: 0.1, step: 0.005, hint: 'Radius of the struck patch.', }, { key: 'vel', label: 'strike speed (m/s)', value: 1.5, min: 0.1, max: 5, step: 0.1, hint: 'Initial velocity the stick imparts. Sets scale only.', }, { key: 'gair', label: 'radiation', value: 1, min: 0, max: 2, step: 0.05, hint: 'Gain on the head radiating from its plane. 0 mutes it.', }, ], placeSpecs: [ { key: 'oz', label: 'height (m)', value: 0.02, min: 0, max: 0.15, step: 0.005, hint: 'Height of the head’s plane above z = 0 — just above a drum shell’s rim.' }, ], makeGrid(size, dt) { const L2 = size.L2 ?? size.L1; const c = (2 * max(this.paramSpecs, 'f0')) / Math.sqrt(1 / (size.L1 * size.L1) + 1 / (L2 * L2)); return makeRectGrid(size.L1, L2, dt, { c, sig1: max(this.paramSpecs, 'sig1') }); }, partials(spec, count) { const { f0 = 0 } = spec.params; const L1 = spec.size.L1; const L2 = spec.size.L2 ?? L1; const base = Math.sqrt(1 / (L1 * L1) + 1 / (L2 * L2)); const out: number[] = []; for (let m = 1; m <= 4; m++) { for (let n = 1; n <= 4; n++) { out.push((f0 * Math.sqrt((m / L1) ** 2 + (n / L2) ** 2)) / base); } } return [...new Set(out.map((f) => Math.round(f * 100) / 100))] .sort((a, b) => a - b) .slice(0, count); }, }; export const OBJECT_TEMPLATES: Record = { string: stringTemplate, bar: barTemplate, membrane: membraneTemplate, }; export const templateOf = (spec: ObjectSpec): ObjectTemplate => OBJECT_TEMPLATES[spec.type];