{ "format": "numbl-instrument-v1", "id": "cymbal", "name": "Cymbal", "description": "Struck cymbal; pitch scales the plate around MIDI 60 (48..72), velocity onto strike speed.", "source": "simulated_instruments/cymbal", "knobs": [ { "key": "strike_r", "label": "strike radius", "min": 0.05, "max": 0.99, "default": 0.94 }, { "key": "strike_angle_deg", "label": "strike angle (deg)", "min": 0, "max": 90, "default": 40 } ], "files": [ { "name": "render_note.m", "content": "function y = render_note(note)\n% MIDI pitch scales the plate geometry: every length by\n% s = 2^-((pitch - 60)/12), which shifts the bending-mode spectrum by the\n% semitone ratio exactly (plate modes go as thickness / radius^2). The\n% stick stays fixed, so this is one player striking a family of cymbal\n% sizes; pitch 60 is the default 16-inch plate, playable 48..72. The\n% sound is inharmonic, so the note number is a handle on register, not a\n% tuned fundamental. Velocity drives the strike speed. Fixed: a single\n% strike, a fixed seed, normalize off so velocity survives in pascals.\nP = cymbal_defaults();\nP.sample_rate = note.sample_rate;\nP.strike_count = 1;\nP.seed = 1;\nP.pre_delay_ms = 0;\nP.normalize = 0;\nif note.preview ~= 0\n P.oversample = 1;\nend\ns = 2 ^ (-(max(48, min(72, note.pitch)) - 60) / 12);\nP.radius_m = P.radius_m * s;\nP.hole_radius_m = P.hole_radius_m * s;\nP.bell_radius_m = P.bell_radius_m * s;\nP.bell_height_m = P.bell_height_m * s;\nP.bow_height_m = P.bow_height_m * s;\nP.thickness_bell_m = P.thickness_bell_m * s;\nP.thickness_rim_m = P.thickness_rim_m * s;\nP.strike_velocity = 0.1 + (8 - 0.1) * note.velocity;\nP.strike_r = note.params.strike_r;\nP.strike_angle_deg = note.params.strike_angle_deg;\ny = cymbal_render(P);\nend\n" }, { "name": "cymbal_at.m", "content": "function [uh, wh, thk, drds, dzds, cs, ct] = cymbal_at(basis, sfrac)\n% CYMBAL_AT Mode shapes and local geometry at an arclength fraction.\n%\n% sfrac addresses the meridian from the hole (0) to the rim (1); it is the\n% coordinate the strike positions are expressed in. The meridional shape U is\n% linear within an element, so it is interpolated linearly; the normal shape W\n% is cubic Hermite, and is interpolated with its own shape functions from the\n% nodal (W, dW/ds) pair, which matters because at the top of the retained band\n% there are only about four nodes per flexural wavelength.\n%\n% Returns row vectors uh, wh (1 x n modes) and the local thickness, meridian\n% direction cosines and principal curvatures at that point.\n\nnn = length(basis.snode);\nS = basis.S;\ns = min(0.999999, max(0, sfrac)) * S;\nidx = sum(basis.snode <= s);\nidx = min(max(idx, 1), nn - 1);\nL = basis.snode(idx + 1) - basis.snode(idx);\nxi = (s - basis.snode(idx)) / L;\n\nuh = basis.NU(idx, :) + xi * (basis.NU(idx + 1, :) - basis.NU(idx, :));\nh1 = 1 - 3 * xi^2 + 2 * xi^3;\nh2 = L * (xi - 2 * xi^2 + xi^3);\nh3 = 3 * xi^2 - 2 * xi^3;\nh4 = L * (-xi^2 + xi^3);\nwh = h1 * basis.NW(idx, :) + h2 * basis.NT(idx, :) ...\n + h3 * basis.NW(idx + 1, :) + h4 * basis.NT(idx + 1, :);\n\nthk = basis.thknode(idx) + xi * (basis.thknode(idx + 1) - basis.thknode(idx));\ndrds = basis.drdsnode(idx) + xi * (basis.drdsnode(idx + 1) - basis.drdsnode(idx));\ndzds = basis.dzdsnode(idx) + xi * (basis.dzdsnode(idx + 1) - basis.dzdsnode(idx));\ncs = basis.csnode(idx) + xi * (basis.csnode(idx + 1) - basis.csnode(idx));\nct = basis.ctnode(idx) + xi * (basis.ctnode(idx + 1) - basis.ctnode(idx));\nend\n" }, { "name": "cymbal_basis.m", "content": "function basis = cymbal_basis(P)\n% CYMBAL_BASIS Mode set of the cymbal: one shell eigensolve per nodal-diameter\n% order, plus damping and the radiation data the later stages need.\n%\n% Because the plate is a shell of revolution, the modes separate exactly in\n% the azimuth (see cymbal_shell.m), so the two-dimensional eigenproblem breaks\n% into n_diameters + 1 one-dimensional ones. Each is reduced to symmetric\n% standard form through the Cholesky factor of the consistent mass matrix and\n% diagonalized by svd rather than eig: the reduced matrix is symmetric\n% positive semidefinite, so its singular vectors are its eigenvectors, and svd\n% keeps them orthonormal for the closely spaced pairs this geometry produces.\n% The eigenvectors come back mass-normalized, phi' M phi = I. Rigid-body modes\n% (two at n = 0, two at n = 1, none above) sit at zero frequency and are\n% dropped with everything below min_mode_hz; modes above max_mode_hz are\n% truncated, and the broadband part they carry is reinstated by the direct\n% term in cymbal_radiate.m.\n%\n% Only the cos(n theta) member of each degenerate pair is retained. That is\n% exact for the model as posed: every contact sits on the meridian theta = 0,\n% where the sin member has a node.\n%\n% HAMMERING. A perfectly axisymmetric plate is a bad cymbal, and the model has\n% to say so. Its modes are exact cos(n theta) shapes, and a mode with n nodal\n% diameters radiates only into the cone where its azimuthal trace speed is\n% supersonic, n < k a sin(theta). Below a few kilohertz that leaves only the\n% handful of n = 0, 1, 2 modes audible at any listening position, so an ideal\n% cymbal sounds like a small bell. Real cymbals are hammered and lathed, and\n% that is not decoration: the irregularity it leaves destroys the exact\n% separation, and every mode of the real plate is a hybrid of the ideal ones\n% that lie near it in frequency. A hybrid carries low-order content, so it\n% radiates.\n%\n% The hybridization is first-order degenerate perturbation theory, applied as\n% a seeded orthogonal mixing of modes lying within hammer_bw of each other in\n% frequency (a sequence of Givens rotations on neighbouring pairs, which is\n% orthogonal by construction and therefore conserves energy exactly), plus a\n% seeded relative detune of hammer_detune, which is what makes near neighbours\n% beat. The mixing matrix is stored as basis.mix and applied downstream\n% wherever the shapes enter linearly: to the stroke's modal projection in\n% cymbal_cascade.m and to the radiation transfer in cymbal_radiate.m. Because\n% the window is relative, the sparse low modes stay pure and only the dense\n% region above a kilohertz hybridizes, which is where a real cymbal's wash\n% lives. hammer_mix = 0 recovers the ideal axisymmetric plate.\n%\n% Damping has three parts.\n% internal damp_rate / om + 0.5 (loss_factor + loss_slope om). The first\n% term is a frequency-independent decay RATE, which is what a\n% resistive path out of the plate gives and what keeps the low\n% modes of a cymbal ringing for tens of seconds; the other two are\n% the usual constant and frequency-proportional loss factors, and\n% they are what makes the shimmer die well before the hum does;\n% mount a point dashpot of mount_loss N s/m on the axial motion at the\n% inner edge, standing in for the felt washer and the stand. With\n% mass-normalized shapes its modal damping is c phi_axial^2 / 2 om,\n% which falls off steeply with n because the shapes of high\n% nodal-diameter modes vanish at the hole. This is what kills the\n% low n = 0 gong modes a mounted cymbal does not sustain;\n% radiation computed from the actual radiated power, below.\n%\n% Radiated power is evaluated in the far field, where it is defined, rather\n% than by summing element powers as the sibling triangle does (an incoherent\n% element sum is mesh dependent for a continuous surface: halving the element\n% size halves the estimate). The plate is treated as a sheet of normal dipoles\n% of volume dS * ldip, and for a cos(n theta) source on a surface of revolution\n% the azimuthal integral of the far-field kernel is a Bessel function, so the\n% far field is exactly\n% p(th_o, ph_o) = -(om^2 rho0 / (4 pi c R)) e^{-ikR} A_n(th_o) cos(n ph_o)\n% A_n = ldip sum_e r_e L_e W_e e^{i k z_e cos th_o}\n% [ 2 pi i^n J_n(al) r'_e cos th_o\n% - pi (i^{n+1} J_{n+1}(al) + i^{n-1} J_{n-1}(al)) z'_e sin th_o ]\n% al = k r_e sin th_o\n% and the power integral over the sphere collapses to one quadrature in\n% cos th_o. The cos(n ph_o) factor is the model's multipole structure made\n% explicit: a mode with n nodal diameters is a 2^n-pole, so high-n modes\n% radiate very little at low frequency, and none of them radiate on the axis.\n%\n% ldip is the one phenomenological length in the radiation model: the distance\n% over which the air short-circuits from one face of the plate to the other.\n% It is taken as half the shorter of the flexural and acoustic wavelengths,\n% capped at the plate radius, and scaled by dipole_scale. Below the plate's\n% coincidence frequency (about 18 kHz for 1 mm bronze) it is the flexural\n% wavelength that sets it, which is why a cymbal is such a poor radiator in\n% the low register and gets steadily better as the cascade fills the top.\n\nG = cymbal_profile(P);\nE = P.youngs_modulus;\nnu = P.poisson_ratio;\nrho = P.density;\nc = P.speed_of_sound;\nrho0 = P.air_density;\nnn = G.nn;\nnel = nn - 1;\nLref = G.S / nel;\nnmax = max(0, round(P.n_diameters));\n\n% ---- radiation sample points: two per element, Hermite-evaluated ----\nnsub = 2;\nnsurf = nel * nsub;\nsurfS = zeros(nsurf, 1);\nsurfLen = zeros(nsurf, 1);\nsurfEl = zeros(nsurf, 1);\nsurfXi = zeros(nsurf, 1);\nfor e = 1:nel\n L = G.snode(e + 1) - G.snode(e);\n for j = 1:nsub\n idx = (e - 1) * nsub + j;\n xi = (j - 0.5) / nsub;\n surfS(idx) = G.snode(e) + xi * L;\n surfLen(idx) = L / nsub;\n surfEl(idx) = e;\n surfXi(idx) = xi;\n end\nend\nsurfR = reshape(interp1(G.s, G.r, surfS), nsurf, 1);\nsurfZ = reshape(interp1(G.s, G.z, surfS), nsurf, 1);\nsurfDr = reshape(interp1(G.s, G.drds, surfS), nsurf, 1);\nsurfDz = reshape(interp1(G.s, G.dzds, surfS), nsurf, 1);\n\n% ---- eigensolve per circumferential order ----\nfs = [];\nnd = [];\nNUall = [];\nNWall = [];\nNTall = [];\nSWall = [];\nomLo = 2 * pi * P.min_mode_hz;\nomHi = 2 * pi * P.max_mode_hz;\nfor n = 0:nmax\n [K, M] = cymbal_shell(G, n, E, nu, rho, Lref);\n Rc = chol(M);\n Am = (Rc') \\ (K / Rc);\n Am = (Am + Am') / 2;\n [Uv, Sv, ~] = svd(Am);\n om2 = flipud(diag(Sv));\n Vv = fliplr(Uv);\n om = sqrt(max(0, om2)) * P.stiffness_trim;\n keep = find(om > omLo & om < omHi);\n if length(keep) == 0\n continue;\n end\n Phi = Rc \\ Vv(:, keep);\n nk = length(keep);\n % svd fixes each singular vector only up to sign, and different linear\n % algebra libraries make different choices. On its own that would not\n % matter, since flipping one mode flips both its excitation and its\n % radiation and the two cancel; but the hammering below forms SUMS of\n % modes, where they do not, and the render would then depend on which\n % machine it was computed on. Canonicalize instead: the largest component\n % of every shape is positive, with ties broken by index so that the rule\n % itself is deterministic.\n for k = 1:nk\n av = abs(Phi(:, k));\n hit = find(av >= max(av) * (1 - 1e-9));\n if Phi(hit(1), k) < 0\n Phi(:, k) = -Phi(:, k);\n end\n end\n Uz = Phi(1:4:4 * nn, :); % meridional, nn x nk\n Wz = Phi(3:4:4 * nn, :); % normal\n Tz = Phi(4:4:4 * nn, :) * Lref; % dW/ds, unscaled back to physical\n % normal displacement at the radiation sample points (Hermite)\n SW = zeros(nsurf, nk);\n for idx = 1:nsurf\n e = surfEl(idx);\n xi = surfXi(idx);\n L = G.snode(e + 1) - G.snode(e);\n h1 = 1 - 3 * xi^2 + 2 * xi^3;\n h2 = L * (xi - 2 * xi^2 + xi^3);\n h3 = 3 * xi^2 - 2 * xi^3;\n h4 = L * (-xi^2 + xi^3);\n SW(idx, :) = h1 * Wz(e, :) + h2 * Tz(e, :) + h3 * Wz(e + 1, :) + h4 * Tz(e + 1, :);\n end\n fs = [fs; om(keep) / (2 * pi)];\n nd = [nd; n * ones(nk, 1)];\n NUall = [NUall, Uz];\n NWall = [NWall, Wz];\n NTall = [NTall, Tz];\n SWall = [SWall, SW];\nend\nN = length(fs);\nif N == 0\n error('No cymbal modes between min_mode_hz and max_mode_hz -- check geometry/material.');\nend\n[fs, order] = sort(fs);\nfs = reshape(fs, N, 1);\nnd = reshape(nd(order), N, 1);\nNUall = NUall(:, order);\nNWall = NWall(:, order);\nNTall = NTall(:, order);\nSWall = SWall(:, order);\n\n% ---- hammering: detune, then hybridize near-degenerate modes ----\nhstate = mod(floor(abs(P.hammer_seed)), 4294967296);\n[hu, hstate] = cymbal_rng(hstate, 2 * N);\nhz = sqrt(-2 * log(hu(1:N))) .* cos(2 * pi * hu(N + 1:2 * N));\nfs = fs .* (1 + P.hammer_detune * hz);\n[fs, ord2] = sort(fs);\nfs = reshape(fs, N, 1);\nnd = reshape(nd(ord2), N, 1);\nNUall = NUall(:, ord2);\nNWall = NWall(:, ord2);\nNTall = NTall(:, ord2);\nSWall = SWall(:, ord2);\n\nHPASS = 4;\nmix = eye(N);\nif P.hammer_mix > 0 && N > 1\n [hu2, hstate] = cymbal_rng(hstate, HPASS * (N - 1));\n q = 0;\n for pass = 1:HPASS\n for i = 1:N - 1\n q = q + 1;\n if (fs(i + 1) - fs(i)) / fs(i) > P.hammer_bw\n continue;\n end\n th = P.hammer_mix * (hu2(q) - 0.5) * pi;\n ca = cos(th);\n sa = sin(th);\n ri = mix(i, :);\n rj = mix(i + 1, :);\n mix(i, :) = ca * ri - sa * rj;\n mix(i + 1, :) = sa * ri + ca * rj;\n end\n end\nend\nmix2 = mix.^2;\n\nomega = 2 * pi * fs;\n\n% ---- the acoustic short-circuit length, per mode ----\ntMean = 2 * pi * trapz(G.s, G.r .* G.thk) / G.area;\ncL = sqrt(E / (rho * (1 - nu^2)));\nkap = tMean / sqrt(12);\nkFlex = sqrt(omega / (kap * cL));\nkAc = omega / c;\nldip = P.dipole_scale * min(P.radius_m, pi ./ max(kFlex, kAc));\n\n% ---- radiated power, and the radiation damping that follows from it ----\nnMu = 24;\nmu = reshape(((1:nMu) - 0.5) / nMu * 2 - 1, 1, nMu); % cos(theta_o) midpoints\ndmu = 2 / nMu;\nsth = sqrt(max(0, 1 - mu.^2));\nzetaRad = zeros(N, 1);\nsigmaEff = zeros(N, 1);\nfor m = 1:N\n om = omega(m);\n k = om / c;\n nn_d = nd(m);\n alpha = (k * surfR) * sth; % nsurf x nMu\n Jn = besselj(nn_d, alpha);\n Jp1 = besselj(nn_d + 1, alpha);\n if nn_d == 0\n Jm1 = -Jp1;\n else\n Jm1 = besselj(nn_d - 1, alpha);\n end\n % i^(n+1) J_{n+1} + i^(n-1) J_{n-1} = i^n * i * (J_{n+1} - J_{n-1})\n ipow = 1i^nn_d;\n Tk = ipow * (2 * pi * Jn .* (surfDr * mu) ...\n - 1i * pi * (Jp1 - Jm1) .* (surfDz * sth));\n carrier = exp(1i * (k * surfZ) * mu);\n wgt = ldip(m) * surfR .* surfLen .* SWall(:, m);\n An = sum((wgt * ones(1, nMu)) .* Tk .* carrier, 1); % 1 x nMu\n Kn = sum(abs(An).^2) * dmu;\n if nn_d == 0\n piN = 2 * pi;\n else\n piN = pi;\n end\n zetaRad(m) = om^3 * rho0 * piN * Kn / (32 * pi^2 * c^3);\n % radiation efficiency, for the diagnostics only\n vv = sum(surfR .* surfLen .* SWall(:, m).^2) * piN;\n sigmaEff(m) = 2 * zetaRad(m) * om / max(1e-30, rho0 * c * vv);\nend\n\n% ---- mount: a point dashpot on the axial motion at the inner edge ----\naxHole = NUall(1, :).' * G.dzdsnode(1) + NWall(1, :).' * G.drdsnode(1);\nzetaMount = P.mount_loss * axHole.^2 ./ (2 * omega);\n\n% The shape-dependent damping terms belong to the ideal modes, so they carry\n% over to the hybridized ones as the diagonal of the transformed damping\n% matrix, which to first order is the mixing weights squared.\nzetaRad = mix2 * zetaRad;\nzetaMount = mix2 * zetaMount;\nsigmaEff = mix2 * sigmaEff;\n\nzeta = P.damp_rate ./ omega ...\n + 0.5 * (P.loss_factor + P.loss_slope * omega) ...\n + P.radiation_loss * zetaRad + zetaMount;\n\nbasis.n = N;\nbasis.f = fs;\nbasis.omega = omega;\nbasis.zeta = zeta;\nbasis.zetaRad = zetaRad;\nbasis.zetaMount = zetaMount;\nbasis.sigma = sigmaEff;\nbasis.ndia = nd;\nbasis.ldip = ldip;\nbasis.mix = mix;\nbasis.NU = NUall;\nbasis.NW = NWall;\nbasis.NT = NTall;\nbasis.rnode = reshape(G.rnode, nn, 1);\nbasis.znode = reshape(G.znode, nn, 1);\nbasis.snode = reshape(G.snode, nn, 1);\nbasis.drdsnode = reshape(G.drdsnode, nn, 1);\nbasis.dzdsnode = reshape(G.dzdsnode, nn, 1);\nbasis.thknode = reshape(G.thknode, nn, 1);\nbasis.csnode = reshape(interp1(G.s, G.cs, G.snode), nn, 1);\nbasis.ctnode = reshape(interp1(G.s, G.ct, G.snode), nn, 1);\nbasis.surfR = surfR;\nbasis.surfZ = surfZ;\nbasis.surfDr = surfDr;\nbasis.surfDz = surfDz;\nbasis.surfLen = surfLen;\nbasis.SW = SWall;\nbasis.S = G.S;\nbasis.area = G.area;\nbasis.mass = G.mass;\nbasis.thkMean = tMean;\nend\n" }, { "name": "cymbal_cascade.m", "content": "function [env, tframe, DV, kf, cinfo] = cymbal_cascade(P, basis, tstr, jstr, taustr, sstr, duration)\n% CYMBAL_CASCADE Modal excitation and the nonlinear flow of energy up the\n% frequency ladder.\n%\n% This is the stage that makes the instrument a cymbal rather than a gong or a\n% bell. A cymbal struck hard is not a linear resonator: the transverse\n% displacement is comparable to the plate thickness, so the von Karman\n% stretching of the mid-surface couples the modes, and energy injected in the\n% low modes by the stick migrates upward over tens of milliseconds. That is\n% what the ear hears as a crash swelling into shimmer after its attack, and it\n% is why the same cymbal struck softly just pings: the coupling strength goes\n% as the square of the amplitude, so a quiet stroke stays linear.\n%\n% Integrating the von Karman equations is out of reach here (the cubic\n% coupling is a dense N^3 tensor, and the sibling repositories render in\n% seconds). What is integrated instead is the ENERGY of each mode. Each mode\n% is given an up-target, the lowest retained mode at least cascade_ratio times\n% its frequency, and a down-target the same ratio below, and energy performs a\n% biased random walk between them:\n% dE_m/dt = -(1 + beta) Gam_m E_m + (arrivals from below and above)\n% - 2 zeta_m om_m E_m\n% Gam_m = cascade_gain om_m (w_m / t_mean)^2 = cascade_gain E_m / (om_m M t^2)\n% w_m^2 = E_m / (om_m^2 M)\n% with beta = cascade_back < 1 the downward-to-upward ratio, so the net flux is\n% toward high frequency. Steps are taken in log frequency rather than in mode\n% index on purpose: four-wave interactions move energy by a fraction of an\n% octave at a time, not by one mode, and a ladder defined on the index would\n% make the swell depend on how densely the band happens to be resolved. The\n% rate is proportional to om_m because the higher a mode sits the faster its\n% interactions run, and to the square of that mode's own\n% amplitude-to-thickness ratio because that is the order of the geometric\n% nonlinearity. Taking the amplitude locally rather than as a plate-wide rms\n% is what keeps the cascade honest at the top of the band: the flux out of a\n% mode goes as the SQUARE of its energy, so a nearly empty mode passes nothing\n% on, the front of the cascade advances rather than the whole band draining at\n% once, and the swell stops on its own as the energy thins out.\n%\n% The scheme conserves energy exactly when the damping is off and the top of\n% the ladder reflects (cascade_leak = 0), which the validation checks; with\n% cascade_leak = 1 the flux out of the top is lost, standing in for the\n% truncated modes above max_mode_hz. cascade_gain is the one fitted number in\n% the model: it is a dimensionless coupling constant, set so that a default\n% crash takes about the 40 ms a real one takes to open up.\n%\n% FLUCTUATION, off by default. What the equations above track is the MEAN\n% energy of each mode, and the mean is not quite what a listener hears: a mode\n% sitting in a turbulent field is continually being filled and drained by its\n% neighbours, and its amplitude wanders about that mean on the timescale of\n% the exchange. Restoring that wander broadens each partial and raises the\n% modal overlap, which on paper is what turns a few hundred audible partials\n% into a continuum. In practice it does not sound like a cymbal. The\n% decorrelation rate this model assigns runs up to the control rate itself, so\n% what it adds is closer to modulation grit than to a wash, and turbulence is\n% therefore 0 at the defaults. It is kept because the physics is right even\n% where this discretization of it is not, and because it is the honest place\n% to start if the graininess is ever worth chasing; small values, a few tenths,\n% are the gentle end.\n%\n% Each mode carries a log-amplitude that follows an Ornstein-Uhlenbeck\n% process discretized at the control rate,\n% rho_m = exp(-(energy exchanged this step) / E_m)\n% g_m <- rho_m g_m + sqrt(1 - rho_m^2) turbulence z_m\n% with the correlation rate taken as the mode's own fractional energy turnover\n% rather than as a fitted time: a mode being fed while nearly empty\n% decorrelates completely every step, a mode merely ringing down does not\n% decorrelate at all. A running variance keeps exp(g) unbiased, so the\n% fluctuation redistributes energy in time without creating or destroying any,\n% and the process is seeded, so a render is reproducible. Where the cascade is\n% off the turnover is zero, rho is one, g stays where it started, and the\n% envelopes are exactly the closed-form exponentials of a linear plate.\n%\n% What this buys is a cymbal that responds to dynamics the way the instrument\n% does, at the cost of one control-rate loop rather than a full nonlinear\n% integration. What it gives up is phase: energy moves between modes but the\n% intermodulation products of a real cascade, and the split-pair beating of an\n% out-of-round cymbal, are not reproduced. cascade_gain = 0 turns the whole\n% mechanism off and leaves an exactly linear plate.\n%\n% Each contact adds ½ (dv_m)^2 to the energy of every mode, where\n% dv_m = J S(om_m tau) (phi_m(x_c) . dhat)\n% with S(x) = cos(x/2) / (1 - (x/pi)^2) the spectrum of the half-sine contact\n% pulse and dhat the stroke direction (strike_angle_deg away from the surface\n% normal, in the meridian plane). The projection is taken through the\n% hammering mixing of cymbal_basis.m, since the modes the stick actually\n% drives are the hybrids, not the ideal cos(n theta) shapes. Energies add incoherently, which is right\n% for a plate already ringing and exact for the first stroke into a silent one.\n%\n% Note that only the MAGNITUDE of the excitation survives into the energy\n% state. The signed jumps DV and the frame each contact lands in are returned\n% alongside, because cymbal_radiate.m needs them for the oscillator phases:\n% amplitude comes from the cascade, phase from the linear impulse response.\n%\n% Returns env (N x nf+1, modal VELOCITY amplitude at each frame boundary),\n% tframe (1 x nf+1), DV (N x number of contacts), kf (the frame index of each\n% contact) and cinfo diagnostics.\n\nN = basis.n;\nomega = basis.omega;\nzeta = basis.zeta;\nh = max(1e-4, P.cascade_step_ms * 1e-3);\nnf = max(1, ceil(duration / h));\ntframe = (0:nf) * h;\n\n% ---- modal velocity jump of each contact ----\nI = length(tstr);\nDV = zeros(N, I);\nang = P.strike_angle_deg * pi / 180;\nfor i = 1:I\n [uh, wh] = cymbal_at(basis, sstr(i));\n proj = -cos(ang) * wh + sin(ang) * uh; % 1 x N, along the stroke\n proj = (basis.mix * reshape(proj, N, 1)).'; % hammered modes are hybrids\n x = omega * taustr(i);\n den = 1 - (x / pi).^2;\n S = cos(x / 2) ./ den;\n sing = abs(den) < 1e-6;\n S(sing) = pi / 4; % limit at x -> pi\n DV(:, i) = jstr(i) * S .* reshape(proj, N, 1);\nend\nkf = min(nf, max(1, floor(tstr / h) + 1)); % frame each contact lands in\n\ndecay = exp(-2 * zeta .* omega * h);\ngmax = 0.5 / (h * (1 + P.cascade_back));\nMtot = basis.mass;\nteff = basis.thkMean;\n\n% ---- the log-frequency ladder ----\n% upIdx(m) is the lowest mode at least cascade_ratio times f(m); modes with no\n% such partner point at N + 1, the sink standing in for the truncated band.\n% dnIdx(m) is the highest mode at most f(m) / cascade_ratio, or m itself at the\n% bottom, which reflects.\nratio = max(1.02, P.cascade_ratio);\nf = basis.f;\nupIdx = zeros(N, 1);\ndnIdx = zeros(N, 1);\nfor m = 1:N\n ju = find(f > f(m) * ratio);\n if length(ju) == 0\n upIdx(m) = N + 1;\n else\n upIdx(m) = ju(1);\n end\n jd = find(f < f(m) / ratio);\n if length(jd) == 0\n dnIdx(m) = m;\n else\n dnIdx(m) = jd(length(jd));\n end\nend\n\nenv = zeros(N, nf + 1);\nEe = zeros(N, 1);\npeakStrength = 0;\npeakWrms = 0;\nlost = 0;\n\n% one independent LCG stream per mode, for the amplitude fluctuation\nsig = P.turbulence;\nst1 = mod(floor(abs(P.seed)) * 2654435761 + (1:N).' * 40503, 4294967296);\nst2 = mod(st1 * 69069 + 12345, 4294967296);\ngl = zeros(N, 1);\ngv = zeros(N, 1);\n\nfor f = 1:nf\n hit = find(kf == f);\n for q = 1:length(hit)\n Ee = Ee + 0.5 * DV(:, hit(q)).^2;\n end\n env(:, f) = sqrt(2 * Ee) .* exp(gl - gv / 2);\n\n wrms2 = sum(Ee ./ omega.^2) / Mtot;\n strength = wrms2 / teff^2;\n if strength > peakStrength\n peakStrength = strength;\n peakWrms = sqrt(wrms2);\n end\n gam = min(gmax, P.cascade_gain * Ee ./ (omega * Mtot * teff^2));\n up = gam .* Ee * h;\n dn = P.cascade_back * gam .* Ee * h;\n arrU = accumarray(upIdx, up, [N + 1, 1]);\n arrD = accumarray(dnIdx, dn, [N, 1]);\n leak = arrU(N + 1);\n En = Ee - up - dn + arrU(1:N) + arrD;\n En(N) = En(N) + (1 - P.cascade_leak) * leak;\n lost = lost + P.cascade_leak * leak;\n\n if sig > 0\n % fractional energy turnover this step sets the decorrelation\n moved = up + dn + arrU(1:N) + arrD;\n rho = exp(-min(30, moved ./ max(Ee, 1e-300)));\n [u1, st1] = cymbal_rngv(st1);\n [u2, st2] = cymbal_rngv(st2);\n zg = sqrt(-2 * log(u1)) .* cos(2 * pi * u2);\n w = 1 - rho.^2;\n gl = rho .* gl + sqrt(w) * sig .* zg;\n gv = rho.^2 .* gv + w * sig^2;\n end\n\n Ee = En .* decay;\nend\nenv(:, nf + 1) = sqrt(2 * Ee) .* exp(gl - gv / 2);\n\ncinfo.frames = nf;\ncinfo.stepS = h;\ncinfo.peakStrength = peakStrength;\ncinfo.peakWrmsUm = peakWrms * 1e6;\ncinfo.cascadeLostJ = lost;\ncinfo.injectedJ = sum(sum(0.5 * DV.^2));\nend\n" }, { "name": "cymbal_defaults.m", "content": "function P = cymbal_defaults()\n% CYMBAL_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% (strike_count - 1) / strike_rate_hz + tail_s.\n\n% Plate geometry (a 16-inch crash-ride)\nP.radius_m = 0.2032;\nP.hole_radius_m = 0.0064;\nP.bell_radius_m = 0.0450;\nP.bell_height_m = 0.0170;\nP.bow_height_m = 0.0200;\nP.thickness_bell_m = 0.00115;\nP.thickness_rim_m = 0.00075;\n\n% Material (B20 bell bronze)\nP.density = 8700.0;\nP.youngs_modulus = 1.05e11;\nP.poisson_ratio = 0.33;\nP.stiffness_trim = 1.00;\n\n% Hammering and lathing: the departure from an exactly axisymmetric plate\nP.hammer_mix = 0.55; % strength of the hybridization of near neighbours\nP.hammer_bw = 0.05; % relative frequency window it acts over\nP.hammer_detune = 0.004; % relative scatter of the mode frequencies\nP.hammer_seed = 7;\n\n% Discretization / mode retention\nP.n_elements = 28;\nP.n_diameters = 56; % highest circumferential order solved for\nP.min_mode_hz = 40.0;\nP.max_mode_hz = 10000.0;\n\n% Damping\nP.damp_rate = 1.70; % s^-1, frequency-independent decay rate\nP.loss_factor = 2.0e-5;\nP.loss_slope = 3.0e-10;\nP.radiation_loss = 1.0;\nP.mount_loss = 3.0; % N s/m dashpot at the inner edge (felt and stand)\n\n% Stick (nylon-tip drumstick)\nP.tip_radius_m = 0.0050;\nP.stick_mass_kg = 0.030;\nP.stick_youngs = 5.0e9;\nP.restitution = 0.55;\n\n% Strike gesture\nP.strike_velocity = 3.0;\nP.strike_r = 0.94; % 0 at the hole, 1 at the rim\nP.strike_angle_deg = 40.0; % 0 = normal to the surface, 90 = tangential\nP.strike_count = 1;\nP.strike_rate_hz = 6.0;\nP.roll_r2 = 0.80; % second contact radius when strike_count > 1\nP.timing_jitter_ms = 8.0;\nP.velocity_spread = 0.18;\nP.seed = 1;\n\n% Nonlinear cascade\nP.cascade_gain = 800.0;\nP.cascade_back = 0.45; % downward / upward transfer ratio\nP.cascade_ratio = 1.19; % frequency ratio of one transfer step (2^0.25)\nP.cascade_leak = 1.0; % 1 = energy leaving the top mode is lost\nP.cascade_step_ms = 1.0;\nP.turbulence = 0.0; % log-amplitude spread of the fluctuation (off)\n\n% Radiation / listener\nP.direct_gain = 1.0;\nP.dipole_scale = 1.0;\nP.n_azimuth = 224; % azimuthal samples in the listener transfer\nP.listener_x_m = 0.40;\nP.listener_y_m = 0.00;\nP.listener_z_m = 0.40;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 3.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 5.0;\nend\n" }, { "name": "cymbal_demo.m", "content": "% Render a cymbal crash with default parameters and print diagnostics.\n% Set P.strike_count = 8 for a repeated ride figure, P.strike_r = 0.1 for the\n% bell, P.strike_velocity = 0.7 for a tap on the same instrument, or\n% P.cascade_gain = 0 to hear the plate as an exactly linear resonator. Setting\n% P.hammer_mix = 0 recovers the ideal axisymmetric plate, which is worth\n% hearing once: it is a bell, because only its lowest few nodal-diameter\n% orders can radiate at all.\n% Run with: numbl run cymbal_demo.m\n\nP = cymbal_defaults();\ntic;\n[y, basis, dinfo] = cymbal_render(P);\nelapsed = toc;\n\nfprintf('modes kept : %d up to n = %d nodal diameters (%.0f Hz to %.0f Hz)\\n', ...\n dinfo.modesKept, dinfo.maxN, basis.f(1), basis.f(end));\nfprintf('lowest : ');\nfprintf('%.0f ', basis.f(1:min(10, basis.n)));\nfprintf('Hz\\n');\nfprintf('contact : r = %.1f cm (%.1f cm in from the rim, %s)', ...\n dinfo.strikeRadiusM * 100, dinfo.strikeRimM * 100, cd_region(P, dinfo.strikeRadiusM));\nif dinfo.strikeCount > 1\n fprintf(', alternating with r = %.1f cm (%s)', ...\n dinfo.roll2RadiusM * 100, cd_region(P, dinfo.roll2RadiusM));\nend\nfprintf('\\n');\nfprintf('strikes : %d at %.1f Hz (mean v %.2f m/s, mean contact %.0f us, peak force %.0f N)\\n', ...\n dinfo.strikeCount, P.strike_rate_hz, dinfo.meanV, dinfo.meanTauUs, dinfo.peakForceN);\nfprintf('cascade : peak w_rms %.1f um = %.2f thicknesses, %.1f%% of the injected energy left the top\\n', ...\n dinfo.peakWrmsUm, sqrt(dinfo.peakStrength), 100 * dinfo.cascadeLostJ / max(1e-30, dinfo.injectedJ));\nfprintf('peak press.: %.4f Pa (%.1f dB SPL) at %.0f cm\\n', ...\n dinfo.peakPa, dinfo.peakSplDb, dinfo.listenerDistM * 100);\nfprintf('length : %.2f s @ %d Hz\\n', length(y) / P.sample_rate, P.sample_rate);\nfprintf('render time: %.2f s\\n', elapsed);\nif dinfo.listenerInside\n fprintf('WARNING : listener is at or inside the plate\\n');\nend\n\nfunction s = cd_region(P, r)\n% Which part of the instrument a contact radius falls on.\nif r <= P.bell_radius_m\n s = 'bell';\nelseif r >= P.radius_m - 0.045\n s = 'edge';\nelse\n s = 'bow';\nend\nend\n" }, { "name": "cymbal_output.m", "content": "function [y, peakPa] = cymbal_output(P, p, oversample)\n% CYMBAL_OUTPUT Anti-aliased decimation and output conditioning.\n% Kaiser-windowed-sinc FIR lowpass at 0.45 * fs_lo, decimate by oversample,\n% pre-roll, tail fade, then either normalize to peak_dbfs or divide by\n% full_scale_pa. Identical in structure to the triangle and maraca output\n% stages.\n\npeakPa = max(abs(p));\nR = oversample;\nT = length(p);\n\nif R > 1\n taps = 12 * R + 1;\n M2 = (taps - 1) / 2;\n fcNorm = 0.45 / R; % cycles per input sample\n m = (0:taps - 1) - M2;\n h = sin(2 * pi * fcNorm * m) ./ (pi * m);\n h(M2 + 1) = 2 * fcNorm; % sinc value at m = 0\n r = m / M2;\n beta = 8;\n h = h .* (cy_i0(beta * sqrt(max(0, 1 - r.^2))) / cy_i0(beta));\n h = h / sum(h); % unity DC gain\n pf = conv(p, h);\n outLen = floor(T / R);\n y = reshape(pf((0:outLen - 1) * R + M2 + 1), 1, outLen); % centered pick\nelse\n y = p;\nend\n\n% Pre-roll, then truncate to the nominal duration\npadN = floor(P.pre_delay_ms * 1e-3 * P.sample_rate);\nK = max(1, round(P.strike_count));\nduration = (K - 1) / max(1e-3, P.strike_rate_hz) + P.tail_s;\ntotal = floor(duration * P.sample_rate);\nif padN > 0\n y = [zeros(1, padN) y];\nend\ny = y(1:min(length(y), total));\n\n% Squared fade on the tail\nfade = min(2048, floor(length(y) / 8));\nif fade > 1\n y(end - fade + 1:end) = y(end - fade + 1:end) .* linspace(1, 0, fade).^2;\nend\n\nif P.normalize ~= 0\n pk = max(abs(y));\n if pk > 0\n y = y / pk * 10^(P.peak_dbfs / 20);\n end\nelse\n y = y / P.full_scale_pa;\nend\nend\n\nfunction v = cy_i0(x)\n% Modified Bessel function of the first kind, order zero (series expansion).\nv = ones(size(x));\nterm = ones(size(x));\nhx = x / 2;\nfor k = 1:40\n term = term .* (hx / k).^2;\n v = v + term;\nend\nend\n" }, { "name": "cymbal_point.m", "content": "function [r, z] = cymbal_point(basis, sfrac)\n% CYMBAL_POINT Meridian coordinates (r, z) at an arclength fraction.\nnn = length(basis.snode);\ns = min(0.999999, max(0, sfrac)) * basis.S;\nidx = sum(basis.snode <= s);\nidx = min(max(idx, 1), nn - 1);\nxi = (s - basis.snode(idx)) / (basis.snode(idx + 1) - basis.snode(idx));\nr = basis.rnode(idx) + xi * (basis.rnode(idx + 1) - basis.rnode(idx));\nz = basis.znode(idx) + xi * (basis.znode(idx + 1) - basis.znode(idx));\nend\n" }, { "name": "cymbal_profile.m", "content": "function G = cymbal_profile(P)\n% CYMBAL_PROFILE Meridian of the cymbal as a shell of revolution.\n%\n% The cymbal is a surface of revolution about the z axis: a central mounting\n% hole of radius hole_radius_m, a raised bell out to bell_radius_m, and a bow\n% falling from the bell to the rim, which sits at z = 0 and r = radius_m.\n% Arclength s runs from the hole (s = 0) to the rim (s = S), so r increases\n% with s and the parameter strike_r addresses the plate as s / S.\n%\n% The bow is a parabola in the rim-referred coordinate u = (a - r) / (a - rb),\n% z = bow_height_m u^2, which is flat at the rim and steepest where it meets\n% the bell. The bell is the quartic in r that matches the bow's height, slope\n% and curvature at r = rb and comes to a horizontal apex at r = 0. Matching\n% the curvature matters: the finite element uses the meridian curvature and\n% its arclength derivative, so a profile that is only C1 would put a delta\n% function in the bending strain at the bell/bow join.\n%\n% Everything the element routine needs is tabulated on a fine grid and\n% interpolated onto the Gauss points:\n% r, z meridian coordinates (m)\n% s arclength from the hole (m)\n% thk shell thickness (m), tapering linearly from bell to rim\n% drds dr/ds = cos(meridian angle)\n% dzds dz/ds\n% cs 1 / R_s, meridian curvature, positive where the surface curves\n% away from the outward normal (a sphere of radius R gives 1/R)\n% ct 1 / R_theta = -dz/ds / r, the circumferential curvature\n% csp, ctp d/ds of those two\n%\n% Returns G with those fields plus S (total arclength), snode (node\n% arclengths), and the node-sampled geometry.\n\na = P.radius_m;\nrh = max(1e-4, min(P.hole_radius_m, 0.5 * a));\nrb = min(max(P.bell_radius_m, 2 * rh), 0.8 * a);\nhb = P.bell_height_m;\nhw = P.bow_height_m;\n\nM = 2001;\nr = linspace(rh, a, M);\n\n% ---- bow: z = hw u^2 with u = (a - r) / (a - rb) ----\nden = a - rb;\nu = (a - r) / den;\nzb = hw * u.^2;\nzb_r = -2 * hw * u / den;\nzb_rr = (2 * hw / den^2) * ones(1, M);\n\n% ---- bell: quartic matching z, z' and z'' at rb, horizontal apex at r = 0 ----\nmb = -2 * hw / den; % dz/dr of the bow at r = rb\nkb = 2 * hw / den^2; % d2z/dr2 of the bow at r = rb\nA0 = hw + hb; % apex height (z at r = 0)\nAmat = [rb^2, rb^3, rb^4; 2 * rb, 3 * rb^2, 4 * rb^3; 2, 6 * rb, 12 * rb^2];\ncoef = Amat \\ [-hb; mb; kb];\nB2 = coef(1); C3 = coef(2); E4 = coef(3);\nzc = A0 + B2 * r.^2 + C3 * r.^3 + E4 * r.^4;\nzc_r = 2 * B2 * r + 3 * C3 * r.^2 + 4 * E4 * r.^3;\nzc_rr = 2 * B2 + 6 * C3 * r + 12 * E4 * r.^2;\n\ninBell = r < rb;\nz = zb;\nz_r = zb_r;\nz_rr = zb_rr;\nz(inBell) = zc(inBell);\nz_r(inBell) = zc_r(inBell);\nz_rr(inBell) = zc_rr(inBell);\n\n% ---- arclength and the differential geometry of the meridian ----\nq = sqrt(1 + z_r.^2);\nsarc = cumtrapz(r, q);\nS = sarc(M);\ndrds = 1 ./ q;\ndzds = z_r ./ q;\nd2zds2 = z_rr ./ q.^4;\ncs = -d2zds2 ./ drds;\nct = -dzds ./ r;\nctp = -d2zds2 ./ r + dzds .* drds ./ r.^2;\n\n% cs involves the third derivative of the profile; the fine grid is dense\n% enough that a central difference is accurate to ~1e-8 relative\ncsp = zeros(1, M);\ncsp(2:M - 1) = (cs(3:M) - cs(1:M - 2)) ./ (sarc(3:M) - sarc(1:M - 2));\ncsp(1) = csp(2);\ncsp(M) = csp(M - 1);\n\n% ---- thickness: constant through the bell, linear taper across the bow ----\ntaper = min(1, max(0, (a - r) / den));\nthk = P.thickness_rim_m + (P.thickness_bell_m - P.thickness_rim_m) * taper;\n\n% ---- nodes, equally spaced in arclength ----\nnn = max(6, round(P.n_elements)) + 1;\nsnode = linspace(0, S, nn);\n\nG.r = r;\nG.z = z;\nG.s = sarc;\nG.thk = thk;\nG.drds = drds;\nG.dzds = dzds;\nG.cs = cs;\nG.ct = ct;\nG.csp = csp;\nG.ctp = ctp;\nG.S = S;\nG.M = M;\nG.nn = nn;\nG.snode = snode;\nG.rnode = interp1(sarc, r, snode);\nG.znode = interp1(sarc, z, snode);\nG.drdsnode = interp1(sarc, drds, snode);\nG.dzdsnode = interp1(sarc, dzds, snode);\nG.thknode = interp1(sarc, thk, snode);\nG.area = 2 * pi * trapz(sarc, r);\nG.mass = 2 * pi * P.density * trapz(sarc, r .* thk);\nend\n" }, { "name": "cymbal_radiate.m", "content": "function [p, radinfo] = cymbal_radiate(P, basis, env, tframe, DV, kf, tstr, taustr, fstr, sstr, fsHi)\n% CYMBAL_RADIATE Free-field pressure at the listener from the modal envelopes.\n%\n% Two contributions:\n%\n% 1. The mode bank. Each retained mode is a normal-dipole sheet on the plate:\n% a surface element of area dS carries a dipole of volume dS * ldip along\n% the local normal (ldip from cymbal_basis.m), and the element dipoles are\n% summed coherently with their relative retardations at the mode's centre\n% frequency,\n% G = sum_e (i om rho0 / 4 pi) V_e (u_e . rhat_e) (i k + 1/r_e)\n% exp(-i k (r_e - rcen)) / r_e,\n% over n_azimuth azimuths and two sample points per meridian element. The\n% common carrier exp(-i k rcen) is stripped, so relative phase across the\n% plate is kept while the overall propagation delay is not double counted.\n% Unlike the far-field form used for the radiated power, this keeps the\n% 1/r^2 near-field term and the true distances, which matters: a listener\n% half a metre from a 40 cm plate is nowhere near the far field. Only the\n% normal component of the surface motion radiates; the in-plane component\n% of a thin plate displaces almost no air.\n%\n% Because the azimuthal shape is exactly cos(n theta), the sum over\n% azimuths carries the multipole cancellation of the ideal mode: on the\n% axis of symmetry every mode with n >= 1 cancels exactly, and off axis a\n% mode radiates only into the cone where its azimuthal trace is supersonic,\n% n < k a sin(theta). That cutoff is why an unhammered plate is heard as a\n% handful of low-order partials; the hybridization applied below is what\n% gives the rest of the modes a way out. Moving the listener re-runs only\n% this stage.\n%\n% The synthesis itself is an oscillator bank rather than the closed-form\n% damped exponentials of the sibling repositories: the cascade makes every\n% amplitude time-varying, so each mode is a fixed-frequency oscillator\n% whose envelope is the cascade solution, interpolated linearly across each\n% control frame. Within a frame the two matrix products below are exact,\n% and the per-frame phase advance is a unit-modulus multiply, so nothing\n% drifts.\n%\n% Amplitude comes from the cascade; PHASE comes from a linear impulse\n% response carried alongside it. Each oscillator keeps a unit phasor and the\n% magnitude the mode would have had with no cascade at all; a contact adds\n% its signed modal velocity jump to that complex state and the phasor is\n% renormalized. A mode that was quiet is therefore re-phased by the stroke,\n% which is what makes the attack a coherent click, while a mode already\n% ringing loudly keeps the phase it had, which is also right. What is lost\n% is the intermodulation the real cascade would produce, since the energy\n% it moves into a mode arrives with whatever phase that mode already had.\n%\n% 2. Direct strike clicks. Modes above max_mode_hz are truncated; their\n% collective off-resonance response is the immediate local motion of the\n% plate under the tip. It is reinstated from the driving-point impedance of\n% an infinite plate, which for a plate is a pure resistance\n% Z = 8 sqrt(D rho t): the contact force divided by Z is the local\n% velocity, which radiates as a dipole over about half a flexural\n% wavelength, evaluated at the pulse's own centre frequency om* = pi / tau.\n% Scaled by direct_gain. This is the least principled term of the model,\n% and direct_gain = 0 turns it off.\n%\n% Returns p (1 x T, pascals at fsHi) and radinfo diagnostics.\n\nc = P.speed_of_sound;\nrho0 = P.air_density;\nN = basis.n;\nnsurf = length(basis.surfR);\nnth = max(8, round(P.n_azimuth));\nlx = P.listener_x_m;\nly = P.listener_y_m;\nlz = P.listener_z_m;\n\n% ---- surface element geometry, meridian samples x azimuths ----\nth = reshape(((0:nth - 1) + 0.5) / nth * 2 * pi, 1, nth);\ndth = 2 * pi / nth;\nct = cos(th);\nst = sin(th);\nex = basis.surfR * ct; % nsurf x nth\ney = basis.surfR * st;\nez = basis.surfZ * ones(1, nth);\nnx = (-basis.surfDz) * ct;\nny = (-basis.surfDz) * st;\nnz = basis.surfDr * ones(1, nth);\ndSa = (basis.surfR .* basis.surfLen) * (dth * ones(1, nth));\n\ndx = lx - ex;\ndy = ly - ey;\ndz = lz - ez;\nre = sqrt(dx.^2 + dy.^2 + dz.^2);\nlistenerInside = min(min(re)) < 0.02;\nre = max(re, 0.01);\ncosg = (nx .* dx + ny .* dy + nz .* dz) ./ re; % nsurf x nth\n\ncx = 0;\ncy = 0;\ncz = sum(basis.surfR .* basis.surfLen .* basis.surfZ) / sum(basis.surfR .* basis.surfLen);\nrcen = sqrt((lx - cx)^2 + (ly - cy)^2 + (lz - cz)^2);\n\n% ---- per-mode listener transfer ----\nG = zeros(N, 1) + 0i;\nfor m = 1:N\n om = basis.omega(m);\n k = om / c;\n azi = cos(basis.ndia(m) * th); % 1 x nth\n amp = (basis.ldip(m) * dSa .* (basis.SW(:, m) * ones(1, nth))) .* (ones(nsurf, 1) * azi);\n ker = amp .* cosg .* (1i * k + 1 ./ re) .* exp(-1i * k * (re - rcen)) ./ re;\n G(m) = (1i * om * rho0 / (4 * pi)) * sum(sum(ker));\nend\n% the plate's real modes are the hammered hybrids, and the transfer is linear\n% in the shape, so the mixing of cymbal_basis.m applies directly here\nG = basis.mix * G;\n\n% ---- oscillator bank driven by the cascade envelopes ----\nT = floor(tframe(end) * fsHi);\np = zeros(1, T);\nnf = length(tframe) - 1;\nL = max(1, round((tframe(2) - tframe(1)) * fsHi));\nomd = basis.omega .* sqrt(max(1e-12, 1 - basis.zeta.^2));\nEb = exp(1i * omd * ((0:L - 1) / fsHi)); % N x L, hoisted\nstepPh = exp(1i * omd * (L / fsHi)); % per-frame advance\ndecLin = exp(-basis.zeta .* basis.omega * (L / fsHi));\nramp = (0:L - 1) / L;\nsPh = ones(N, 1) + 0i;\naLin = zeros(N, 1);\npos = 1;\nfor f = 1:nf\n if pos > T\n break;\n end\n hit = find(kf == f);\n for q = 1:length(hit)\n cc = aLin .* sPh + DV(:, hit(q));\n aLin = abs(cc);\n sPh = cc ./ max(1e-300, aLin);\n end\n len = min(L, T - pos + 1);\n A0 = env(:, f);\n A1 = env(:, f + 1);\n c1 = (G .* A0 .* sPh).';\n c2 = (G .* (A1 - A0) .* sPh).';\n seg = real((c1 * Eb(:, 1:len)) + (c2 * Eb(:, 1:len)) .* ramp(1:len));\n p(pos:pos + len - 1) = seg;\n sPh = sPh .* stepPh;\n aLin = aLin .* decLin;\n pos = pos + L;\nend\n\n% ---- direct strike clicks ----\nE = P.youngs_modulus;\nnu = P.poisson_ratio;\nrho = P.density;\ncL = sqrt(E / (rho * (1 - nu^2)));\nI = length(tstr);\nks = min(T, max(1, round(tstr * fsHi) + 1));\nang = P.strike_angle_deg * pi / 180;\ndirectPeak = 0;\nfor i = 1:I\n [~, ~, thk, drds, dzds] = cymbal_at(basis, sstr(i));\n [rs, zs] = cymbal_point(basis, sstr(i));\n dvx = cos(ang) * dzds + sin(ang) * drds; % stroke direction at theta = 0\n dvz = -cos(ang) * drds + sin(ang) * dzds;\n rsx = lx - rs;\n rsy = ly;\n rsz = lz - zs;\n rr = max(0.01, sqrt(rsx^2 + rsy^2 + rsz^2));\n cg = (rsx * dvx + rsz * dvz) / rr;\n nP = max(2, round(taustr(i) * fsHi));\n % the click carries the true path delay relative to the carrier that was\n % stripped from G, which is negative when the contact is nearer the\n % listener than the plate centroid\n k0 = max(1, ks(i) + round((rr - rcen) / c * fsHi));\n if k0 + nP > T\n continue;\n end\n D = E * thk^3 / (12 * (1 - nu^2));\n Zp = 8 * sqrt(D * rho * thk);\n omStar = pi / taustr(i);\n kap = thk / sqrt(12);\n kB = sqrt(omStar / (kap * cL));\n lamB = 2 * pi / kB;\n Aeff = pi * (lamB / 4)^2; % patch driven by the contact\n ldir = pi / max(kB, omStar / c);\n mu = Aeff * ldir / Zp; % dipole volume per unit force\n amp = P.direct_gain * fstr(i) * mu * rho0 * omStar^2 * cg / (4 * pi * rr * c);\n pulse = amp * sin(pi * (0:nP) / nP);\n p(k0:k0 + nP) = p(k0:k0 + nP) + pulse;\n if abs(amp) > directPeak\n directPeak = abs(amp);\n end\nend\n\nradinfo.gainAbs = abs(G);\nradinfo.directPeakPa = directPeak;\nradinfo.listenerInside = listenerInside;\nradinfo.listenerDistM = rcen;\nend\n" }, { "name": "cymbal_render.m", "content": "function [y, basis, dinfo] = cymbal_render(P)\n% CYMBAL_RENDER Full render: basis + strikes -> cascade -> radiate -> output.\n% Convenience composition of the stage functions; the web app calls the stages\n% separately so it can cache intermediates (the shell eigensolves above all).\n\nbasis = cymbal_basis(P);\nfsHi = P.sample_rate * P.oversample;\n[tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = cymbal_strikes(P, basis);\n[env, tframe, DV, kf, cinfo] = cymbal_cascade(P, basis, tstr, jstr, taustr, sstr, sinfo.durationS);\n[p, radinfo] = cymbal_radiate(P, basis, env, tframe, DV, kf, tstr, taustr, fstr, sstr, fsHi);\n[y, peakPa] = cymbal_output(P, p, P.oversample);\n\n[r1, z1] = cymbal_point(basis, P.strike_r);\n[r2, z2] = cymbal_point(basis, P.roll_r2);\ndinfo.strikeRadiusM = r1;\ndinfo.strikeRimM = P.radius_m - r1;\ndinfo.strikeZM = z1;\ndinfo.roll2RadiusM = r2;\ndinfo.roll2RimM = P.radius_m - r2;\ndinfo.strikeCount = sinfo.count;\ndinfo.meanV = sinfo.meanV;\ndinfo.maxV = sinfo.maxV;\ndinfo.meanTauUs = sinfo.meanTauUs;\ndinfo.peakForceN = sinfo.peakForceN;\ndinfo.durationS = sinfo.durationS;\ndinfo.modesKept = basis.n;\ndinfo.maxN = max(basis.ndia);\ndinfo.f1 = basis.f(1);\ndinfo.peakPa = peakPa;\ndinfo.peakSplDb = 20 * log10(peakPa / 20e-6 + 1e-30);\ndinfo.directPeakPa = radinfo.directPeakPa;\ndinfo.listenerInside = radinfo.listenerInside;\ndinfo.listenerDistM = radinfo.listenerDistM;\ndinfo.peakStrength = cinfo.peakStrength;\ndinfo.peakWrmsUm = cinfo.peakWrmsUm;\ndinfo.injectedJ = cinfo.injectedJ;\ndinfo.cascadeLostJ = cinfo.cascadeLostJ;\ndinfo.cascadeFrames = cinfo.frames;\ndinfo.sinfo = sinfo;\nend\n" }, { "name": "cymbal_rng.m", "content": "function [r, state] = cymbal_rng(state, n)\n% CYMBAL_RNG Portable seeded uniform generator (32-bit LCG).\n% Returns n uniforms in (0, 1) as a column vector, plus the advanced state.\n% Deliberately not MATLAB's rand: the same seed must produce the identical\n% strike pattern in the numbl CLI, Node, and the browser, and every double\n% here stays below 2^53 so the arithmetic is exact everywhere.\nr = zeros(n, 1);\nfor i = 1:n\n state = mod(state * 1664525 + 1013904223, 4294967296);\n r(i) = (state + 0.5) / 4294967296;\nend\nend\n" }, { "name": "cymbal_rngv.m", "content": "function [r, state] = cymbal_rngv(state)\n% CYMBAL_RNGV Vectorized seeded uniform generator: one 32-bit LCG stream per\n% element of `state`, all advanced by one step. Returns the uniforms in (0, 1)\n% and the advanced state, both the same shape as the input.\n%\n% The scalar cymbal_rng draws a sequence from one stream, which is what the\n% stroke process wants. The cascade wants one fresh number per mode per\n% control frame, hundreds of thousands of them, so it runs hundreds of\n% independent streams side by side instead of looping. Every product below\n% stays under 2^53 (2^32 * 1664525 < 2^53), so the arithmetic is exact in the\n% CLI, in Node and in the browser alike, and a given seed reproduces the\n% identical render everywhere.\nstate = mod(state * 1664525 + 1013904223, 4294967296);\nr = (state + 0.5) / 4294967296;\nend\n" }, { "name": "cymbal_shell.m", "content": "function [K, M] = cymbal_shell(G, n, E, nu, rho, Lref)\n% CYMBAL_SHELL Free-free thin-shell system for one circumferential order n.\n%\n% The cymbal is a shell of revolution, so its modes separate exactly in the\n% azimuth: every mode has n nodal diameters and is\n% u(s, th) = U(s) cos(n th) (meridional, along the surface toward the rim)\n% v(s, th) = V(s) sin(n th) (circumferential)\n% w(s, th) = W(s) cos(n th) (along the outward normal)\n% with a partner rotated by pi / 2n. A strike on the meridian th = 0 excites\n% only the cos member of each pair, which is why the model solves one member\n% per (n, k) and says so in the README. The 1-D energy functional below is the\n% th integral of the Love-Kirchhoff shell energy; every quadratic term carries\n% cos^2 or sin^2, so the integral contributes the same constant factor (pi, or\n% 2 pi at n = 0) to K and M and drops out of the eigenvalues. At n = 0 the V\n% field decouples and gives the axisymmetric torsional modes, which have no\n% normal motion and therefore radiate nothing.\n%\n% Strains (Kraus, with the curvatures signed so that a sphere of radius R has\n% 1 / R_s = 1 / R_theta = +1 / R for the outward normal):\n% eps_s = U' + W cs\n% eps_th = (n V + U r') / r + W ct\n% gamma = -n U / r + V' - V r' / r (engineering shear)\n% beta_s = U cs - W' (rotation of the normal)\n% beta_th = n W / r + V ct\n% kap_s = beta_s' = U' cs + U cs' - W''\n% kap_th = (n beta_th + beta_s r') / r\n% kap_sth = -n beta_s / r + beta_th' - beta_th r' / r (the full twist,\n% factor 2 included)\n% and the energy densities are the standard C = E t / (1 - nu^2) membrane and\n% D = E t^3 / (12 (1 - nu^2)) bending forms, integrated with weight r ds.\n%\n% Elements have two nodes and four degrees of freedom each: U and V are linear\n% (C0), W is cubic Hermite (C1) with dW/ds as its second freedom, so beta_s and\n% its derivative are both well defined. Four-point Gauss quadrature per\n% element evaluates the tabulated geometry at the quadrature points rather\n% than freezing it per element, so a curved meridian is integrated rather than\n% approximated by conical frusta.\n%\n% Rotary inertia rho t^3 / 12 (beta_s^2 + beta_th^2) is kept. It shifts the\n% retained frequencies by well under a percent, but it is what gives the\n% dW/ds freedoms mass and so makes M positive definite, which is what lets the\n% eigensolve reduce through chol(M). The dW/ds freedoms are then scaled by\n% Lref (a congruence, so the eigenvalues are untouched) to bring them onto the\n% same numerical scale as the displacements.\n%\n% Returns K and M, each 4 nn x 4 nn, symmetric, M positive definite. The\n% returned system is in the SCALED freedoms; multiply an eigenvector by\n% diag(1, 1, 1, Lref) per node to recover physical dW/ds.\n\nnn = G.nn;\nndof = 4 * nn;\nK = zeros(ndof);\nM = zeros(ndof);\n\n% four-point Gauss on [0, 1]\ngx = [0.5 - 0.5 * 0.8611363115940526, 0.5 - 0.5 * 0.3399810435848563, ...\n 0.5 + 0.5 * 0.3399810435848563, 0.5 + 0.5 * 0.8611363115940526];\ngw = [0.3478548451374538, 0.6521451548625461, ...\n 0.6521451548625461, 0.3478548451374538] / 2;\n\nfor e = 1:nn - 1\n s0 = G.snode(e);\n s1 = G.snode(e + 1);\n L = s1 - s0;\n Ke = zeros(8);\n Me = zeros(8);\n for g = 1:4\n xi = gx(g);\n sg = s0 + xi * L;\n rr = interp1(G.s, G.r, sg);\n tt = interp1(G.s, G.thk, sg);\n dr = interp1(G.s, G.drds, sg);\n cs = interp1(G.s, G.cs, sg);\n ct = interp1(G.s, G.ct, sg);\n csp = interp1(G.s, G.csp, sg);\n ctp = interp1(G.s, G.ctp, sg);\n\n % shape functions, DOF order [U1 V1 W1 W1' U2 V2 W2 W2']\n NU = [1 - xi, 0, 0, 0, xi, 0, 0, 0];\n NUp = [-1 / L, 0, 0, 0, 1 / L, 0, 0, 0];\n NV = [0, 1 - xi, 0, 0, 0, xi, 0, 0];\n NVp = [0, -1 / L, 0, 0, 0, 1 / L, 0, 0];\n h1 = 1 - 3 * xi^2 + 2 * xi^3;\n h2 = xi - 2 * xi^2 + xi^3;\n h3 = 3 * xi^2 - 2 * xi^3;\n h4 = -xi^2 + xi^3;\n d1 = -6 * xi + 6 * xi^2;\n d2 = 1 - 4 * xi + 3 * xi^2;\n d3 = 6 * xi - 6 * xi^2;\n d4 = -2 * xi + 3 * xi^2;\n e1 = -6 + 12 * xi;\n e2 = -4 + 6 * xi;\n e3 = 6 - 12 * xi;\n e4 = -2 + 6 * xi;\n NW = [0, 0, h1, L * h2, 0, 0, h3, L * h4];\n NWp = [0, 0, d1 / L, d2, 0, 0, d3 / L, d4];\n NWpp = [0, 0, e1 / L^2, e2 / L, 0, 0, e3 / L^2, e4 / L];\n\n Bes = NUp + cs * NW;\n Bet = (n * NV + dr * NU) / rr + ct * NW;\n Bg = -n * NU / rr + NVp - dr * NV / rr;\n Bbs = cs * NU - NWp;\n Bbt = n * NW / rr + ct * NV;\n Bks = csp * NU + cs * NUp - NWpp;\n Bkt = (n * Bbt + dr * Bbs) / rr;\n Bbtp = n * (NWp / rr - dr * NW / rr^2) + ctp * NV + ct * NVp;\n Bkst = -n * Bbs / rr + Bbtp - dr * Bbt / rr;\n\n Cm = E * tt / (1 - nu^2);\n Db = E * tt^3 / (12 * (1 - nu^2));\n wq = gw(g) * L * rr;\n\n Ke = Ke + wq * (Cm * (Bes' * Bes + Bet' * Bet ...\n + nu * (Bes' * Bet + Bet' * Bes) ...\n + (1 - nu) / 2 * (Bg' * Bg)) ...\n + Db * (Bks' * Bks + Bkt' * Bkt ...\n + nu * (Bks' * Bkt + Bkt' * Bks) ...\n + (1 - nu) / 2 * (Bkst' * Bkst)));\n Me = Me + wq * (rho * tt * (NU' * NU + NV' * NV + NW' * NW) ...\n + rho * tt^3 / 12 * (Bbs' * Bbs + Bbt' * Bbt));\n end\n d = [4 * e - 3, 4 * e - 2, 4 * e - 1, 4 * e, 4 * e + 1, 4 * e + 2, 4 * e + 3, 4 * e + 4];\n K(d, d) = K(d, d) + Ke;\n M(d, d) = M(d, d) + Me;\nend\n\n% scale the dW/ds freedoms so every freedom is numerically comparable\nsc = ones(ndof, 1);\nsc(4:4:ndof) = Lref;\nSd = sc * sc';\nK = (K .* Sd + (K .* Sd)') / 2;\nM = (M .* Sd + (M .* Sd)') / 2;\nend\n" }, { "name": "cymbal_strikes.m", "content": "function [tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = cymbal_strikes(P, basis)\n% CYMBAL_STRIKES Stick contacts: a single strike, or a seeded repeated figure.\n%\n% strike_count = 1 gives one deterministic strike of strike_velocity at\n% strike_r at t = 0; no random numbers are drawn. strike_count > 1 repeats it\n% at strike_rate_hz, alternating between strike_r and roll_r2 (a ride pattern\n% moving between the bow and the edge), with Gaussian timing jitter\n% (timing_jitter_ms) and lognormal velocity spread (velocity_spread). The\n% first hit stays at t = 0 so the onset is fixed. All randomness comes from\n% cymbal_rng seeded with P.seed, so a given seed reproduces the identical\n% pattern in the CLI, in Node, and in the browser. Every contact sits on the\n% meridian theta = 0; see cymbal_basis.m for why.\n%\n% Each contact is a Hertz impact of the stick tip on the plate:\n% E* = 1 / ((1 - nu_t^2) / E_t + (1 - nu_p^2) / E_p)\n% R_eff= 1 / sqrt((1/r_tip + 1/R_s)(1/r_tip + 1/R_theta)) (sphere on a\n% doubly curved surface, at the local principal curvatures)\n% k_h = (4/3) E* sqrt(R_eff)\n% tau = 3.218 (m*^2 / (k_h^2 v))^{1/5} (Hertz contact time)\n% J = (1 + restitution) m* v\n% Fpk = pi J / (2 tau) (half-sine peak)\n%\n% The reduced mass m* pairs the stick with the plate mass engaged during the\n% contact. Here the sibling triangle's flexural-group-velocity estimate is\n% replaced by something exact: the driving-point impedance of an infinite\n% plate is a pure resistance, Z = 8 sqrt(D rho t), independent of frequency,\n% so the mass the stick sees during a pulse of duration tau is simply Z tau. A\n% short fixed-point iteration closes the loop, since tau depends on m* which\n% depends on tau. What this captures is that a heavy stick, a soft tip or a\n% slow stroke all lengthen the contact and darken the strike; what it leaves\n% out is the millisecond detail of the contact itself, which is prescribed\n% rather than simulated.\n%\n% Returns row vectors sorted by time: tstr (s), vstr (m/s), jstr (N s),\n% taustr (s), fstr (peak N), sstr (arclength fraction of the contact), and\n% sinfo with summary statistics.\n\nK = max(1, round(P.strike_count));\nrate = max(1e-3, P.strike_rate_hz);\nduration = (K - 1) / rate + P.tail_s;\n\nE = P.youngs_modulus;\nnu = P.poisson_ratio;\nrho = P.density;\nnut = 0.35; % stick tip Poisson ratio, fixed\nEstar = 1 / ((1 - nut^2) / P.stick_youngs + (1 - nu^2) / E);\nrt = max(1e-4, P.tip_radius_m);\n\nstate = mod(floor(abs(P.seed)), 4294967296);\nz1 = zeros(K, 1);\nz2 = zeros(K, 1);\nif K > 1\n [u, state] = cymbal_rng(state, 4 * K);\n U = reshape(u, K, 4);\n z1 = sqrt(-2 * log(U(:, 1))) .* cos(2 * pi * U(:, 2));\n z2 = sqrt(-2 * log(U(:, 3))) .* cos(2 * pi * U(:, 4));\nend\n\ntstr = zeros(1, K);\nvstr = zeros(1, K);\njstr = zeros(1, K);\ntaustr = zeros(1, K);\nfstr = zeros(1, K);\nsstr = zeros(1, K);\nfor i = 1:K\n t = (i - 1) / rate;\n if i > 1\n t = max(0, t + P.timing_jitter_ms * 1e-3 * z1(i));\n end\n v = P.strike_velocity;\n if K > 1\n v = v * exp(P.velocity_spread * z2(i));\n end\n v = max(0.01, v);\n if mod(i, 2) == 1\n sfrac = P.strike_r;\n else\n sfrac = P.roll_r2;\n end\n sfrac = min(0.999, max(0.001, sfrac));\n\n % local plate properties at the contact\n [~, ~, thk, ~, ~, cs, ct] = cymbal_at(basis, sfrac);\n Rrel = 1 / sqrt(max(1e-9, (1 / rt + cs) * (1 / rt + ct)));\n kh = (4 / 3) * Estar * sqrt(Rrel);\n D = E * thk^3 / (12 * (1 - nu^2));\n Zp = 8 * sqrt(D * rho * thk); % driving-point resistance of the plate\n\n tau = 1e-4;\n mstar = P.stick_mass_kg;\n for it = 1:6\n mloc = Zp * tau;\n mstar = P.stick_mass_kg * mloc / (P.stick_mass_kg + mloc);\n tau = 3.218 * (mstar^2 / (kh^2 * v))^(1 / 5);\n end\n\n tstr(i) = t;\n vstr(i) = v;\n jstr(i) = (1 + P.restitution) * mstar * v;\n taustr(i) = tau;\n fstr(i) = pi * jstr(i) / (2 * tau);\n sstr(i) = sfrac;\nend\n\n[tstr, order] = sort(tstr);\ntstr = reshape(tstr, 1, K);\nvstr = reshape(vstr(order), 1, K);\njstr = reshape(jstr(order), 1, K);\ntaustr = reshape(taustr(order), 1, K);\nfstr = reshape(fstr(order), 1, K);\nsstr = reshape(sstr(order), 1, K);\n\nsinfo.count = K;\nsinfo.meanV = sum(vstr) / K;\nsinfo.maxV = max(vstr);\nsinfo.meanTauUs = sum(taustr) / K * 1e6;\nsinfo.peakForceN = max(fstr);\nsinfo.durationS = duration;\nend\n" }, { "name": "cymbal_validate.m", "content": "% Validate the cymbal model. There is no external reference implementation, so\n% the checks are (a) closed-form identities the physics must satisfy, (b)\n% structural identities of the shell eigensolve and of the radiation integral,\n% (c) exact reproducibility of the seeded strike process, and (d) pinned\n% end-to-end diagnostics from the default configuration, recorded when the\n% model was built and treated as a regression reference since.\n% Run with: numbl run cymbal_validate.m\n\nfails = 0;\n\n% ---- 1. flat free circular plate against the closed form ----\n% The same shell routine that builds the cymbal, run on a flat meridian of\n% constant thickness with a pinhole at the centre, must reproduce the\n% completely free circular plate: the roots of the 2 x 2 determinant built\n% from J_n and I_n under vanishing radial moment and Kelvin-Kirchhoff shear,\n% and exactly two rigid-body modes at n = 0 (axial translation and rotation\n% about the axis), two at n = 1 (translation and tilt) and none above.\nP = cymbal_defaults();\nQ = P;\nQ.bow_height_m = 0; Q.bell_height_m = 0;\nQ.thickness_bell_m = 0.001; Q.thickness_rim_m = 0.001;\nQ.hole_radius_m = 0.002; Q.radius_m = 0.2; Q.n_elements = 40;\nGf = cymbal_profile(Q);\nDp = Q.youngs_modulus * 0.001^3 / (12 * (1 - Q.poisson_ratio^2));\nscal = sqrt(Dp / (Q.density * 0.001)) / Q.radius_m^2 / (2 * pi);\nLr = Gf.S / (Gf.nn - 1);\nerrP = 0;\nrigid = zeros(1, 5);\nfor n = 0:4\n f = cv_freqs(Gf, n, Q, Lr);\n rigid(n + 1) = sum(f < 3);\n fe = f(f > 3);\n lam = cv_plate_roots(n, Q.poisson_ratio);\n fc = lam.^2 * scal;\n nc = min(4, min(length(fe), length(fc)));\n errP = max(errP, max(abs(fe(1:nc).' - fc(1:nc)) ./ fc(1:nc)));\nend\nok = errP < 3e-3 && rigid(1) == 2 && rigid(2) == 2 && sum(rigid(3:5)) == 0;\nfails = fails + (~ok);\nfprintf('flat plate : max freq err %.1e, rigid modes %d %d %d %d %d %s\\n', ...\n errP, rigid(1), rigid(2), rigid(3), rigid(4), rigid(5), cv_verdict(ok));\n\n% ---- 2. eigensolve identities on the cymbal itself ----\n% The Cholesky reduction must hand back mass-orthonormal shapes that\n% diagonalize the stiffness: phi' M phi = I and phi' K phi = diag(om^2).\nG = cymbal_profile(P);\nLref = G.S / (G.nn - 1);\n[K, M] = cymbal_shell(G, 5, P.youngs_modulus, P.poisson_ratio, P.density, Lref);\nRc = chol(M);\nAm = (Rc') \\ (K / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nPhi = Rc \\ fliplr(Uv);\neOrth = max(max(abs(Phi' * M * Phi - eye(length(om2)))));\neDiag = max(max(abs(Phi' * K * Phi - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve : orthonormality %.1e, diagonalization %.1e %s\\n', eOrth, eDiag, cv_verdict(ok));\n\n% ---- 3. the Bessel far field against a direct sum over the dipole sheet ----\n% cymbal_basis reduces the azimuthal integral of the far-field kernel to\n% Bessel functions in order to get the radiated power; that reduction must\n% agree with brute force summation over a finely discretized dipole sheet.\nQ = P;\nQ.n_diameters = 4; Q.max_mode_hz = 1200;\nb4 = cymbal_basis(Q);\nerrF = 0;\nfor m = [1 3 5 9]\n [pd, pb] = cv_farfield(Q, b4, m, 200, 720, 0.6, 0.4);\n errF = max(errF, abs(pb / pd - 1));\nend\nok = errF < 0.01;\nfails = fails + (~ok);\nfprintf('far field : Bessel vs direct dipole sum, max err %.2e %s\\n', errF, cv_verdict(ok));\n\n% ---- 4. on the axis, every mode with a nodal diameter cancels exactly ----\n% A cos(n theta) source on a surface of revolution has no monopole, dipole or\n% any lower multipole than 2^n, and on the symmetry axis all of them vanish.\n% This is the sharpest available check that the azimuthal sum in the listener\n% transfer is assembled correctly. It is a statement about the IDEAL plate, so\n% the hammering is switched off: hybridizing the modes is precisely what gives\n% the high-order ones a way out, and with it on they radiate on the axis like\n% anything else.\nQ = P;\nQ.hammer_mix = 0; Q.hammer_detune = 0;\nQ.n_diameters = 6; Q.max_mode_hz = 1500;\nQ.listener_x_m = 0; Q.listener_y_m = 0; Q.listener_z_m = 0.6;\nb6 = cymbal_basis(Q);\n[tstr, ~, jstr, taustr, fstr, sstr, sinf] = cymbal_strikes(Q, b6);\n[env, tfr, DV, kf] = cymbal_cascade(Q, b6, tstr, jstr, taustr, sstr, 0.05);\n[~, ri] = cymbal_radiate(Q, b6, env, tfr, DV, kf, tstr, taustr, fstr, sstr, Q.sample_rate);\ng0 = max(ri.gainAbs(b6.ndia == 0));\ng1 = max(ri.gainAbs(b6.ndia >= 1));\nok = g1 / g0 < 1e-10;\nfails = fails + (~ok);\nfprintf('on axis : |G| of n>=1 modes is %.1e of the n=0 modes %s\\n', g1 / g0, cv_verdict(ok));\n\n% ---- 4b. the hammering mixing is exactly orthogonal ----\n% The hybridization is a product of Givens rotations, so it must be orthogonal\n% to round-off. That is what makes it energy conserving: it redistributes the\n% stroke among the modes and the radiation among them again, without inventing\n% or destroying any.\nbh = cymbal_basis(P);\neMix = max(max(abs(bh.mix * bh.mix.' - eye(bh.n))));\nspread = sum(bh.mix.^2 > 0.01, 2);\nok = eMix < 1e-12 && median(spread) > 2;\nfails = fails + (~ok);\nfprintf('hammering : orthogonality %.1e, median %d ideal modes per hybrid %s\\n', ...\n eMix, round(median(spread)), cv_verdict(ok));\n\n% ---- 5. Hertz contact-time scaling tau ~ v^(-1/5) ----\n% With a stick much lighter than the plate mass engaged during the contact the\n% reduced mass is constant and the free-sphere scaling must emerge.\nQ = P;\nQ.strike_count = 1;\nQ.stick_mass_kg = 2e-5;\n[~, ~, ~, tau1] = cymbal_strikes(Q, b6);\nQ.strike_velocity = 2 * P.strike_velocity;\n[~, ~, ~, tau2] = cymbal_strikes(Q, b6);\nratio = tau2(1) / tau1(1);\nok = abs(ratio - 2^(-1 / 5)) < 0.03;\nfails = fails + (~ok);\nfprintf('hertz : tau ratio %.4f (target %.4f) %s\\n', ratio, 2^(-1 / 5), cv_verdict(ok));\n\n% ---- 6. the cascade conserves energy, and switches off cleanly ----\n% With the damping removed, the top of the ladder reflecting and the\n% fluctuation off, the transfer scheme is a Markov generator on the modal\n% energies and must conserve their sum to round-off. With cascade_gain = 0\n% nothing is exchanged, so the fluctuation freezes at zero on its own and\n% every envelope must collapse onto the closed-form exp(-zeta om t) of an\n% uncoupled damped oscillator, fluctuation still switched on.\nQ = P;\nQ.n_diameters = 6; Q.max_mode_hz = 1500; Q.cascade_leak = 0; Q.turbulence = 0;\nbz = b6;\nbz.zeta = zeros(bz.n, 1);\n[tstr, ~, jstr, taustr, ~, sstr] = cymbal_strikes(Q, bz);\n[envC, ~, DVc] = cymbal_cascade(Q, bz, tstr, jstr, taustr, sstr, 0.4);\nEtot = sum(envC.^2 / 2, 1);\neCons = max(abs(Etot / Etot(1) - 1));\nQ.cascade_gain = 0;\nQ.turbulence = 0.9; % explicitly on, so the freeze is actually tested\n[envL, tfrL] = cymbal_cascade(Q, b6, tstr, jstr, taustr, sstr, 0.4);\npred = envL(:, 1) * exp(-(b6.zeta .* b6.omega).' * 0 + 0);\npred = (envL(:, 1) * ones(1, length(tfrL))) .* exp(-(b6.zeta .* b6.omega) * tfrL);\nsel = envL(:, 1) > 1e-12;\neLin = max(max(abs(envL(sel, :) ./ pred(sel, :) - 1)));\nok = eCons < 1e-10 && eLin < 1e-9;\nfails = fails + (~ok);\nfprintf('cascade : energy drift %.1e, linear limit err %.1e %s\\n', eCons, eLin, cv_verdict(ok));\n\n% ---- 7. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the origin\n% so every element dipole keeps its directivity factor.\nQ = P;\nQ.normalize = 0; Q.n_diameters = 12; Q.max_mode_hz = 3000;\nQ.tail_s = 0.4; Q.oversample = 1; Q.n_azimuth = 96;\nQ.listener_x_m = 0.8; Q.listener_y_m = 0.5; Q.listener_z_m = 1.8;\ny1 = cymbal_render(Q);\nQ.listener_x_m = 1.6; Q.listener_y_m = 1.0; Q.listener_z_m = 3.6;\ny2 = cymbal_render(Q);\ndrop = 10 * log10(sum(y2.^2) / sum(y1.^2));\nok = abs(drop + 6.02) < 0.25;\nfails = fails + (~ok);\nfprintf('falloff : 2 m -> 4 m %.2f dB rms (target -6.02) %s\\n', drop, cv_verdict(ok));\n\n% ---- 8. the seeded ride figure is exactly reproducible ----\nQ = P;\nQ.strike_count = 8;\n[t1, v1, j1, ~, ~, s1, g1] = cymbal_strikes(Q, b6);\n[t2, v2, ~, ~, ~, s2] = cymbal_strikes(Q, b6);\nsame = max(abs(t1 - t2)) == 0 && max(abs(v1 - v2)) == 0 && max(abs(s1 - s2)) == 0;\nok = same ...\n && g1.count == 8 ...\n && abs(g1.meanV - 3.111518886) < 2e-6 ...\n && abs(sum(j1) - 0.150818144) < 1e-9 ...\n && abs(t1(2) - 0.159427523) < 1e-9;\nfails = fails + (~ok);\nfprintf('ride (seed %d): %d strikes, mean v %.6f m/s, sum J %.9f N s %s\\n', ...\n Q.seed, g1.count, g1.meanV, sum(j1), cv_verdict(ok));\n\n% ---- 9. pinned end-to-end diagnostics at the defaults ----\n% The rms is pinned alongside the peak deliberately. The peak of a sum of\n% several hundred oscillators is a fragile thing to compare across machines,\n% where the rms is not, so a mismatch on the peak alone says the phases have\n% moved and a mismatch on both says the physics has.\n[y, basis, dinfo] = cymbal_render(P);\nyrms = sqrt(sum(y.^2) / length(y));\nok = abs(dinfo.peakPa - 13.6155) < 0.02 ...\n && abs(yrms - 0.04652826) < 2e-6 ...\n && abs(basis.f(1) - 49.4130) < 0.02 ...\n && basis.n == 378;\nfails = fails + (~ok);\nfprintf('pinned : peak %.4f Pa (target %.4f), rms %.8f (target %.8f), mode1 %.3f Hz, %d modes %s\\n', ...\n dinfo.peakPa, 13.6155, yrms, 0.04652826, basis.f(1), basis.n, cv_verdict(ok));\n\n% ---- 10. the wash decays ----\n% A cymbal rings for seconds, so the tail is not silent, but the energy in the\n% last half second must sit well below that of the opening half second.\nfs = P.sample_rate;\nn = length(y);\nhead = y(1:round(0.5 * fs));\ntail = y(n - round(0.5 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -20 && dropE > -80;\nfails = fails + (~ok);\nfprintf('decay : last/first half second %.1f dB %s\\n', dropE, cv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction f = cv_freqs(G, n, P, Lref)\n[K, M] = cymbal_shell(G, n, P.youngs_modulus, P.poisson_ratio, P.density, Lref);\nRc = chol(M);\nAm = (Rc') \\ (K / Rc);\nAm = (Am + Am') / 2;\n[~, Sv, ~] = svd(Am);\nf = sort(sqrt(diag(Sv))) / (2 * pi);\nend\n\nfunction lam = cv_plate_roots(n, nu)\n% Roots of the free-edge frequency equation of a circular plate, bracketed on\n% a coarse scan and bisected.\nlam = [];\nprev = cv_plate_det(n, nu, 0.30);\nfor x = 0.30:0.01:26\n cur = cv_plate_det(n, nu, x + 0.01);\n if prev * cur < 0\n lo = x;\n hi = x + 0.01;\n for it = 1:60\n mid = (lo + hi) / 2;\n if cv_plate_det(n, nu, lo) * cv_plate_det(n, nu, mid) < 0\n hi = mid;\n else\n lo = mid;\n end\n end\n lam = [lam, (lo + hi) / 2];\n end\n prev = cur;\nend\nend\n\nfunction d = cv_plate_det(n, nu, x)\n% W = A J_n(lam r/a) + B I_n(lam r/a); the free edge sets the radial moment\n% and the Kelvin-Kirchhoff shear to zero, and the 2 x 2 determinant vanishes.\nJ = besselj(n, x); I = besseli(n, x);\nJp = besselj(n - 1, x) - (n / x) * J;\nIp = besseli(n - 1, x) - (n / x) * I;\nJpp = -Jp / x + (n^2 / x^2 - 1) * J;\nIpp = -Ip / x + (n^2 / x^2 + 1) * I;\na11 = Jpp + nu * (Jp / x - n^2 * J / x^2);\na12 = Ipp + nu * (Ip / x - n^2 * I / x^2);\na21 = -Jp - (1 - nu) * (n^2 / x^2) * (Jp - J / x);\na22 = Ip - (1 - nu) * (n^2 / x^2) * (Ip - I / x);\nd = a11 * a22 - a12 * a21;\nend\n\nfunction [pd, pb] = cv_farfield(P, b, m, R, nth, tho, pho)\n% Far-field pressure of one mode, per unit modal velocity, computed by direct\n% summation over a theta-discretized dipole sheet and by the Bessel reduction.\nc = P.speed_of_sound;\nrho0 = P.air_density;\nom = b.omega(m);\nk = om / c;\nn = b.ndia(m);\nld = b.ldip(m);\nrh = [sin(tho) * cos(pho), sin(tho) * sin(pho), cos(tho)];\nth = ((0:nth - 1) + 0.5) / nth * 2 * pi;\ndth = 2 * pi / nth;\nacc = 0;\nfor e = 1:length(b.surfR)\n x = b.surfR(e) * cos(th);\n y = b.surfR(e) * sin(th);\n z = b.surfZ(e) * ones(1, nth);\n nx = -b.surfDz(e) * cos(th);\n ny = -b.surfDz(e) * sin(th);\n nz = b.surfDr(e) * ones(1, nth);\n dx = R * rh(1) - x;\n dy = R * rh(2) - y;\n dz = R * rh(3) - z;\n rr = sqrt(dx.^2 + dy.^2 + dz.^2);\n proj = (nx .* dx + ny .* dy + nz .* dz) ./ rr * b.SW(e, m) .* cos(n * th);\n V = ld * b.surfR(e) * b.surfLen(e) * dth;\n acc = acc + sum((1i * om * rho0 / (4 * pi)) * V * proj .* (1i * k + 1 ./ rr) .* exp(-1i * k * rr) ./ rr);\nend\npd = abs(acc);\nmu = cos(tho);\nsth = sin(tho);\nal = k * b.surfR * sth;\nJn = besselj(n, al);\nJp1 = besselj(n + 1, al);\nif n == 0\n Jm1 = -Jp1;\nelse\n Jm1 = besselj(n - 1, al);\nend\nip = 1i^n;\nTk = ip * (2 * pi * Jn .* (b.surfDr * mu) - 1i * pi * (Jp1 - Jm1) .* (b.surfDz * sth));\nAn = sum(ld * b.surfR .* b.surfLen .* b.SW(:, m) .* Tk .* exp(1i * (k * b.surfZ) * mu));\npb = abs(-(om^2 * rho0 / (4 * pi * c * R)) * exp(-1i * k * R) * An * cos(n * pho));\nend\n\nfunction s = cv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n" } ] }