Make the contact point a first-class control
The stroke position is addressed by arclength fraction along the meridian,
which is the right coordinate for the physics and a poor one to aim with.
- a StrikePicker card draws the profile to scale with the bell, bow and edge
marked and a ruler in centimetres, and both contacts are draggable along it;
- the strip under it shows what the current contact excites, band by band,
from the stroke's own modal velocity jumps;
- the contact radius and its distance from the rim are reported by the
diagnostics panel and by cymbal_demo.m;
- lib/profile.ts gains the meridian arclength table, so the picker and the 3D
scene place the stick exactly where cymbal_point.m does.
There is no azimuth control on purpose: the plate is a surface of revolution
and the hammering carries no azimuthal structure, so striking at a different
azimuth is exactly equivalent to rotating the listener.
13 changed files+419−45
README.mdmodified+2−0View file
@@ -55,4 +55,6 @@ A 16-inch B20 bronze crash-ride: 20.3 cm radius, a 4.5 cm bell rising 1.7 cm abo
5555
5656 The damping is deliberately flat across the band, about 4 seconds of `T60` at 300 Hz falling to 2 seconds at 6 kHz. That is a choice, and an audible one: a steeper law, with the low modes ringing for the fifteen seconds a frequency-independent loss factor would give them, leaves a handful of low partials sounding alone once the wash has gone, which is again a bell. `damp_rate` is the term that holds the whole band together, and `loss_factor` and `loss_slope` are what tilt it.
5757
58+Where the stick lands matters as much as how hard. The contact is addressed by arclength fraction along the meridian, `strike_r`, running from the hole at 0 to the rim at 1; the default 0.94 is 1.2 cm in from the edge. Because a mode with `n` nodal diameters falls off as roughly `r^n` toward the axis, the position selects which modes are driven at all: at the bell an `n = 40` mode sits at 1e-22 of its rim amplitude, so a bell stroke puts 91 of the 378 modes into play and rings clear and sparse, while a stroke on the outermost edge puts 178 into play and washes. `roll_r2` is where even-numbered strokes land, so a figure can move between the bow and the edge. The web app draws the profile to scale and lets both contacts be dragged along it.
59+
5860 Setting `strike_count` to 8 gives a repeated figure alternating between the edge and the bow; moving `strike_r` to 0.6 and dropping the velocity gives a ride, and to 0.1 the bell. Dropping `strike_velocity` to a few tenths of a metre per second turns the same instrument back into a ping, because the cascade has nothing left to work with.
matlab/cymbal_demo.mmodified+18−0View file
@@ -17,6 +17,13 @@ fprintf('modes kept : %d up to n = %d nodal diameters (%.0f Hz to %.0f Hz)\n', .
1717 fprintf('lowest : ');
1818 fprintf('%.0f ', basis.f(1:min(10, basis.n)));
1919 fprintf('Hz\n');
20+fprintf('contact : r = %.1f cm (%.1f cm in from the rim, %s)', ...
21+ dinfo.strikeRadiusM * 100, dinfo.strikeRimM * 100, cd_region(P, dinfo.strikeRadiusM));
22+if dinfo.strikeCount > 1
23+ fprintf(', alternating with r = %.1f cm (%s)', ...
24+ dinfo.roll2RadiusM * 100, cd_region(P, dinfo.roll2RadiusM));
25+end
26+fprintf('\n');
2027 fprintf('strikes : %d at %.1f Hz (mean v %.2f m/s, mean contact %.0f us, peak force %.0f N)\n', ...
2128 dinfo.strikeCount, P.strike_rate_hz, dinfo.meanV, dinfo.meanTauUs, dinfo.peakForceN);
2229 fprintf('cascade : peak w_rms %.1f um = %.2f thicknesses, %.1f%% of the injected energy left the top\n', ...
@@ -28,3 +35,14 @@ fprintf('render time: %.2f s\n', elapsed);
2835 if dinfo.listenerInside
2936 fprintf('WARNING : listener is at or inside the plate\n');
3037 end
38+
39+function s = cd_region(P, r)
40+% Which part of the instrument a contact radius falls on.
41+if r <= P.bell_radius_m
42+ s = 'bell';
43+elseif r >= P.radius_m - 0.045
44+ s = 'edge';
45+else
46+ s = 'bow';
47+end
48+end
matlab/cymbal_render.mmodified+7−0View file
@@ -10,6 +10,13 @@ fsHi = P.sample_rate * P.oversample;
1010 [p, radinfo] = cymbal_radiate(P, basis, env, tframe, DV, kf, tstr, taustr, fstr, sstr, fsHi);
1111 [y, peakPa] = cymbal_output(P, p, P.oversample);
1212
13+[r1, z1] = cymbal_point(basis, P.strike_r);
14+[r2, z2] = cymbal_point(basis, P.roll_r2);
15+dinfo.strikeRadiusM = r1;
16+dinfo.strikeRimM = P.radius_m - r1;
17+dinfo.strikeZM = z1;
18+dinfo.roll2RadiusM = r2;
19+dinfo.roll2RimM = P.radius_m - r2;
1320 dinfo.strikeCount = sinfo.count;
1421 dinfo.meanV = sinfo.meanV;
1522 dinfo.maxV = sinfo.maxV;
matlab/d_now.mdeleted+0−21View file
@@ -1,21 +0,0 @@
1-P = cymbal_defaults(); Q = P; Q.normalize = 0; Q.full_scale_pa = 1;
2-fs = P.sample_rate; win = 4096;
3-for turb = [0 0.9]
4- R = Q; R.turbulence = turb;
5- [y, b, d] = cymbal_render(R);
6- fprintf('turbulence %.1f peak %6.2f Pa flat@ ', turb, d.peakPa);
7- for tt = [0.01 0.10 0.40 1.00 2.00]
8- i0 = round(tt*fs)+1;
9- x = y(i0:i0+win-1) .* (0.5*(1-cos(2*pi*(0:win-1)/(win-1))));
10- Y = abs(fft(x)).^2; Y = Y(1:win/2); fq = (0:win/2-1)*fs/win;
11- Ys = Y(fq>=1000 & fq<8000) + 1e-24;
12- fprintf('%.3f ', exp(mean(log(Ys)))/mean(Ys));
13- end
14- i0 = round(1.0*fs)+1;
15- x = y(i0:i0+win-1) .* (0.5*(1-cos(2*pi*(0:win-1)/(win-1))));
16- Y = abs(fft(x)).^2; Y = Y(1:win/2); fq = (0:win/2-1)*fs/win;
17- fprintf(' | 1s low-vs-high %.0f dB\n', 10*log10(sum(Y(fq<600))+1e-30) - 10*log10(sum(Y(fq>=1500&fq<8000))+1e-30));
18-end
19-P2 = cymbal_defaults();
20-[y2, b2, d2] = cymbal_render(P2);
21-fprintf('\npinned: peakPa %.6f f1 %.6f n %d\n', d2.peakPa, b2.f(1), b2.n);
webapp/README.mdmodified+8−2View file
@@ -37,14 +37,20 @@ The New seed button redraws the timing and velocities of a repeated figure witho
3737 - `src/params.ts` — parameter schema, defaults, tier assignment, persistence
3838 - `src/mfiles.ts` — the `.m` sources, bundled at build time and hashed for cache keying
3939 - `src/worker/renderWorker.ts` — off-thread numbl execution with tiered caching
40-- `src/lib/profile.ts` — the cymbal meridian, mirrored from `cymbal_profile.m` for the scene
41-- `src/components/` — 3D scene (three.js), parameter panel, code panel and editor, plots (strokes, cascade, waveform, spectrum, spectrogram, mode map, mode shapes)
40+- `src/lib/profile.ts` — the cymbal meridian, mirrored from `cymbal_profile.m`, with the arclength table that converts `strike_r` to and from a radius for the scene and the picker
41+- `src/components/` — strike picker, 3D scene (three.js), parameter panel, code panel and editor, plots (strokes, cascade, waveform, spectrum, spectrogram, mode map, mode shapes)
4242 - `src/selftest.ts` — Node harness for the MATLAB validation script
4343
4444 ## The cascade plot
4545
4646 The plot the sibling repositories do not have. The cascade solve tracks the energy of every mode at the control rate, and the plot collapses that onto a log-frequency by time grid, so the upward flux is visible directly. Dropping `Cascade gain` to zero flattens it into horizontal streaks, every mode simply decaying where the stick left it, and the sound turns from a crash into a gong.
4747
48+## Placing the stick
49+
50+The model addresses the contact by arclength fraction along the meridian, `strike_r`, which is the right coordinate for the physics and a poor one to aim with. The cross-section at the top of the page draws the real profile to scale, with the bell, bow and edge marked and a ruler in centimetres, and both contacts are draggable: the amber marker is `strike_r` and the dim one is `roll_r2`, which even-numbered strokes land on. The strip underneath shows what the current contact actually excites, band by band, which is the quickest way to see why the bell and the edge are different instruments: a mode with `n` nodal diameters falls off as roughly `r^n` toward the axis, so at the bell an `n = 40` mode sits at 1e-22 of its rim amplitude and simply is not driven. Striking the bell puts 91 of the 378 modes into play, striking the outermost edge 178.
51+
52+There is deliberately no azimuth control. The plate is a surface of revolution and the hammering is applied as a statistical mixing with no azimuthal structure, so striking at a different azimuth is exactly equivalent to rotating the listener, which the listener coordinates already do.
53+
4854 ## Three sliders worth pulling first
4955
5056 `Hybridization` at 0 is the ideal, perfectly round plate. Its high nodal-diameter modes cannot radiate at all, so a few low-order partials carry everything and the instrument becomes a small bell; sliding it back up is hammering the cymbal. `Fluctuation` is off at the defaults: it broadens each partial by letting its amplitude wander about the mean the cascade solves for, which on paper closes the comb into a wash and in practice adds grit, so a few tenths is as far as it is worth taking. `Stroke velocity` sweeps the whole dynamic behaviour through one physical quantity, because the cascade strength goes as the square of the amplitude: at 0.7 m/s the same cymbal pings, at 6 m/s it opens right up.
webapp/src/App.tsxmodified+12−0View file
@@ -6,6 +6,7 @@ import { encodeWav } from './lib/wav';
66 import ParamPanel from './components/ParamPanel';
77 import CodePanel from './components/CodePanel';
88 import Scene3D from './components/Scene3D';
9+import StrikePicker from './components/StrikePicker';
910 import DiagnosticsPanel from './components/DiagnosticsPanel';
1011 import WaveformPlot from './components/plots/WaveformPlot';
1112 import SpectrumPlot from './components/plots/SpectrumPlot';
@@ -203,6 +204,17 @@ export default function App() {
203204 </div>
204205 </div>
205206 )}
207+ <div className="card wide">
208+ <h2>Contact point — drag the stick along the profile</h2>
209+ <StrikePicker
210+ params={params}
211+ onChange={onChange}
212+ excDb={result?.excDb}
213+ excBands={result?.cascadeBands}
214+ excFLo={result?.cascadeFLo}
215+ excFHi={result?.cascadeFHi}
216+ />
217+ </div>
206218 <div className="card wide">
207219 <h2>Geometry — plate, stick, microphone</h2>
208220 <Scene3D params={params} />
webapp/src/components/DiagnosticsPanel.tsxmodified+9−0View file
@@ -18,6 +18,15 @@ export default function DiagnosticsPanel({ result }: { result: RenderResponse })
1818 </tr>
1919 <tr>
2020 <th>Contact</th>
21+ <td>
22+ r = {(d.strikeRadiusM * 100).toFixed(1)} cm ({(d.strikeRimM * 100).toFixed(1)} cm in
23+ from the rim{d.strikeCount > 1
24+ ? `, alternating with ${(d.roll2RadiusM * 100).toFixed(1)} cm`
25+ : ''})
26+ </td>
27+ </tr>
28+ <tr>
29+ <th>Impact</th>
2130 <td>
2231 {d.meanTauUs.toFixed(0)} µs mean (Hertz) · peak force {d.peakForceN.toFixed(0)} N
2332 </td>
webapp/src/components/Scene3D.tsxmodified+5−20View file
@@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react';
99 import * as THREE from 'three';
1010 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
1111 import type { CymbalParams } from '../params';
12-import { cymbalMeridian } from '../lib/profile';
12+import { cymbalMeridian, meridianSamples, pointAtFrac } from '../lib/profile';
1313
1414 function makeLabel(text: string, color: string, scale = 1): THREE.Sprite {
1515 const canvas = document.createElement('canvas');
@@ -142,28 +142,13 @@ export default function Scene3D({ params }: Props) {
142142 params.thickness_rim_m,
143143 );
144144
145- // meridian sample points, used both for the lathe and for placing the stick
145+ // meridian samples, shared with the strike picker so that both place the
146+ // stick exactly where cymbal_point.m does
146147 const NS = 64;
147148 const rs: number[] = [];
148149 for (let i = 0; i <= NS; i++) rs.push(mer.rHole + (i / NS) * (mer.rRim - mer.rHole));
149- // arclength, so that strike_r addresses the same point the model does
150- const arc: number[] = [0];
151- for (let i = 1; i <= NS; i++) {
152- arc.push(arc[i - 1] + Math.hypot(rs[i] - rs[i - 1], mer.z(rs[i]) - mer.z(rs[i - 1])));
153- }
154- const total = arc[NS];
155- const atFrac = (frac: number) => {
156- const s = Math.min(0.9999, Math.max(0, frac)) * total;
157- let i = 0;
158- while (i < NS - 1 && arc[i + 1] < s) i++;
159- const t = (s - arc[i]) / Math.max(1e-12, arc[i + 1] - arc[i]);
160- const r = rs[i] + t * (rs[i + 1] - rs[i]);
161- const z = mer.z(rs[i]) + t * (mer.z(rs[i + 1]) - mer.z(rs[i]));
162- const dr = rs[i + 1] - rs[i];
163- const dz = mer.z(rs[i + 1]) - mer.z(rs[i]);
164- const dl = Math.hypot(dr, dz) || 1;
165- return { r, z, tr: dr / dl, tz: dz / dl };
166- };
150+ const samp = meridianSamples(mer);
151+ const atFrac = (frac: number) => pointAtFrac(samp, frac);
167152
168153 // the plate: a lathe of the meridian offset to both faces
169154 const pts: THREE.Vector2[] = [];
webapp/src/components/StrikePicker.tsxadded+277−0View file
@@ -0,0 +1,277 @@
1+// Where the stick lands, set by dragging on a scale cross-section of the
2+// instrument. The model addresses the contact by arclength fraction along the
3+// meridian (strike_r, 0 at the hole and 1 at the rim), which is the right
4+// coordinate for the physics and a poor one to aim with, so this draws the
5+// real profile and converts. Both contacts are draggable: the amber one is
6+// strike_r, the dim one is roll_r2, which even-numbered strokes land on.
7+//
8+// Position is not a cosmetic control here. A mode with n nodal diameters has
9+// an amplitude falling roughly as r^n toward the axis, so at the bell an
10+// n = 40 mode is 1e-22 of its rim value: striking the bell drives only the
11+// low-order modes and gives a sparse, clear tone, while striking the edge
12+// drives everything and gives the wash. The strip underneath shows what the
13+// current contact actually excites, band by band.
14+
15+import { useCallback, useMemo, useRef } from 'react';
16+import type { CymbalParams } from '../params';
17+import { cymbalMeridian, meridianSamples, pointAtFrac, fracAtRadius } from '../lib/profile';
18+import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './plots/canvasUtils';
19+
20+interface Props {
21+ params: CymbalParams;
22+ onChange: (key: keyof CymbalParams, value: number) => void;
23+ excDb?: Float32Array; // stroke excitation per log-frequency band, dB
24+ excBands?: number;
25+ excFLo?: number;
26+ excFHi?: number;
27+}
28+
29+const HEIGHT = 210;
30+const PROFILE_H = 150;
31+
32+export default function StrikePicker({ params, onChange, excDb, excBands, excFLo, excFHi }: Props) {
33+ const mer = useMemo(
34+ () =>
35+ cymbalMeridian(
36+ params.radius_m,
37+ params.hole_radius_m,
38+ params.bell_radius_m,
39+ params.bell_height_m,
40+ params.bow_height_m,
41+ params.thickness_bell_m,
42+ params.thickness_rim_m,
43+ ),
44+ [
45+ params.radius_m, params.hole_radius_m, params.bell_radius_m,
46+ params.bell_height_m, params.bow_height_m,
47+ params.thickness_bell_m, params.thickness_rim_m,
48+ ],
49+ );
50+ const samp = useMemo(() => meridianSamples(mer), [mer]);
51+ const dragRef = useRef<'primary' | 'second' | null>(null);
52+ const geomRef = useRef({ padL: 46, pw: 100 });
53+
54+ const two = params.strike_count > 1;
55+
56+ const ref = useCanvas(
57+ (ctx, w, h) => {
58+ drawFrame(ctx, w, h);
59+ const padL = 46;
60+ const padR = 14;
61+ const pw = w - padL - padR;
62+ geomRef.current = { padL, pw };
63+ const a = mer.rRim;
64+ const px = (r: number) => padL + (r / a) * pw;
65+ // vertical scale: exaggerate the profile so the bell and bow are legible
66+ const zSpan = Math.max(1e-4, mer.apexHeight);
67+ const baseY = PROFILE_H - 30;
68+ const py = (z: number) => baseY - (z / zSpan) * (PROFILE_H - 74);
69+
70+ // region shading: bell, bow, edge
71+ const regions: [number, number, string, string][] = [
72+ [0, mer.rBell, 'rgba(232, 163, 61, 0.10)', 'bell'],
73+ [mer.rBell, Math.max(mer.rBell, a - 0.045), 'rgba(77, 208, 225, 0.07)', 'bow'],
74+ [Math.max(mer.rBell, a - 0.045), a, 'rgba(111, 191, 115, 0.10)', 'edge'],
75+ ];
76+ ctx.font = '10px system-ui';
77+ for (const [r0, r1, fill, name] of regions) {
78+ ctx.fillStyle = fill;
79+ ctx.fillRect(px(r0), 8, px(r1) - px(r0), PROFILE_H - 20);
80+ ctx.fillStyle = PLOT_COLORS.axis;
81+ ctx.textAlign = 'center';
82+ ctx.fillText(name, (px(r0) + px(r1)) / 2, 19);
83+ }
84+
85+ // centimetre ruler along the radius
86+ ctx.strokeStyle = PLOT_COLORS.grid;
87+ ctx.textAlign = 'center';
88+ for (let cm = 0; cm <= a * 100 + 1e-9; cm += 2) {
89+ const x = px(cm / 100);
90+ ctx.beginPath();
91+ ctx.moveTo(x, baseY + 6);
92+ ctx.lineTo(x, baseY + (cm % 10 === 0 ? 12 : 9));
93+ ctx.stroke();
94+ if (cm % 10 === 0) {
95+ ctx.fillStyle = PLOT_COLORS.axis;
96+ ctx.fillText(`${cm}`, x, baseY + 23);
97+ }
98+ }
99+ ctx.fillStyle = PLOT_COLORS.axis;
100+ ctx.textAlign = 'left';
101+ ctx.fillText('radius (cm)', padL, baseY + 23);
102+
103+ // the profile itself, drawn with its thickness
104+ ctx.strokeStyle = '#c9a227';
105+ ctx.lineWidth = 2;
106+ ctx.beginPath();
107+ for (let i = 0; i < samp.r.length; i++) {
108+ const x = px(samp.r[i]);
109+ const y = py(samp.z[i]);
110+ if (i === 0) ctx.moveTo(x, y);
111+ else ctx.lineTo(x, y);
112+ }
113+ ctx.stroke();
114+ // thickness, exaggerated, as a faint band under the centreline
115+ ctx.strokeStyle = 'rgba(201, 162, 39, 0.35)';
116+ ctx.lineWidth = 1;
117+ ctx.beginPath();
118+ for (let i = 0; i < samp.r.length; i++) {
119+ const x = px(samp.r[i]);
120+ const y = py(samp.z[i]) + mer.thickness(samp.r[i]) * 6000;
121+ if (i === 0) ctx.moveTo(x, y);
122+ else ctx.lineTo(x, y);
123+ }
124+ ctx.stroke();
125+
126+ // the mounting rod through the hole
127+ ctx.strokeStyle = 'rgba(139, 147, 161, 0.5)';
128+ ctx.setLineDash([3, 3]);
129+ ctx.beginPath();
130+ ctx.moveTo(px(0), 10);
131+ ctx.lineTo(px(0), baseY + 4);
132+ ctx.stroke();
133+ ctx.setLineDash([]);
134+
135+ // contact markers
136+ const drawContact = (frac: number, primary: boolean) => {
137+ const pt = pointAtFrac(samp, frac);
138+ const x = px(pt.r);
139+ const y = py(pt.z);
140+ const ang = (params.strike_angle_deg * Math.PI) / 180;
141+ // stroke direction: -cos(angle) * normal + sin(angle) * tangent, drawn
142+ // in the (radius, height) plane the cross-section shows
143+ const nr = -pt.tz;
144+ const nz = pt.tr;
145+ let dr = -Math.cos(ang) * nr + Math.sin(ang) * pt.tr;
146+ let dz = -Math.cos(ang) * nz + Math.sin(ang) * pt.tz;
147+ const dl = Math.hypot(dr, dz) || 1;
148+ dr /= dl;
149+ dz /= dl;
150+ const L = 34;
151+ const color = primary ? PLOT_COLORS.accent : 'rgba(154, 122, 61, 0.85)';
152+ // the shaft is drawn along the stroke direction as the exaggerated
153+ // vertical scale of this cross-section renders it
154+ const sx = dr * (pw / a);
155+ const sy = -dz * ((PROFILE_H - 74) / zSpan);
156+ const sl = Math.hypot(sx, sy) || 1;
157+ ctx.strokeStyle = color;
158+ ctx.lineWidth = primary ? 2.5 : 1.8;
159+ ctx.beginPath();
160+ ctx.moveTo(x - (sx / sl) * L, y - (sy / sl) * L);
161+ ctx.lineTo(x - (sx / sl) * 9, y - (sy / sl) * 9);
162+ ctx.stroke();
163+ ctx.fillStyle = color;
164+ ctx.beginPath();
165+ ctx.arc(x - (sx / sl) * 7, y - (sy / sl) * 7, primary ? 6 : 4.5, 0, 2 * Math.PI);
166+ ctx.fill();
167+ ctx.strokeStyle = color;
168+ ctx.lineWidth = 1;
169+ ctx.beginPath();
170+ ctx.moveTo(x, y - 4);
171+ ctx.lineTo(x, y + 4);
172+ ctx.stroke();
173+ if (primary) {
174+ ctx.fillStyle = PLOT_COLORS.accent;
175+ ctx.font = '11px system-ui';
176+ ctx.textAlign = x > padL + pw * 0.6 ? 'right' : 'left';
177+ ctx.fillText(
178+ `r = ${(pt.r * 100).toFixed(1)} cm · ${((a - pt.r) * 100).toFixed(1)} cm in from the rim`,
179+ x + (x > padL + pw * 0.6 ? -10 : 10),
180+ baseY - 6,
181+ );
182+ }
183+ };
184+ if (two) drawContact(params.roll_r2, false);
185+ drawContact(params.strike_r, true);
186+
187+ // what this contact excites, band by band
188+ const stripTop = PROFILE_H + 12;
189+ const stripH = h - stripTop - 16;
190+ ctx.fillStyle = PLOT_COLORS.axis;
191+ ctx.font = '10px system-ui';
192+ ctx.textAlign = 'left';
193+ if (excDb && excBands && excBands > 0 && excFLo && excFHi) {
194+ let mx = -Infinity;
195+ for (let i = 0; i < excDb.length; i++) mx = Math.max(mx, excDb[i]);
196+ const bw = pw / excBands;
197+ for (let b = 0; b < excBands; b++) {
198+ const v = Math.min(1, Math.max(0, (excDb[b] - mx + 45) / 45));
199+ const [r, g, bl] = colormap(v);
200+ ctx.fillStyle = `rgb(${r},${g},${bl})`;
201+ ctx.fillRect(padL + b * bw, stripTop, bw + 0.6, stripH);
202+ }
203+ ctx.fillStyle = PLOT_COLORS.axis;
204+ ctx.textAlign = 'center';
205+ const lLo = Math.log10(excFLo);
206+ const lHi = Math.log10(Math.max(excFLo * 1.2, excFHi));
207+ for (const f of [100, 300, 1000, 3000, 10000]) {
208+ if (f < excFLo || f > excFHi) continue;
209+ const x = padL + ((Math.log10(f) - lLo) / (lHi - lLo)) * pw;
210+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 4);
211+ }
212+ ctx.textAlign = 'right';
213+ ctx.fillText('excited by this stroke', padL + pw, stripTop - 3);
214+ ctx.textAlign = 'left';
215+ ctx.fillText('modal energy', padL, stripTop - 3);
216+ } else {
217+ ctx.fillText('render to see what this contact excites', padL, stripTop + 12);
218+ }
219+ },
220+ [
221+ params.strike_r, params.roll_r2, params.strike_angle_deg, params.strike_count,
222+ mer, samp, excDb, excBands, excFLo, excFHi, two,
223+ ],
224+ HEIGHT,
225+ );
226+
227+ const fracFromEvent = useCallback(
228+ (e: React.PointerEvent<HTMLCanvasElement>) => {
229+ const rect = e.currentTarget.getBoundingClientRect();
230+ const { padL, pw } = geomRef.current;
231+ const radius = ((e.clientX - rect.left - padL) / pw) * mer.rRim;
232+ return Math.min(0.999, Math.max(0.001, fracAtRadius(samp, radius)));
233+ },
234+ [mer, samp],
235+ );
236+
237+ const onDown = useCallback(
238+ (e: React.PointerEvent<HTMLCanvasElement>) => {
239+ const f = fracFromEvent(e);
240+ const which =
241+ two && Math.abs(f - params.roll_r2) < Math.abs(f - params.strike_r) ? 'second' : 'primary';
242+ dragRef.current = which;
243+ e.currentTarget.setPointerCapture(e.pointerId);
244+ onChange(which === 'primary' ? 'strike_r' : 'roll_r2', f);
245+ },
246+ [fracFromEvent, onChange, params.strike_r, params.roll_r2, two],
247+ );
248+
249+ const onMove = useCallback(
250+ (e: React.PointerEvent<HTMLCanvasElement>) => {
251+ if (!dragRef.current) return;
252+ onChange(dragRef.current === 'primary' ? 'strike_r' : 'roll_r2', fracFromEvent(e));
253+ },
254+ [fracFromEvent, onChange],
255+ );
256+
257+ const onUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
258+ dragRef.current = null;
259+ e.currentTarget.releasePointerCapture(e.pointerId);
260+ }, []);
261+
262+ return (
263+ <div className="strike-picker">
264+ <canvas
265+ ref={ref}
266+ style={{ cursor: 'ew-resize', touchAction: 'none' }}
267+ onPointerDown={onDown}
268+ onPointerMove={onMove}
269+ onPointerUp={onUp}
270+ onPointerCancel={onUp}
271+ />
272+ <div className="picker-hint">
273+ drag to move the stick{two ? ' · the dim marker is the second contact' : ''}
274+ </div>
275+ </div>
276+ );
277+}
webapp/src/lib/profile.tsmodified+48−0View file
@@ -72,3 +72,51 @@ function solve3(M: number[][], b: number[]): [number, number, number] {
7272 }
7373 return [A[0][3], A[1][3], A[2][3]];
7474 }
75+
76+export interface MeridianSamples {
77+ r: Float64Array; // radius of each sample, m
78+ z: Float64Array; // height above the rim plane, m
79+ sFrac: Float64Array; // arclength fraction from the hole, the model's strike_r
80+ total: number; // meridian arclength, m
81+}
82+
83+// Sample the meridian and tabulate arclength, so that the model's strike_r
84+// (an arclength fraction) can be converted to and from a radius. Both the 3D
85+// scene and the strike picker need this; the physics reads the same geometry
86+// from cymbal_profile.m.
87+export function meridianSamples(mer: Meridian, n = 256): MeridianSamples {
88+ const r = new Float64Array(n + 1);
89+ const z = new Float64Array(n + 1);
90+ const s = new Float64Array(n + 1);
91+ for (let i = 0; i <= n; i++) {
92+ r[i] = mer.rHole + (i / n) * (mer.rRim - mer.rHole);
93+ z[i] = mer.z(r[i]);
94+ }
95+ for (let i = 1; i <= n; i++) s[i] = s[i - 1] + Math.hypot(r[i] - r[i - 1], z[i] - z[i - 1]);
96+ const total = s[n];
97+ const sFrac = new Float64Array(n + 1);
98+ for (let i = 0; i <= n; i++) sFrac[i] = s[i] / total;
99+ return { r, z, sFrac, total };
100+}
101+
102+// strike_r -> the point on the meridian, matching cymbal_point.m
103+export function pointAtFrac(m: MeridianSamples, frac: number) {
104+ const f = Math.min(0.999999, Math.max(0, frac));
105+ let i = 0;
106+ while (i < m.sFrac.length - 2 && m.sFrac[i + 1] < f) i++;
107+ const t = (f - m.sFrac[i]) / Math.max(1e-12, m.sFrac[i + 1] - m.sFrac[i]);
108+ const dr = m.r[i + 1] - m.r[i];
109+ const dz = m.z[i + 1] - m.z[i];
110+ const dl = Math.hypot(dr, dz) || 1;
111+ return { r: m.r[i] + t * dr, z: m.z[i] + t * dz, tr: dr / dl, tz: dz / dl };
112+}
113+
114+// radius -> strike_r, the inverse the picker needs when the pointer moves
115+export function fracAtRadius(m: MeridianSamples, radius: number): number {
116+ const n = m.r.length - 1;
117+ const rr = Math.min(m.r[n], Math.max(m.r[0], radius));
118+ let i = 0;
119+ while (i < n - 1 && m.r[i + 1] < rr) i++;
120+ const t = (rr - m.r[i]) / Math.max(1e-12, m.r[i + 1] - m.r[i]);
121+ return m.sFrac[i] + t * (m.sFrac[i + 1] - m.sFrac[i]);
122+}
webapp/src/params.tsmodified+2−2View file
@@ -196,7 +196,7 @@ export const PARAM_GROUPS: ParamGroup[] = [
196196 name: 'Stroke',
197197 specs: [
198198 { key: 'strike_velocity', label: 'Stroke velocity', unit: 'm/s', min: 0.1, max: 8.0, step: 0.05, help: 'Stick speed at contact. Dynamics and timbre together: the cascade strength goes as the square of the amplitude, so a soft stroke pings and a hard one crashes.' },
199- { key: 'strike_r', label: 'Contact radius', min: 0.02, max: 0.995, step: 0.005, help: 'Arclength fraction from the hole (0) to the rim (1). ~0.95 is a crash on the edge, ~0.6 a ride on the bow, ~0.1 the bell.' },
199+ { key: 'strike_r', label: 'Contact position', min: 0.02, max: 0.995, step: 0.005, help: 'Arclength fraction from the hole (0) to the rim (1); easier to set by dragging the stick on the cross-section above, which shows it in centimetres. It is not a cosmetic control: a mode with n nodal diameters falls off as roughly r^n toward the axis, so the bell drives only the low-order modes and rings clear, while the edge drives everything and washes.' },
200200 { key: 'strike_angle_deg', label: 'Stroke angle', unit: '°', min: 0, max: 85, step: 1, help: '0 strikes square onto the surface, 85 is a glancing swipe along it. Only the normal component drives the plate strongly.' },
201201 ],
202202 },
@@ -224,7 +224,7 @@ export const PARAM_GROUPS: ParamGroup[] = [
224224 specs: [
225225 { key: 'strike_count', label: 'Strokes', min: 1, max: 32, step: 1, integer: true, help: '1 is a single crash; more gives a repeated figure alternating between the two contact radii.' },
226226 { key: 'strike_rate_hz', label: 'Rate', unit: 'Hz', min: 1, max: 20, step: 0.5, help: 'Strokes per second.' },
227- { key: 'roll_r2', label: 'Second radius', min: 0.02, max: 0.995, step: 0.005, help: 'Even-numbered strokes land here: a ride pattern moving between the bow and the edge.' },
227+ { key: 'roll_r2', label: 'Second position', min: 0.02, max: 0.995, step: 0.005, help: 'Even-numbered strokes land here, so a figure can move between the bow and the edge. Draggable on the cross-section too, as the dim marker.' },
228228 { key: 'timing_jitter_ms', label: 'Timing jitter', unit: 'ms', min: 0, max: 40, step: 0.5, help: 'Gaussian jitter on each stroke after the first. 0 sounds mechanical.' },
229229 { key: 'velocity_spread', label: 'Velocity spread', min: 0, max: 0.7, step: 0.01, help: 'Lognormal spread of stroke velocities.' },
230230 { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the timing and velocities; the same seed always reproduces them.' },
webapp/src/style.cssmodified+10−0View file
@@ -356,3 +356,13 @@ details.group[open] summary { border-bottom: 1px solid var(--border); }
356356 @media (max-width: 1100px) {
357357 .content { grid-template-columns: 1fr; }
358358 }
359+
360+/* Strike picker */
361+.strike-picker { position: relative; }
362+.strike-picker canvas { display: block; width: 100%; }
363+.picker-hint {
364+ font-size: 11px;
365+ color: var(--muted);
366+ text-align: right;
367+ margin-top: 2px;
368+}
webapp/src/worker/renderWorker.tsmodified+21−0View file
@@ -41,6 +41,7 @@ export interface RenderResponse {
4141 nodeZ: Float32Array;
4242 shapeW: Float32Array; // normal displacement per mode, nn x n col-major
4343 cascadeDb: Float32Array; // energy grid, nBands x nFrames col-major, dB
44+ excDb: Float32Array; // first stroke's modal excitation per band, dB
4445 cascadeBands: number;
4546 cascadeFrames: number;
4647 cascadeFLo: number;
@@ -59,6 +60,9 @@ export interface RenderResponse {
5960 peakSplDb: number;
6061 directPeakPa: number;
6162 listenerDistM: number;
63+ strikeRadiusM: number;
64+ strikeRimM: number;
65+ roll2RadiusM: number;
6266 peakWrmsUm: number;
6367 peakStrength: number;
6468 cascadeLostFrac: number;
@@ -109,9 +113,13 @@ interface RadiateEntry {
109113 strikeV: Float32Array;
110114 strikeS: Float32Array;
111115 cascadeDb: Float32Array;
116+ excDb: Float32Array;
112117 cascadeBands: number;
113118 cascadeFrames: number;
114119 cascadeTEnd: number;
120+ strikeRadiusM: number;
121+ strikeRimM: number;
122+ roll2RadiusM: number;
115123 strikeCount: number;
116124 meanV: number;
117125 maxV: number;
@@ -332,7 +340,11 @@ for q = 1:nt
332340 grid(:, q) = accumarray(bandOf, e, [nb, 1]);
333341 end
334342 cascadeDb = 10 * log10(grid + 1e-30);
343+excDb = 10 * log10(accumarray(bandOf, 0.5 * DV(:, 1).^2, [nb, 1]) + 1e-30);
335344 cascadeTEnd = tframe(length(tframe));
345+[strikeRadiusM, ~] = cymbal_point(basis, P.strike_r);
346+strikeRimM = P.radius_m - strikeRadiusM;
347+[roll2RadiusM, ~] = cymbal_point(basis, P.roll_r2);
336348 strikeCount = sinfo.count;
337349 meanV = sinfo.meanV;
338350 maxV = sinfo.maxV;
@@ -353,9 +365,13 @@ cascadeLostFrac = cinfo.cascadeLostJ / max(1e-30, cinfo.injectedJ);
353365 strikeV: toF32(r.variableValues.vstr),
354366 strikeS: toF32(r.variableValues.sstr),
355367 cascadeDb: toF32(r.variableValues.cascadeDb),
368+ excDb: toF32(r.variableValues.excDb),
356369 cascadeBands: CASCADE_BANDS,
357370 cascadeFrames: CASCADE_FRAMES,
358371 cascadeTEnd: asScalar(r.variableValues.cascadeTEnd),
372+ strikeRadiusM: asScalar(r.variableValues.strikeRadiusM),
373+ strikeRimM: asScalar(r.variableValues.strikeRimM),
374+ roll2RadiusM: asScalar(r.variableValues.roll2RadiusM),
359375 strikeCount: asScalar(r.variableValues.strikeCount),
360376 meanV: asScalar(r.variableValues.meanV),
361377 maxV: asScalar(r.variableValues.maxV),
@@ -433,6 +449,7 @@ ${pLiteral(params)}
433449 nodeZ: solveEntry.nodeZ.slice(),
434450 shapeW: solveEntry.shapeW.slice(),
435451 cascadeDb: radEntry.cascadeDb.slice(),
452+ excDb: radEntry.excDb.slice(),
436453 cascadeBands: radEntry.cascadeBands,
437454 cascadeFrames: radEntry.cascadeFrames,
438455 cascadeFLo: solveEntry.modeF[0],
@@ -451,6 +468,9 @@ ${pLiteral(params)}
451468 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
452469 directPeakPa: radEntry.directPeakPa,
453470 listenerDistM: radEntry.listenerDistM,
471+ strikeRadiusM: radEntry.strikeRadiusM,
472+ strikeRimM: radEntry.strikeRimM,
473+ roll2RadiusM: radEntry.roll2RadiusM,
454474 peakWrmsUm: radEntry.peakWrmsUm,
455475 peakStrength: radEntry.peakStrength,
456476 cascadeLostFrac: radEntry.cascadeLostFrac,
@@ -477,5 +497,6 @@ ${pLiteral(params)}
477497 resp.nodeZ.buffer,
478498 resp.shapeW.buffer,
479499 resp.cascadeDb.buffer,
500+ resp.excDb.buffer,
480501 ]);
481502 }