/ simulated_instruments / cymbal2
Sign in
simulated_instruments / cymbal2
cymbal2 / exports / cymbal2.json
74 lines · 40.6 KBCodeBlameHistory
2 "format": "numbl-instrument-v1",
3 "id": "cymbal2",
4 "name": "Cymbal2",
5 "description": "Time-domain nonlinear shell cymbal (Samejima 2021); pitch scales the shell around MIDI 60 (48..72), velocity onto stroke speed. Renders are slow: the crash is computed, not modeled.",
6 "source": "simulated_instruments/cymbal2",
7 "knobs": [
8 {
9 "key": "strike_r",
10 "label": "strike radius",
11 "min": 0.05,
12 "max": 0.99,
13 "default": 0.6
14 },
15 {
16 "key": "grip_spring",
17 "label": "grip stiffness (N m/rad)",
18 "min": 20,
19 "max": 2000,
20 "default": 140,
21 "scale": "log"
22 }
23 ],
24 "files": [
25 {
26 "name": "render_note.m",
27 "content": "function y = render_note(note)\n% MIDI pitch scales the shell geometry: every length by\n% s = 2^-((pitch - 60)/12), which shifts the whole spectrum by the\n% semitone ratio exactly (shell modes go as thickness / radius^2, and the\n% curvature and washer frequencies scale the same way when the washer\n% circle scales too). The mallet stays fixed, so this is one player\n% striking a family of cymbal sizes; pitch 60 is the default 18-inch\n% shell, playable 48..72. The sound is inharmonic, so the note number is\n% a handle on register, not a tuned fundamental. Velocity drives the hand\n% speed of the stroke, and the crash character follows from the physics:\n% soft notes stay nearly linear, hard notes wash. Fixed: a single stroke,\n% a fixed seed, normalize off so velocity survives. The preview tier\n% coarsens the azimuthal grid and shortens the tail; the full tier\n% carries the ring to the end and takes minutes per distinct note.\nP = cymbal2_defaults();\nP.sample_rate = note.sample_rate;\nP.strike_count = 1;\nP.seed = 1;\nP.pre_delay_ms = 0;\nP.normalize = 0;\n% The preview tier drops the oversampling and shortens the tail but keeps\n% the azimuthal grid: hard strokes need the full wavenumber band to carry\n% the cascade, and coarsening it is what destabilizes the integration.\nif note.preview ~= 0\n P.oversample = 1;\n P.tail_s = 1.5;\nend\ns = 2 ^ (-(max(48, min(72, note.pitch)) - 60) / 12);\nP.radius_m = P.radius_m * s;\nP.rise_m = P.rise_m * s;\nP.thickness_m = P.thickness_m * s;\nP.washer_radius_m = P.washer_radius_m * s;\nP.strike_velocity = 0.3 + (6.5 - 0.3) * note.velocity;\nP.strike_r = note.params.strike_r;\nP.grip_spring = note.params.grip_spring;\ny = cymbal2_render(P);\nend\n"
28 },
29 {
30 "name": "cymbal2_defaults.m",
31 "content": "function P = cymbal2_defaults()\n% CYMBAL2_DEFAULTS Default parameters, following Samejima (2021) Table 1:\n% an 18-inch medium crash (45 cm diameter) modeled as a shallow spherical\n% shell, supported at the center by a washer of finite impedance and struck\n% by a mallet whose stick is an elastic beam held in a compliant grip.\n% The output duration is not a parameter: it is\n% (strike_count - 1) / strike_rate_hz + tail_s.\n\n% Shell geometry (a shallow spherical cap; rise sets the radius of curvature\n% Rs = radius^2 / (2 * rise))\nP.radius_m = 0.230;\nP.rise_m = 0.040;\nP.thickness_m = 0.001;\n\n% Material (bronze, per the paper's measured cymbal)\nP.youngs_modulus = 1.1e11;\nP.poisson_ratio = 0.33;\nP.density = 8860.0;\n\n% Damping: decay rate sigma0 + sigma1 * a^2 * k^2 at flexural wavenumber k\nP.sigma0 = 1.34; % s^-1, frequency-independent\nP.sigma1 = 1.2e-3; % s^-1 per unit nondimensional k^2\n\n% Support at the center: an SDOF washer per unit length of the inner\n% circumference, entering as the shear boundary condition at r = washer\n% radius. support_clamped = 1 replaces it with the rigid clamp of the\n% earlier literature (Bilbao 2010), which is worth hearing once.\nP.washer_radius_m = 0.029;\nP.washer_stiffness = 1.65e5; % N/m per m\nP.washer_mass = 0.0; % kg per m (paper sets 0)\nP.washer_damping = 0.0; % N s/m per m (paper sets 0)\nP.support_clamped = 0;\n\n% Mallet head (SDOF, Hertz-type nonlinear stiffness f = K * delta^alpha)\nP.head_radius_m = 0.025;\nP.head_mass_kg = 0.028;\nP.head_stiffness = 1.6e8; % N m^-alpha\nP.head_alpha = 2.54;\n\n% Mallet stick (Euler-Bernoulli beam, FEM)\nP.stick_length_m = 0.390;\nP.stick_area_m2 = 1.43139e-4;\nP.stick_youngs = 8.8e9;\nP.stick_density = 400.0;\nP.stick_inertia_m4 = 1.63044e-9;\nP.stick_damping_rate = 10.0; % mass-proportional, R = rate * M\nP.n_beam = 20; % beam elements (21 nodes)\n\n% Grip: 0 = rotational spring hand (loose grip), 1 = clamped hand (tight)\nP.grip_clamped = 0;\nP.grip_spring = 140.0; % N m/rad\n\n% Strike gesture: the hand accelerates downward to strike_velocity, coasts\n% until the head touches, holds briefly, and retracts to its rest height.\n% The paper prescribes constant acceleration to the same terminal speed\n% but never reverses, so it cannot play a second stroke. Because the stick\n% is compliant, the tip arrives somewhat slower than the hand.\nP.strike_velocity = 5.75; % peak hand speed, m/s; reproduces the\n % character of the authors' published demo\nP.strike_accel = 250.0; % downstroke hand acceleration, m/s^2\nP.retract_accel = 2500.0; % wrist-snap retract acceleration, m/s^2\nP.strike_hold_ms = 0.5; % dwell between contact and retract\nP.strike_gap_m = -1; % rest clearance; <= 0 sizes it to the stroke\nP.strike_r = 0.6; % contact radius, 0 at the washer, 1 at the rim\nP.strike_theta_deg = 180.0;\nP.strike_count = 1;\nP.strike_rate_hz = 4.0;\nP.velocity_spread = 0.0; % relative velocity scatter of repeated strokes\nP.seed = 1;\n\n% Pickup: normal acceleration of the shell at one point, as in the paper\nP.pickup_r = 0.6;\nP.pickup_theta_deg = 0.0;\n\n% Discretization\nP.n_radial = 24; % radial grid intervals (the paper's N_r)\nP.n_theta = 60; % azimuthal grid points (the paper's N_theta)\nP.g_param = 0.2498; % free parameter of the implicit shell scheme\nP.phi_param = 0.7; % free parameter of the washer and beam schemes\n\n% Rendering / output\nP.sample_rate = 48000;\nP.oversample = 2; % simulation runs at oversample * sample_rate\nP.tail_s = 6.0; % the low modes carry T60 of about 5 s\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_accel = 4.0e4; % m/s^2 mapped to full scale when normalize = 0\nend\n"
32 },
33 {
34 "name": "cymbal2_demo.m",
35 "content": "% Render a cymbal crash with default parameters and print diagnostics.\n% Set P.strike_velocity = 0.5 for a tap on the same instrument, or\n% P.support_clamped = 1 to hear the rigid center clamp of the earlier\n% literature instead of the washer. P.strike_count = 4 plays a repeated\n% figure; the hand retracts between strokes, which the paper's\n% constant-acceleration hand cannot do.\n% Run with: numbl run cymbal2_demo.m\n\nP = cymbal2_defaults();\ntic;\n[y, dinfo] = cymbal2_render(P);\nelapsed = toc;\n\nfprintf('grid : %d radial x %d azimuthal, %.1f kHz simulation rate\\n', ...\n P.n_radial, P.n_theta, dinfo.simRate / 1000);\nfprintf('shell : a = %.0f cm, rise %.0f mm -> Rs = %.2f m, washer at %.1f cm\\n', ...\n P.radius_m * 100, P.rise_m * 1000, dinfo.RsM, P.washer_radius_m * 100);\nf0 = cymbal2_modes(P, 0); f0 = f0(f0 > 1);\nf1 = cymbal2_modes(P, 1); f1 = f1(f1 > 1);\nf2 = cymbal2_modes(P, 2); f2 = f2(f2 > 1);\nfprintf('low modes : %.1f Hz (m=1 tilt), %.1f Hz (m=2), %.1f Hz (m=0 bounce)\\n', ...\n f1(1), f2(1), f0(1));\nfprintf('stroke : hand %.1f m/s -> tip %.2f m/s at contact\\n', ...\n dinfo.meanV, abs(dinfo.contactVel(1)));\nfprintf('contact : %.0f N peak, %.2f mm compression, %.1f ms over %d event(s)\\n', ...\n dinfo.peakForceN, dinfo.peakXiMm, dinfo.contactMs, dinfo.contactEvents);\nfprintf('shell peak : %.2f mm displacement = %.1f thicknesses\\n', ...\n dinfo.peakDispM * 1000, dinfo.peakDispThickness);\nfprintf('pickup : %.2e m/s^2 peak acceleration at r = %.1f cm\\n', ...\n dinfo.peakAccel, dinfo.pickupRadiusM * 100);\nfprintf('length : %.2f s @ %d Hz\\n', dinfo.lengthS, P.sample_rate);\nfprintf('render time: %.1f s\\n', elapsed);\n"
36 },
37 {
38 "name": "cymbal2_grid.m",
39 "content": "function G = cymbal2_grid(P)\n% CYMBAL2_GRID Polar grid, radial difference matrices, and the azimuthal\n% transform. The radial grid carries l = 1..Nr+2 lines: real nodes at\n% l = 3..Nr spanning [washer radius, rim radius], and two virtual lines\n% beyond each boundary, eliminated by the boundary-condition rows of the\n% assembled systems (Samejima 2021, Fig. 4). Azimuthal operators are\n% circulant, so every linear solve block-diagonalizes over the real\n% cos/sin basis; F maps physical theta samples to coefficients (U * F)\n% and Gi maps back (Uhat * Gi), with mcol giving each column's wavenumber.\n\na = P.radius_m;\nc = P.washer_radius_m;\nNr = P.n_radial;\nnth = P.n_theta;\nif mod(nth, 2) ~= 0\n error('cymbal2: n_theta must be even');\nend\nif Nr < 8\n error('cymbal2: n_radial must be at least 8');\nend\nnrt = Nr + 2;\nhr = (a - c) / (Nr - 3);\nif c <= hr\n error('cymbal2: washer_radius too small for n_radial (need c > (a-c)/(n_radial-3))');\nend\n\nr = c + ((1:nrt)' - 3) * hr; % r(1) = c - 2hr may be tiny on coarse grids;\nr(1) = max(r(1), hr / 2); % row 1 of every operator is never used\n\n% Radial stencils (dense; nrt is small). Rows outside the valid range are\n% left zero and are overwritten by boundary-condition rows downstream.\nD1 = zeros(nrt, nrt);\nD2 = zeros(nrt, nrt);\nD3 = zeros(nrt, nrt);\nfor l = 2:nrt - 1\n D1(l, l - 1) = -1 / (2 * hr); D1(l, l + 1) = 1 / (2 * hr);\n D2(l, l - 1) = 1 / hr^2; D2(l, l) = -2 / hr^2; D2(l, l + 1) = 1 / hr^2;\nend\nfor l = 3:nrt - 2\n D3(l, l - 2) = -1 / (2 * hr^3);\n D3(l, l - 1) = 2 / (2 * hr^3);\n D3(l, l + 1) = -2 / (2 * hr^3);\n D3(l, l + 2) = 1 / (2 * hr^3);\nend\n\n% Azimuthal stencils as dense circulant matrices, applied on the right\n% (U * T2 etc., so they carry a transpose; T2 is symmetric, T1 is\n% antisymmetric and T1t below is arranged so Uth = U * T1t).\nhth = 2 * pi / nth;\nT2 = zeros(nth, nth);\nT1t = zeros(nth, nth);\nfor j = 1:nth\n jm = 1 + mod(j - 2, nth);\n jp = 1 + mod(j, nth);\n T2(j, j) = -2 / hth^2; T2(j, jm) = 1 / hth^2; T2(j, jp) = 1 / hth^2;\n % row j of T1 has -1 at jm, +1 at jp (scaled); transpose: column j\n T1t(jm, j) = -1 / (2 * hth);\n T1t(jp, j) = 1 / (2 * hth);\nend\n\n% Real transform: columns ordered m = 0, (1c,1s), (2c,2s), ..., nth/2\nth = ((1:nth)' - 1) * hth;\nF = zeros(nth, nth);\nGi = zeros(nth, nth);\nmcol = zeros(1, nth);\nF(:, 1) = 1 / nth;\nGi(1, :) = 1;\ncol = 2;\nfor m = 1:nth / 2 - 1\n F(:, col) = 2 * cos(m * th) / nth; Gi(col, :) = cos(m * th)'; mcol(col) = m; col = col + 1;\n F(:, col) = 2 * sin(m * th) / nth; Gi(col, :) = sin(m * th)'; mcol(col) = m; col = col + 1;\nend\nF(:, col) = cos((nth / 2) * th) / nth;\nGi(col, :) = cos((nth / 2) * th)';\nmcol(col) = nth / 2;\n\n% Eigenvalue of the circulant second-difference for wavenumber m\nlam2 = (2 * cos(mcol * hth) - 2) / hth^2;\n\nG.a = a; G.c = c; G.Nr = Nr; G.nrt = nrt; G.nth = nth;\nG.hr = hr; G.hth = hth;\nG.r = r; G.th = th;\nG.D1 = D1; G.D2 = D2; G.D3 = D3;\nG.T1t = T1t; G.T2 = T2;\nG.F = F; G.Gi = Gi; G.mcol = mcol; G.lam2 = lam2;\nG.Rd = 1.0 ./ r; % 1/r column, broadcast over theta\nG.R2d = 1.0 ./ r.^2;\nend\n"
40 },
41 {
42 "name": "cymbal2_mallet.m",
43 "content": "function MB = cymbal2_mallet(P)\n% CYMBAL2_MALLET Precompute the striking mechanism: the mallet stick as an\n% Euler-Bernoulli beam (Hermite cubic elements, consistent mass,\n% mass-proportional damping), held at one end by the hand, with the mallet\n% head attached at the other end as a separate SDOF body (Samejima 2021,\n% Sec. 3). The hand grips either rigidly (grip_clamped = 1: transverse\n% displacement and rotation both prescribed) or through a rotational\n% spring (grip_clamped = 0: displacement prescribed, rotation free against\n% grip_spring). The head couples to the beam tip by the rigid connection\n% u_M = u_S. The paper integrates the head as its own SDOF (Eq. 34) and\n% solves the connection force from the constraint; because that couples an\n% explicit head update to the implicit beam, the discrete pair is\n% unstable, so the head mass is lumped onto the tip node instead, which is\n% the same physics under the rigid connection (substituting u_M = u_S and\n% f_S = -f_M2 into Eq. (31) puts M_M on the tip and f_M1 on the beam).\n\nne = P.n_beam;\nnn = ne + 1;\nndof = 2 * nn;\nle = P.stick_length_m / ne;\nEI = P.stick_youngs * P.stick_inertia_m4;\nrA = P.stick_density * P.stick_area_m2;\n\nKe = EI / le^3 * [12, 6 * le, -12, 6 * le; ...\n 6 * le, 4 * le^2, -6 * le, 2 * le^2; ...\n -12, -6 * le, 12, -6 * le; ...\n 6 * le, 2 * le^2, -6 * le, 4 * le^2];\nMe = rA * le / 420 * [156, 22 * le, 54, -13 * le; ...\n 22 * le, 4 * le^2, 13 * le, -3 * le^2; ...\n 54, 13 * le, 156, -22 * le; ...\n -13 * le, -3 * le^2, -22 * le, 4 * le^2];\n\nK = zeros(ndof, ndof);\nM = zeros(ndof, ndof);\nfor e = 1:ne\n ix = (2 * e - 1):(2 * e + 2);\n K(ix, ix) = K(ix, ix) + Ke;\n M(ix, ix) = M(ix, ix) + Me;\nend\nR = P.stick_damping_rate * M;\nM(ndof - 1, ndof - 1) = M(ndof - 1, ndof - 1) + P.head_mass_kg;\n\n% Node 1 is the grip, node nn the tip. Dof 1 (grip displacement) is always\n% prescribed to the hand motion; dof 2 (grip rotation) is prescribed to 0\n% for the clamped hand, or free with the rotational spring added.\nif P.grip_clamped ~= 0\n fi = 3:ndof;\nelse\n K(2, 2) = K(2, 2) + P.grip_spring;\n fi = 2:ndof;\nend\nKf = K(fi, fi); Mf = M(fi, fi); Rf = R(fi, fi);\nKc = K(fi, 1); Mc = M(fi, 1); Rc = R(fi, 1);\n\nht = 1 / (P.sample_rate * P.oversample);\nps = P.phi_param;\n% Stiffness split with the implicit weight ps/2 on the (k+1, k-1) average,\n% as in the shell's washer row; the complementary split printed in the\n% paper is unstable for the beam's highest elements at audio rates.\nAS = Mf + (ht / 2) * Rf + (ps / 2) * ht^2 * Kf;\nBS = 2 * Mf - (1 - ps) * ht^2 * Kf;\nCS = -Mf + (ht / 2) * Rf - (ps / 2) * ht^2 * Kf;\nASi = inv(AS);\nMB.ASiB = ASi * BS;\nMB.ASiC = ASi * CS;\n\n% Hand coupling, folded so the per-step load is\n% vM * hA(k) + vR * hB(k) + vK * hC(k)\n% with hA = -(uH+ - 2 uH + uH-), hB = -(ht/2)(uH+ - uH-),\n% hC = -ht^2 (ps uH + ((1-ps)/2)(uH+ + uH-)).\nMB.vM = ASi * Mc;\nMB.vR = ASi * Rc;\nMB.vK = ASi * Kc;\n\ntipi = find(fi == ndof - 1);\nMB.tipi = tipi;\nMB.tipcol = ASi(:, tipi);\nMB.gtip = ASi(tipi, tipi);\nMB.nfree = length(fi);\nMB.ht = ht;\nMB.ps = ps;\nend\n"
44 },
45 {
46 "name": "cymbal2_modes.m",
47 "content": "function [f, W] = cymbal2_modes(P, m)\n% CYMBAL2_MODES Eigenfrequencies (Hz) of the linearized shell for one\n% azimuthal wavenumber m, with the membrane (curvature) stiffening and the\n% support boundary condition included; the washer is taken as its static\n% stiffness (its mass and damping are ignored here, as in the paper's\n% linear FEM reference). Used by the demo and the validation, not by the\n% renderer. W returns the mode shapes on the real radial nodes l = 3..Nr.\n\nG = cymbal2_grid(P);\nnrt = G.nrt;\nNr = G.Nr;\nhth = G.hth;\nlam2 = (2 * cos(m * hth) - 2) / hth^2;\nop = cymbal2_perm(P, G, lam2);\n\nE = P.youngs_modulus;\nh = P.thickness_m;\nrhoh = P.density * h;\nRs = P.radius_m^2 / (2 * P.rise_m);\n\n% Airy solve operator: Fm * phi = Z * (E h / Rs) * Lap * u, with\n% phi = phi_r = 0 at both circles and identity rows on the outermost\n% virtual lines.\nFm = zeros(nrt, nrt);\nFm(1, 1) = 1;\nFm(2, :) = G.D1(3, :);\nFm(3, 3) = 1;\nfor l = 4:Nr - 1\n Fm(l, :) = op.Bih(l, :);\nend\nFm(Nr, Nr) = 1;\nFm(Nr + 1, :) = G.D1(Nr, :);\nFm(Nr + 2, Nr + 2) = 1;\nZl = zeros(nrt, nrt);\nfor l = 4:Nr - 1\n Zl(l, :) = op.Lap(l, :);\nend\nPhimap = Fm \\ ((E * h / Rs) * Zl);\n\n% Full stiffness on the equation-of-motion rows l = 3..Nr\nKfull = zeros(nrt, nrt);\nKterm = op.Dflex * op.Bih + (1 / Rs) * op.Lap * Phimap;\nfor l = 3:Nr\n Kfull(l, :) = Kterm(l, :);\nend\n\n% Boundary rows, static. With the washer the shell's equation of motion\n% holds on l = 3..Nr and the four virtual lines are eliminated; with the\n% rigid clamp u and u_r vanish at l = 3, so u(3) itself joins the\n% eliminated set and the equations of motion start at l = 4 (the innermost\n% virtual line is then never engaged).\ne3 = zeros(1, nrt); e3(3) = 1;\nif P.support_clamped ~= 0\n Cmat = [e3; G.D1(3, :); op.MomE; op.ShE];\n keep = 4:Nr;\n elim = [2, 3, Nr + 1, Nr + 2];\nelse\n Cmat = [op.MomW; op.ShW - P.washer_stiffness * e3; op.MomE; op.ShE];\n keep = 3:Nr;\n elim = [1, 2, Nr + 1, Nr + 2];\nend\nX = -Cmat(:, elim) \\ Cmat(:, keep);\nKred = Kfull(keep, keep) + Kfull(keep, elim) * X;\n\n[V, Dm] = eig(Kred / rhoh);\nom2 = real(diag(Dm));\n[om2, idx] = sort(om2);\nom2 = max(om2, 0);\nf = sqrt(om2) / (2 * pi);\nW = real(V(:, idx));\nend\n"
48 },
49 {
50 "name": "cymbal2_output.m",
51 "content": "function [y, peakAccel] = cymbal2_output(P, acc)\n% CYMBAL2_OUTPUT Output conditioning: DC blocker on the raw pickup\n% acceleration, Kaiser-windowed-sinc anti-alias decimation from the\n% simulation rate to the output rate, tail fade, and normalization. Same\n% structure as the sibling instruments' output stages.\n\npeakAccel = max(abs(acc));\nR = P.oversample;\nT = length(acc);\n\n% One-pole DC blocker (the pickup is an acceleration, but the contact\n% leaves a small quasi-static offset while the washer relaxes)\nfc = 15.0;\na1 = exp(-2 * pi * fc / (P.sample_rate * R));\np = zeros(1, T);\nxp = 0; yp = 0;\nfor k = 1:T\n yv = acc(k) - xp + a1 * yp;\n xp = acc(k);\n yp = yv;\n p(k) = yv;\nend\n\nif R > 1\n taps = 12 * R + 1;\n M2 = (taps - 1) / 2;\n fcNorm = 0.45 / R;\n m = (0:taps - 1) - M2;\n hh = sin(2 * pi * fcNorm * m) ./ (pi * m);\n hh(M2 + 1) = 2 * fcNorm;\n r = m / M2;\n beta = 8;\n hh = hh .* (c2_i0(beta * sqrt(max(0, 1 - r.^2))) / c2_i0(beta));\n hh = hh / sum(hh);\n pf = conv(p, hh);\n outLen = floor(T / R);\n y = reshape(pf((0:outLen - 1) * R + M2 + 1), 1, outLen);\nelse\n y = p;\nend\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_accel;\nend\nend\n\nfunction v = c2_i0(x)\n% Modified Bessel function of the first kind, order zero (series)\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"
52 },
53 {
54 "name": "cymbal2_perm.m",
55 "content": "function op = cymbal2_perm(P, G, lam2)\n% CYMBAL2_PERM Radial operators for one azimuthal wavenumber, dimensional.\n% lam2 is the (negative) eigenvalue of the circulant second-difference in\n% theta for this wavenumber, so every d^2/dtheta^2 becomes multiplication\n% by lam2. Returns the Laplacian, the biharmonic (as the exact discrete\n% composition Lap * Lap), and the radial-moment and Kelvin-Kirchhoff shear\n% rows used as boundary conditions at the washer (l = 3) and at the free\n% edge (l = Nr). The moment and shear expressions are those of Samejima\n% (2021), Eqs. (6)-(7) and (14)-(15).\n\nnrt = G.nrt;\nnu = P.poisson_ratio;\nDfl = P.youngs_modulus * P.thickness_m^3 / (12 * (1 - nu^2));\n\nRm = diag(G.Rd);\nR2 = diag(G.R2d);\nR3 = diag(G.Rd.^3);\n\nLap = G.D2 + Rm * G.D1 + lam2 * R2;\nBih = Lap * Lap;\n\n% Radial bending moment (per unit length, divided by -D):\n% u_rr + nu * (u_r / r + u_thth / r^2)\nMom = G.D2 + nu * (Rm * G.D1 + lam2 * R2);\n\n% Kelvin-Kirchhoff shear: V = -D * [u_rrr + u_rr / r - u_r / r^2\n% + (nu - 3) / r^3 * u_thth + (2 - nu) / r^2 * u_r_thth]\nSh = -Dfl * (G.D3 + Rm * G.D2 - R2 * G.D1 ...\n + (nu - 3) * lam2 * R3 + (2 - nu) * lam2 * R2 * G.D1);\n\nop.Lap = Lap;\nop.Bih = Bih;\nop.MomW = Mom(3, :); % moment row at the washer circle\nop.MomE = Mom(nrt - 2, :); % moment row at the free edge (l = Nr)\nop.ShW = Sh(3, :);\nop.ShE = Sh(nrt - 2, :);\nop.Dflex = Dfl;\nend\n"
56 },
57 {
58 "name": "cymbal2_render.m",
59 "content": "function [y, dinfo] = cymbal2_render(P)\n% CYMBAL2_RENDER Full render: precompute -> time integration -> output.\n\nS = cymbal2_system(P);\nMB = cymbal2_mallet(P);\n[acc, dinfo] = cymbal2_sim(P, S, MB);\n[y, peakAccel] = cymbal2_output(P, acc);\n\ndinfo.peakAccel = peakAccel;\ndinfo.strikeRadiusM = S.strikeRadiusM;\ndinfo.pickupRadiusM = S.pickupRadiusM;\ndinfo.RsM = S.Rs;\ndinfo.lengthS = length(y) / P.sample_rate;\nend\n"
60 },
61 {
62 "name": "cymbal2_sim.m",
63 "content": "function [acc, dinfo, fhist] = cymbal2_sim(P, S, MB)\n% CYMBAL2_SIM Time-domain integration of the full system: von Karman\n% shallow spherical shell (implicit in the stiff linear terms, explicit in\n% the nonlinearity), washer support built into the boundary rows, mallet\n% head + stick coupled through the Hertz contact. Returns the normal\n% acceleration of the shell at the pickup node at the simulation rate,\n% diagnostics, and the contact force history.\n\nG = S.G;\nnrt = G.nrt;\nNr = G.Nr;\nnth = G.nth;\nht = S.ht;\nrhoh = S.rhoh;\nRs = S.Rs;\nEhRs = S.Eh / Rs;\nEh2 = S.Eh / 2;\nls = S.ls; js = S.js; lp = S.lp; jp = S.jp;\nfsc = S.forceScale;\nnlr0 = S.eomRow0;\n\nK = max(1, round(P.strike_count));\n\n% Stroke schedule; per-stroke peak hand speeds with a seeded spread. Each\n% stroke is launched early by its predicted hand travel time (acceleration\n% plus coast over the rest clearance), so the contacts land near the\n% nominal beats instead of drifting with velocity. A nonpositive\n% strike_gap_m asks for the clearance to be sized to the stroke: contact\n% arrives just after the hand reaches full speed, which keeps the tip's\n% whip phase, and with it the contact speed, consistent across strokes.\ntstr = P.pre_delay_ms * 1e-3 + (0:K - 1) / max(1e-3, P.strike_rate_hz);\nvstr = zeros(1, K);\nstate = P.seed * 2654435761;\nfor i = 1:K\n state = mod(state * 1664525 + 1013904223, 4294967296);\n x = state / 4294967296;\n vstr(i) = max(0.05, P.strike_velocity * (1 + P.velocity_spread * (2 * x - 1)));\nend\ngap = P.strike_gap_m;\nif gap <= 0\n gap = 1.05 * max(vstr)^2 / (2 * P.strike_accel) + 0.005;\nend\ntravel = gap ./ vstr + vstr / (2 * P.strike_accel);\ntlaunch = tstr - travel;\noff = -min(0, min(tlaunch));\ntlaunch = tlaunch + off;\n\nduration = off + (K - 1) / max(1e-3, P.strike_rate_hz) + P.tail_s;\nnsteps = floor(duration * P.sample_rate * P.oversample);\n\nKM = P.head_stiffness;\nalM = P.head_alpha;\ntipi = MB.tipi;\nps = MB.ps;\n\n% Hand state machine: the hand accelerates downward, coasts at the stroke\n% speed until the head makes contact (or a miss depth), holds briefly,\n% then retracts at half speed and brakes so it arrives back at rest,\n% ready for the next scheduled stroke. This replaces the paper's\n% constant-acceleration hand, which never reverses. The machine runs one\n% step ahead because the beam coupling needs u_H at k + 1.\naH = P.strike_accel;\naHr = P.retract_accel;\nholdSteps = max(1, round(P.strike_hold_ms * 1e-3 / ht));\nmissDepth = gap + 0.06;\nuHm = 0; uH0 = 0; uHp = 0;\nvH = 0;\nphase = 0;\nstrokeIdx = 1;\nholdLeft = 0;\nvtar = 0;\n\nD1p = G.D1; D2p = G.D2;\nT1t = G.T1t; T2 = G.T2;\nF = G.F; Gi = G.Gi;\nRd = G.Rd; R2d = G.R2d;\nAiB = S.AiB; AiC = S.AiC; Ai = S.Ai; Fi = S.Fi;\nsNL = ht^2 / rhoh;\n\nU = zeros(nrt, nth);\nU1 = zeros(nrt, nth);\nUh3 = zeros(nrt, 1, nth);\nUh13 = zeros(nrt, 1, nth);\nd = zeros(MB.nfree, 1);\nd1 = zeros(MB.nfree, 1);\n\nacc = zeros(1, nsteps);\nfhist = zeros(1, nsteps);\nshellActive = 0;\npeakDisp = 0;\npeakXi = 0;\ninContact = 0;\ncvel = zeros(1, K);\n\nfor k = 1:nsteps\n % ---- mallet: contact force from the state at step k ----\n xi = U(ls, js) - d(tipi) - gap;\n if xi > 0\n fM1 = KM * xi^alM;\n shellActive = 1;\n if inContact == 0 && strokeIdx <= K && cvel(strokeIdx) == 0\n cvel(strokeIdx) = (d(tipi) - d1(tipi)) / ht; % tip speed at contact\n end\n inContact = 1;\n if xi > peakXi\n peakXi = xi;\n end\n else\n fM1 = 0;\n inContact = 0;\n end\n fhist(k) = fM1;\n hA = -(uHp - 2 * uH0 + uHm);\n hB = -(ht / 2) * (uHp - uHm);\n hC = -ht^2 * (ps * uH0 + ((1 - ps) / 2) * (uHp + uHm));\n dnew = MB.ASiB * d + MB.ASiC * d1 + MB.vM * hA + MB.vR * hB + MB.vK * hC ...\n + (ht^2 * fM1) * MB.tipcol;\n\n % ---- advance the hand one step ----\n if phase == 0\n if strokeIdx <= K && k * ht >= tlaunch(strokeIdx)\n phase = 1;\n vtar = vstr(strokeIdx);\n end\n elseif phase == 1\n vH = max(vH - aH * ht, -vtar);\n if inContact ~= 0\n phase = 3;\n holdLeft = holdSteps;\n elseif vH <= -vtar\n phase = 2;\n end\n elseif phase == 2\n if inContact ~= 0\n phase = 3;\n holdLeft = holdSteps;\n elseif uHp <= -missDepth\n phase = 4;\n end\n elseif phase == 3\n holdLeft = holdLeft - 1;\n if holdLeft <= 0\n phase = 4;\n end\n elseif phase == 4\n vH = min(vH + aHr * ht, 0.5 * vtar);\n if vH > 0 && uHp >= -vH^2 / (2 * aHr)\n phase = 5;\n end\n else\n vH = vH - aHr * ht;\n if vH <= 0 || uHp >= 0\n vH = 0;\n phase = 0;\n strokeIdx = strokeIdx + 1;\n end\n end\n uHm = uH0;\n uH0 = uHp;\n uHp = uHp + vH * ht;\n\n if shellActive ~= 0\n % ---- shell derivatives and von Karman brackets at step k ----\n Ur = D1p * U;\n Urr = D2p * U;\n v1 = Rd .* Ur + R2d .* (U * T2);\n v2 = Rd .* (Ur * T1t) - R2d .* (U * T1t);\n LapU = Urr + v1;\n rhsA = EhRs * LapU - Eh2 * 2 * (Urr .* v1 - v2 .* v2);\n rhsA(1:3, :) = 0;\n rhsA(Nr:nrt, :) = 0;\n Ph3 = pagemtimes(Fi, reshape(rhsA * F, nrt, 1, nth));\n Phi = reshape(Ph3, nrt, nth) * Gi;\n Pr = D1p * Phi;\n Prr = D2p * Phi;\n w1 = Rd .* Pr + R2d .* (Phi * T2);\n w2 = Rd .* (Pr * T1t) - R2d .* (Phi * T1t);\n NL = Prr .* v1 + Urr .* w1 - 2 * w2 .* v2 - (Prr + w1) / Rs;\n NL(ls, js) = NL(ls, js) - fM1 * fsc;\n NL(1:nlr0 - 1, :) = 0;\n NL(Nr + 1:nrt, :) = 0;\n NLh = pagemtimes(Ai, reshape((sNL * NL) * F, nrt, 1, nth));\n Uh3n = pagemtimes(AiB, Uh3) + pagemtimes(AiC, Uh13) + NLh;\n Unew = reshape(Uh3n, nrt, nth) * Gi;\n\n acc(k) = (Unew(lp, jp) - 2 * U(lp, jp) + U1(lp, jp)) / ht^2;\n U1 = U;\n U = Unew;\n Uh13 = Uh3;\n Uh3 = Uh3n;\n if mod(k, 512) == 0\n pd = max(abs(U(:, js)));\n if pd > peakDisp\n peakDisp = pd;\n end\n if ~isfinite(pd)\n error('cymbal2: simulation diverged at t = %.4f s (reduce strike_velocity, or raise n_theta / oversample)', k * ht);\n end\n end\n end\n\n d1 = d;\n d = dnew;\nend\n\n% Contact bookkeeping from the force history\ninC = fhist > 0;\ndinfo.peakForceN = max(fhist);\ndinfo.contactSteps = sum(inC);\ndinfo.contactMs = 1000 * ht * dinfo.contactSteps;\ndinfo.contactEvents = sum(diff([0, inC]) > 0);\ndinfo.peakXiMm = peakXi * 1000;\ndinfo.peakDispM = peakDisp;\ndinfo.peakDispThickness = peakDisp / P.thickness_m;\ndinfo.peakAccel = max(abs(acc));\ndinfo.nsteps = nsteps;\ndinfo.simRate = P.sample_rate * P.oversample;\ndinfo.tstr = tstr;\ndinfo.vstr = vstr;\ndinfo.meanV = mean(vstr);\ndinfo.contactVel = cvel; % tip speed at first contact of each stroke\ndinfo.strokesPlayed = min(strokeIdx - 1 + (phase > 0), K);\nend\n"
64 },
65 {
66 "name": "cymbal2_system.m",
67 "content": "function S = cymbal2_system(P)\n% CYMBAL2_SYSTEM Precompute everything the time loop needs. The linear\n% updates block-diagonalize over azimuthal wavenumber, so the implicit\n% shell solve and the Airy (stress function) solve are stored as one small\n% radial matrix per transform column: the time step becomes\n% Uhat+ = (A^-1 B) Uhat + (A^-1 C) Uhat- + A^-1 (ht^2/rho h) NLhat\n% with A, B, C the matrices of Samejima (2021) Eq. (20) restricted to one\n% wavenumber, the boundary-condition rows of Eqs. (22)-(30) built in, and\n% NL the explicit nonlinear + forcing terms evaluated in physical space.\n% Rows are equilibrated before inversion; the stored inverses absorb the\n% scaling so the loop applies them to raw right-hand sides.\n\nG = cymbal2_grid(P);\nnrt = G.nrt;\nNr = G.Nr;\nnth = G.nth;\n\nh = P.thickness_m;\nrhoh = P.density * h;\nEh = P.youngs_modulus * h;\nRs = P.radius_m^2 / (2 * P.rise_m);\nht = 1 / (P.sample_rate * P.oversample);\ng = P.g_param;\nphiw = P.phi_param;\nkap = 0; % set below from cymbal2_perm (needs Dflex)\ns1 = P.sigma1 * P.radius_m^2;\ns0 = P.sigma0;\n\nMW = P.washer_mass;\nRW = P.washer_damping;\nKW = P.washer_stiffness;\n% Spring term split as phiw/2 on the (k+1, k-1) average and (1 - phiw) on\n% step k. Note the paper prints the complementary split, which is unstable\n% for a massless washer (the roots of (1-phi)/2 (z^2+1) + phi z include\n% |z| > 1 whenever phi > 1/2); with the implicit weight on the average,\n% phi = 0.7 is unconditionally stable.\nAW = MW + (ht / 2) * RW + (phiw / 2) * ht^2 * KW;\nBW = 2 * MW - (1 - phiw) * ht^2 * KW;\nCW = -MW + (ht / 2) * RW - (phiw / 2) * ht^2 * KW;\n\nAiB = zeros(nrt, nrt, nth);\nAiC = zeros(nrt, nrt, nth);\nAi = zeros(nrt, nrt, nth);\nFi = zeros(nrt, nrt, nth);\nnDropped = 0;\n\ne1 = zeros(1, nrt); e1(1) = 1;\ne3 = zeros(1, nrt); e3(3) = 1;\nI = eye(nrt);\n\nfor m = 0:nth / 2\n lam2 = (2 * cos(m * G.hth) - 2) / G.hth^2;\n op = cymbal2_perm(P, G, lam2);\n kap = op.Dflex / rhoh;\n\n Am = zeros(nrt, nrt);\n Bm = zeros(nrt, nrt);\n Cm = zeros(nrt, nrt);\n if P.support_clamped ~= 0\n l0 = 4;\n else\n l0 = 3;\n end\n for l = l0:Nr\n Am(l, :) = (1 + s0 * ht) * I(l, :) + g * kap * ht^2 * op.Bih(l, :) - s1 * ht * op.Lap(l, :);\n Bm(l, :) = 2 * I(l, :) - (1 - 2 * g) * kap * ht^2 * op.Bih(l, :);\n Cm(l, :) = (s0 * ht - 1) * I(l, :) - g * kap * ht^2 * op.Bih(l, :) - s1 * ht * op.Lap(l, :);\n end\n if P.support_clamped ~= 0\n Am(1, :) = e1;\n Am(2, :) = e3;\n Am(3, :) = G.D1(3, :);\n else\n % The shear side of the washer balance gets the same implicit split as\n % the spring; the paper evaluates it at step k only, which feeds a\n % spurious mode on the virtual lines when the washer is massless.\n Am(1, :) = op.MomW;\n Am(2, :) = AW * e3 - (phiw / 2) * ht^2 * op.ShW;\n Bm(2, :) = BW * e3 + (1 - phiw) * ht^2 * op.ShW;\n Cm(2, :) = CW * e3 + (phiw / 2) * ht^2 * op.ShW;\n end\n Am(Nr + 1, :) = op.MomE;\n Am(Nr + 2, :) = op.ShE;\n\n % Row equilibration, absorbed into the stored inverses\n sc = 1.0 ./ max(abs(Am), [], 2);\n Asc = sc .* Am;\n Aisc = inv(Asc);\n AiBm = Aisc * (sc .* Bm);\n AiCm = Aisc * (sc .* Cm);\n Aim = Aisc .* sc'; % inv(Am) applied to raw rhs\n\n % On grids too coarse to resolve a wavenumber's inner boundary layer,\n % the discretized boundary conditions can hand that wavenumber a\n % negative stiffness; such columns are truncated rather than integrated.\n % At the default grid none are.\n sr = max(abs(eig([AiBm, AiCm; I, zeros(nrt)])));\n if sr > 1 + 1e-7\n AiBm = zeros(nrt, nrt);\n AiCm = zeros(nrt, nrt);\n Aim = zeros(nrt, nrt);\n nDropped = nDropped + 1 + (m > 0 && m < nth / 2);\n end\n\n % Airy solve: biharmonic rows inside, phi = phi_r = 0 at both circles\n Fm = zeros(nrt, nrt);\n Fm(1, 1) = 1;\n Fm(2, :) = G.D1(3, :);\n Fm(3, 3) = 1;\n for l = 4:Nr - 1\n Fm(l, :) = op.Bih(l, :);\n end\n Fm(Nr, Nr) = 1;\n Fm(Nr + 1, :) = G.D1(Nr, :);\n Fm(Nr + 2, Nr + 2) = 1;\n scf = 1.0 ./ max(abs(Fm), [], 2);\n Fim = inv(scf .* Fm) .* scf';\n\n if m == 0\n cols = 1;\n elseif m == nth / 2\n cols = nth;\n else\n cols = [2 * m, 2 * m + 1];\n end\n for cc = 1:length(cols)\n AiB(:, :, cols(cc)) = AiBm;\n AiC(:, :, cols(cc)) = AiCm;\n Ai(:, :, cols(cc)) = Aim;\n Fi(:, :, cols(cc)) = Fim;\n end\nend\n\nif nDropped > nth / 2\n error(['cymbal2: the radial grid cannot resolve the inner boundary layer ' ...\n '(need roughly washer_radius > 3 (a - c) / (n_radial - 3)); raise n_radial']);\nend\n\n% Strike and pickup nodes (radial fraction 0 at the washer, 1 at the rim)\nls = 3 + round(min(1, max(0, P.strike_r)) * (Nr - 3));\nlp = 3 + round(min(1, max(0, P.pickup_r)) * (Nr - 3));\njs = 1 + mod(round(P.strike_theta_deg / 360 * nth), nth);\njp = 1 + mod(round(P.pickup_theta_deg / 360 * nth), nth);\n\nS.G = G;\nS.AiB = AiB; S.AiC = AiC; S.Ai = Ai; S.Fi = Fi;\nS.rhoh = rhoh; S.Eh = Eh; S.Rs = Rs; S.ht = ht;\nS.ls = ls; S.lp = lp; S.js = js; S.jp = jp;\nS.eomRow0 = 3 + (P.support_clamped ~= 0); % first equation-of-motion row\nS.nDropped = nDropped; % truncated theta columns\nS.strikeRadiusM = G.r(ls);\nS.pickupRadiusM = G.r(lp);\nS.forceScale = 1 / (G.r(ls) * G.hr * G.hth); % point force -> pressure\nend\n"
68 },
69 {
70 "name": "cymbal2_validate.m",
71 "content": "% Validate the cymbal2 model. There is no external reference\n% implementation, so the checks are (a) closed-form limits the\n% discretization must reproduce (the free circular plate, rigid-body\n% motion on the washer, cantilever statics of the stick), (b) agreement\n% with the linear-FEM eigenfrequencies published in Samejima (2021) for\n% the modes that model resolves the same way, (c) structural identities\n% of the transform and of the assembled update, (d) exact reproducibility,\n% and (e) pinned end-to-end diagnostics from a reduced configuration,\n% recorded when the model was built and treated as a regression reference.\n% Run with: numbl run cymbal2_validate.m\n\nfails = 0;\nP = cymbal2_defaults();\n\n% ---- 1. flat limit: the completely free circular plate ----\n% With the curvature removed and the washer stiffness zeroed, the shell\n% must reproduce the free plate's frequency equation (the 2 x 2 J_n / I_n\n% determinant under zero radial moment and zero Kelvin-Kirchhoff shear).\n% m = 0 is excluded: its limit as the free hole closes is logarithmic, so\n% a finite hole shifts it far more than the m >= 1 modes.\nQ = P;\nQ.rise_m = 1e-9;\nQ.washer_stiffness = 0;\nQ.washer_radius_m = 0.010;\nQ.n_radial = 96;\nDp = Q.youngs_modulus * Q.thickness_m^3 / (12 * (1 - Q.poisson_ratio^2));\nscal = sqrt(Dp / (Q.density * Q.thickness_m)) / Q.radius_m^2 / (2 * pi);\nerrP = 0;\nfor m = 1:4\n f = cymbal2_modes(Q, m);\n fe = f(f > 0.5);\n lam = c2v_plate_roots(m, Q.poisson_ratio);\n fc = lam.^2 * scal;\n nc = min(3, min(length(fe), length(fc)));\n errP = max(errP, max(abs(fe(1:nc) - fc(1:nc)') ./ fc(1:nc)'));\nend\nok = errP < 0.04;\nfails = fails + (~ok);\nfprintf('flat plate : max freq err %.1e over m = 1..4 %s\\n', errP, c2v_verdict(ok));\n\n% ---- 2. rigid limit: bounce and tilt on the washer ----\n% With the plate stiffened a hundredfold the lowest m = 0 and m = 1 modes\n% must approach the closed-form rigid-body values on the line spring:\n% bounce f^2 = KW 2 pi c / (rho h pi (a^2 - c^2)) / (2 pi)^2 and tilt\n% f^2 = 4 KW c^3 / (rho h (a^4 - c^4)) / (2 pi)^2.\nQ = P;\nQ.rise_m = 1e-9;\nQ.youngs_modulus = P.youngs_modulus * 100;\nQ.n_radial = 96;\na = Q.radius_m; c = Q.washer_radius_m;\nrhoh = Q.density * Q.thickness_m;\nKW = Q.washer_stiffness;\nf0r = sqrt(KW * 2 * pi * c / (rhoh * pi * (a^2 - c^2))) / (2 * pi);\nf1r = sqrt(4 * KW * c^3 / (rhoh * (a^4 - c^4))) / (2 * pi);\nfa = cymbal2_modes(Q, 0); fa = fa(fa > 0.01);\nfb = cymbal2_modes(Q, 1); fb = fb(fb > 0.01);\ne0 = abs(fa(1) / f0r - 1);\ne1 = abs(fb(1) / f1r - 1);\nok = e0 < 0.03 && e1 < 0.03;\nfails = fails + (~ok);\nfprintf('washer : bounce err %.1e, tilt err %.1e vs rigid closed form %s\\n', e0, e1, c2v_verdict(ok));\n\n% ---- 3. the paper's linear FEM reference, where comparable ----\n% Samejima's Fig. 9 gives linear-FEM eigenfrequencies of the same shell.\n% For the clamped center, the (2,1), (3,1), (4,1) modes must agree to a\n% few percent ((1,1) is excluded: it is controlled by the support model,\n% where this shell's moment-free hole and their 2-D elements differ).\nQ = P;\nQ.support_clamped = 1;\nref = [20.1, 43.3, 77.7];\ngot = zeros(1, 3);\nfor m = 2:4\n f = cymbal2_modes(Q, m);\n f = f(f > 1);\n got(m - 1) = f(1);\nend\nerrF = max(abs(got - ref) ./ ref);\nok = errF < 0.06;\nfails = fails + (~ok);\nfprintf('paper FEM : clamped (2,1)(3,1)(4,1) = %.1f %.1f %.1f Hz (ref %.1f %.1f %.1f), err %.1e %s\\n', ...\n got(1), got(2), got(3), ref(1), ref(2), ref(3), errF, c2v_verdict(ok));\n\n% ---- 4. transform and update-structure identities ----\n% The azimuthal transform must be exactly invertible, and the physical\n% derivative pipeline must agree with the per-wavenumber operators: for a\n% field cos(m theta) g(r), the physical-space Laplacian must equal the\n% per-m radial Laplacian applied to g.\nG = cymbal2_grid(P);\neT = max(max(abs(G.F * G.Gi - eye(G.nth))));\ngr = exp(-((G.r - 0.12) / 0.05).^2);\nm = 7;\nUf = gr * cos(m * G.th');\nLapPhys = G.D2 * Uf + G.Rd .* (G.D1 * Uf) + G.R2d .* (Uf * G.T2);\nlam2 = (2 * cos(m * G.hth) - 2) / G.hth^2;\nop = cymbal2_perm(P, G, lam2);\nLapM = (op.Lap * gr) * cos(m * G.th');\nr2 = 2:G.nrt - 1;\neL = max(max(abs(LapPhys(r2, :) - LapM(r2, :)))) / max(max(abs(LapM(r2, :))));\nok = eT < 1e-10 && eL < 1e-10;\nfails = fails + (~ok);\nfprintf('operators : transform inverse %.1e, physical vs per-m Laplacian %.1e %s\\n', ...\n eT, eL, c2v_verdict(ok));\n\n% ---- 5. the assembled update is not amplifying ----\n% Every azimuthal column's two-step update must have spectral radius at\n% most 1 (the damping makes physical modes strictly contractive; boundary\n% constraints contribute zeros). The system builder truncates columns\n% that fail; at the defaults none may be.\nS = cymbal2_system(P);\nnrt = S.G.nrt;\nsrMax = 0;\nfor col = [1, 2, S.G.nth / 2, S.G.nth]\n T = [S.AiB(:, :, col), S.AiC(:, :, col); eye(nrt), zeros(nrt)];\n srMax = max(srMax, max(abs(eig(T))));\nend\nok = S.nDropped == 0 && srMax <= 1 + 1e-9;\nfails = fails + (~ok);\nfprintf('stability : %d truncated columns, sampled spectral radius %.9f %s\\n', ...\n S.nDropped, srMax, c2v_verdict(ok));\n\n% ---- 6. stick statics ----\n% A static tip load on the clamped-hand stick must deflect by\n% F L^3 / (3 E I); the rotational-spring hand adds F L^2 / K_R times L.\nMBc = cymbal2_mallet(setfield(P, 'grip_clamped', 1));\nMBs = cymbal2_mallet(P);\nEI = P.stick_youngs * P.stick_inertia_m4;\nL = P.stick_length_m;\nFl = 1.0;\n% static balance of the update: at a fixed point, (I - A^-1 B - A^-1 C) d\n% equals A^-1 ht^2 K d, so the deflection under a constant tip load F is\n% (I - ASiB - ASiC) \\ (tipcol * ht^2 * F)\nhtv = MBc.ht;\ndc = ((eye(size(MBc.ASiB, 1)) - MBc.ASiB - MBc.ASiC) \\ (MBc.tipcol * htv^2)) * Fl;\nds = ((eye(size(MBs.ASiB, 1)) - MBs.ASiB - MBs.ASiC) \\ (MBs.tipcol * htv^2)) * Fl;\nwc = dc(MBc.tipi);\nws = ds(MBs.tipi);\nwcRef = Fl * L^3 / (3 * EI);\nwsRef = wcRef + (Fl * L / P.grip_spring) * L; % grip rotation F L / K_R times the arm\nec = abs(wc / wcRef - 1);\nes = abs(ws / wsRef - 1);\nok = ec < 1e-6 && es < 1e-6;\nfails = fails + (~ok);\nfprintf('stick : clamped-hand tip %.3e (ref %.3e), spring-hand %.3e (ref %.3e) %s\\n', ...\n wc, wcRef, ws, wsRef, c2v_verdict(ok));\n\n% ---- 7. reproducibility and pinned diagnostics ----\n% Two identical renders of a reduced configuration must agree exactly,\n% and its diagnostics are pinned. The trajectory of a nonlinear shell is\n% sensitive, so the acoustic pins carry percent-level tolerances while\n% the contact pins are tight.\nQ = P;\nQ.sample_rate = 44100;\nQ.oversample = 1;\nQ.tail_s = 1.5;\n[y1, d1] = cymbal2_render(Q);\n[y2, d2] = cymbal2_render(Q);\nrms1 = sqrt(mean(y1.^2));\nrms2 = sqrt(mean(y2.^2));\nsame = max(abs(y1 - y2)) == 0;\nok = same ...\n && d1.contactEvents == d2.contactEvents ...\n && abs(d1.peakForceN - 311.14) < 4 ...\n && abs(d1.contactVel(1) + 8.223) < 0.08 ...\n && abs(rms1 - 0.0973) < 0.010;\nfails = fails + (~ok);\nfprintf('pinned : repro %d, peakF %.2f N (ref 311.14), cvel %.3f (ref -8.223), rms %.4f (ref 0.0973) %s\\n', ...\n same, d1.peakForceN, d1.contactVel(1), rms1, c2v_verdict(ok));\n\n% ---- 8. the crash decays ----\nn = length(y1);\nfs = Q.sample_rate;\nhead = y1(1:round(0.2 * fs));\ntail = y1(n - round(0.2 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -3 && dropE > -50;\nfails = fails + (~ok);\nfprintf('decay : last/first 0.2 s %.1f dB %s\\n', dropE, c2v_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction lam = c2v_plate_roots(n, nu)\n% Roots of the free-edge frequency equation of a circular plate\nlam = [];\nprev = c2v_plate_det(n, nu, 0.30);\nfor x = 0.30:0.01:20\n cur = c2v_plate_det(n, nu, x + 0.01);\n if prev * cur < 0\n lo = x; hi = x + 0.01;\n for it = 1:60\n mid = (lo + hi) / 2;\n if c2v_plate_det(n, nu, lo) * c2v_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 = c2v_plate_det(n, nu, x)\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 s = c2v_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
72 }
73 ]
↑↓ move↵openescclose