/ simulated_instruments / maraca
Sign in
simulated_instruments / maraca
maraca / exports / maraca.json
70 lines · 28.5 KBCodeBlameHistory
2 "format": "numbl-instrument-v1",
3 "id": "maraca",
4 "name": "Maraca",
5 "description": "One maraca stroke per MIDI event; unpitched, velocity onto shake speed.",
6 "source": "simulated_instruments/maraca",
7 "knobs": [
8 {
9 "key": "grain_count",
10 "label": "grain count",
11 "min": 40,
12 "max": 600,
13 "default": 320,
14 "step": 1
15 },
16 {
17 "key": "burst_width_ms",
18 "label": "burst width (ms)",
19 "min": 5,
20 "max": 120,
21 "default": 45
22 }
23 ],
24 "files": [
25 {
26 "name": "render_note.m",
27 "content": "function y = render_note(note)\n% Unpitched: MIDI pitch is ignored, velocity drives the stroke speed.\n% Fixed: a single stroke, a fixed seed, normalize off so velocity\n% survives in pascals.\nP = maraca_defaults();\nP.sample_rate = note.sample_rate;\nP.stroke_count = 1;\nP.seed = 1;\nP.pre_delay_ms = 0;\nP.normalize = 0;\nif note.preview ~= 0\n P.oversample = 1;\nend\nP.shake_velocity = 0.2 + (6 - 0.2) * note.velocity;\nP.grain_count = round(note.params.grain_count);\nP.burst_width_ms = note.params.burst_width_ms;\ny = maraca_render(P);\nend\n"
28 },
29 {
30 "name": "maraca_basis.m",
31 "content": "function basis = maraca_basis(P)\n% MARACA_BASIS Mode set of a complete thin spherical shell.\n%\n% Membrane theory of the full sphere gives two branches per degree l from\n% Omega^4 - (lam + 1 + 3 nu) Omega^2 + (lam - 2)(1 - nu^2) = 0,\n% lam = l(l+1), Omega = omega R / c_p, c_p = sqrt(E / (rho (1 - nu^2))).\n% Checks: l = 0 upper root is the breathing mode Omega^2 = 2(1+nu); the l = 1\n% lower root is exactly 0 (rigid translation, held by the hand and neglected).\n% The lower branch flattens toward Omega^2 = 1 - nu^2 at high l, so a bending\n% term beta^2 (lam - 2)^2, beta^2 = h^2/(12 R^2), is added to it; this is the\n% flat-plate asymptote and is what spreads the high-l modes upward in\n% frequency. The membrane eigenvector is kept for the bending-corrected modes,\n% an approximation that is good while the bending term is a correction.\n%\n% Eigenvector ratio (tangential potential V over radial W, W = 1):\n% V = (2(1+nu) - Omega^2) / (lam (1+nu)),\n% giving the modal mass factor mu = 1 + lam V^2 and modal mass rho h R^2 mu.\n%\n% The closed cavity enters as an air stiffness on the l = 0 (breathing)\n% components only: d(omega^2) = 3 rho0 c^2 / (rho h R), scaled by\n% cavity_coupling. Modes with l >= 1 displace no net volume, and the interior\n% inertial loading they see is of order rho0 R / ((l+1) rho h), well under a\n% percent here, so it is neglected.\n%\n% Damping: zeta = 0.5 (loss_factor + loss_slope omega) plus radiation loss\n% rho0 c sigma_l / (2 rho h omega mu), sigma_l = Re[-i h_l(ka)/h_l'(ka)].\n\nR = P.shell_radius_m;\nh = P.shell_thickness_m;\nnu = P.poisson_ratio;\nrho = P.density;\ncp = sqrt(P.youngs_modulus / (rho * (1 - nu^2)));\nbeta2 = h^2 / (12 * R^2);\nc = P.speed_of_sound;\nrho0 = P.air_density;\n\ncavShift2 = P.cavity_coupling * 3 * rho0 * c^2 / (rho * h * R); % rad^2/s^2\n\nmaxN = 2 * (P.max_mode_l + 1);\nll = zeros(maxN, 1);\nff = zeros(maxN, 1);\nmus = zeros(maxN, 1);\nn = 0;\nbreathingDryHz = 0;\nbreathingHz = 0;\nfor l = 0:P.max_mode_l\n lam = l * (l + 1);\n b = lam + 1 + 3 * nu;\n det2 = (lam - 2) * (1 - nu^2);\n disc = sqrt(max(0, b^2 - 4 * det2));\n om2lo = (b - disc) / 2;\n om2hi = (b + disc) / 2;\n if l == 0\n cand = om2hi; % breathing; lower root is negative\n elseif l == 1\n cand = om2hi; % lower root 0 = rigid translation\n else\n cand = [om2lo + beta2 * (lam - 2)^2, om2hi];\n end\n for ci = 1:length(cand)\n Om2 = cand(ci);\n om = sqrt(Om2) * cp / R * P.stiffness_trim;\n if l == 0\n breathingDryHz = om / (2 * pi);\n om = sqrt(om^2 + cavShift2); % closed-cavity air stiffness\n breathingHz = om / (2 * pi);\n end\n fm = om / (2 * pi);\n if fm > P.max_mode_hz\n continue;\n end\n if lam > 0\n V = (2 * (1 + nu) - Om2) / (lam * (1 + nu));\n else\n V = 0;\n end\n n = n + 1;\n ll(n) = l;\n ff(n) = fm;\n mus(n) = 1 + lam * V^2;\n end\nend\nif n == 0\n error('No shell modes below max_mode_hz -- check geometry/material.');\nend\n[ff, order] = sort(ff(1:n, 1));\nff = reshape(ff, n, 1);\nlls = reshape(ll(order), n, 1);\nmuss = reshape(mus(order), n, 1);\n\nomega = 2 * pi * ff;\nsigma = zeros(n, 1);\nfor m = 1:n\n ka = omega(m) / c * R;\n [hh, hhp] = maraca_hankel(lls(m), ka);\n sigma(m) = real(-1i * hh(lls(m) + 1) / hhp(lls(m) + 1));\nend\nzeta = 0.5 * (P.loss_factor + P.loss_slope * omega) ...\n + P.radiation_loss * rho0 * c * sigma ./ (2 * rho * h * omega .* muss);\n\nbasis.n = n;\nbasis.l = lls;\nbasis.f = ff;\nbasis.omega = omega;\nbasis.zeta = zeta;\nbasis.mu = muss;\nbasis.massKg = rho * h * R^2 * muss;\nbasis.sigma = sigma;\nbasis.breathingDryHz = breathingDryHz;\nbasis.breathingHz = breathingHz;\nend\n"
32 },
33 {
34 "name": "maraca_defaults.m",
35 "content": "function P = maraca_defaults()\n% MARACA_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% stroke_count / stroke_rate_hz + tail_s.\n\n% Shell geometry (complete thin spherical shell)\nP.shell_radius_m = 0.034;\nP.shell_thickness_m = 0.0022;\n\n% Shell material (dried gourd / hardwood veneer scale)\nP.density = 720.0;\nP.youngs_modulus = 2.5e9;\nP.poisson_ratio = 0.30;\nP.stiffness_trim = 1.00;\nP.max_mode_l = 16;\nP.max_mode_hz = 22000.0;\n\n% Damping\nP.loss_factor = 0.015;\nP.loss_slope = 6.0e-7;\nP.radiation_loss = 1.0;\n\n% Grains (seed beads)\nP.grain_radius_m = 0.0015;\nP.grain_density = 1300.0;\nP.grain_youngs = 6.0e8;\nP.grain_size_spread = 0.25;\nP.restitution = 0.55;\n\n% Shake gesture / stochastic impact process\nP.stroke_rate_hz = 4.5;\nP.stroke_count = 8;\nP.accent_period = 2;\nP.accent_gain = 1.5;\nP.grain_count = 320;\nP.bounces_per_grain = 1.3;\nP.shake_velocity = 2.2;\nP.slosh_lag_ms = 14.0;\nP.burst_width_ms = 45.0;\nP.impact_spread_deg = 35.0;\nP.seed = 1;\n\n% Closed air cavity\nP.cavity_coupling = 1.0;\n\n% Radiation / listener\nP.direct_gain = 1.0;\nP.listener_x_m = 0.15;\nP.listener_y_m = 0.25;\nP.listener_z_m = 0.30;\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 = 0.40;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 20.0;\nend\n"
36 },
37 {
38 "name": "maraca_demo.m",
39 "content": "% Render a maraca shake with default parameters and print diagnostics.\n% Run with: numbl run maraca_demo.m\n\nP = maraca_defaults();\ntic;\n[y, basis, dinfo] = maraca_render(P);\nelapsed = toc;\n\nfprintf('modes kept : %d\\n', basis.n);\nfprintf('mode l : ');\nfprintf('%d ', basis.l);\nfprintf('\\nmode freq : ');\nfprintf('%.0f ', basis.f);\nfprintf('Hz\\n');\nfprintf('breathing : %.0f Hz (dry %.0f Hz, cavity shift %+.1f Hz)\\n', ...\n dinfo.breathingHz, dinfo.breathingDryHz, dinfo.breathingHz - dinfo.breathingDryHz);\nfprintf('grains : %d, %.2f bounces each per stroke\\n', P.grain_count, P.bounces_per_grain);\nfprintf('impacts : %d (mean v %.2f m/s, max %.2f; mean contact %.0f us)\\n', ...\n dinfo.impactCount, dinfo.meanV, dinfo.maxV, dinfo.meanTauUs);\nfprintf('peak press.: %.3f 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 inside/at the shell surface\\n');\nend\n"
40 },
41 {
42 "name": "maraca_hankel.m",
43 "content": "function [h, hp] = maraca_hankel(lmax, x)\n% MARACA_HANKEL Spherical Hankel functions of the second kind and derivatives.\n% h(l+1) = h_l^(2)(x) = j_l(x) - i y_l(x), hp(l+1) = d/dx h_l^(2)(x), for\n% l = 0..lmax, real x > 0. With the e^{+i omega t} convention used throughout,\n% h^(2)(kr) is the outgoing wave.\n%\n% j_l uses downward (Miller) recursion, stable for x below l; y_l uses upward\n% recursion, stable everywhere. Verified against the closed forms for l = 0, 1.\n\nif x <= 0\n error('maraca_hankel: x must be positive');\nend\n\n% ---- j_l: downward recursion, normalized by j_0 = sin(x)/x ----\nLstart = lmax + 15 + ceil(x);\njj = zeros(Lstart + 2, 1); % jj(n+1) holds j_n up to a common scale\njj(Lstart + 2) = 0;\njj(Lstart + 1) = 1e-30;\nfor n = Lstart:-1:1\n jj(n) = ((2 * n + 1) / x) * jj(n + 1) - jj(n + 2);\n if abs(jj(n)) > 1e250 % renormalize to avoid overflow\n jj = jj * 1e-250;\n end\nend\n% The explicit second index pins the column orientation. numbl <= 0.4.19\n% guessed a row here when the JIT could not prove jj was a column (its\n% length is only known at runtime), silently transposing the result; fixed\n% upstream, but kept until a release carrying the fix is out.\nj = jj(1:lmax + 1, 1) * ((sin(x) / x) / jj(1));\n\n% ---- y_l: upward recursion ----\ny = zeros(lmax + 1, 1);\ny(1) = -cos(x) / x;\nif lmax >= 1\n y(2) = -cos(x) / x^2 - sin(x) / x;\n for n = 1:lmax - 1\n y(n + 2) = ((2 * n + 1) / x) * y(n + 1) - y(n);\n end\nend\n\nh = j - 1i * y;\n\n% ---- derivatives: h'_l = h_{l-1} - (l+1)/x h_l, h'_0 = -h_1 ----\nhp = zeros(lmax + 1, 1) + 0i;\nh1 = j(min(lmax + 1, 2)) - 1i * y(min(lmax + 1, 2));\nif lmax == 0\n % need h_1 explicitly for h'_0\n y1 = -cos(x) / x^2 - sin(x) / x;\n j1 = sin(x) / x^2 - cos(x) / x;\n h1 = j1 - 1i * y1;\nend\nhp(1) = -h1;\nfor l = 1:lmax\n hp(l + 1) = h(l) - ((l + 1) / x) * h(l + 1);\nend\nend\n"
44 },
45 {
46 "name": "maraca_impacts.m",
47 "content": "function [timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P)\n% MARACA_IMPACTS Stochastic grain-shell impact process for a shake pattern.\n%\n% The shell holds grain_count grains. A stroke throws them at one wall, where\n% each makes bounces_per_grain contacts on average (one arrival plus whatever\n% rattle it has before the next reversal), so the expected number of impacts\n% in stroke k is grain_count * bounces_per_grain * sqrt(accent_k). The grains\n% are not tracked individually: at a few hundred grains in a shell barely\n% wider than a few grain diameters, the arrival process is what is audible,\n% not which grain arrived. That is the central approximation of this model,\n% and it is what makes a few thousand impacts cost milliseconds.\n%\n% Each stroke k of the gesture produces a burst of independent grain impacts:\n% count ~ Poisson with that mean (Gaussian approximation, exact enough\n% at hundreds per stroke);\n% times = stroke onset + slosh_lag + Gamma(3, burst_width/3) delay, the\n% collective slosh of the grain mass arriving at the far wall;\n% speeds ~ Rayleigh with mean shake_velocity * sqrt(accent_k);\n% points ~ von Mises-Fisher about the pole the grains are thrown at, which\n% alternates between +x and -x on successive strokes;\n% size ~ lognormal spread of the grain radius (grain_size_spread).\n%\n% Each impact is a Hertz contact of a small sphere on the shell wall:\n% k_h = (4/3) E* sqrt(r_g), 1/E* = (1-nu_g^2)/E_g + (1-nu_s^2)/E_s\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 force pulse is modeled as a half sine of duration tau carrying impulse J,\n% so J, tau, and Fpk are mutually consistent by construction.\n%\n% All randomness comes from maraca_rng seeded with P.seed, so a given seed\n% reproduces the identical process in the CLI, Node, and the browser.\n%\n% Returns row vectors sorted by time: timp (s), jimp (N s), tauimp (s),\n% fimp (peak N), and ximp (3 x I unit impact positions on the shell).\n% ginfo carries the expected-rate envelope and summary statistics for display.\n\nstate = mod(floor(abs(P.seed)), 4294967296);\nK = P.stroke_count;\nlag = P.slosh_lag_ms * 1e-3;\ntheta = (P.burst_width_ms * 1e-3) / 3; % Gamma(3, theta): mean = width\nspreadRad = P.impact_spread_deg * pi / 180;\nkappa = 1 / max(1e-4, spreadRad^2);\nduration = P.stroke_count / P.stroke_rate_hz + P.tail_s;\n\n% Hertz constants shared by all impacts (grain Poisson ratio fixed at 0.35)\nnuG = 0.35;\nEstar = 1 / ((1 - nuG^2) / P.grain_youngs + (1 - P.poisson_ratio^2) / P.youngs_modulus);\nrg0 = P.grain_radius_m;\nmg0 = (4 / 3) * pi * rg0^3 * P.grain_density;\n\nmaxI = ceil(K * P.grain_count * P.bounces_per_grain * sqrt(P.accent_gain) * 1.5) + 64;\ntimp = zeros(1, maxI);\njimp = zeros(1, maxI);\ntauimp = zeros(1, maxI);\nfimp = zeros(1, maxI);\nvimp = zeros(1, maxI);\nximp = zeros(3, maxI);\nI = 0;\n\ncounts = zeros(K, 1);\naccents = zeros(K, 1);\nfor k = 1:K\n tk = (k - 1) / P.stroke_rate_hz;\n if mod(k - 1, max(1, round(P.accent_period))) == 0\n a = P.accent_gain;\n else\n a = 1.0;\n end\n accents(k) = a;\n meanCount = P.grain_count * P.bounces_per_grain * sqrt(a);\n [u, state] = maraca_rng(state, 2);\n z = sqrt(-2 * log(u(1))) * cos(2 * pi * u(2));\n nk = max(0, round(meanCount + sqrt(meanCount) * z));\n counts(k) = nk;\n if nk == 0\n continue;\n end\n [u, state] = maraca_rng(state, nk * 8);\n U = reshape(u, nk, 8);\n\n % times: Gamma(3, theta) as the sum of three exponentials\n tt = tk + lag - theta * log(U(:, 1) .* U(:, 2) .* U(:, 3));\n % speeds: Rayleigh, mean shake_velocity * sqrt(a), capped at 2.5x the mean\n % (a grain cannot exceed a small multiple of the shell speed)\n sigV = P.shake_velocity * sqrt(a) / sqrt(pi / 2);\n vv = sigV * sqrt(-2 * log(U(:, 4)));\n vv = min(vv, 2.5 * P.shake_velocity * sqrt(a));\n % positions: von Mises-Fisher about the active pole (+x odd, -x even strokes)\n w = 1 + log(U(:, 5) * (1 - exp(-2 * kappa)) + exp(-2 * kappa)) / kappa;\n st = sqrt(max(0, 1 - w.^2));\n phi = 2 * pi * U(:, 6);\n pole = 1 - 2 * mod(k - 1, 2); % +1, -1, +1, ...\n xs = [pole * w.'; (st .* cos(phi)).'; (st .* sin(phi)).'];\n % grain size: lognormal scale on the radius\n zs = sqrt(-2 * log(U(:, 7))) .* cos(2 * pi * U(:, 8));\n s = exp(P.grain_size_spread * zs);\n mg = mg0 * s.^3;\n kh = (4 / 3) * Estar * sqrt(rg0 * s);\n\n tau = 3.218 * (mg.^2 ./ (kh.^2 .* vv)).^(1 / 5);\n J = (1 + P.restitution) * mg .* vv;\n Fpk = pi * J ./ (2 * tau);\n\n keep = find(tt < duration - 2e-3);\n nkeep = length(keep);\n idx = I + 1:I + nkeep;\n timp(idx) = tt(keep).';\n jimp(idx) = J(keep).';\n tauimp(idx) = tau(keep).';\n fimp(idx) = Fpk(keep).';\n vimp(idx) = vv(keep).';\n ximp(:, idx) = xs(:, keep);\n I = I + nkeep;\nend\nif I == 0\n error('The impact process produced no impacts -- check the gesture parameters.');\nend\n\n[timp, order] = sort(timp(1, 1:I));\ntimp = reshape(timp, 1, I);\njimp = reshape(jimp(order), 1, I);\ntauimp = reshape(tauimp(order), 1, I);\nfimp = reshape(fimp(order), 1, I);\nvimp = reshape(vimp(order), 1, I);\nxs = ximp(:, 1:I);\nximp = xs(:, order);\n\n% Expected-rate envelope on a 1 ms grid, for plotting and validation\ntg = 0:1e-3:duration;\nrate = zeros(size(tg));\nfor k = 1:K\n tk = (k - 1) / P.stroke_rate_hz + lag;\n tau0 = max(0, tg - tk);\n g = (tau0.^2 / (2 * theta^3)) .* exp(-tau0 / theta); % Gamma(3,theta) pdf\n rate = rate + P.grain_count * P.bounces_per_grain * sqrt(accents(k)) * g;\nend\n\nginfo.count = I;\nginfo.counts = counts;\nginfo.accents = accents;\nginfo.meanV = sum(vimp) / I;\nginfo.maxV = max(vimp);\nginfo.vimp = vimp;\nginfo.meanTauUs = sum(tauimp) / I * 1e6;\nginfo.rateT = tg;\nginfo.rateHz = rate;\nginfo.durationS = duration;\nend\n"
48 },
49 {
50 "name": "maraca_output.m",
51 "content": "function [y, peakPa] = maraca_output(P, p, oversample)\n% MARACA_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 output stage.\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 .* (mr_i0(beta * sqrt(max(0, 1 - r.^2))) / mr_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);\nduration = P.stroke_count / P.stroke_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(256, 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 = mr_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"
52 },
53 {
54 "name": "maraca_radiate.m",
55 "content": "function [p, radinfo] = maraca_radiate(P, basis, timp, jimp, tauimp, fimp, ximp, fsHi)\n% MARACA_RADIATE Modal synthesis and free-field pressure at the listener.\n%\n% Two contributions:\n%\n% 1. Modal ringing. An impact with impulse J at surface point x_i gives every\n% mode (l, m) a velocity jump J S(omega tau) Y_lm(x_i) / m_l, where\n% S(x) = cos(x/2) / (1 - (x/pi)^2) is the spectrum of the half-sine contact\n% pulse (harder or faster grains excite higher modes). At mode frequency\n% the sphere radiates each (l, m) with the exact multipole transfer\n% -i rho0 c h_l(kr) / h_l'(ka); modes are narrow-band, so the transfer is\n% evaluated at the mode centre frequency. Because all m of a degree share\n% one frequency and one transfer, the addition theorem\n% sum_m Y_lm(x_i) Y_lm(x_r) = (2l+1)/(4pi) P_l(cos gamma)\n% collapses the m sum: only one damped resonator per retained mode is\n% synthesized, carrying listener-projected weights. The listener-direction\n% projection therefore lives in this stage, and moving the listener re-runs\n% only this stage. The carrier phase e^{-ikr} is stripped from h_l(kr) and\n% the propagation applied as a common group delay, so envelope and phase\n% are not double counted.\n%\n% 2. Direct impact clicks. Modal truncation at max_mode_hz discards the dense\n% high-l modes whose collective off-resonance response is the immediate\n% forced motion of the wall around the contact point. That broadband\n% component is reinstated with the classic point-driven infinite plate\n% result p = rho0 F(t - r/c) / (2 pi rho_s h r), scaled by direct_gain,\n% weighted (1 + cos gamma)/2 for shadowing of impacts on the far side, and\n% advanced/delayed by -R cos(gamma)/c for the position on the sphere. This\n% is the least principled term of the model: the plate formula ignores the\n% shell's curvature and closure.\n%\n% The synthesis is exact in time: between impacts every resonator evolves as\n% exp(lambda t), evaluated per inter-impact segment; 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;\nR = P.shell_radius_m;\nduration = P.stroke_count / P.stroke_rate_hz + P.tail_s;\nT = floor(duration * fsHi);\nN = basis.n;\nI = length(timp);\n\nlx = P.listener_x_m; ly = P.listener_y_m; lz = P.listener_z_m;\nrmag = sqrt(lx^2 + ly^2 + lz^2);\nlistenerInside = rmag < R + 0.02;\nrmag = max(rmag, R + 0.005);\nxhat = [lx; ly; lz] / max(1e-9, sqrt(lx^2 + ly^2 + lz^2));\n\n% ---- listener geometry per impact: Legendre table P_l(cos gamma) ----\ncosg = xhat.' * ximp; % 1 x I\nlmax = max(basis.l);\nPL = zeros(lmax + 1, I);\nPL(1, :) = ones(1, I);\nif lmax >= 1\n PL(2, :) = cosg;\n for l = 1:lmax - 1\n PL(l + 2, :) = ((2 * l + 1) * cosg .* PL(l + 1, :) - l * PL(l, :)) / (l + 1);\n end\nend\n\n% ---- per-mode radiation transfer and complex pole ----\nG = zeros(N, 1) + 0i;\nlam = zeros(N, 1) + 0i;\nphv = zeros(N, 1) + 0i; % velocity phasor per unit jump\nfor m = 1:N\n l = basis.l(m);\n om = basis.omega(m);\n zt = basis.zeta(m);\n k = om / c;\n [ha, hpa] = maraca_hankel(l, k * R);\n [hr, hpr] = maraca_hankel(l, k * rmag);\n hred = hr(l + 1) * exp(1i * k * rmag); % strip carrier: no double delay\n G(m) = -1i * rho0 * c * hred / hpa(l + 1);\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; % v(t) = Re[phv e^{lam t}]\nend\n\n% ---- per-impact modal weights: A(n, i) velocity-jump phasors ----\nx = basis.omega * tauimp; % N x I\nden = 1 - (x / pi).^2;\nS = cos(x / 2) ./ den;\nsing = abs(den) < 1e-6;\nS(sing) = pi / 4; % limit at x -> pi\nCl = ((2 * basis.l + 1) / (4 * pi)) .* (1 ./ basis.massKg); % N x 1\nA = (phv * jimp) .* S .* (Cl * ones(1, I)) .* PL(basis.l + 1, :);\n\n% ---- segment synthesis: exact damped-exponential evolution ----\nks = min(T, max(1, round(timp * fsHi) + 1)); % impact sample indices, sorted\np = zeros(1, T);\ns = zeros(N, 1) + 0i;\nGt = G.';\ndt = 1 / fsHi;\nprevK = ks(1);\ni = 1;\nwhile i <= I\n % accumulate all impacts landing on this sample\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 len = nextK - prevK;\n if len > 0\n seg = real(Gt * ((s * ones(1, len)) .* exp(lam * ((0:len - 1) * dt))));\n p(prevK:nextK - 1) = p(prevK:nextK - 1) + seg;\n s = s .* exp(lam * (len * dt));\n end\n prevK = nextK;\n i = i + 1;\nend\n\n% ---- direct impact clicks (point-driven plate approximation) ----\nmpp = P.density * P.shell_thickness_m; % shell mass per unit area\ndirScale = P.direct_gain * rho0 / (2 * pi * mpp * rmag);\ndirectPeak = 0;\nfor i = 1:I\n nP = max(2, round(tauimp(i) * fsHi));\n k0 = ks(i) + round(-R * cosg(i) / c * fsHi); % near side arrives earlier\n if k0 < 1 || k0 + nP > T\n continue;\n end\n amp = dirScale * fimp(i) * 0.5 * (1 + cosg(i));\n pulse = amp * sin(pi * (0:nP) / nP);\n p(k0:k0 + nP) = p(k0:k0 + nP) + pulse;\n if amp > directPeak\n directPeak = amp;\n end\nend\n\nradinfo.gainAbs = abs(G);\nradinfo.directPeakPa = directPeak;\nradinfo.listenerInside = listenerInside;\nradinfo.listenerDistM = rmag;\nend\n"
56 },
57 {
58 "name": "maraca_render.m",
59 "content": "function [y, basis, dinfo] = maraca_render(P)\n% MARACA_RENDER Full render: basis + impacts -> radiate -> output.\n% Convenience composition of the stage functions; the web app calls the\n% stages separately so it can cache intermediates.\n\nbasis = maraca_basis(P);\nfsHi = P.sample_rate * P.oversample;\n[timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P);\n[p, radinfo] = maraca_radiate(P, basis, timp, jimp, tauimp, fimp, ximp, fsHi);\n[y, peakPa] = maraca_output(P, p, P.oversample);\n\ndinfo.impactCount = ginfo.count;\ndinfo.meanV = ginfo.meanV;\ndinfo.maxV = ginfo.maxV;\ndinfo.meanTauUs = ginfo.meanTauUs;\ndinfo.durationS = ginfo.durationS;\ndinfo.breathingDryHz = basis.breathingDryHz;\ndinfo.breathingHz = basis.breathingHz;\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.ginfo = ginfo;\nend\n"
60 },
61 {
62 "name": "maraca_rng.m",
63 "content": "function [r, state] = maraca_rng(state, n)\n% MARACA_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% impact process 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"
64 },
65 {
66 "name": "maraca_validate.m",
67 "content": "% Validate the maraca model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) exact reproducibility of the seeded impact process, and (c) 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 maraca_validate.m\n\nfails = 0;\n\n% ---- 1. spherical Hankel functions against closed forms ----\nx = 3.7;\n[h, hp] = maraca_hankel(4, x);\nh0c = sin(x) / x - 1i * (-cos(x) / x);\nh1c = (sin(x) / x^2 - cos(x) / x) - 1i * (-cos(x) / x^2 - sin(x) / x);\ne1 = max(abs(h(1) - h0c), abs(h(2) - h1c));\nd = 1e-6;\n[ha, ~] = maraca_hankel(4, x + d);\n[hb, ~] = maraca_hankel(4, x - d);\ne2 = max(abs(hp - (ha - hb) / (2 * d)));\nxs = 0.5; % small argument: Miller recursion\n[hs, ~] = maraca_hankel(2, xs);\nj2c = (3 / xs^3 - 1 / xs) * sin(xs) - 3 / xs^2 * cos(xs);\ne3 = abs(real(hs(3)) - j2c);\nok = e1 < 1e-12 && e2 < 1e-6 && e3 < 1e-12;\nfails = fails + (~ok);\nfprintf('hankel closed forms: errs %.1e %.1e %.1e %s\\n', e1, e2, e3, verdict(ok));\n\n% ---- 2. breathing mode: membrane closed form + cavity stiffness ----\nP = maraca_defaults();\nbasis = maraca_basis(P);\nnu = P.poisson_ratio;\ncp = sqrt(P.youngs_modulus / (P.density * (1 - nu^2)));\nfdry = sqrt(2 * (1 + nu)) * cp / (2 * pi * P.shell_radius_m) * P.stiffness_trim;\ncav = P.cavity_coupling * 3 * P.air_density * P.speed_of_sound^2 ...\n / (P.density * P.shell_thickness_m * P.shell_radius_m);\nfwet = sqrt((2 * pi * fdry)^2 + cav) / (2 * pi);\nok = abs(basis.breathingHz - fwet) < 1e-6 * fwet && all(basis.zeta > 0);\nfails = fails + (~ok);\nfprintf('breathing mode: %.1f Hz (closed form %.1f) %s\\n', basis.breathingHz, fwet, verdict(ok));\n\n% ---- 3. seeded impact process is exactly reproducible ----\n[timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P);\nok = ginfo.count == 3628 ...\n && abs(ginfo.meanV - 2.462374) < 1e-5 ...\n && abs(sum(jimp) - 3.586588617e-01) < 1e-9 ...\n && abs(timp(1) - 0.016172179) < 1e-8;\nfails = fails + (~ok);\nfprintf('impact process (seed %d): %d impacts, mean v %.4f m/s, sum J %.6f N s %s\\n', ...\n P.seed, ginfo.count, ginfo.meanV, sum(jimp), verdict(ok));\n\n% ---- 4. Hertz contact-time scaling tau ~ v^(-1/5) ----\nQ = P;\nQ.grain_count = 150;\n[~, ~, tau1, ~, ~, g1] = maraca_impacts(Q);\nQ.shake_velocity = 2 * P.shake_velocity;\n[~, ~, tau2, ~, ~, g2] = maraca_impacts(Q);\nratio = (sum(tau2) / length(tau2)) / (sum(tau1) / length(tau1));\nok = abs(ratio - 2^(-1 / 5)) < 0.03; % same seed, so same draws\nfails = fails + (~ok);\nfprintf('hertz scaling: mean tau ratio %.4f (target %.4f) %s\\n', ratio, 2^(-1 / 5), verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS, not peak: the per-mode radiation gains scale exactly as 1/r, but the\n% near-field phase terms of h_l shift the interference at the peak sample by\n% a few tenths of a dB between distances, which is physics rather than error.\nQ = P;\nQ.normalize = 0;\nQ.listener_x_m = 0; Q.listener_y_m = 0; Q.listener_z_m = 1.0;\n[y1, ~, ~] = maraca_render(Q);\nQ.listener_z_m = 2.0;\n[y2, ~, ~] = maraca_render(Q);\ndrop = 10 * log10(sum(y2.^2) / sum(y1.^2));\nok = abs(drop + 6.02) < 0.15;\nfails = fails + (~ok);\nfprintf('falloff 1 m -> 2 m: %.2f dB rms (target -6.02) %s\\n', drop, verdict(ok));\n\n% ---- 6. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = maraca_render(P);\nok = abs(dinfo.peakPa - 14.935221) < 0.02 ...\n && abs(basis.f(1) - 6445.5174) < 0.1 ...\n && basis.n == 11;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.3f Pa (target 14.935), mode1 %.1f Hz (target 6445.5), %d modes %s\\n', ...\n dinfo.peakPa, basis.f(1), basis.n, verdict(ok));\n\n% ---- 7. the tail decays to silence ----\nfs = P.sample_rate;\nn = length(y);\ntail = y(n - round(0.08 * fs):n);\ntailRms = sqrt(sum(tail.^2) / length(tail));\nok = tailRms < 0.02 * max(abs(y));\nfails = fails + (~ok);\nfprintf('tail decay: rms %.2e of peak %s\\n', tailRms / max(abs(y)), verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction s = verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
68 }
69 ]
↑↓ move↵openescclose