1/**
2 * The model: its MATLAB source, and the metadata the host owns.
3 *
4 * The model's *algorithm* lives in models/dulcimer.m. Everything around it
5 * lives here: the parameter names the .m may take as arguments, their
6 * defaults and slider ranges, the state fields it advances, and which of
7 * them is the pressure the app draws and the microphone records. The .m
8 * declares nothing about these — it just names the parameters it wants, and
9 * `GpuModel` matches each against this table.
10 */
11import dulcimerSource from '../../models/dulcimer.m?raw';
12import type { StateField } from './model.ts';
14export type Params = Record<string, number>;
16/** A tunable scalar the .m may take as an argument. */
17export interface ParamSpec {
18 key: string;
19 label: string;
20 value: number;
21 min: number;
22 max: number;
23 step: number;
24 /** Shown as a tooltip. */
25 hint?: string;
26}
28export interface MModel {
29 key: string;
30 label: string;
31 blurb: string;
32 /** State fields the .m advances, in the order its functions return them. */
33 state: StateField[];
34 /** The air pressure field — what gets drawn and recorded. */
35 pressure: string;
36 /** The string displacement field — what the string plot shows. */
37 displacement: string;
38 params: ParamSpec[];
39 /** MATLAB source — the algorithm itself. */
40 source: string;
41}
43export const dulcimerModel: MModel = {
44 key: 'dulcimer',
45 label: 'Dulcimer',
46 blurb:
47 'A stiff, damped string released from a triangular pluck, driving the ' +
48 'acoustic wave equation around a rigid box.',
49 state: [
50 { name: 'u', grid: 'string' },
51 { name: 'um', grid: 'string' },
52 { name: 'p', grid: 'air' },
53 { name: 'pm', grid: 'air' },
54 ],
55 pressure: 'p',
56 displacement: 'u',
57 params: [
58 {
59 key: 'f0',
60 label: 'fundamental (Hz)',
61 value: 294,
62 min: 100,
63 max: 600,
64 step: 1,
65 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.',
66 },
67 {
68 key: 'B',
69 label: 'inharmonicity',
70 value: 0.0001,
71 min: 0,
72 max: 0.002,
73 step: 0.00002,
74 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.',
75 },
76 {
77 key: 't60',
78 label: 'decay t60 (s)',
79 value: 4,
80 min: 0.2,
81 max: 8,
82 step: 0.1,
83 hint: 'Seconds for the string to decay 60 dB. What the note’s overall ring is.',
84 },
85 {
86 key: 'sig1',
87 label: 'brightness decay (m²/s)',
88 value: 0.005,
89 min: 0,
90 max: 0.05,
91 step: 0.001,
92 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.',
93 },
94 {
95 key: 'pluckpos',
96 label: 'pluck position',
97 value: 0.22,
98 min: 0.08,
99 max: 0.92,
100 step: 0.01,
101 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.',
102 },
103 {
104 key: 'amp',
105 label: 'pluck height (m)',
106 value: 0.002,
107 min: 0.0002,
108 max: 0.005,
109 step: 0.0002,
110 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.',
111 },
112 {
113 key: 'gline',
114 label: 'string radiation',
115 value: 0.3,
116 min: 0,
117 max: 2,
118 step: 0.05,
119 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.',
120 },
121 {
122 key: 'gbridge',
123 label: 'bridge drive',
124 value: 1,
125 min: 0,
126 max: 2,
127 step: 0.05,
128 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.',
129 },
130 ],
131 source: dulcimerSource,
132};
134/** The slider maxima the string grid must stay stable for, whatever the user
135 * drags to (see makeStringGrid). */
136export function worstCase(model: MModel): { f0: number; B: number; sig1: number } {
137 const max = (key: string): number =>
138 model.params.find((p) => p.key === key)?.max ?? 0;
139 return { f0: max('f0'), B: max('B'), sig1: max('sig1') };
140}
142export const defaultParams = (m: MModel): Params =>
143 Object.fromEntries(m.params.map((p) => [p.key, p.value]));