/ simulated_instruments / triangle
Sign in
simulated_instruments / triangle
triangle / exports / triangle.json
80 lines · 36.7 KBCodeBlameHistory
2 "format": "numbl-instrument-v1",
3 "id": "triangle",
4 "name": "Triangle",
1056e40Pitch support by geometric scaling in the exported driverJeremy Magland 5 "description": "Struck triangle; pitch scales the rod frame around MIDI 84 (72..96), velocity onto beater speed.",
578d248Replace instrument.json with exported instrument bundlesJeremy Magland 6 "source": "simulated_instruments/triangle",
7 "knobs": [
8 {
9 "key": "strike_pos",
10 "label": "strike position",
11 "min": 0.02,
12 "max": 0.98,
13 "default": 0.17
14 },
15 {
16 "key": "strike_angle_deg",
17 "label": "strike angle (deg)",
18 "min": 0,
19 "max": 90,
20 "default": 20
21 },
22 {
23 "key": "tail_s",
24 "label": "ring-out (s)",
25 "min": 1,
26 "max": 8,
27 "default": 5
28 }
29 ],
30 "files": [
31 {
32 "name": "render_note.m",
1056e40Pitch support by geometric scaling in the exported driverJeremy Magland 33 "content": "function y = render_note(note)\n% MIDI pitch scales the rod frame: side length, gap, and rod diameter by\n% s = 2^-((pitch - 84)/12), which shifts the bending-mode spectrum by the\n% semitone ratio exactly (rod modes go as diameter / length^2). The\n% beater stays fixed. Pitch 84 is the default 6-inch triangle, playable\n% 72..96; the spectrum is inharmonic, so the note number is a handle on\n% register, not a tuned fundamental. Velocity drives the beater speed.\n% Fixed: a single strike, a fixed seed, normalize off so velocity\n% survives in pascals.\nP = triangle_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(72, min(96, note.pitch)) - 84) / 12);\nP.side_length_m = P.side_length_m * s;\nP.gap_m = P.gap_m * s;\nP.rod_diameter_m = P.rod_diameter_m * s;\nP.strike_velocity = 0.1 + (6 - 0.1) * note.velocity;\nP.strike_pos = note.params.strike_pos;\nP.strike_angle_deg = note.params.strike_angle_deg;\nP.tail_s = note.params.tail_s;\ny = triangle_render(P);\nend\n"
35 {
36 "name": "triangle_basis.m",
37 "content": "function basis = triangle_basis(P)\n% TRIANGLE_BASIS Mode set of the open triangle: FEM assembly and eigensolve.\n%\n% The planar geometry splits the frame into two exactly decoupled families\n% (see triangle_fem.m): in-plane modes (bending + stretching) and\n% out-of-plane modes (bending + torsion, converted into each other at the\n% two closed corners). Each family gives a generalized eigenproblem\n% K phi = om^2 M phi, reduced to symmetric standard form through the\n% Cholesky factor of the consistent mass matrix and diagonalized by svd\n% rather than eig: the reduced matrix is symmetric positive semidefinite,\n% so its singular vectors are its eigenvectors, and svd keeps them\n% orthonormal even for the near-degenerate pairs this geometry produces\n% (eig's nonsymmetric path returns complex, non-orthogonal vectors there).\n% The eigenvectors then come back mass-normalized: phi' M phi = I. The\n% three rigid-body modes of each family sit at zero frequency and are\n% dropped along with anything below min_mode_hz; modes above max_mode_hz\n% are truncated (the broadband part they carry is reinstated by the direct\n% term in triangle_radiate.m).\n%\n% For radiation and excitation the basis stores, per retained mode, the\n% displacement at every node (NUx, NUy, NUz; out-of-plane modes have only\n% NUz, in-plane modes only NUx, NUy) and the transverse displacement at\n% every element center (EUpx, EUpy, EUpz), with the axial component removed:\n% axial motion of a thin rod displaces almost no air. Element centers,\n% tangents, and lengths are kept alongside so the radiation stage can treat\n% each element as a point dipole.\n%\n% Damping: zeta = 0.5 (loss_factor + loss_slope om) plus radiation damping\n% from the incoherent sum of the element dipole powers,\n% zeta_rad = rho0 om^3 sum_e (Vdip_e^2 |u_perp,e|^2) / (24 pi c^3),\n% Vdip_e = 2 pi a^2 l_e (the factor 2 is the added-mass coefficient of a\n% transversally moving cylinder). The incoherent sum ignores the\n% cancellation between anti-phase sections, which matters only below the\n% coincidence frequency (about 1.9 kHz for an 8 mm steel rod); above it the\n% flexural waves are supersonic and the estimate is fair.\n\ngeo = triangle_geometry(P);\nnn = geo.nn;\nnel = nn - 1;\nE = P.youngs_modulus;\nrho = P.density;\nnu = P.poisson_ratio;\nd = P.rod_diameter_m;\nc = P.speed_of_sound;\nrho0 = P.air_density;\nnodes = geo.nodes;\n\n% ---- element geometry ----\nex = zeros(nel, 1);\ney = zeros(nel, 1);\ntx = zeros(nel, 1);\nty = zeros(nel, 1);\nel = zeros(nel, 1);\nfor e = 1:nel\n dx = nodes(1, e + 1) - nodes(1, e);\n dy = nodes(2, e + 1) - nodes(2, e);\n el(e) = sqrt(dx^2 + dy^2);\n tx(e) = dx / el(e);\n ty(e) = dy / el(e);\n ex(e) = (nodes(1, e) + nodes(1, e + 1)) / 2;\n ey(e) = (nodes(2, e) + nodes(2, e + 1)) / 2;\nend\n\n% ---- eigensolve per family, collect retained modes ----\nfs = [];\nfams = [];\nNUx = [];\nNUy = [];\nNUz = [];\nEUpx = [];\nEUpy = [];\nEUpz = [];\nomLo = 2 * pi * P.min_mode_hz;\nfor family = 0:1\n [K, M] = triangle_fem(nodes, family, E, rho, nu, d);\n Rc = chol(M);\n Am = (Rc') \\ (K / Rc);\n Am = (Am + Am') / 2;\n [Uv, Sv, ~] = svd(Am);\n om2 = flipud(diag(Sv)); % ascending\n Vv = fliplr(Uv);\n om = sqrt(max(0, om2)) * P.stiffness_trim;\n keep = find(om > omLo & om < 2 * pi * P.max_mode_hz);\n if length(keep) == 0\n continue;\n end\n Phi = Rc \\ Vv(:, keep); % mass-normalized shapes, 3 nn x nk\n nk = length(keep);\n if family == 0\n ux = Phi(1:3:3 * nn, :);\n uy = Phi(2:3:3 * nn, :);\n uz = zeros(nn, nk);\n eux = (ux(1:nn - 1, :) + ux(2:nn, :)) / 2;\n euy = (uy(1:nn - 1, :) + uy(2:nn, :)) / 2;\n ax = eux .* (tx * ones(1, nk)) + euy .* (ty * ones(1, nk));\n upx = eux - ax .* (tx * ones(1, nk));\n upy = euy - ax .* (ty * ones(1, nk));\n upz = zeros(nel, nk);\n else\n ux = zeros(nn, nk);\n uy = zeros(nn, nk);\n uz = Phi(1:3:3 * nn, :);\n upx = zeros(nel, nk);\n upy = zeros(nel, nk);\n upz = (uz(1:nn - 1, :) + uz(2:nn, :)) / 2;\n end\n fs = [fs; om(keep) / (2 * pi)];\n fams = [fams; family * ones(nk, 1)];\n NUx = [NUx, ux];\n NUy = [NUy, uy];\n NUz = [NUz, uz];\n EUpx = [EUpx, upx];\n EUpy = [EUpy, upy];\n EUpz = [EUpz, upz];\nend\nn = length(fs);\nif n == 0\n error('No rod modes between min_mode_hz and max_mode_hz -- check geometry/material.');\nend\n\n[fs, order] = sort(fs);\nfs = reshape(fs, n, 1);\nfams = reshape(fams(order), n, 1);\nNUx = NUx(:, order);\nNUy = NUy(:, order);\nNUz = NUz(:, order);\nEUpx = EUpx(:, order);\nEUpy = EUpy(:, order);\nEUpz = EUpz(:, order);\n\n% ---- damping: material + radiation ----\nomega = 2 * pi * fs;\na = d / 2;\nVdip = 2 * pi * a^2 * el; % nel x 1 dipole volume per element\nup2 = EUpx.^2 + EUpy.^2 + EUpz.^2; % nel x n\nS2 = (Vdip.^2).' * up2; % 1 x n\nzetaRad = rho0 * omega.^3 .* reshape(S2, n, 1) / (24 * pi * c^3);\nzeta = 0.5 * (P.loss_factor + P.loss_slope * omega) + P.radiation_loss * zetaRad;\n\nbasis.n = n;\nbasis.f = fs;\nbasis.omega = omega;\nbasis.zeta = zeta;\nbasis.zetaRad = zetaRad;\nbasis.family = fams;\nbasis.nodeX = nodes(1, :);\nbasis.nodeY = nodes(2, :);\nbasis.sfrac = geo.sfrac;\nbasis.totalLen = geo.totalLen;\nbasis.NUx = NUx;\nbasis.NUy = NUy;\nbasis.NUz = NUz;\nbasis.elemX = ex;\nbasis.elemY = ey;\nbasis.elemTanX = tx;\nbasis.elemTanY = ty;\nbasis.elemLen = el;\nbasis.EUpx = EUpx;\nbasis.EUpy = EUpy;\nbasis.EUpz = EUpz;\nend\n"
38 },
39 {
40 "name": "triangle_defaults.m",
41 "content": "function P = triangle_defaults()\n% TRIANGLE_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% Rod geometry (equilateral, open at the bottom-left corner)\nP.side_length_m = 0.152;\nP.gap_m = 0.015;\nP.rod_diameter_m = 0.008;\n\n% Rod material (steel)\nP.density = 7850.0;\nP.youngs_modulus = 2.0e11;\nP.poisson_ratio = 0.30;\nP.stiffness_trim = 1.00;\n\n% Discretization / mode retention\nP.n_elements = 60;\nP.min_mode_hz = 20.0;\nP.max_mode_hz = 22000.0;\n\n% Damping\nP.loss_factor = 6.0e-5;\nP.loss_slope = 4.0e-10;\nP.radiation_loss = 1.0;\n\n% Beater (steel rod tip)\nP.beater_radius_m = 0.0045;\nP.beater_mass_kg = 0.022;\nP.beater_youngs = 2.0e11;\nP.restitution = 0.60;\n\n% Strike / roll gesture\nP.strike_velocity = 1.5;\nP.strike_pos = 0.17; % arclength fraction: middle of the base\nP.strike_angle_deg = 20.0; % 0 = in the plane of the triangle, 90 = normal to it\nP.strike_count = 12;\nP.strike_rate_hz = 12.0;\nP.roll_pos2 = 0.40; % second contact point of a roll (the right leg)\nP.timing_jitter_ms = 6.0;\nP.velocity_spread = 0.15;\nP.seed = 1;\n\n% Radiation / listener\nP.direct_gain = 1.0;\nP.listener_x_m = 0.20;\nP.listener_y_m = 0.10;\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 = 5.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
42 },
43 {
44 "name": "triangle_demo.m",
45 "content": "% Render a triangle roll with default parameters and print diagnostics.\n% Set P.strike_count = 1 for a single strike.\n% Run with: numbl run triangle_demo.m\n\nP = triangle_defaults();\ntic;\n[y, basis, dinfo] = triangle_render(P);\nelapsed = toc;\n\nfprintf('modes kept : %d (%d in-plane, %d out-of-plane)\\n', basis.n, dinfo.modesIn, dinfo.modesOut);\nfprintf('mode freq : ');\nfprintf('%.0f ', basis.f);\nfprintf('Hz\\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('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 rod\\n');\nend\n"
46 },
47 {
48 "name": "triangle_fem.m",
49 "content": "function [K, M] = triangle_fem(nodes, family, E, rho, nu, d)\n% TRIANGLE_FEM Free-free frame system for one mode family of a bent rod.\n%\n% The rod centerline is the open polyline `nodes` (2 x nn, metres, in the\n% x-y plane), discretized into nn - 1 two-node elements. Because the geometry\n% is planar, the six degrees of freedom per node decouple exactly into two\n% independent families of three, and each family is assembled separately:\n% family 0 (in-plane): ux, uy, theta_z (in-plane bending + stretching)\n% family 1 (out-of-plane): uz, theta_x, theta_y (out-of-plane bending + torsion)\n% The bends are where the families earn their keep: within one straight leg\n% out-of-plane bending and torsion are independent, but a change of direction\n% rotates the (torsion, bending-rotation) pair, so the corner converts one\n% into the other. That conversion is what the per-element rotation T applies,\n% and it is what makes a triangle sound different from a straight rod.\n%\n% Elements are Euler-Bernoulli (cubic Hermite bending, linear axial and\n% torsion interpolation) with consistent mass including rotary inertia.\n% Shear deformation is neglected, which leaves the highest retained modes a\n% few percent sharp for a rod this thick; see the README.\n%\n% Sign conventions: in-plane bending uses theta_z = +dv/dx, giving the\n% textbook stiffness block; out-of-plane bending uses theta_y = -dw/dx, so\n% the odd coupling terms flip sign (the similarity transform Sf below).\n%\n% Returns K and M, each 3 nn x 3 nn, symmetric; M is positive definite, so\n% chol(M) reduces the generalized eigenproblem to a symmetric standard one.\n\nnn = size(nodes, 2);\nndof = 3 * nn;\nK = zeros(ndof);\nM = zeros(ndof);\n\nAa = pi * d^2 / 4; % cross-section area\nIz = pi * d^4 / 64; % bending second moment (circular)\nJp = pi * d^4 / 32; % polar moment = torsion constant (circular)\nGs = E / (2 * (1 + nu));\n\nfor e = 1:nn - 1\n dx = nodes(1, e + 1) - nodes(1, e);\n dy = nodes(2, e + 1) - nodes(2, e);\n l = sqrt(dx^2 + dy^2);\n c = dx / l;\n s = dy / l;\n\n % bending block on the (transverse, rotation) pairs of both nodes\n Kb = (E * Iz / l^3) * ...\n [12, 6 * l, -12, 6 * l; ...\n 6 * l, 4 * l^2, -6 * l, 2 * l^2; ...\n -12, -6 * l, 12, -6 * l; ...\n 6 * l, 2 * l^2, -6 * l, 4 * l^2];\n Mb = (rho * Aa * l / 420) * ...\n [156, 22 * l, 54, -13 * l; ...\n 22 * l, 4 * l^2, 13 * l, -3 * l^2; ...\n 54, 13 * l, 156, -22 * l; ...\n -13 * l, -3 * l^2, -22 * l, 4 * l^2] ...\n + (rho * Iz / (30 * l)) * ...\n [36, 3 * l, -36, 3 * l; ...\n 3 * l, 4 * l^2, -3 * l, -l^2; ...\n -36, -3 * l, 36, -3 * l; ...\n 3 * l, -l^2, -3 * l, 4 * l^2];\n\n Ke = zeros(6, 6);\n Me = zeros(6, 6);\n if family == 0\n % local order (u1, v1, t1, u2, v2, t2): axial on u, bending on (v, t)\n Ka2 = (E * Aa / l) * [1, -1; -1, 1];\n Ma2 = (rho * Aa * l / 6) * [2, 1; 1, 2];\n ia = [1 4];\n ib = [2 3 5 6];\n Ke(ia, ia) = Ka2;\n Me(ia, ia) = Ma2;\n Ke(ib, ib) = Kb;\n Me(ib, ib) = Mb;\n T = [c, s, 0; -s, c, 0; 0, 0, 1];\n else\n % local order (w1, tx1, ty1, w2, tx2, ty2): torsion on tx (about the leg\n % axis), bending on (w, ty) with theta_y = -dw/dx\n Kt2 = (Gs * Jp / l) * [1, -1; -1, 1];\n Mt2 = (rho * Jp * l / 6) * [2, 1; 1, 2];\n Sf = diag([1, -1, 1, -1]);\n ia = [2 5];\n ib = [1 3 4 6];\n Ke(ia, ia) = Kt2;\n Me(ia, ia) = Mt2;\n Ke(ib, ib) = Sf * Kb * Sf;\n Me(ib, ib) = Sf * Mb * Sf;\n T = [1, 0, 0; 0, c, s; 0, -s, c];\n end\n\n TT = blkdiag(T, T);\n Kg = TT' * Ke * TT;\n Mg = TT' * Me * TT;\n dofs = [3 * e - 2, 3 * e - 1, 3 * e, 3 * e + 1, 3 * e + 2, 3 * e + 3];\n K(dofs, dofs) = K(dofs, dofs) + Kg;\n M(dofs, dofs) = M(dofs, dofs) + Mg;\nend\nend\n"
50 },
51 {
52 "name": "triangle_geometry.m",
53 "content": "function G = triangle_geometry(P)\n% TRIANGLE_GEOMETRY Node polyline of the open triangle centerline.\n%\n% An equilateral triangle of side side_length_m in the x-y plane, apex up:\n% base from V2 (bottom left) to V3 (bottom right) along y = 0, apex V1 above\n% the origin. The rod is open at the bottom-left corner: gap_m of arclength\n% is removed there, half from the base and half from the left leg, so the\n% centerline runs A -> V3 -> V1 -> B counterclockwise, where A and B sit\n% gap_m / 2 from V2 along their legs. Total rod length is 3 L - gap.\n%\n% The polyline is discretized into about n_elements equal-length elements,\n% with the two closed corners landing exactly on nodes. Arclength fraction 0\n% is the free end on the base, 1 the free end on the left leg; strike_pos and\n% roll_pos2 are expressed in this coordinate.\n%\n% Returns G with:\n% nodes 2 x nn node coordinates (m)\n% sfrac 1 x nn arclength fraction of each node\n% totalLen rod arclength (m)\n% nn, ne node count and per-leg element counts (1 x 3)\n\nL = P.side_length_m;\ng = min(P.gap_m, 0.8 * L);\nV1 = [0; L * sqrt(3) / 2];\nV2 = [-L / 2; 0];\nV3 = [L / 2; 0];\ne1 = (V3 - V2) / L;\ne3 = (V2 - V1) / L;\nA = V2 + e1 * (g / 2);\nB = V2 - e3 * (g / 2);\n\nstarts = [A, V3, V1];\nstops = [V3, V1, B];\nlens = [L - g / 2, L, L - g / 2];\ntotal = lens(1) + lens(2) + lens(3);\n\nne = zeros(1, 3);\nfor k = 1:3\n ne(k) = max(2, round(P.n_elements * lens(k) / total));\nend\nnn = ne(1) + ne(2) + ne(3) + 1;\n\nnodes = zeros(2, nn);\nsarc = zeros(1, nn);\nnodes(:, 1) = A;\nidx = 1;\nacc = 0;\nfor k = 1:3\n for j = 1:ne(k)\n idx = idx + 1;\n frac = j / ne(k);\n nodes(:, idx) = starts(:, k) + (stops(:, k) - starts(:, k)) * frac;\n sarc(idx) = acc + lens(k) * frac;\n end\n acc = acc + lens(k);\nend\n\nG.nodes = nodes;\nG.sfrac = sarc / total;\nG.totalLen = total;\nG.nn = nn;\nG.ne = ne;\nend\n"
54 },
55 {
56 "name": "triangle_output.m",
57 "content": "function [y, peakPa] = triangle_output(P, p, oversample)\n% TRIANGLE_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 woodblock 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 .* (tr_i0(beta * sqrt(max(0, 1 - r.^2))) / tr_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 = tr_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"
58 },
59 {
60 "name": "triangle_radiate.m",
61 "content": "function [p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, fsHi)\n% TRIANGLE_RADIATE Modal synthesis and free-field pressure at the listener.\n%\n% Two contributions:\n%\n% 1. Modal ringing. A strike with impulse J at arclength position s gives\n% every mode a modal-velocity jump J S(omega tau) (phi(s) . dhat), where\n% S(x) = cos(x/2) / (1 - (x/pi)^2) is the spectrum of the half-sine\n% contact pulse and dhat the strike direction (strike_angle_deg between\n% the leg's in-plane normal and the plane normal, so one strike excites\n% both mode families). The rod radiates through its transverse motion:\n% each element is a point dipole of moment Vdip u_perp qdot (Vdip from\n% triangle_basis.m), and per mode the element dipoles are summed\n% coherently with their relative retardations,\n% G = sum_e (i om rho0 / 4 pi) Vdip_e (u_e . rhat_e)\n% (i k + 1/r_e) exp(-i k (r_e - rcen)) / r_e,\n% evaluated at the mode's centre frequency (modes are narrow-band). The\n% common carrier exp(-i k rcen) is stripped, so relative phase across the\n% triangle is kept while the overall propagation delay is not double\n% counted. This sum is where the interference across the frame lives:\n% moving the listener re-runs only this stage.\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% rod around the contact. It is reinstated from the classic driving-point\n% impedance of an infinite Euler beam, Z = 2 rho A c_B (1 + i) with\n% c_B = sqrt(om kappa cL), evaluated at the pulse's own centre frequency\n% om* = pi / tau: the contact force divided by |Z| is the local velocity,\n% which radiates as a dipole of length half a flexural wavelength along\n% the strike direction. Scaled by direct_gain. This is the least\n% principled term of the model, and direct_gain = 0 turns it off.\n%\n% The synthesis is exact in time: between strikes every mode evolves as\n% exp(lambda t), evaluated in chunks over each inter-strike segment (the\n% chunking only bounds memory; nothing is integrated).\n%\n% Returns p (1 x T, pascals at fsHi) and radinfo diagnostics.\n\nc = P.speed_of_sound;\nrho0 = P.air_density;\nK = max(1, round(P.strike_count));\nduration = (K - 1) / max(1e-3, P.strike_rate_hz) + P.tail_s;\nT = floor(duration * fsHi);\nN = basis.n;\nI = length(tstr);\nnel = length(basis.elemLen);\nnn = length(basis.sfrac);\n\nlx = P.listener_x_m;\nly = P.listener_y_m;\nlz = P.listener_z_m;\n\n% ---- element-to-listener geometry ----\nrex = lx - basis.elemX; % nel x 1\nrey = ly - basis.elemY;\nrez = lz * ones(nel, 1);\nre = sqrt(rex.^2 + rey.^2 + rez.^2);\nlistenerInside = min(re) < 0.02;\nre = max(re, 0.01);\nrhx = rex ./ re;\nrhy = rey ./ re;\nrhz = rez ./ re;\ncx = sum(basis.elemX .* basis.elemLen) / sum(basis.elemLen);\ncy = sum(basis.elemY .* basis.elemLen) / sum(basis.elemLen);\nrcen = sqrt((lx - cx)^2 + (ly - cy)^2 + lz^2);\n\n% ---- per-mode radiation transfer and complex pole ----\na = P.rod_diameter_m / 2;\nVdip = 2 * pi * a^2 * basis.elemLen; % nel x 1\nG = zeros(N, 1) + 0i;\nlam = zeros(N, 1) + 0i;\nphv = zeros(N, 1) + 0i; % modal-velocity phasor per unit jump\nfor m = 1:N\n om = basis.omega(m);\n zt = basis.zeta(m);\n k = om / c;\n proj = basis.EUpx(:, m) .* rhx + basis.EUpy(:, m) .* rhy + basis.EUpz(:, m) .* rhz;\n ph = exp(-1i * k * (re - rcen));\n G(m) = (1i * om * rho0 / (4 * pi)) * sum(Vdip .* proj .* (1i * k + 1 ./ re) .* ph ./ re);\n omd = om * sqrt(max(1e-12, 1 - zt^2));\n lam(m) = -zt * om + 1i * omd;\n phv(m) = 1 + 1i * zt * om / omd; % qdot(t) = Re[phv e^{lam t}] per unit jump\nend\n\n% ---- per-strike modal weights: A(n, i) modal-velocity-jump phasors ----\nA = zeros(N, I) + 0i;\ndirx = zeros(1, I);\ndiry = zeros(1, I);\ndirz = zeros(1, I);\nhitx = zeros(1, I);\nhity = zeros(1, I);\nang = P.strike_angle_deg * pi / 180;\nfor i = 1:I\n s = min(0.999, max(0.001, sstr(i)));\n idx = sum(basis.sfrac <= s);\n idx = min(max(idx, 1), nn - 1);\n w = (s - basis.sfrac(idx)) / (basis.sfrac(idx + 1) - basis.sfrac(idx));\n uxh = basis.NUx(idx, :) + w * (basis.NUx(idx + 1, :) - basis.NUx(idx, :));\n uyh = basis.NUy(idx, :) + w * (basis.NUy(idx + 1, :) - basis.NUy(idx, :));\n uzh = basis.NUz(idx, :) + w * (basis.NUz(idx + 1, :) - basis.NUz(idx, :));\n % strike direction: the leg's inward in-plane normal, tilted out of plane\n ex = basis.elemTanX(idx);\n ey = basis.elemTanY(idx);\n dirx(i) = cos(ang) * (-ey);\n diry(i) = cos(ang) * ex;\n dirz(i) = sin(ang);\n hitx(i) = basis.nodeX(idx) + w * (basis.nodeX(idx + 1) - basis.nodeX(idx));\n hity(i) = basis.nodeY(idx) + w * (basis.nodeY(idx + 1) - basis.nodeY(idx));\n proj = uxh * dirx(i) + uyh * diry(i) + uzh * dirz(i); % 1 x N\n x = basis.omega * taustr(i); % N x 1\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 A(:, i) = phv .* (jstr(i) * S) .* reshape(proj, N, 1);\nend\n\n% ---- segment synthesis: exact damped-exponential evolution, chunked ----\nks = min(T, max(1, round(tstr * fsHi) + 1));\np = zeros(1, T);\ns = zeros(N, 1) + 0i;\nGt = G.';\ndt = 1 / fsHi;\nCH = 32768; % chunk length: bounds the exp matrix\nprevK = ks(1);\ni = 1;\nwhile i <= I\n s = s + A(:, i);\n while i < I && ks(i + 1) == ks(i)\n i = i + 1;\n s = s + A(:, i);\n end\n if i < I\n nextK = ks(i + 1);\n else\n nextK = T + 1;\n end\n pos = prevK;\n while pos < nextK\n len = min(CH, nextK - pos);\n seg = real(Gt * ((s * ones(1, len)) .* exp(lam * ((0:len - 1) * dt))));\n p(pos:pos + len - 1) = p(pos:pos + len - 1) + seg;\n s = s .* exp(lam * (len * dt));\n pos = pos + len;\n end\n prevK = nextK;\n i = i + 1;\nend\n\n% ---- direct strike clicks (driving-point velocity radiating as a dipole) ----\nAarea = pi * (2 * a)^2 / 4;\nkapcL = (a / 2) * sqrt(P.youngs_modulus / P.density); % kappa * cL\ndirectPeak = 0;\nfor i = 1:I\n rsx = lx - hitx(i);\n rsy = ly - hity(i);\n rs = max(0.01, sqrt(rsx^2 + rsy^2 + lz^2));\n cosg = (rsx * dirx(i) + rsy * diry(i) + lz * dirz(i)) / rs;\n nP = max(2, round(taustr(i) * fsHi));\n k0 = ks(i) + round((rs - rcen) / c * fsHi);\n if k0 < 1 || k0 + nP > T\n continue;\n end\n omStar = pi / taustr(i);\n cB = sqrt(omStar * kapcL);\n Zb = 2 * sqrt(2) * P.density * Aarea * cB; % |2 rho A cB (1 + i)|\n lamB = 2 * pi * cB / omStar; % flexural wavelength at om*\n mu = 2 * pi * a^2 * (lamB / 2) / Zb; % dipole moment per unit force\n amp = P.direct_gain * fstr(i) * mu * rho0 * omStar^2 * cosg / (4 * pi * rs * 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"
62 },
63 {
64 "name": "triangle_render.m",
65 "content": "function [y, basis, dinfo] = triangle_render(P)\n% TRIANGLE_RENDER Full render: basis + strikes -> radiate -> output.\n% Convenience composition of the stage functions; the web app calls the\n% stages separately so it can cache intermediates (the eigensolve above all).\n\nbasis = triangle_basis(P);\nfsHi = P.sample_rate * P.oversample;\n[tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P);\n[p, radinfo] = triangle_radiate(P, basis, tstr, vstr, jstr, taustr, fstr, sstr, fsHi);\n[y, peakPa] = triangle_output(P, p, P.oversample);\n\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.modesIn = sum(basis.family == 0);\ndinfo.modesOut = sum(basis.family == 1);\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.sinfo = sinfo;\nend\n"
66 },
67 {
68 "name": "triangle_rng.m",
69 "content": "function [r, state] = triangle_rng(state, n)\n% TRIANGLE_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% roll 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"
70 },
71 {
72 "name": "triangle_strikes.m",
73 "content": "function [tstr, vstr, jstr, taustr, fstr, sstr, sinfo] = triangle_strikes(P)\n% TRIANGLE_STRIKES Beater strike events: a single strike, or a seeded roll.\n%\n% strike_count = 1 gives one deterministic strike of strike_velocity at\n% strike_pos at t = 0; no random numbers are drawn. strike_count > 1 gives a\n% roll: nominally periodic hits at strike_rate_hz, alternating between\n% strike_pos and roll_pos2 (the beater rattling in a corner strikes the two\n% legs that form it in turn), with Gaussian timing jitter (timing_jitter_ms)\n% and lognormal velocity spread (velocity_spread). The first hit stays at\n% t = 0 so the onset is fixed. All randomness comes from triangle_rng seeded\n% with P.seed, so a given seed reproduces the identical roll in the CLI,\n% Node, and the browser.\n%\n% Each strike is a Hertz contact of the beater tip (a steel sphere of radius\n% beater_radius_m) on the rod:\n% k_h = (4/3) E* sqrt(R_eff), R_eff = sqrt(r_b R''),\n% 1/R'' = 1/r_b + 2/d (sphere on cylinder: R_eff is the\n% geometric mean of the two relative\n% principal curvatures)\n% tau = 3.218 (m*^2 / (k_h^2 v))^{1/5} (Hertz contact time)\n% J = (1 + restitution) m* v (momentum transferred)\n% Fpk = pi J / (2 tau) (half-sine pulse peak)\n% The reduced mass m* pairs the beater with the rod mass engaged during the\n% contact. The engaged length is estimated from the flexural group velocity\n% at the pulse's own bandwidth, l = 2 sqrt(pi kappa cL tau), which depends\n% on tau, so a short fixed-point iteration closes the loop. This is an\n% estimate, not a contact simulation: what it captures is that a heavy\n% beater lengthens the contact and darkens the strike, while a light beater\n% approaches the free-sphere limit and tau ~ v^(-1/5).\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;\nrho = P.density;\nd = P.rod_diameter_m;\nnub = 0.30; % beater Poisson ratio, fixed\nEstar = 1 / ((1 - nub^2) / P.beater_youngs + (1 - P.poisson_ratio^2) / E);\nrb = P.beater_radius_m;\nRpp = 1 / (1 / rb + 2 / d);\nReff = sqrt(rb * Rpp);\nkh = (4 / 3) * Estar * sqrt(Reff);\nkapcL = (d / 4) * sqrt(E / rho); % kappa * cL: flexural dispersion constant\nAarea = pi * d^2 / 4;\n\nstate = mod(floor(abs(P.seed)), 4294967296);\nz1 = zeros(K, 1);\nz2 = zeros(K, 1);\nif K > 1\n [u, state] = triangle_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\n % engaged-mass fixed point for the Hertz contact time\n tau = 30e-6;\n mstar = P.beater_mass_kg;\n for it = 1:4\n leng = 2 * sqrt(pi * kapcL * tau);\n mloc = rho * Aarea * leng;\n mstar = P.beater_mass_kg * mloc / (P.beater_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 if mod(i, 2) == 1\n sstr(i) = P.strike_pos;\n else\n sstr(i) = P.roll_pos2;\n end\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"
74 },
75 {
76 "name": "triangle_validate.m",
77 "content": "% Validate the triangle model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) structural identities of the FEM eigensolve, (c) exact reproducibility\n% of the seeded roll process, and (d) pinned end-to-end diagnostics from the\n% default configuration, recorded when the model was built and treated as a\n% regression reference since.\n% Run with: numbl run triangle_validate.m\n\nfails = 0;\n\n% ---- 1. straight free-free rod against closed forms ----\n% The same element routines that build the triangle, assembled on a straight\n% polyline, must reproduce the textbook free-free bar: bending frequencies\n% (beta L)^2 kappa cL / (2 pi L^2), the first axial mode cL / 2L, the first\n% torsional mode cT / 2L, and exactly three rigid-body modes per family.\nE = 2e11; rho = 7850; nu = 0.30; d = 0.008; L = 0.44;\nnodes = [linspace(0, L, 61); zeros(1, 61)];\ncL = sqrt(E / rho);\nkap = d / 4;\nblTab = [4.73004074 7.85320462 10.99560784];\nfbClosed = blTab.^2 * kap * cL / (2 * pi * L^2);\nfaClosed = cL / (2 * L);\nftClosed = sqrt(E / (2 * (1 + nu)) / rho) / (2 * L);\nf0 = tv_eigfreqs(nodes, 0, E, rho, nu, d);\nf1 = tv_eigfreqs(nodes, 1, E, rho, nu, d);\n% the rigid-body zeros come back as numerical noise well below the first\n% elastic mode (185 Hz here); 5 Hz separates them cleanly\nnRigid0 = sum(f0 < 5);\nnRigid1 = sum(f1 < 5);\ne0 = f0(f0 > 1);\ne1 = f1(f1 > 1);\nerrB = max(abs(e0(1:3).' - fbClosed) ./ fbClosed);\n% rotary inertia lowers the FEM bending values slightly below the closed\n% form, which neglects it; 0.5% covers that plus discretization\nerrA = abs(min(abs(e0 - faClosed)) / faClosed);\nerrT = abs(min(abs(e1 - ftClosed)) / ftClosed);\nok = nRigid0 == 3 && nRigid1 == 3 && errB < 5e-3 && errA < 2e-3 && errT < 2e-3;\nfails = fails + (~ok);\nfprintf('straight rod: bend err %.1e, axial %.1e, torsion %.1e, rigid %d/%d %s\\n', ...\n errB, errA, errT, nRigid0, nRigid1, tv_verdict(ok));\n\n% ---- 2. eigensolve identities on the triangle 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).\nP = triangle_defaults();\ngeo = triangle_geometry(P);\n[K, M] = triangle_fem(geo.nodes, 0, P.youngs_modulus, P.density, P.poisson_ratio, P.rod_diameter_m);\nRc = chol(M);\nAm = (Rc') \\ (K / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nVv = fliplr(Uv);\nPhi = Rc \\ Vv;\neOrth = max(max(abs(Phi' * M * Phi - eye(length(om2)))));\nKP = Phi' * K * Phi;\neDiag = max(max(abs(KP - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve: orthonormality %.1e, diagonalization %.1e %s\\n', eOrth, eDiag, tv_verdict(ok));\n\n% ---- 3. Hertz contact-time scaling tau ~ v^(-1/5) ----\n% With a beater much lighter than the engaged rod mass the reduced mass is a\n% constant and the free-sphere scaling must emerge. A single strike, so the\n% check does not depend on the default gesture or on the velocity spread.\nQ = P;\nQ.strike_count = 1;\nQ.beater_mass_kg = 5e-4;\n[~, ~, ~, tau1, ~, ~, ~] = triangle_strikes(Q);\nQ.strike_velocity = 2 * P.strike_velocity;\n[~, ~, ~, tau2, ~, ~, ~] = triangle_strikes(Q);\nratio = tau2(1) / tau1(1);\nok = abs(ratio - 2^(-1 / 5)) < 0.03;\nfails = fails + (~ok);\nfprintf('hertz scaling: tau ratio %.4f (target %.4f) %s\\n', ratio, 2^(-1 / 5), tv_verdict(ok));\n\n% ---- 4. seeded roll is exactly reproducible ----\nQ = P;\nQ.strike_count = 12;\n[t1r, v1r, j1r, ~, ~, s1r, g1] = triangle_strikes(Q);\n[t2r, v2r, j2r, ~, ~, s2r, g2] = triangle_strikes(Q);\nsame = max(abs(t1r - t2r)) == 0 && max(abs(v1r - v2r)) == 0 && max(abs(s1r - s2r)) == 0;\nok = same ...\n && g1.count == 12 ...\n && abs(g1.meanV - 1.501211020) < 1e-6 ...\n && abs(sum(j1r) - 0.383069278968) < 1e-9 ...\n && abs(t1r(2) - 0.087732781124) < 1e-9;\nfails = fails + (~ok);\nfprintf('roll (seed %d): %d strikes, mean v %.6f m/s, sum J %.9f N s %s\\n', ...\n Q.seed, g1.count, g1.meanV, sum(j1r), tv_verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the\n% origin so every element dipole keeps its directivity factor: off the ray\n% the in-plane dipoles fall faster than 1/r, which is physics rather than\n% error. Modes are capped at 8 kHz so 2 m is beyond the Rayleigh distance\n% of the frame; at 20 kHz it is not, and Fresnel-zone interference shifts\n% the ratio by a few tenths of a dB, again physics rather than error.\nQ = P;\nQ.normalize = 0;\nQ.strike_count = 1;\nQ.tail_s = 1.0;\nQ.max_mode_hz = 8000;\nQ.listener_x_m = 0.6; Q.listener_y_m = 0.5; Q.listener_z_m = 1.8;\n[y1, ~, ~] = triangle_render(Q);\nQ.listener_x_m = 1.2; Q.listener_y_m = 1.0; Q.listener_z_m = 3.6;\n[y2, ~, ~] = triangle_render(Q);\ndrop = 10 * log10(sum(y2.^2) / sum(y1.^2));\nok = abs(drop + 6.02) < 0.2;\nfails = fails + (~ok);\nfprintf('falloff 2 m -> 4 m: %.2f dB rms (target -6.02) %s\\n', drop, tv_verdict(ok));\n\n% ---- 6. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = triangle_render(P);\nok = abs(dinfo.peakPa - 13.769445) < 0.02 ...\n && abs(basis.f(1) - 163.9200) < 0.1 ...\n && basis.n == 40;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.4f Pa (target %.4f), mode1 %.2f Hz (target %.2f), %d modes %s\\n', ...\n dinfo.peakPa, 13.769445, basis.f(1), 163.9200, basis.n, tv_verdict(ok));\n\n% ---- 7. the ring decays ----\n% A triangle rings for seconds, so the tail is not silent; but the energy in\n% the last half second must sit well below that of the opening half second,\n% which at the defaults carries the first six hits of the roll.\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 < -25;\nfails = fails + (~ok);\nfprintf('ring decay: last/first half second %.1f dB %s\\n', dropE, tv_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 = tv_eigfreqs(nodes, family, E, rho, nu, d)\n[K, M] = triangle_fem(nodes, family, E, rho, nu, d);\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 s = tv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
78 }
79 ]
↑↓ move↵openescclose