/ proof-of-concept / cymplay
Sign in
proof-of-concept / cymplay
cymplay / src / design.ts
179 lines · 8.4 KBCodeBlameHistory
ae4a407cymplay: design and play a cymsim cymbal in the browserJeremy Magland 1// The design panel: every parameter of the cymbal that the player exposes, in two tiers.
2//
3// A "live" parameter enters the modal bank analytically and takes effect while the slider moves:
4// the two loss coefficients, Young's modulus, the density and the listener. A "structural"
5// parameter changes the eigenproblem itself, so editing it marks the modes stale and the user
6// presses Recompute. The tiers are explained in precompute/pipeline.ts.
7//
8// Values are stored in SI, as the cymsim schema has them, and displayed in whatever unit reads
9// best (millimetres for the geometry, gigapascals for the modulus), through a per-field factor.
10import type { SimulationConfig } from "./config/schema.ts";
12export type Tier = "live" | "structural";
14export interface Field {
15 section: "material" | "profile" | "imperfections" | "listener";
16 key: string;
17 index?: number; // component of a vector field (the listener position)
18 label: string;
19 unit: string;
20 scale: number; // displayed = stored * scale
21 min: number; // in displayed units
22 max: number;
23 step: number;
24 tier: Tier;
25 title?: string;
28const f = (section: Field["section"], key: string, label: string, unit: string, scale: number,
29 min: number, max: number, step: number, tier: Tier, title?: string): Field =>
30 ({ section, key, label, unit, scale, min, max, step, tier, title });
32export const GROUPS: { name: string; note: string; fields: Field[] }[] = [
33 {
34 name: "Sustain and material",
35 note: "live: these change the bank without a new eigensolve",
36 fields: [
37 f("material", "sigma0", "uniform loss", "1/s", 1, 0.05, 20, 0.05, "live",
38 "frequency-independent decay; the 60 dB time of the low modes is about 6.9 / sigma0"),
39 f("material", "sigma1", "loss rise with frequency", "1e-4 m^2/s", 1e4, 0, 60, 0.25, "live",
40 "adds 2 sigma1 k^2 to the decay rate, so high modes die first; this sets how dark the tail is"),
41 f("material", "E", "Young's modulus", "GPa", 1e-9, 20, 260, 1, "live",
42 "scales every frequency as sqrt(E); B20 bronze is 110 GPa"),
43 f("material", "rho", "density", "kg/m^3", 1, 2000, 12000, 50, "live",
44 "scales every frequency as 1/sqrt(rho); B20 bronze is 8700"),
45 f("material", "nu", "Poisson ratio", "", 1, 0.0, 0.48, 0.01, "structural"),
46 ],
47 },
48 {
49 name: "Size and profile",
50 note: "structural: a change needs the modes recomputed",
51 fields: [
52 f("profile", "radius", "radius", "mm", 1e3, 100, 400, 1, "structural", "18 inch is 228.6 mm"),
53 f("profile", "h_rim", "thickness at the rim", "mm", 1e3, 0.3, 4, 0.01, "structural",
54 "the single strongest control on pitch and weight"),
55 f("profile", "h_shoulder", "thickness at the shoulder", "mm", 1e3, 0.3, 5, 0.01, "structural"),
56 f("profile", "h_bell", "thickness in the bell", "mm", 1e3, 0.3, 6, 0.01, "structural"),
57 f("profile", "bow_height", "bow rise", "mm", 1e3, 0, 80, 0.5, "structural",
58 "height of the shoulder above the rim; more curvature stiffens the low modes"),
59 f("profile", "bow_power", "bow shape", "", 1, 0.5, 4, 0.05, "structural",
60 "1 is a cone; above 1 the bow curves near the rim and flattens at the shoulder"),
61 f("profile", "bell_height", "bell height", "mm", 1e3, 0, 40, 0.5, "structural"),
62 f("profile", "bell_top_radius", "bell plateau radius", "mm", 1e3, 5, 60, 1, "structural"),
63 f("profile", "bell_shoulder_radius", "shoulder radius", "mm", 1e3, 20, 150, 1, "structural"),
64 ],
65 },
66 {
67 name: "Hammering and asymmetry",
68 note: "structural: these break the symmetry and give the cymbal its character",
69 fields: [
70 f("imperfections", "seed", "seed", "", 1, 0, 999, 1, "structural", "a different draw of the same statistics"),
71 f("imperfections", "n_dents", "hammer marks", "", 1, 0, 1500, 10, "structural"),
72 f("imperfections", "dent_depth", "mark depth", "mm", 1e3, 0, 1.5, 0.01, "structural"),
73 f("imperfections", "dent_sigma", "mark width", "mm", 1e3, 2, 30, 0.5, "structural",
74 "keep this at or above the playback element size or the mesh cannot see the marks"),
75 f("imperfections", "rim_warp", "rim warp", "mm", 1e3, 0, 5, 0.05, "structural",
76 "low azimuthal harmonics that split the degenerate mode pairs"),
77 f("imperfections", "shoulder_ovality", "shoulder ovality", "", 1, 0, 0.25, 0.005, "structural"),
78 f("imperfections", "bell_offset", "bell off-centre", "mm", 1e3, 0, 10, 0.1, "structural"),
79 ],
80 },
81 {
82 name: "Listener",
83 note: "live: the gains are redone in the background, in a fraction of a second",
84 fields: [
85 { ...f("listener", "position", "listener x", "m", 1, -3, 3, 0.05, "live"), index: 0 },
86 { ...f("listener", "position", "listener y", "m", 1, -3, 3, 0.05, "live"), index: 1 },
87 { ...f("listener", "position", "listener z", "m", 1, -3, 3, 0.05, "live"), index: 2 },
88 f("listener", "back_diffraction_gain", "back-side cancellation", "", 1, 0, 1.5, 0.05, "live",
89 "0 is a baffled plate, 1 the full rim-diffraction estimate; raising it thins the low end"),
90 f("listener", "c_air", "speed of sound", "m/s", 1, 300, 380, 1, "live"),
91 f("listener", "rho_air", "air density", "kg/m^3", 1, 0.7, 1.6, 0.01, "live"),
92 ],
93 },
94];
96export const ALL_FIELDS = GROUPS.flatMap((g) => g.fields);
98export function getValue(cfg: SimulationConfig, field: Field): number {
99 const section = cfg[field.section] as unknown as Record<string, number | number[]>;
100 const v = section[field.key];
101 return field.index === undefined ? (v as number) : (v as number[])[field.index];
104export function setValue(cfg: SimulationConfig, field: Field, value: number): void {
105 const section = cfg[field.section] as unknown as Record<string, number | number[]>;
106 if (field.index === undefined) section[field.key] = value;
107 else (section[field.key] as number[])[field.index] = value;
110function fmt(v: number, step: number): string {
111 const digits = Math.max(0, Math.min(6, Math.ceil(-Math.log10(step) + 0.001)));
112 return v.toFixed(digits);
115/** Build the panel. ``onChange`` fires on every input event with the tier that changed. */
116export class DesignPanel {
117 private rows = new Map<Field, { range: HTMLInputElement; num: HTMLInputElement }>();
118 private cfg: () => SimulationConfig;
119 private onChange: (field: Field, tier: Tier) => void;
121 constructor(container: HTMLElement, cfg: () => SimulationConfig, onChange: (field: Field, tier: Tier) => void) {
122 this.cfg = cfg;
123 this.onChange = onChange;
124 for (const group of GROUPS) {
125 const box = document.createElement("details");
126 box.className = "group";
127 const sum = document.createElement("summary");
128 sum.textContent = group.name;
129 box.appendChild(sum);
130 const note = document.createElement("div");
131 note.className = "note";
132 note.textContent = group.note;
133 box.appendChild(note);
134 for (const field of group.fields) {
135 box.appendChild(this.row(field));
136 }
137 container.appendChild(box);
138 }
139 }
141 private row(field: Field): HTMLElement {
142 const row = document.createElement("div");
143 row.className = `field ${field.tier}`;
144 const label = document.createElement("label");
145 label.textContent = field.label + (field.unit ? ` [${field.unit}]` : "");
146 if (field.title) label.title = field.title;
147 const range = document.createElement("input");
148 range.type = "range";
149 range.min = String(field.min);
150 range.max = String(field.max);
151 range.step = String(field.step);
152 const num = document.createElement("input");
153 num.type = "number";
154 num.min = String(field.min);
155 num.max = String(field.max);
156 num.step = String(field.step);
157 const apply = (raw: number, from: HTMLInputElement) => {
158 const v = Math.min(field.max, Math.max(field.min, raw));
159 setValue(this.cfg(), field, v / field.scale);
160 if (from !== range) range.value = String(v);
161 if (from !== num) num.value = fmt(v, field.step);
162 this.onChange(field, field.tier);
163 };
164 range.addEventListener("input", () => apply(Number(range.value), range));
165 num.addEventListener("change", () => apply(Number(num.value), num));
166 row.append(label, range, num);
167 this.rows.set(field, { range, num });
168 return row;
169 }
171 /** Push the configuration into the inputs (after loading a preset or a YAML file). */
172 refresh(): void {
173 for (const [field, els] of this.rows) {
174 const v = getValue(this.cfg(), field) * field.scale;
175 els.range.value = String(v);
176 els.num.value = fmt(v, field.step);
177 }
178 }
↑↓ move↵openescclose