Hammer the cymbal: hybridize near-degenerate modes, add the turbulent fluctuation about the mean cascade, and flatten the decay across the band
An exactly axisymmetric plate radiates its n-diameter mode only into the cone
where n < k a sin(theta), so of 303 retained modes six carried half the
radiated energy and three carried half the tail: the model sounded like a
bell. Real cymbals are hammered, which destroys the separation and makes every
mode a hybrid of its neighbours. Three changes follow from that:
- cymbal_basis.m applies the hybridization as a seeded orthogonal mixing of
modes within hammer_bw of each other, plus a relative detune, and stores the
mixing matrix for the excitation and radiation stages to apply.
- cymbal_cascade.m gives each mode a log-amplitude fluctuation about its mean
energy at its own turnover rate, which broadens the partials past modal
overlap and closes the comb into a wash.
- the damping is flattened across the band so the low modes no longer outlast
the shimmer by a factor of fifteen.
The band now runs to 10 kHz over 56 nodal diameters. Spectral flatness in the
1-8 kHz wash rises from 0.002 to 0.12, and the validation gains a check that
the hammering mixing is orthogonal.
12 changed files+257−53
README.mdmodified+16−7View file
@@ -1,6 +1,6 @@
11 # Cymbal
22
3-A physically-based simulation of a struck cymbal, sampled as acoustic pressure at a point in space. The instrument is a bronze shell of revolution with a raised bell and a tapered bow; a Hertz stick contact drives its bending and stretching modes, computed by a one-dimensional finite element eigensolve per nodal-diameter order, and the geometric nonlinearity that turns a hard stroke into a crash is carried as a cascade of energy up the modal frequency ladder. The plate radiates to a listener point as a sheet of normal dipoles.
3+A physically-based simulation of a struck cymbal, sampled as acoustic pressure at a point in space. The instrument is a bronze shell of revolution with a raised bell and a tapered bow; a Hertz stick contact drives its bending and stretching modes, computed by a one-dimensional finite element eigensolve per nodal-diameter order, and the geometric nonlinearity that turns a hard stroke into a crash is carried as a cascade of energy up the modal frequency ladder. The hammering that makes a real cymbal irregular is carried too, because without it the model is a bell. The plate radiates to a listener point as a sheet of normal dipoles.
44
55 There is no microphone model: no directivity, no capsule response, no gain staging. The output is p(r, t) in pascals.
66
@@ -21,29 +21,38 @@ The signal chain, with each stage in its own `.m` file:
2121 meridian profile: bell, bow, thickness taper cymbal_profile.m
2222 -> thin-shell elements for one Fourier order cymbal_shell.m
2323 -> one eigensolve per nodal-diameter order cymbal_basis.m
24+ + hammering: hybridize near neighbours
2425 + radiated power -> radiation damping
2526 stroke gesture -> Hertz stick contacts cymbal_strikes.m
26- -> modal energies, and their nonlinear flow
27- up the frequency ladder cymbal_cascade.m
27+ -> modal energies, their nonlinear flow up the
28+ frequency ladder, and their fluctuation cymbal_cascade.m
2829 -> oscillator bank + dipole-sheet transfer cymbal_radiate.m
2930 -> free-field pressure at the listener
3031 -> anti-aliased decimation -> PCM cymbal_output.m
3132 ```
3233
33-Three points are worth stating plainly, because they are where the model commits itself.
34+Four points are worth stating plainly, because they are where the model commits itself.
3435
35-**The modes come from a shell of revolution, one Fourier order at a time.** A cymbal is axisymmetric, so its displacement field separates exactly in the azimuth: every mode carries `n` nodal diameters and varies as `cos(n theta)`, and the two-dimensional eigenproblem breaks into one one-dimensional problem per `n`. Each is discretized along the meridian with two-node Love-Kirchhoff shell elements, linear in the meridional and circumferential displacements and cubic Hermite in the normal one, integrated by Gauss quadrature against the tabulated profile rather than frozen into conical frusta (`cymbal_shell.m`). Rotary inertia is kept, which shifts the retained frequencies by well under a percent but is what makes the mass matrix positive definite, so each order reduces through the Cholesky factor of the mass matrix and is diagonalized by svd. The curvature of the bow is what earns this formulation its keep: on a flat disc, bending and stretching are independent, and it is the meridian curvature that couples them and pulls the partials of a cymbal away from the Chladni frequencies of a plate. Shear deformation is neglected, and the mounting is taken as free-free with a point dashpot at the hole for the felt washer, which reaches the `n = 0` and `n = 1` modes and almost nothing else. Only the `cos` member of each degenerate pair is retained: that is exact for the model as posed, since every contact sits on the meridian `theta = 0` where the `sin` member has a node. A real cymbal is not perfectly round, and the resulting slow beating of each split pair is outside this model.
36+**The modes come from a shell of revolution, one Fourier order at a time.** A cymbal is axisymmetric, so its displacement field separates exactly in the azimuth: every mode carries `n` nodal diameters and varies as `cos(n theta)`, and the two-dimensional eigenproblem breaks into one one-dimensional problem per `n`. Each is discretized along the meridian with two-node Love-Kirchhoff shell elements, linear in the meridional and circumferential displacements and cubic Hermite in the normal one, integrated by Gauss quadrature against the tabulated profile rather than frozen into conical frusta (`cymbal_shell.m`). Rotary inertia is kept, which shifts the retained frequencies by well under a percent but is what makes the mass matrix positive definite, so each order reduces through the Cholesky factor of the mass matrix and is diagonalized by svd. The curvature of the bow is what earns this formulation its keep: on a flat disc, bending and stretching are independent, and it is the meridian curvature that couples them and pulls the partials of a cymbal away from the Chladni frequencies of a plate. Shear deformation is neglected, and the mounting is taken as free-free with a point dashpot at the hole for the felt washer, which reaches the `n = 0` and `n = 1` modes and almost nothing else. Only the `cos` member of each degenerate pair is retained: that is exact for the model as posed, since every contact sits on the meridian `theta = 0` where the `sin` member has a node.
37+
38+**A perfectly axisymmetric plate is a bad cymbal, and the model has to say so.** This is the point at which the idealization fails audibly rather than quietly. On an exactly round plate the modes are exact `cos(n theta)` shapes, and such a mode radiates only into the cone where its azimuthal trace speed is supersonic, `n < k a sin(theta)`. Below a few kilohertz that leaves only the handful of `n = 0, 1, 2` modes reaching any listening position at all: of the several hundred modes the eigensolve retains, six carry half the radiated energy and three carry half of the tail, and the result is unmistakably a small bell. Real cymbals are hammered and lathed, and that is not decoration. The irregularity destroys the exact azimuthal separation, so every mode of the real plate is a hybrid of the ideal ones lying near it in frequency, and a hybrid carries low-order content and therefore radiates. The model applies that as first-order degenerate perturbation theory: a seeded orthogonal mixing of modes within `hammer_bw` of each other in frequency, built from Givens rotations on neighbouring pairs so that it conserves energy exactly, plus a seeded relative detune of `hammer_detune` which is what makes near neighbours beat. Because the window is relative, the sparse low modes stay pure and only the dense region above a kilohertz hybridizes, which is where the wash lives. `hammer_mix = 0` recovers the ideal plate, and is worth listening to once. What this does not do is model the hammering pattern itself; the mixing is statistical, so a specific instrument cannot be reproduced from a photograph of its bow.
3639
3740 **The nonlinearity is carried as energy, not as a coupled integration.** A cymbal struck hard is not a linear resonator. Its transverse displacement is comparable to the plate thickness, the von Karman stretching of the mid-surface couples the modes, and energy injected low by the stick migrates upward over tens of milliseconds, which is what the ear hears as a crash opening into shimmer after its attack. Integrating that directly means a dense cubic coupling tensor over hundreds of modes, so what is integrated instead is the energy of each mode (`cymbal_cascade.m`): each mode has an up-target a fixed frequency ratio above it and a down-target the same ratio below, and energy performs a biased random walk between them at a rate `cascade_gain * om * (w/t)^2`, proportional to the mode's own squared amplitude-to-thickness ratio because that is the order of the geometric nonlinearity. Steps are taken in log frequency rather than in mode index on purpose, so the swell does not depend on how densely the band happens to be resolved. Because the flux out of a mode goes as the square of its energy, a nearly empty mode passes nothing on, the front of the cascade advances rather than the whole band draining at once, and the swell stops on its own. What this buys is the instrument's central behaviour: the same cymbal pings when tapped and crashes when hit, with no velocity layers anywhere in the model. What it gives up is phase. Amplitude comes from the cascade and phase from a linear impulse response carried alongside it, so a stroke re-phases the modes it finds quiet and a coherent attack survives, but the intermodulation products of a real cascade do not. `cascade_gain` is the one fitted number in the model; setting it to zero leaves an exactly linear plate, which sounds like a small gong.
3841
42+What those equations track is the *mean* energy of each mode, and the mean is not what a listener hears. A mode sitting in a turbulent field is continually being filled and drained by its neighbours, and its amplitude wanders about that mean on the timescale of the exchange. This matters more than it sounds. A few hundred modes with smooth envelopes are a few hundred audible partials, spaced tens of hertz apart with bandwidths of a couple of hertz, and that is a chime however the energy is distributed among them; give each one a wandering amplitude with a correlation time of a few milliseconds and each partial broadens by a couple of hundred hertz, the modal overlap passes one, and the comb closes into the continuum a wash actually is. Each mode therefore carries a log-amplitude following an Ornstein-Uhlenbeck process at the control rate, with the correlation rate taken as that mode's own fractional energy turnover rather than as a fitted time, so a mode being fed while nearly empty decorrelates every step and a mode merely ringing down does not decorrelate at all. A running variance keeps the exponential unbiased, so the fluctuation redistributes energy in time without creating or destroying any, and it switches itself off exactly where the cascade does. `turbulence = 0` removes it and leaves the mean-energy solve, which chimes.
43+
3944 **Radiation is a dipole sheet, computed two ways because two regimes are needed.** Each surface element carries a dipole along the local normal of volume `dS * ldip`, where `ldip` is half the shorter of the flexural and acoustic wavelengths, capped at the plate radius: the distance over which the air short-circuits from one face to the other. That length is the one phenomenological quantity in the radiation model, and below the plate's coincidence frequency, about 18 kHz for 1 mm bronze, it is the flexural wavelength that sets it, which is why a cymbal is such a poor radiator in the low register and improves steadily as the cascade fills the top. For the listener, who at half a metre from a 40 cm plate is nowhere near the far field, the element dipoles are summed coherently over the azimuth with their true distances and the near-field term kept (`cymbal_radiate.m`). For the radiated power, which sets the radiation damping and is defined only in the far field, the azimuthal integral of the same kernel collapses onto Bessel functions and the power integral reduces to one quadrature in `cos(theta)` (`cymbal_basis.m`); the two agree to a few parts in a thousand, which the validation checks. Summing element powers incoherently, as the sibling `triangle` does for a rod, would not do here: for a continuous surface that estimate halves when the elements do. The multipole structure comes out of the azimuthal sum on its own. A mode with `n` nodal diameters is a `2^n`-pole, so the high-order modes are weak until their wavelength is short, and on the symmetry axis every one of them cancels exactly.
4045
4146 ## Validation
4247
43-`matlab/cymbal_validate.m` (also `npm run selftest` in `webapp/`) checks the shell elements in their flat limit against the closed-form free circular plate, whose frequency equation is the vanishing of a two-by-two determinant in `J_n` and `I_n` under zero radial moment and zero Kelvin-Kirchhoff shear, together with the rigid-body count each Fourier order must have (two at `n = 0`, two at `n = 1`, none above); the mass-orthonormality and stiffness-diagonalization identities of the eigensolve; the Bessel far-field reduction against brute-force summation over a finely discretized dipole sheet; the exact cancellation of every mode with a nodal diameter on the symmetry axis; the Hertz `v^(-1/5)` contact-time scaling in the light-stick limit; exact energy conservation of the cascade scheme with the damping off and the top of the ladder reflecting, and its collapse onto closed-form damped exponentials when `cascade_gain` is zero; the far-field `-6 dB` per doubling of distance; exact reproducibility of the seeded stroke pattern; and pinned end-to-end diagnostics.
48+`matlab/cymbal_validate.m` (also `npm run selftest` in `webapp/`) checks the shell elements in their flat limit against the closed-form free circular plate, whose frequency equation is the vanishing of a two-by-two determinant in `J_n` and `I_n` under zero radial moment and zero Kelvin-Kirchhoff shear, together with the rigid-body count each Fourier order must have (two at `n = 0`, two at `n = 1`, none above); the mass-orthonormality and stiffness-diagonalization identities of the eigensolve; the exact orthogonality of the hammering mixing, which is what makes it energy conserving; the Bessel far-field reduction against brute-force summation over a finely discretized dipole sheet; the exact cancellation of every mode with a nodal diameter on the symmetry axis, with the hammering switched off, since hybridizing the modes is precisely what breaks that cancellation; the Hertz `v^(-1/5)` contact-time scaling in the light-stick limit; exact energy conservation of the cascade scheme with the damping off, the top of the ladder reflecting and the fluctuation suppressed, and its collapse onto closed-form damped exponentials when `cascade_gain` is zero, with the fluctuation left switched on so that its freezing in the linear limit is checked too; the far-field `-6 dB` per doubling of distance; exact reproducibility of the seeded stroke pattern; and pinned end-to-end diagnostics.
4449
4550 As in the sibling `maraca`, `triangle`, `recorder` and `bass` repositories, there is no separate Python reference implementation: the MATLAB code is the reference, and the pinned numbers were recorded from it when the model was built.
4651
4752 ## Defaults
4853
49-A 16-inch B20 bronze crash-ride: 20.3 cm radius, a 4.5 cm bell rising 1.7 cm above a bow that itself rises 2 cm from the rim, and a thickness tapering from 1.15 mm at the bell to 0.75 mm at the edge, which comes to 1.05 kg. Solving to 48 nodal diameters retains 303 modes between 49 Hz and 8.0 kHz. The default gesture is a single crash on the edge at 3 m/s with a 30 g nylon-tipped stick held 40 degrees off the surface normal: an 88 microsecond Hertz contact, a 291 N peak force, a peak plate displacement of 150 micrometres or 0.16 thicknesses, and a peak of 13.4 Pa (116 dB SPL) at the 58 cm listener before normalization. About 6% of the struck energy leaves the top of the retained band, which is the shimmer above 8 kHz the model does not carry; raising `max_mode_hz` and `n_diameters` together recovers it at the cost of the eigensolves. Setting `strike_count` to 8 gives a repeated figure alternating between the edge and the bow, and 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.
54+A 16-inch B20 bronze crash-ride: 20.3 cm radius, a 4.5 cm bell rising 1.7 cm above a bow that itself rises 2 cm from the rim, and a thickness tapering from 1.15 mm at the bell to 0.75 mm at the edge, which comes to 1.05 kg. Solving to 56 nodal diameters retains 378 modes between 49 Hz and 10.0 kHz, each a hybrid of about five ideal ones. The default gesture is a single crash on the edge at 3 m/s with a 30 g nylon-tipped stick held 40 degrees off the surface normal: an 88 microsecond Hertz contact, a 291 N peak force, a peak plate displacement of 149 micrometres or 0.16 thicknesses, and a peak of 20.5 Pa (120 dB SPL) at the 56 cm listener before normalization. About 6% of the struck energy leaves the top of the retained band, which is the shimmer above 10 kHz the model does not carry; raising `max_mode_hz` and `n_diameters` together recovers it at the cost of the eigensolves. A full render takes about six seconds, of which the eigensolves are three.
55+
56+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.
57+
58+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_basis.mmodified+69−3View file
@@ -17,9 +17,30 @@ function basis = cymbal_basis(P)
1717 %
1818 % Only the cos(n theta) member of each degenerate pair is retained. That is
1919 % exact for the model as posed: every contact sits on the meridian theta = 0,
20-% where the sin member has a node. A real cymbal is not perfectly round, which
21-% splits each pair into a slowly beating doublet; that splitting is outside
22-% this model, and the README says so.
20+% where the sin member has a node.
21+%
22+% HAMMERING. A perfectly axisymmetric plate is a bad cymbal, and the model has
23+% to say so. Its modes are exact cos(n theta) shapes, and a mode with n nodal
24+% diameters radiates only into the cone where its azimuthal trace speed is
25+% supersonic, n < k a sin(theta). Below a few kilohertz that leaves only the
26+% handful of n = 0, 1, 2 modes audible at any listening position, so an ideal
27+% cymbal sounds like a small bell. Real cymbals are hammered and lathed, and
28+% that is not decoration: the irregularity it leaves destroys the exact
29+% separation, and every mode of the real plate is a hybrid of the ideal ones
30+% that lie near it in frequency. A hybrid carries low-order content, so it
31+% radiates.
32+%
33+% The hybridization is first-order degenerate perturbation theory, applied as
34+% a seeded orthogonal mixing of modes lying within hammer_bw of each other in
35+% frequency (a sequence of Givens rotations on neighbouring pairs, which is
36+% orthogonal by construction and therefore conserves energy exactly), plus a
37+% seeded relative detune of hammer_detune, which is what makes near neighbours
38+% beat. The mixing matrix is stored as basis.mix and applied downstream
39+% wherever the shapes enter linearly: to the stroke's modal projection in
40+% cymbal_cascade.m and to the radiation transfer in cymbal_radiate.m. Because
41+% the window is relative, the sparse low modes stay pure and only the dense
42+% region above a kilohertz hybridizes, which is where a real cymbal's wash
43+% lives. hammer_mix = 0 recovers the ideal axisymmetric plate.
2344 %
2445 % Damping has three parts.
2546 % internal damp_rate / om + 0.5 (loss_factor + loss_slope om). The first
@@ -152,6 +173,43 @@ NUall = NUall(:, order);
152173 NWall = NWall(:, order);
153174 NTall = NTall(:, order);
154175 SWall = SWall(:, order);
176+
177+% ---- hammering: detune, then hybridize near-degenerate modes ----
178+hstate = mod(floor(abs(P.hammer_seed)), 4294967296);
179+[hu, hstate] = cymbal_rng(hstate, 2 * N);
180+hz = sqrt(-2 * log(hu(1:N))) .* cos(2 * pi * hu(N + 1:2 * N));
181+fs = fs .* (1 + P.hammer_detune * hz);
182+[fs, ord2] = sort(fs);
183+fs = reshape(fs, N, 1);
184+nd = reshape(nd(ord2), N, 1);
185+NUall = NUall(:, ord2);
186+NWall = NWall(:, ord2);
187+NTall = NTall(:, ord2);
188+SWall = SWall(:, ord2);
189+
190+HPASS = 4;
191+mix = eye(N);
192+if P.hammer_mix > 0 && N > 1
193+ [hu2, hstate] = cymbal_rng(hstate, HPASS * (N - 1));
194+ q = 0;
195+ for pass = 1:HPASS
196+ for i = 1:N - 1
197+ q = q + 1;
198+ if (fs(i + 1) - fs(i)) / fs(i) > P.hammer_bw
199+ continue;
200+ end
201+ th = P.hammer_mix * (hu2(q) - 0.5) * pi;
202+ ca = cos(th);
203+ sa = sin(th);
204+ ri = mix(i, :);
205+ rj = mix(i + 1, :);
206+ mix(i, :) = ca * ri - sa * rj;
207+ mix(i + 1, :) = sa * ri + ca * rj;
208+ end
209+ end
210+end
211+mix2 = mix.^2;
212+
155213 omega = 2 * pi * fs;
156214
157215 % ---- the acoustic short-circuit length, per mode ----
@@ -204,6 +262,13 @@ end
204262 axHole = NUall(1, :).' * G.dzdsnode(1) + NWall(1, :).' * G.drdsnode(1);
205263 zetaMount = P.mount_loss * axHole.^2 ./ (2 * omega);
206264
265+% The shape-dependent damping terms belong to the ideal modes, so they carry
266+% over to the hybridized ones as the diagonal of the transformed damping
267+% matrix, which to first order is the mixing weights squared.
268+zetaRad = mix2 * zetaRad;
269+zetaMount = mix2 * zetaMount;
270+sigmaEff = mix2 * sigmaEff;
271+
207272 zeta = P.damp_rate ./ omega ...
208273 + 0.5 * (P.loss_factor + P.loss_slope * omega) ...
209274 + P.radiation_loss * zetaRad + zetaMount;
@@ -217,6 +282,7 @@ basis.zetaMount = zetaMount;
217282 basis.sigma = sigmaEff;
218283 basis.ndia = nd;
219284 basis.ldip = ldip;
285+basis.mix = mix;
220286 basis.NU = NUall;
221287 basis.NW = NWall;
222288 basis.NT = NTall;
matlab/cymbal_cascade.mmodified+52−3View file
@@ -42,6 +42,31 @@ function [env, tframe, DV, kf, cinfo] = cymbal_cascade(P, basis, tstr, jstr, tau
4242 % the model: it is a dimensionless coupling constant, set so that a default
4343 % crash takes about the 40 ms a real one takes to open up.
4444 %
45+% FLUCTUATION. What the equations above track is the MEAN energy of each mode,
46+% and the mean is not what a listener hears. A mode sitting in a turbulent
47+% field does not hold a smooth amplitude; it is continually being filled and
48+% drained by its neighbours, and its amplitude wanders about that mean on the
49+% timescale of the exchange. This matters more than it sounds: a few hundred
50+% modes with smooth envelopes are a few hundred audible partials, spaced tens
51+% of hertz apart with bandwidths of a couple of hertz, which is a chime. Give
52+% each one a wandering amplitude with a correlation time of a few milliseconds
53+% and each partial broadens by a couple of hundred hertz, the modal overlap
54+% passes one, and the comb closes into the continuum a wash actually is.
55+%
56+% So each mode carries a log-amplitude that follows an Ornstein-Uhlenbeck
57+% process discretized at the control rate,
58+% rho_m = exp(-(energy exchanged this step) / E_m)
59+% g_m <- rho_m g_m + sqrt(1 - rho_m^2) turbulence z_m
60+% with the correlation rate taken as the mode's own fractional energy turnover
61+% rather than as a fitted time: a mode being fed while nearly empty
62+% decorrelates completely every step, a mode merely ringing down does not
63+% decorrelate at all. A running variance keeps exp(g) unbiased, so the
64+% fluctuation redistributes energy in time without creating or destroying any,
65+% and the process is seeded, so a render is reproducible. Where the cascade is
66+% off the turnover is zero, rho is one, g stays where it started, and the
67+% envelopes are exactly the closed-form exponentials of a linear plate.
68+% turbulence = 0 removes the fluctuation and leaves the mean-energy solve.
69+%
4570 % What this buys is a cymbal that responds to dynamics the way the instrument
4671 % does, at the cost of one control-rate loop rather than a full nonlinear
4772 % integration. What it gives up is phase: energy moves between modes but the
@@ -53,7 +78,9 @@ function [env, tframe, DV, kf, cinfo] = cymbal_cascade(P, basis, tstr, jstr, tau
5378 % dv_m = J S(om_m tau) (phi_m(x_c) . dhat)
5479 % with S(x) = cos(x/2) / (1 - (x/pi)^2) the spectrum of the half-sine contact
5580 % pulse and dhat the stroke direction (strike_angle_deg away from the surface
56-% normal, in the meridian plane). Energies add incoherently, which is right
81+% normal, in the meridian plane). The projection is taken through the
82+% hammering mixing of cymbal_basis.m, since the modes the stick actually
83+% drives are the hybrids, not the ideal cos(n theta) shapes. Energies add incoherently, which is right
5784 % for a plate already ringing and exact for the first stroke into a silent one.
5885 %
5986 % Note that only the MAGNITUDE of the excitation survives into the energy
@@ -79,6 +106,7 @@ ang = P.strike_angle_deg * pi / 180;
79106 for i = 1:I
80107 [uh, wh] = cymbal_at(basis, sstr(i));
81108 proj = -cos(ang) * wh + sin(ang) * uh; % 1 x N, along the stroke
109+ proj = (basis.mix * reshape(proj, N, 1)).'; % hammered modes are hybrids
82110 x = omega * taustr(i);
83111 den = 1 - (x / pi).^2;
84112 S = cos(x / 2) ./ den;
@@ -122,12 +150,20 @@ Ee = zeros(N, 1);
122150 peakStrength = 0;
123151 peakWrms = 0;
124152 lost = 0;
153+
154+% one independent LCG stream per mode, for the amplitude fluctuation
155+sig = P.turbulence;
156+st1 = mod(floor(abs(P.seed)) * 2654435761 + (1:N).' * 40503, 4294967296);
157+st2 = mod(st1 * 69069 + 12345, 4294967296);
158+gl = zeros(N, 1);
159+gv = zeros(N, 1);
160+
125161 for f = 1:nf
126162 hit = find(kf == f);
127163 for q = 1:length(hit)
128164 Ee = Ee + 0.5 * DV(:, hit(q)).^2;
129165 end
130- env(:, f) = sqrt(2 * Ee);
166+ env(:, f) = sqrt(2 * Ee) .* exp(gl - gv / 2);
131167
132168 wrms2 = sum(Ee ./ omega.^2) / Mtot;
133169 strength = wrms2 / teff^2;
@@ -144,9 +180,22 @@ for f = 1:nf
144180 En = Ee - up - dn + arrU(1:N) + arrD;
145181 En(N) = En(N) + (1 - P.cascade_leak) * leak;
146182 lost = lost + P.cascade_leak * leak;
183+
184+ if sig > 0
185+ % fractional energy turnover this step sets the decorrelation
186+ moved = up + dn + arrU(1:N) + arrD;
187+ rho = exp(-min(30, moved ./ max(Ee, 1e-300)));
188+ [u1, st1] = cymbal_rngv(st1);
189+ [u2, st2] = cymbal_rngv(st2);
190+ zg = sqrt(-2 * log(u1)) .* cos(2 * pi * u2);
191+ w = 1 - rho.^2;
192+ gl = rho .* gl + sqrt(w) * sig .* zg;
193+ gv = rho.^2 .* gv + w * sig^2;
194+ end
195+
147196 Ee = En .* decay;
148197 end
149-env(:, nf + 1) = sqrt(2 * Ee);
198+env(:, nf + 1) = sqrt(2 * Ee) .* exp(gl - gv / 2);
150199
151200 cinfo.frames = nf;
152201 cinfo.stepS = h;
matlab/cymbal_defaults.mmodified+16−9View file
@@ -18,16 +18,22 @@ P.youngs_modulus = 1.05e11;
1818 P.poisson_ratio = 0.33;
1919 P.stiffness_trim = 1.00;
2020
21+% Hammering and lathing: the departure from an exactly axisymmetric plate
22+P.hammer_mix = 0.55; % strength of the hybridization of near neighbours
23+P.hammer_bw = 0.05; % relative frequency window it acts over
24+P.hammer_detune = 0.004; % relative scatter of the mode frequencies
25+P.hammer_seed = 7;
26+
2127 % Discretization / mode retention
2228 P.n_elements = 28;
23-P.n_diameters = 48; % highest circumferential order solved for
29+P.n_diameters = 56; % highest circumferential order solved for
2430 P.min_mode_hz = 40.0;
25-P.max_mode_hz = 8000.0;
31+P.max_mode_hz = 10000.0;
2632
2733 % Damping
28-P.damp_rate = 0.35; % s^-1, frequency-independent decay rate
29-P.loss_factor = 1.5e-4;
30-P.loss_slope = 2.0e-9;
34+P.damp_rate = 1.70; % s^-1, frequency-independent decay rate
35+P.loss_factor = 2.0e-5;
36+P.loss_slope = 3.0e-10;
3137 P.radiation_loss = 1.0;
3238 P.mount_loss = 3.0; % N s/m dashpot at the inner edge (felt and stand)
3339
@@ -53,15 +59,16 @@ P.cascade_gain = 800.0;
5359 P.cascade_back = 0.45; % downward / upward transfer ratio
5460 P.cascade_ratio = 1.19; % frequency ratio of one transfer step (2^0.25)
5561 P.cascade_leak = 1.0; % 1 = energy leaving the top mode is lost
56-P.cascade_step_ms = 0.5;
62+P.cascade_step_ms = 1.0;
63+P.turbulence = 0.9; % log-amplitude spread of the fluctuation
5764
5865 % Radiation / listener
5966 P.direct_gain = 1.0;
6067 P.dipole_scale = 1.0;
61-P.n_azimuth = 96; % azimuthal samples in the listener transfer
62-P.listener_x_m = 0.30;
68+P.n_azimuth = 224; % azimuthal samples in the listener transfer
69+P.listener_x_m = 0.40;
6370 P.listener_y_m = 0.00;
64-P.listener_z_m = 0.50;
71+P.listener_z_m = 0.40;
6572 P.speed_of_sound = 343.0;
6673 P.air_density = 1.204;
6774
matlab/cymbal_demo.mmodified+6−2View file
@@ -1,6 +1,10 @@
11 % Render a cymbal crash with default parameters and print diagnostics.
2-% Set P.strike_count = 8 for a repeated ride figure, or P.cascade_gain = 0
3-% to hear the same plate as an exactly linear resonator.
2+% Set P.strike_count = 8 for a repeated ride figure, P.strike_r = 0.1 for the
3+% bell, P.strike_velocity = 0.7 for a tap on the same instrument, or
4+% P.cascade_gain = 0 to hear the plate as an exactly linear resonator. Setting
5+% P.hammer_mix = 0 recovers the ideal axisymmetric plate, which is worth
6+% hearing once: it is a bell, because only its lowest few nodal-diameter
7+% orders can radiate at all.
48 % Run with: numbl run cymbal_demo.m
59
610 P = cymbal_defaults();
matlab/cymbal_radiate.mmodified+10−4View file
@@ -20,10 +20,13 @@ function [p, radinfo] = cymbal_radiate(P, basis, env, tframe, DV, kf, tstr, taus
2020 % of a thin plate displaces almost no air.
2121 %
2222 % Because the azimuthal shape is exactly cos(n theta), the sum over
23-% azimuths carries the multipole cancellation of the mode: on the axis of
24-% symmetry every mode with n >= 1 cancels exactly, and off axis the high-n
25-% modes are weak until their wavelength is short. Moving the listener
26-% re-runs only this stage.
23+% azimuths carries the multipole cancellation of the ideal mode: on the
24+% axis of symmetry every mode with n >= 1 cancels exactly, and off axis a
25+% mode radiates only into the cone where its azimuthal trace is supersonic,
26+% n < k a sin(theta). That cutoff is why an unhammered plate is heard as a
27+% handful of low-order partials; the hybridization applied below is what
28+% gives the rest of the modes a way out. Moving the listener re-runs only
29+% this stage.
2730 %
2831 % The synthesis itself is an oscillator bank rather than the closed-form
2932 % damped exponentials of the sibling repositories: the cascade makes every
@@ -100,6 +103,9 @@ for m = 1:N
100103 ker = amp .* cosg .* (1i * k + 1 ./ re) .* exp(-1i * k * (re - rcen)) ./ re;
101104 G(m) = (1i * om * rho0 / (4 * pi)) * sum(sum(ker));
102105 end
106+% the plate's real modes are the hammered hybrids, and the transfer is linear
107+% in the shape, so the mixing of cymbal_basis.m applies directly here
108+G = basis.mix * G;
103109
104110 % ---- oscillator bank driven by the cascade envelopes ----
105111 T = floor(tframe(end) * fsHi);
matlab/cymbal_rngv.madded+15−0View file
@@ -0,0 +1,15 @@
1+function [r, state] = cymbal_rngv(state)
2+% CYMBAL_RNGV Vectorized seeded uniform generator: one 32-bit LCG stream per
3+% element of `state`, all advanced by one step. Returns the uniforms in (0, 1)
4+% and the advanced state, both the same shape as the input.
5+%
6+% The scalar cymbal_rng draws a sequence from one stream, which is what the
7+% stroke process wants. The cascade wants one fresh number per mode per
8+% control frame, hundreds of thousands of them, so it runs hundreds of
9+% independent streams side by side instead of looping. Every product below
10+% stays under 2^53 (2^32 * 1664525 < 2^53), so the arithmetic is exact in the
11+% CLI, in Node and in the browser alike, and a given seed reproduces the
12+% identical render everywhere.
13+state = mod(state * 1664525 + 1013904223, 4294967296);
14+r = (state + 0.5) / 4294967296;
15+end
matlab/cymbal_validate.mmodified+31−11View file
@@ -78,8 +78,12 @@ fprintf('far field : Bessel vs direct dipole sum, max err %.2e %s\n', errF, cv_
7878 % A cos(n theta) source on a surface of revolution has no monopole, dipole or
7979 % any lower multipole than 2^n, and on the symmetry axis all of them vanish.
8080 % This is the sharpest available check that the azimuthal sum in the listener
81-% transfer is assembled correctly.
81+% transfer is assembled correctly. It is a statement about the IDEAL plate, so
82+% the hammering is switched off: hybridizing the modes is precisely what gives
83+% the high-order ones a way out, and with it on they radiate on the axis like
84+% anything else.
8285 Q = P;
86+Q.hammer_mix = 0; Q.hammer_detune = 0;
8387 Q.n_diameters = 6; Q.max_mode_hz = 1500;
8488 Q.listener_x_m = 0; Q.listener_y_m = 0; Q.listener_z_m = 0.6;
8589 b6 = cymbal_basis(Q);
@@ -92,6 +96,19 @@ ok = g1 / g0 < 1e-10;
9296 fails = fails + (~ok);
9397 fprintf('on axis : |G| of n>=1 modes is %.1e of the n=0 modes %s\n', g1 / g0, cv_verdict(ok));
9498
99+% ---- 4b. the hammering mixing is exactly orthogonal ----
100+% The hybridization is a product of Givens rotations, so it must be orthogonal
101+% to round-off. That is what makes it energy conserving: it redistributes the
102+% stroke among the modes and the radiation among them again, without inventing
103+% or destroying any.
104+bh = cymbal_basis(P);
105+eMix = max(max(abs(bh.mix * bh.mix.' - eye(bh.n))));
106+spread = sum(bh.mix.^2 > 0.01, 2);
107+ok = eMix < 1e-12 && median(spread) > 2;
108+fails = fails + (~ok);
109+fprintf('hammering : orthogonality %.1e, median %d ideal modes per hybrid %s\n', ...
110+ eMix, round(median(spread)), cv_verdict(ok));
111+
95112 % ---- 5. Hertz contact-time scaling tau ~ v^(-1/5) ----
96113 % With a stick much lighter than the plate mass engaged during the contact the
97114 % reduced mass is constant and the free-sphere scaling must emerge.
@@ -107,12 +124,14 @@ fails = fails + (~ok);
107124 fprintf('hertz : tau ratio %.4f (target %.4f) %s\n', ratio, 2^(-1 / 5), cv_verdict(ok));
108125
109126 % ---- 6. the cascade conserves energy, and switches off cleanly ----
110-% With the damping removed and the top of the ladder reflecting, the transfer
111-% scheme is a Markov generator on the modal energies and must conserve their
112-% sum to round-off. With cascade_gain = 0 every envelope must instead collapse
113-% onto the closed-form exp(-zeta om t) of an uncoupled damped oscillator.
127+% With the damping removed, the top of the ladder reflecting and the
128+% fluctuation off, the transfer scheme is a Markov generator on the modal
129+% energies and must conserve their sum to round-off. With cascade_gain = 0
130+% nothing is exchanged, so the fluctuation freezes at zero on its own and
131+% every envelope must collapse onto the closed-form exp(-zeta om t) of an
132+% uncoupled damped oscillator, fluctuation still switched on.
114133 Q = P;
115-Q.n_diameters = 6; Q.max_mode_hz = 1500; Q.cascade_leak = 0;
134+Q.n_diameters = 6; Q.max_mode_hz = 1500; Q.cascade_leak = 0; Q.turbulence = 0;
116135 bz = b6;
117136 bz.zeta = zeros(bz.n, 1);
118137 [tstr, ~, jstr, taustr, ~, sstr] = cymbal_strikes(Q, bz);
@@ -120,6 +139,7 @@ bz.zeta = zeros(bz.n, 1);
120139 Etot = sum(envC.^2 / 2, 1);
121140 eCons = max(abs(Etot / Etot(1) - 1));
122141 Q.cascade_gain = 0;
142+Q.turbulence = P.turbulence;
123143 [envL, tfrL] = cymbal_cascade(Q, b6, tstr, jstr, taustr, sstr, 0.4);
124144 pred = envL(:, 1) * exp(-(b6.zeta .* b6.omega).' * 0 + 0);
125145 pred = (envL(:, 1) * ones(1, length(tfrL))) .* exp(-(b6.zeta .* b6.omega) * tfrL);
@@ -134,7 +154,7 @@ fprintf('cascade : energy drift %.1e, linear limit err %.1e %s\n', eCons, eLi
134154 % so every element dipole keeps its directivity factor.
135155 Q = P;
136156 Q.normalize = 0; Q.n_diameters = 12; Q.max_mode_hz = 3000;
137-Q.tail_s = 0.4; Q.oversample = 1;
157+Q.tail_s = 0.4; Q.oversample = 1; Q.n_azimuth = 96;
138158 Q.listener_x_m = 0.8; Q.listener_y_m = 0.5; Q.listener_z_m = 1.8;
139159 y1 = cymbal_render(Q);
140160 Q.listener_x_m = 1.6; Q.listener_y_m = 1.0; Q.listener_z_m = 3.6;
@@ -161,12 +181,12 @@ fprintf('ride (seed %d): %d strikes, mean v %.6f m/s, sum J %.9f N s %s\n', ...
161181
162182 % ---- 9. pinned end-to-end diagnostics at the defaults ----
163183 [y, basis, dinfo] = cymbal_render(P);
164-ok = abs(dinfo.peakPa - 13.4297) < 0.02 ...
165- && abs(basis.f(1) - 49.0808) < 0.02 ...
166- && basis.n == 303;
184+ok = abs(dinfo.peakPa - 20.5278) < 0.02 ...
185+ && abs(basis.f(1) - 49.4130) < 0.02 ...
186+ && basis.n == 378;
167187 fails = fails + (~ok);
168188 fprintf('pinned : peak %.4f Pa (target %.4f), mode1 %.3f Hz (target %.3f), %d modes %s\n', ...
169- dinfo.peakPa, 13.4297, basis.f(1), 49.0808, basis.n, cv_verdict(ok));
189+ dinfo.peakPa, 20.5278, basis.f(1), 49.4130, basis.n, cv_verdict(ok));
170190
171191 % ---- 10. the wash decays ----
172192 % A cymbal rings for seconds, so the tail is not silent, but the energy in the
webapp/README.mdmodified+5−1View file
@@ -2,7 +2,7 @@
22
33 Interactive browser implementation of the physical model described in `../README.md`. The algorithm itself is written in MATLAB syntax, in `../matlab/*.m`, and executed client-side by [numbl](https://numbl.org) inside a Web Worker; the TypeScript here is only the UI, the plots, the WAV encoder, and the marshalling of parameters and tensors in and out of the numbl runtime.
44
5-The signal chain: a Hertz stick contact (or a seeded repeated figure, with `Strokes` above 1), the modes of a bronze shell of revolution from one finite element eigensolve per nodal-diameter order, a nonlinear cascade of modal energy up the frequency ladder, an oscillator bank radiating through a coherent sum of surface dipoles to the listener point, and Kaiser-windowed anti-aliased decimation to 48 kHz.
5+The signal chain: a Hertz stick contact (or a seeded repeated figure, with `Strokes` above 1), the modes of a bronze shell of revolution from one finite element eigensolve per nodal-diameter order, hybridized to stand for the hammering that makes a real cymbal irregular, a nonlinear cascade of modal energy up the frequency ladder with the turbulent fluctuation about it, an oscillator bank radiating through a coherent sum of surface dipoles to the listener point, and Kaiser-windowed anti-aliased decimation to 48 kHz.
66
77 ## Usage
88
@@ -45,6 +45,10 @@ The New seed button redraws the timing and velocities of a repeated figure witho
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+## Three sliders worth pulling first
49+
50+`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` at 0 leaves every mode with a smooth envelope, which is a few hundred clean partials, that is to say a chime; up at 0.9 each partial broadens into a band and they close into a wash. `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.
51+
4852 ## A note on the seeded generator
4953
5054 The stroke pattern draws from a 32-bit LCG in `cymbal_rng.m` rather than MATLAB's `rand`, so a given seed produces the identical pattern in the numbl CLI, in Node, and in the browser. That is what lets the self-test pin exact impulse sums and stroke times.
webapp/src/mfiles.tsmodified+1−0View file
@@ -28,6 +28,7 @@ export const EXECUTED_BY_APP = new Set([
2828 'cymbal_point.m',
2929 'cymbal_strikes.m',
3030 'cymbal_rng.m',
31+ 'cymbal_rngv.m',
3132 'cymbal_cascade.m',
3233 'cymbal_radiate.m',
3334 'cymbal_output.m',
webapp/src/params.tsmodified+35−12View file
@@ -23,6 +23,11 @@ export interface CymbalParams {
2323 youngs_modulus: number;
2424 poisson_ratio: number;
2525 stiffness_trim: number;
26+ // Hammering and lathing
27+ hammer_mix: number;
28+ hammer_bw: number;
29+ hammer_detune: number;
30+ hammer_seed: number;
2631 // Discretization / mode retention
2732 n_elements: number;
2833 n_diameters: number;
@@ -55,6 +60,7 @@ export interface CymbalParams {
5560 cascade_ratio: number;
5661 cascade_leak: number;
5762 cascade_step_ms: number;
63+ turbulence: number;
5864 // Radiation / listener
5965 direct_gain: number;
6066 dipole_scale: number;
@@ -87,13 +93,17 @@ export const DEFAULTS: CymbalParams = {
8793 youngs_modulus: 1.05e11,
8894 poisson_ratio: 0.33,
8995 stiffness_trim: 1.0,
96+ hammer_mix: 0.55,
97+ hammer_bw: 0.05,
98+ hammer_detune: 0.004,
99+ hammer_seed: 7,
90100 n_elements: 28,
91- n_diameters: 48,
101+ n_diameters: 56,
92102 min_mode_hz: 40.0,
93- max_mode_hz: 8000.0,
94- damp_rate: 0.35,
95- loss_factor: 1.5e-4,
96- loss_slope: 2.0e-9,
103+ max_mode_hz: 10000.0,
104+ damp_rate: 1.7,
105+ loss_factor: 2.0e-5,
106+ loss_slope: 3.0e-10,
97107 radiation_loss: 1.0,
98108 mount_loss: 3.0,
99109 tip_radius_m: 0.005,
@@ -113,13 +123,14 @@ export const DEFAULTS: CymbalParams = {
113123 cascade_back: 0.45,
114124 cascade_ratio: 1.19,
115125 cascade_leak: 1.0,
116- cascade_step_ms: 0.5,
126+ cascade_step_ms: 1.0,
127+ turbulence: 0.9,
117128 direct_gain: 1.0,
118129 dipole_scale: 1.0,
119- n_azimuth: 96,
120- listener_x_m: 0.3,
130+ n_azimuth: 224,
131+ listener_x_m: 0.4,
121132 listener_y_m: 0.0,
122- listener_z_m: 0.5,
133+ listener_z_m: 0.4,
123134 speed_of_sound: 343.0,
124135 air_density: 1.204,
125136 sample_rate: 48000,
@@ -141,6 +152,7 @@ export const SOLVE_KEYS: (keyof CymbalParams)[] = [
141152 'radius_m', 'hole_radius_m', 'bell_radius_m', 'bell_height_m', 'bow_height_m',
142153 'thickness_bell_m', 'thickness_rim_m',
143154 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
155+ 'hammer_mix', 'hammer_bw', 'hammer_detune', 'hammer_seed',
144156 'n_elements', 'n_diameters', 'min_mode_hz', 'max_mode_hz',
145157 'damp_rate', 'loss_factor', 'loss_slope', 'radiation_loss', 'mount_loss',
146158 'dipole_scale', 'speed_of_sound', 'air_density',
@@ -152,6 +164,7 @@ export const RADIATE_KEYS: (keyof CymbalParams)[] = [
152164 'strike_count', 'strike_rate_hz', 'roll_r2',
153165 'timing_jitter_ms', 'velocity_spread', 'seed',
154166 'cascade_gain', 'cascade_back', 'cascade_ratio', 'cascade_leak', 'cascade_step_ms',
167+ 'turbulence',
155168 'direct_gain', 'n_azimuth',
156169 'listener_x_m', 'listener_y_m', 'listener_z_m',
157170 'sample_rate', 'tail_s',
@@ -193,9 +206,19 @@ export const PARAM_GROUPS: ParamGroup[] = [
193206 { key: 'cascade_gain', label: 'Cascade gain', min: 0, max: 4000, step: 10, help: 'Coupling constant of the energy cascade. 0 leaves an exactly linear plate, which sounds like a gong; raising it opens the crash up. This is the one fitted number in the model.' },
194207 { key: 'cascade_ratio', label: 'Step ratio', min: 1.03, max: 2.0, step: 0.01, help: 'Frequency ratio of one transfer step. Smaller means a slower, smoother climb; 1.19 is a quarter of an octave.' },
195208 { key: 'cascade_back', label: 'Backward share', min: 0, max: 0.95, step: 0.01, help: 'Downward transfers per upward one. At 1 the flux stops and energy only diffuses.' },
209+ { key: 'turbulence', label: 'Fluctuation', min: 0, max: 2.0, step: 0.05, help: 'Log-amplitude spread of the turbulent wander about the mean modal energy. This is what broadens each partial into a band and turns a few hundred audible tones into a wash; at 0 the modes hold smooth envelopes and the cymbal chimes.' },
196210 { key: 'cascade_leak', label: 'Top-of-band leak', min: 0, max: 1, step: 0.05, help: 'Fraction of the flux leaving the highest retained mode that is lost, standing in for the truncated band above. 0 reflects it back.' },
197211 ],
198212 },
213+ {
214+ name: 'Hammering',
215+ specs: [
216+ { key: 'hammer_mix', label: 'Hybridization', min: 0, max: 1.0, step: 0.01, help: 'How strongly near-degenerate modes hybridize. 0 is a perfectly round plate, whose high nodal-diameter modes cannot radiate at all and which therefore sounds like a small bell. Hammering is what lets the rest of the modes out.' },
217+ { key: 'hammer_bw', label: 'Mixing window', min: 0.005, max: 0.3, step: 0.005, help: 'Relative frequency window the hybridization acts over. Wider means a coarser instrument; the sparse low modes stay pure either way.' },
218+ { key: 'hammer_detune', label: 'Frequency scatter', min: 0, max: 0.03, step: 0.0005, help: 'Relative scatter of the mode frequencies, which is what makes near neighbours beat.' },
219+ { key: 'hammer_seed', label: 'Hammer seed', min: 1, max: 9999, step: 1, integer: true, help: 'Which cymbal off the rack. Independent of the stroke seed, because the hammering belongs to the instrument.' },
220+ ],
221+ },
199222 {
200223 name: 'Repeats',
201224 specs: [
@@ -240,7 +263,7 @@ export const PARAM_GROUPS: ParamGroup[] = [
240263 {
241264 name: 'Damping',
242265 specs: [
243- { key: 'damp_rate', label: 'Decay rate', unit: 's⁻¹', min: 0.02, max: 5.0, log: true, help: 'Frequency-independent decay rate: T60 = 6.91 / this, before the other terms. It is what makes a cymbal ring for tens of seconds.' },
266+ { key: 'damp_rate', label: 'Decay rate', unit: 's⁻¹', min: 0.02, max: 10.0, log: true, help: 'Frequency-independent decay rate: T60 = 6.91 / this, before the other terms. It sets the ring time of the whole band at once, which is what keeps the low modes from outlasting the wash and leaving a bell behind.' },
244267 { key: 'loss_factor', label: 'Loss factor η₀', min: 1e-6, max: 3e-3, log: true, help: 'Constant material loss factor; gives a T60 falling as 1/f, so it shortens the shimmer before the hum.' },
245268 { key: 'loss_slope', label: 'Loss slope η₁', unit: 's', min: 1e-11, max: 1e-7, log: true, help: 'Extra high-frequency damping; controls how quickly the top of the wash fades.' },
246269 { key: 'mount_loss', label: 'Mount damping', unit: 'N s/m', min: 0, max: 200, step: 0.5, help: 'A dashpot on the axial motion at the hole, standing in for the felt washer and stand. It reaches the n = 0 and n = 1 modes and almost nothing else.' },
@@ -272,9 +295,9 @@ export const PARAM_GROUPS: ParamGroup[] = [
272295 { key: 'n_elements', label: 'Meridian elements', min: 12, max: 60, step: 1, integer: true, help: 'Shell elements along the meridian. More converges the high modes; each eigensolve costs its cube.' },
273296 { key: 'n_diameters', label: 'Max nodal diameters', min: 4, max: 64, step: 1, integer: true, help: 'Highest circumferential order solved for. One eigensolve per order, so this is the main cost. Too low and the band above a couple of kHz is missing its high-order modes.' },
274297 { key: 'min_mode_hz', label: 'Min mode freq', unit: 'Hz', min: 20, max: 400, step: 5 },
275- { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 1000, max: 16000, step: 250, help: 'Everything above is truncated; the cascade treats it as a sink and the direct click stands in for it.' },
298+ { key: 'max_mode_hz', label: 'Max mode freq', unit: 'Hz', min: 1000, max: 18000, step: 250, help: 'Everything above is truncated; the cascade treats it as a sink and the direct click stands in for it.' },
276299 { key: 'cascade_step_ms', label: 'Cascade step', unit: 'ms', min: 0.1, max: 4.0, step: 0.1, help: 'Control-rate step of the energy solve, and the envelope resolution of the oscillator bank.' },
277- { key: 'n_azimuth', label: 'Azimuth samples', min: 24, max: 256, step: 8, integer: true, help: 'Azimuthal samples in the listener transfer. Must comfortably exceed twice the highest nodal-diameter order.' },
300+ { key: 'n_azimuth', label: 'Azimuth samples', min: 24, max: 384, step: 8, integer: true, help: 'Azimuthal samples in the listener transfer. Must comfortably exceed twice the highest nodal-diameter order.' },
278301 { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Synthesis rate multiple; 2 for render, 1 for preview. The mode bank needs none, the contact click does.' },
279302 { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
280303 { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
webapp/src/worker/renderWorker.tsmodified+1−1View file
@@ -199,7 +199,7 @@ function toF32(v: RuntimeValue): Float32Array {
199199 // The basis fields handed from the solve stage into the radiate stage, in the
200200 // order the reassembly script expects.
201201 const BASIS_FIELDS = [
202- 'f', 'omega', 'zeta', 'zetaRad', 'zetaMount', 'sigma', 'ndia', 'ldip',
202+ 'f', 'omega', 'zeta', 'zetaRad', 'zetaMount', 'sigma', 'ndia', 'ldip', 'mix',
203203 'NU', 'NW', 'NT',
204204 'rnode', 'znode', 'snode', 'drdsnode', 'dzdsnode', 'thknode', 'csnode', 'ctnode',
205205 'surfR', 'surfZ', 'surfDr', 'surfDz', 'surfLen', 'SW',