/** * The model: its MATLAB source, and the metadata the host owns. * * The model's *algorithm* lives in models/dulcimer.m. Everything around it * lives here: the parameter names the .m may take as arguments, their * defaults and slider ranges, the state fields it advances, and which of * them is the pressure the app draws and the microphone records. The .m * declares nothing about these — it just names the parameters it wants, and * `GpuModel` matches each against this table. */ import dulcimerSource from '../../models/dulcimer.m?raw'; import type { StateField } from './model.ts'; export type Params = Record; /** A tunable scalar the .m may take as an argument. */ export interface ParamSpec { key: string; label: string; value: number; min: number; max: number; step: number; /** Shown as a tooltip. */ hint?: string; } export interface MModel { key: string; label: string; blurb: string; /** State fields the .m advances, in the order its functions return them. */ state: StateField[]; /** The air pressure field — what gets drawn and recorded. */ pressure: string; /** The string displacement field — what the string plot shows. */ displacement: string; params: ParamSpec[]; /** MATLAB source — the algorithm itself. */ source: string; } export const dulcimerModel: MModel = { key: 'dulcimer', label: 'Dulcimer', blurb: 'A stiff, damped string released from a triangular pluck, driving the ' + 'acoustic wave equation around a rigid box.', state: [ { name: 'u', grid: 'string' }, { name: 'um', grid: 'string' }, { name: 'p', grid: 'air' }, { name: 'pm', grid: 'air' }, ], pressure: 'p', displacement: 'u', params: [ { key: 'f0', label: 'fundamental (Hz)', value: 294, min: 100, max: 600, step: 1, hint: 'The pitch the string is tuned to. 294 Hz is the D above middle C, a common dulcimer melody string. The wave speed on the string follows: cs = 2·Ls·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 is a light steel string; higher starts to sound bell-like.', }, { key: 't60', label: 'decay t60 (s)', value: 4, min: 0.2, max: 8, step: 0.1, hint: 'Seconds for the string to decay 60 dB. What the note’s overall ring is.', }, { key: 'sig1', label: 'brightness decay (m²/s)', value: 0.005, min: 0, max: 0.05, step: 0.001, hint: 'Frequency-dependent damping. High partials die faster than low ones, so the note starts bright and mellows; zero keeps it buzzing to the end.', }, { key: 'pluckpos', label: 'pluck position', value: 0.22, min: 0.08, max: 0.92, step: 0.01, hint: 'Where along the string it is plucked, as a fraction of its length. Near the middle favours the odd partials (hollower); near the end excites them all (brighter). Takes effect on the next pluck.', }, { 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. The equations are linear, so this scales everything and changes nothing else. Takes effect on the next pluck.', }, { key: 'gline', label: 'string radiation', value: 0.3, min: 0, max: 2, step: 0.05, hint: 'Gain on the string radiating directly along its length. A thin string barely does this in reality, so it is the idealized route; 0 switches it off.', }, { key: 'gbridge', label: 'bridge drive', value: 1, min: 0, max: 2, step: 0.05, hint: 'Gain on the string’s pull at the bridge driving the top-plate patch — the real instrument’s main route into the air. 0 switches it off.', }, ], source: dulcimerSource, }; /** The slider maxima the string grid must stay stable for, whatever the user * drags to (see makeStringGrid). */ export function worstCase(model: MModel): { f0: number; B: number; sig1: number } { const max = (key: string): number => model.params.find((p) => p.key === key)?.max ?? 0; return { f0: max('f0'), B: max('B'), sig1: max('sig1') }; } export const defaultParams = (m: MModel): Params => Object.fromEntries(m.params.map((p) => [p.key, p.value]));