Add instrument/: the maraca behind the track-render interface
The instrument/ module implements the whole-track instrument interface
used by midiboot and now by the web app (one render path; the webapp
worker is gone). Each distinct pitch and velocity level is one render
of the stochastic impact process, and finished renders persist in
IndexedDB with LRU eviction and a 30-day age-out. Replaces the
exported bundle; export-src/ and exports/ are removed.
22 changed files+3573−695
.github/workflows/deploy.ymlmodified+4−0View file
@@ -39,6 +39,10 @@ jobs:
3939 with:
4040 node-version: 22
4141
42+ - name: Install (instrument)
43+ working-directory: instrument
44+ run: npm ci
45+
4246 - name: Install
4347 working-directory: webapp
4448 run: npm ci
README.mdmodified+1−0View file
@@ -9,6 +9,7 @@ There is no microphone model: no directivity, no capsule response, no gain stagi
99 | Path | What it is |
1010 |---|---|
1111 | `matlab/` | The algorithm in MATLAB syntax, run by [numbl](https://numbl.org) |
12+| `instrument/` | The model behind the track-render instrument interface used by [midiboot](../../proof-of-concept/midiboot) and by the web app (worker, note mapping, caching; `npm test` there runs the model in node) |
1213 | `webapp/` | Interactive browser app that executes the MATLAB code client-side |
1314
1415 The MATLAB code runs from the command line with `cd matlab && numbl run maraca_demo.m`, or `maraca_validate.m` to run the checks. The web app is a Vite project; see `webapp/README.md`.
export-src/maraca/export.jsondeleted+0−23View file
@@ -1,23 +0,0 @@
1-{
2- "id": "maraca",
3- "name": "Maraca",
4- "description": "One maraca stroke per MIDI event; pitch scales the gourd around MIDI 60 (48..72), velocity onto shake speed.",
5- "source": "simulated_instruments/maraca",
6- "knobs": [
7- {
8- "key": "grain_count",
9- "label": "grain count",
10- "min": 40,
11- "max": 600,
12- "default": 320,
13- "step": 1
14- },
15- {
16- "key": "burst_width_ms",
17- "label": "burst width (ms)",
18- "min": 5,
19- "max": 120,
20- "default": 45
21- }
22- ]
23-}
export-src/maraca/render_note.mdeleted+0−26View file
@@ -1,26 +0,0 @@
1-function y = render_note(note)
2-% MIDI pitch scales the gourd: shell radius and thickness by
3-% s = 2^-((pitch - 60)/12), shifting the shell modes and the cavity
4-% resonance by the semitone ratio while the seed beads stay the same
5-% size, so higher notes are smaller, brighter shakers. Pitch 60 is the
6-% default gourd, playable 48..72; the sound is noise-based, so the note
7-% number sets register rather than a tuned fundamental. Velocity drives
8-% the stroke speed. Fixed: a single stroke, a fixed seed, normalize off
9-% so velocity survives in pascals.
10-P = maraca_defaults();
11-P.sample_rate = note.sample_rate;
12-P.stroke_count = 1;
13-P.seed = 1;
14-P.pre_delay_ms = 0;
15-P.normalize = 0;
16-if note.preview ~= 0
17- P.oversample = 1;
18-end
19-s = 2 ^ (-(max(48, min(72, note.pitch)) - 60) / 12);
20-P.shell_radius_m = P.shell_radius_m * s;
21-P.shell_thickness_m = P.shell_thickness_m * s;
22-P.shake_velocity = 0.2 + (6 - 0.2) * note.velocity;
23-P.grain_count = round(note.params.grain_count);
24-P.burst_width_ms = note.params.burst_width_ms;
25-y = maraca_render(P);
26-end
exports/maraca.jsondeleted+0−70View file
@@ -1,70 +0,0 @@
1-{
2- "format": "numbl-instrument-v1",
3- "id": "maraca",
4- "name": "Maraca",
5- "description": "One maraca stroke per MIDI event; pitch scales the gourd around MIDI 60 (48..72), 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% MIDI pitch scales the gourd: shell radius and thickness by\n% s = 2^-((pitch - 60)/12), shifting the shell modes and the cavity\n% resonance by the semitone ratio while the seed beads stay the same\n% size, so higher notes are smaller, brighter shakers. Pitch 60 is the\n% default gourd, playable 48..72; the sound is noise-based, so the note\n% number sets register rather than a tuned fundamental. Velocity drives\n% the stroke speed. Fixed: a single stroke, a fixed seed, normalize off\n% so velocity 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\ns = 2 ^ (-(max(48, min(72, note.pitch)) - 60) / 12);\nP.shell_radius_m = P.shell_radius_m * s;\nP.shell_thickness_m = P.shell_thickness_m * s;\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- ]
70-}
instrument/.gitignoreadded+1−0View file
@@ -0,0 +1 @@
1+node_modules/
instrument/core.tsadded+534−0View file
@@ -0,0 +1,534 @@
1+// The maraca as a track-render instrument: pure rendering logic with no
2+// DOM or Worker dependency, shared by the browser worker (worker.ts) and
3+// the node tests (test.ts). Two request shapes arrive here:
4+//
5+// - the track path (midiboot): every note of a MIDI track. Notes are
6+// grouped by cache key (pitch and bucketed velocity; a shake ignores
7+// note duration), each distinct stroke renders once through
8+// maraca_render, and copies are summed at the onsets. MIDI pitch
9+// scales the gourd around pitch 60, exactly as the retired per-note
10+// export driver did. Velocity and both knobs feed the stochastic
11+// impact process itself, so there is no pitch-only stage to share and
12+// each distinct sound is one render.
13+//
14+// - the explorer path (the maraca web app, wantDiagnostics set): the
15+// full parameter surface and live-edited .m sources drive one staged
16+// render (solve -> radiate -> output, intermediates cached per tier)
17+// with the diagnostics payload the app's plots consume.
18+
19+import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
20+import type { MFile, TrackRenderRequest } from './types.ts';
21+import { MARACA_KNOBS, type MaracaVariant } from './knobs.ts';
22+import { NoteCache } from './noteCache.ts';
23+
24+export { MARACA_KNOBS, type MaracaVariant };
25+
26+/** The environment the host provides: sources, progress, cancellation, and
27+ * a macrotask yield so a browser worker can receive a cancel message. */
28+export interface RenderEnv {
29+ mfiles: MFile[];
30+ post: (frac: number, stage: string) => void;
31+ /** Throws to abandon the render; called between stages and notes. */
32+ checkCancel: () => void;
33+ yieldToHost: () => Promise<void>;
34+}
35+
36+// --- diagnostics payload for the explorer path (the web app's plots) ---
37+
38+export interface MaracaExploreDiag {
39+ quality: 'preview' | 'full';
40+ impactT: Float32Array;
41+ impactV: Float32Array;
42+ impactPos: Float32Array;
43+ rateT: Float32Array;
44+ rateHz: Float32Array;
45+ modeF: Float32Array;
46+ modeL: Float32Array;
47+ modeZeta: Float32Array;
48+ modeSigma: Float32Array;
49+ diag: {
50+ modesKept: number;
51+ breathingHz: number;
52+ breathingDryHz: number;
53+ impactCount: number;
54+ meanV: number;
55+ maxV: number;
56+ meanTauUs: number;
57+ durationS: number;
58+ peakPa: number;
59+ peakSplDb: number;
60+ directPeakPa: number;
61+ listenerDistM: number;
62+ warnings: string[];
63+ solveMs: number;
64+ radiateMs: number;
65+ solveCached: boolean;
66+ radiateCached: boolean;
67+ oversample: number;
68+ };
69+}
70+
71+// --- shared helpers ---
72+
73+export function hashMFiles(files: MFile[]): string {
74+ let h = 0x811c9dc5;
75+ for (const f of files) {
76+ const s = f.name + ' ' + f.source;
77+ for (let i = 0; i < s.length; i++) {
78+ h ^= s.charCodeAt(i);
79+ h = Math.imul(h, 0x01000193);
80+ }
81+ }
82+ return (h >>> 0).toString(36);
83+}
84+
85+function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
86+ const v = cache.get(key);
87+ if (v !== undefined) {
88+ cache.delete(key);
89+ cache.set(key, v);
90+ }
91+ return v;
92+}
93+
94+function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
95+ cache.delete(key);
96+ cache.set(key, value);
97+ while (cache.size > max) {
98+ const oldest = cache.keys().next().value as string;
99+ cache.delete(oldest);
100+ }
101+}
102+
103+type Params = Record<string, number | boolean>;
104+
105+function pLiteral(params: Params): string {
106+ const lines: string[] = [];
107+ for (const [k, v] of Object.entries(params)) {
108+ if (typeof v === 'number') {
109+ if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
110+ lines.push(`P.${k} = ${v};`);
111+ } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
112+ }
113+ return lines.join('\n');
114+}
115+
116+function keyFor(params: Params, keys: readonly string[]): string {
117+ return keys.map((k) => String(params[k] ?? 'd')).join('|');
118+}
119+
120+function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
121+ return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
122+}
123+
124+function asTensor(v: RuntimeValue): RuntimeTensor {
125+ const t = v as RuntimeTensor;
126+ if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
127+ return t;
128+}
129+
130+function asScalar(v: RuntimeValue): number {
131+ if (typeof v === 'number') return v;
132+ const t = v as RuntimeTensor;
133+ if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
134+ if (typeof v === 'boolean') return v ? 1 : 0;
135+ throw new Error('expected scalar result from numbl stage');
136+}
137+
138+function toF32(v: RuntimeValue): Float32Array {
139+ const t = asTensor(v);
140+ const out = new Float32Array(t.data.length);
141+ for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
142+ return out;
143+}
144+
145+function knobValue(settings: Record<string, number>, key: string): number {
146+ const spec = MARACA_KNOBS.find((k) => k.key === key)!;
147+ const raw = settings[key];
148+ return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
149+}
150+
151+/** Velocity quantized to a few levels so identical strokes share a cached
152+ * render; 8 levels is inaudible next to the model's own dynamics. */
153+export function bucketVelocity(velocity01: number, levels = 8): number {
154+ return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
155+}
156+
157+const SOLVE_KEYS = [
158+ 'shell_radius_m', 'shell_thickness_m',
159+ 'density', 'youngs_modulus', 'poisson_ratio', 'stiffness_trim',
160+ 'max_mode_l', 'max_mode_hz',
161+ 'loss_factor', 'loss_slope', 'radiation_loss',
162+ 'grain_radius_m', 'grain_density', 'grain_youngs', 'grain_size_spread',
163+ 'restitution',
164+ 'stroke_rate_hz', 'stroke_count', 'accent_period', 'accent_gain',
165+ 'grain_count', 'bounces_per_grain', 'shake_velocity', 'slosh_lag_ms', 'burst_width_ms',
166+ 'impact_spread_deg', 'seed',
167+ 'cavity_coupling',
168+ 'speed_of_sound', 'air_density', 'sample_rate', 'tail_s',
169+];
170+
171+const RADIATE_KEYS = [
172+ 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
173+];
174+
175+// Impacts drawn for the raster plot. The full process runs to thousands,
176+// and plotting every one of them is neither visible nor worth the
177+// transfer.
178+const MAX_PLOT_IMPACTS = 4000;
179+
180+interface SolveEntry {
181+ timp: RuntimeValue;
182+ jimp: RuntimeValue;
183+ tauimp: RuntimeValue;
184+ fimp: RuntimeValue;
185+ ximp: RuntimeValue;
186+ fsHi: number;
187+ impactT: Float32Array;
188+ impactV: Float32Array;
189+ impactPos: Float32Array;
190+ rateT: Float32Array;
191+ rateHz: Float32Array;
192+ modeF: Float32Array;
193+ modeL: Float32Array;
194+ modeZeta: Float32Array;
195+ modeSigma: Float32Array;
196+ breathingHz: number;
197+ breathingDryHz: number;
198+ impactCount: number;
199+ meanV: number;
200+ maxV: number;
201+ meanTauUs: number;
202+ durationS: number;
203+}
204+
205+interface RadiateEntry {
206+ p: RuntimeValue;
207+ directPeakPa: number;
208+ listenerDistM: number;
209+ listenerInside: boolean;
210+}
211+
212+// Explorer-tier caches, keyed under the code hash so editing the .m
213+// sources invalidates everything computed from the previous version.
214+const solveCache = new Map<string, SolveEntry>();
215+const radiateCache = new Map<string, RadiateEntry>();
216+const SOLVE_CACHE_MAX = 2;
217+const RADIATE_CACHE_MAX = 3;
218+
219+// Finished per-note audio: an in-memory LRU in front of an IndexedDB
220+// store, so renders survive page reloads (budgets and eviction live in
221+// noteCache.ts).
222+const noteCache = new NoteCache('maraca-instrument-notes');
223+
224+// --- the explorer path (full params, staged, with diagnostics) ---
225+
226+async function renderExplore(
227+ req: TrackRenderRequest,
228+ env: RenderEnv,
229+): Promise<{ audio: Float32Array; diag: MaracaExploreDiag }> {
230+ const params = req.params ?? {};
231+ const mfiles = env.mfiles;
232+ let quality = req.quality;
233+
234+ const codeKey = hashMFiles(mfiles);
235+ const solveParamsKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
236+ const fullOversample = Number(params.oversample ?? 2);
237+ const fullSolveKey = solveParamsKey + `|os=${fullOversample}`;
238+ if (quality === 'preview' && solveCache.has(fullSolveKey)) {
239+ quality = 'full'; // full-rate impact process already cached
240+ }
241+
242+ const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
243+ const sampleRate = Number(params.sample_rate ?? req.sampleRate);
244+ const fsHi = sampleRate * oversample;
245+
246+ const base = `P = maraca_defaults();\n${pLiteral(params)}`;
247+
248+ // ---- solve stage: mode basis and the stochastic impact process ----
249+ const solveKey = solveParamsKey + `|os=${oversample}`;
250+ let solveMs = 0;
251+ let solveCached = true;
252+ let solveEntry = lruGet(solveCache, solveKey);
253+ if (!solveEntry) {
254+ solveCached = false;
255+ env.post(0.15, 'solve');
256+ await env.yieldToHost();
257+ env.checkCancel();
258+ const t0 = performance.now();
259+ const r = runStage(mfiles, `
260+${base}
261+basis = maraca_basis(P);
262+[timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P);
263+modeF = basis.f;
264+modeL = basis.l;
265+modeZeta = basis.zeta;
266+modeSigma = basis.sigma;
267+breathingHz = basis.breathingHz;
268+breathingDryHz = basis.breathingDryHz;
269+impactCount = ginfo.count;
270+meanV = ginfo.meanV;
271+maxV = ginfo.maxV;
272+meanTauUs = ginfo.meanTauUs;
273+durationS = ginfo.durationS;
274+rateT = ginfo.rateT;
275+rateHz = ginfo.rateHz;
276+vimp = ginfo.vimp;
277+`);
278+ solveMs = performance.now() - t0;
279+
280+ const impactCount = asScalar(r.variableValues.impactCount);
281+ const allT = toF32(r.variableValues.timp);
282+ const allV = toF32(r.variableValues.vimp);
283+ const allX = toF32(r.variableValues.ximp);
284+ // thin uniformly for the raster plot, keeping the time ordering
285+ const stride = Math.max(1, Math.ceil(impactCount / MAX_PLOT_IMPACTS));
286+ const nPlot = Math.ceil(impactCount / stride);
287+ const impactT = new Float32Array(nPlot);
288+ const impactV = new Float32Array(nPlot);
289+ const impactPos = new Float32Array(nPlot * 3);
290+ for (let i = 0, j = 0; j < nPlot; i += stride, j++) {
291+ impactT[j] = allT[i];
292+ impactV[j] = allV[i];
293+ impactPos[j * 3] = allX[i * 3];
294+ impactPos[j * 3 + 1] = allX[i * 3 + 1];
295+ impactPos[j * 3 + 2] = allX[i * 3 + 2];
296+ }
297+
298+ solveEntry = {
299+ timp: r.variableValues.timp,
300+ jimp: r.variableValues.jimp,
301+ tauimp: r.variableValues.tauimp,
302+ fimp: r.variableValues.fimp,
303+ ximp: r.variableValues.ximp,
304+ fsHi,
305+ impactT,
306+ impactV,
307+ impactPos,
308+ rateT: toF32(r.variableValues.rateT),
309+ rateHz: toF32(r.variableValues.rateHz),
310+ modeF: toF32(r.variableValues.modeF),
311+ modeL: toF32(r.variableValues.modeL),
312+ modeZeta: toF32(r.variableValues.modeZeta),
313+ modeSigma: toF32(r.variableValues.modeSigma),
314+ breathingHz: asScalar(r.variableValues.breathingHz),
315+ breathingDryHz: asScalar(r.variableValues.breathingDryHz),
316+ impactCount,
317+ meanV: asScalar(r.variableValues.meanV),
318+ maxV: asScalar(r.variableValues.maxV),
319+ meanTauUs: asScalar(r.variableValues.meanTauUs),
320+ durationS: asScalar(r.variableValues.durationS),
321+ };
322+ lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
323+ }
324+
325+ // ---- radiate stage ----
326+ const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
327+ let radiateMs = 0;
328+ let radiateCached = true;
329+ let radEntry = lruGet(radiateCache, radiateKey);
330+ if (!radEntry) {
331+ radiateCached = false;
332+ env.post(0.55, 'radiate');
333+ await env.yieldToHost();
334+ env.checkCancel();
335+ const t0 = performance.now();
336+ const r = runStage(mfiles, `
337+${base}
338+basis = maraca_basis(P);
339+[p, radinfo] = maraca_radiate(P, basis, timp, jimp, tauimp, fimp, ximp, ${solveEntry.fsHi});
340+directPeakPa = radinfo.directPeakPa;
341+listenerDistM = radinfo.listenerDistM;
342+listenerInside = radinfo.listenerInside;
343+`, {
344+ timp: solveEntry.timp,
345+ jimp: solveEntry.jimp,
346+ tauimp: solveEntry.tauimp,
347+ fimp: solveEntry.fimp,
348+ ximp: solveEntry.ximp,
349+ });
350+ radiateMs = performance.now() - t0;
351+ radEntry = {
352+ p: r.variableValues.p,
353+ directPeakPa: asScalar(r.variableValues.directPeakPa),
354+ listenerDistM: asScalar(r.variableValues.listenerDistM),
355+ listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
356+ };
357+ lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
358+ }
359+
360+ // ---- output stage ----
361+ env.post(0.9, 'output');
362+ await env.yieldToHost();
363+ env.checkCancel();
364+ const rOut = runStage(mfiles, `
365+${base}
366+[y, peakPa] = maraca_output(P, p, ${oversample});
367+`, { p: radEntry.p });
368+
369+ const y32 = toF32(rOut.variableValues.y);
370+ const peakPa = asScalar(rOut.variableValues.peakPa);
371+
372+ const warnings: string[] = [];
373+ if (radEntry.listenerInside) {
374+ warnings.push('the listener is at or inside the shell surface; the multipole field is evaluated at the nearest valid radius instead');
375+ }
376+ if (!params.normalize) {
377+ let peak = 0;
378+ for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
379+ if (peak > 1.0) {
380+ warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
381+ }
382+ }
383+ const topMode = solveEntry.modeF[solveEntry.modeF.length - 1];
384+ const maxModeHz = Number(params.max_mode_hz ?? 0);
385+ if (maxModeHz > 0 && topMode < 0.4 * maxModeHz) {
386+ warnings.push(`the highest retained mode is only ${topMode.toFixed(0)} Hz; raise max degree l to keep the shell from sounding truncated`);
387+ }
388+
389+ const diag: MaracaExploreDiag = {
390+ quality,
391+ impactT: solveEntry.impactT.slice(),
392+ impactV: solveEntry.impactV.slice(),
393+ impactPos: solveEntry.impactPos.slice(),
394+ rateT: solveEntry.rateT.slice(),
395+ rateHz: solveEntry.rateHz.slice(),
396+ modeF: solveEntry.modeF.slice(),
397+ modeL: solveEntry.modeL.slice(),
398+ modeZeta: solveEntry.modeZeta.slice(),
399+ modeSigma: solveEntry.modeSigma.slice(),
400+ diag: {
401+ modesKept: solveEntry.modeF.length,
402+ breathingHz: solveEntry.breathingHz,
403+ breathingDryHz: solveEntry.breathingDryHz,
404+ impactCount: solveEntry.impactCount,
405+ meanV: solveEntry.meanV,
406+ maxV: solveEntry.maxV,
407+ meanTauUs: solveEntry.meanTauUs,
408+ durationS: solveEntry.durationS,
409+ peakPa,
410+ peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
411+ directPeakPa: radEntry.directPeakPa,
412+ listenerDistM: radEntry.listenerDistM,
413+ warnings,
414+ solveMs,
415+ radiateMs,
416+ solveCached,
417+ radiateCached,
418+ oversample,
419+ },
420+ };
421+ return { audio: y32, diag };
422+}
423+
424+// --- the track path (midiboot): per-stroke rendering with caching ---
425+
426+function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string {
427+ // The note-to-parameter mapping: MIDI pitch scales the gourd, shell
428+ // radius and thickness by s = 2^-((pitch - 60)/12), shifting the shell
429+ // modes and the cavity resonance by the semitone ratio while the seed
430+ // beads stay the same size, so higher notes are smaller, brighter
431+ // shakers. Pitch 60 is the default gourd, playable 48..72; the sound is
432+ // noise-based, so the note number sets register rather than a tuned
433+ // fundamental. Velocity drives the stroke speed. Fixed: a single
434+ // stroke, a fixed seed, normalize off so velocity survives in pascals.
435+ const settings = req.settings ?? {};
436+ const s = Math.pow(2, -(Math.max(48, Math.min(72, Math.round(note.pitch))) - 60) / 12);
437+ const lines = [
438+ 'P = maraca_defaults();',
439+ `P.sample_rate = ${req.sampleRate};`,
440+ 'P.stroke_count = 1;',
441+ 'P.seed = 1;',
442+ 'P.pre_delay_ms = 0;',
443+ 'P.normalize = 0;',
444+ ];
445+ if (req.quality === 'preview') lines.push('P.oversample = 1;');
446+ lines.push(
447+ `P.shell_radius_m = P.shell_radius_m * ${s};`,
448+ `P.shell_thickness_m = P.shell_thickness_m * ${s};`,
449+ `P.shake_velocity = ${0.2 + (6 - 0.2) * note.velocity};`,
450+ `P.grain_count = ${Math.round(knobValue(settings, 'grain_count'))};`,
451+ `P.burst_width_ms = ${knobValue(settings, 'burst_width_ms')};`,
452+ );
453+ return lines.join('\n');
454+}
455+
456+async function renderTrackNotes(
457+ req: TrackRenderRequest,
458+ env: RenderEnv,
459+): Promise<{ audio: Float32Array }> {
460+ if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
461+ const codeKey = hashMFiles(env.mfiles);
462+ const settings = req.settings ?? {};
463+ const knobsKey = MARACA_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
464+
465+ // Group notes by everything that affects their sound. A shake ignores
466+ // note duration, so a repeated pattern costs one render per distinct
467+ // pitch and velocity level.
468+ const groups = new Map<string, { pitch: number; velocity: number; onsets: number[] }>();
469+ for (const n of req.notes) {
470+ const pitch = Math.round(n.pitch);
471+ const vel = bucketVelocity(n.velocity);
472+ const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${pitch}|${vel}`;
473+ let g = groups.get(key);
474+ if (!g) groups.set(key, (g = { pitch, velocity: vel, onsets: [] }));
475+ g.onsets.push(n.timeSec);
476+ }
477+
478+ // Resolve cache hits first (memory, then IndexedDB), so the progress
479+ // denominator counts only the sounds that actually need rendering.
480+ const resolved = new Map<string, Float32Array>();
481+ for (const key of groups.keys()) {
482+ const hit = await noteCache.get(key);
483+ if (hit) resolved.set(key, hit);
484+ }
485+ const misses = groups.size - resolved.size;
486+
487+ const placed: { buf: Float32Array; onsets: number[] }[] = [];
488+ let done = 0;
489+ for (const [key, g] of groups) {
490+ let buf = resolved.get(key);
491+ if (!buf) {
492+ env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: solve`);
493+ await env.yieldToHost();
494+ env.checkCancel();
495+ const r = runStage(env.mfiles, `
496+${notePLines(g, req)}
497+[y, basis, dinfo] = maraca_render(P);
498+`);
499+ buf = toF32(r.variableValues.y);
500+ noteCache.put(key, buf);
501+ done++;
502+ }
503+ placed.push({ buf, onsets: g.onsets });
504+ }
505+
506+ // lengthSamples is a minimum: size the output to the longest tail so a
507+ // ring that outlives the last note is returned in full, and place every
508+ // copy.
509+ let length = req.lengthSamples;
510+ for (const p of placed) {
511+ for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
512+ }
513+ const out = new Float32Array(length);
514+ for (const p of placed) {
515+ for (const t of p.onsets) {
516+ const off = Math.round(t * req.sampleRate);
517+ const end = Math.min(out.length, off + p.buf.length);
518+ for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
519+ }
520+ }
521+ return { audio: out };
522+}
523+
524+/** The single entry point: explorer path when diagnostics are requested,
525+ * the per-stroke track path otherwise. */
526+export async function renderRequest(
527+ variant: MaracaVariant,
528+ req: TrackRenderRequest,
529+ env: RenderEnv,
530+): Promise<{ audio: Float32Array; diag?: MaracaExploreDiag }> {
531+ void variant;
532+ if (req.wantDiagnostics) return renderExplore(req, env);
533+ return renderTrackNotes(req, env);
534+}
instrument/index.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+// The maraca behind the generic track-render instrument interface, for
2+// direct import by midiboot and by the maraca web app. The MATLAB model
3+// in ../matlab is executed by numbl in a lazily spawned worker that this
4+// module owns; the worker is kept alive across renders so its caches (the
5+// explorer stage tiers and finished stroke renders) persist. Aborting a
6+// request is cooperative, checked between render stages; if the worker
7+// does not respond within a grace period it is torn down (and the caches
8+// with it) and respawned.
9+import type { Instrument, TrackRenderRequest, TrackRenderResult } from './types.ts';
10+import { MARACA_KNOBS, type MaracaVariant } from './knobs.ts';
11+import type { WorkerCancelMsg, WorkerRenderMsg, WorkerResponse } from './worker.ts';
12+
13+const CANCEL_GRACE_MS = 5000;
14+
15+interface Pending {
16+ resolve: (r: TrackRenderResult) => void;
17+ reject: (e: Error) => void;
18+ onProgress?: (p: { frac: number; stage: string }) => void;
19+}
20+
21+let worker: Worker | null = null;
22+let nextId = 1;
23+const pending = new Map<number, Pending>();
24+
25+function ensureWorker(): Worker {
26+ if (worker) return worker;
27+ worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
28+ worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
29+ const msg = ev.data;
30+ const p = pending.get(msg.id);
31+ if (!p) return;
32+ if (msg.kind === 'progress') {
33+ p.onProgress?.({ frac: msg.frac, stage: msg.stage });
34+ return;
35+ }
36+ pending.delete(msg.id);
37+ if (msg.kind === 'result') p.resolve({ audio: msg.audio, diag: msg.diag });
38+ else if (msg.kind === 'cancelled') p.reject(new DOMException('render cancelled', 'AbortError'));
39+ else p.reject(Object.assign(new Error(msg.message), { file: msg.file, line: msg.line }));
40+ };
41+ worker.onerror = (ev) => {
42+ const err = new Error(`maraca instrument worker failed: ${ev.message}`);
43+ for (const p of pending.values()) p.reject(err);
44+ pending.clear();
45+ };
46+ return worker;
47+}
48+
49+function teardownWorker(reason: Error): void {
50+ if (worker) worker.terminate();
51+ worker = null;
52+ for (const p of pending.values()) p.reject(reason);
53+ pending.clear();
54+}
55+
56+function renderOnWorker(variant: MaracaVariant, req: TrackRenderRequest): Promise<TrackRenderResult> {
57+ if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError'));
58+ const w = ensureWorker();
59+ const id = nextId++;
60+ const msg: WorkerRenderMsg = {
61+ kind: 'render',
62+ id,
63+ variant,
64+ notes: req.notes.map((n) => ({ timeSec: n.timeSec, durationSec: n.durationSec, pitch: n.pitch, velocity: n.velocity })),
65+ sampleRate: req.sampleRate,
66+ lengthSamples: req.lengthSamples,
67+ quality: req.quality,
68+ settings: req.settings ?? {},
69+ params: req.params,
70+ mfiles: req.mfiles,
71+ wantDiagnostics: req.wantDiagnostics,
72+ };
73+ return new Promise<TrackRenderResult>((resolve, reject) => {
74+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
75+ const onAbort = () => {
76+ const cancel: WorkerCancelMsg = { kind: 'cancel', id };
77+ worker?.postMessage(cancel);
78+ // Escalation: a stage that never yields (or a hung worker) is torn
79+ // down after the grace period, losing the caches.
80+ graceTimer = setTimeout(() => {
81+ if (pending.has(id)) teardownWorker(new DOMException('render cancelled', 'AbortError'));
82+ }, CANCEL_GRACE_MS);
83+ };
84+ req.signal?.addEventListener('abort', onAbort, { once: true });
85+ pending.set(id, {
86+ resolve: (r) => {
87+ req.signal?.removeEventListener('abort', onAbort);
88+ if (graceTimer !== null) clearTimeout(graceTimer);
89+ resolve(r);
90+ },
91+ reject: (e) => {
92+ req.signal?.removeEventListener('abort', onAbort);
93+ if (graceTimer !== null) clearTimeout(graceTimer);
94+ reject(e);
95+ },
96+ onProgress: req.onProgress,
97+ });
98+ w.postMessage(msg);
99+ });
100+}
101+
102+class MaracaInstrument implements Instrument {
103+ readonly id = 'maraca';
104+ readonly name = 'Maraca';
105+ readonly knobs = MARACA_KNOBS;
106+ renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
107+ return renderOnWorker('maraca', req);
108+ }
109+}
110+
111+export const maracaInstrument: Instrument = new MaracaInstrument();
112+export const maracaInstruments: Instrument[] = [maracaInstrument];
instrument/knobs.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+// Curated knob declarations, in their own module so the app-facing
2+// index.ts can import them without pulling numbl into the main bundle.
3+import type { KnobSpec } from './types.ts';
4+
5+export const MARACA_KNOBS: KnobSpec[] = [
6+ { key: 'grain_count', label: 'grain count', min: 40, max: 600, default: 320, step: 1 },
7+ { key: 'burst_width_ms', label: 'burst width (ms)', min: 5, max: 120, default: 45 },
8+];
9+
10+export type MaracaVariant = 'maraca';
instrument/mfiles.tsadded+14−0View file
@@ -0,0 +1,14 @@
1+// The shipped MATLAB sources, loaded from ../matlab at build time. Browser
2+// only (import.meta.glob is a Vite feature); node tests read the same files
3+// through fs instead.
4+import type { MFile } from './types.ts';
5+
6+const modules = import.meta.glob('../matlab/*.m', {
7+ eager: true,
8+ query: '?raw',
9+ import: 'default',
10+}) as Record<string, string>;
11+
12+export const DEFAULT_MFILES: MFile[] = Object.entries(modules)
13+ .map(([path, source]) => ({ name: path.split('/').pop()!, source }))
14+ .sort((a, b) => a.name.localeCompare(b.name));
instrument/noteCache.tsadded+180−0View file
@@ -0,0 +1,180 @@
1+// Persistent cache for finished note renders: an in-memory LRU in front of
2+// an IndexedDB store, so a rendered note survives page reloads, worker
3+// teardowns, and browser restarts. Keys already carry everything that
4+// identifies a sound (the .m code hash, quality tier, sample rate, knob
5+// values, pitch, bucketed velocity/duration), so a stale entry from older
6+// code is simply never asked for again and ages out.
7+//
8+// Eviction is by budget and by age. The memory tier is bounded by total
9+// samples and evicts least-recently-used, as before. The IndexedDB tier
10+// has its own larger sample budget, trimmed least-recently-used after
11+// each write, and entries unused for 30 days are dropped when the store
12+// is opened. Sample data and bookkeeping live in separate object stores
13+// ('data' and 'meta'), so the eviction scan never loads audio.
14+//
15+// Everything degrades to memory-only: no indexedDB global (the node
16+// tests), a blocked or failing database, or a private-mode quota all just
17+// disable persistence.
18+
19+interface MetaRec {
20+ key: string;
21+ sampleCount: number;
22+ lastUsedAt: number;
23+}
24+
25+interface DataRec {
26+ key: string;
27+ samples: Float32Array;
28+}
29+
30+const MEM_MAX_SAMPLES = 48e6; // ~192 MB of Float32 renders in memory
31+const DB_MAX_SAMPLES = 96e6; // ~384 MB on disk
32+const MAX_AGE_MS = 30 * 24 * 3600e3;
33+
34+function reqDone<T>(req: IDBRequest<T>): Promise<T> {
35+ return new Promise((resolve, reject) => {
36+ req.onsuccess = () => resolve(req.result);
37+ req.onerror = () => reject(req.error);
38+ });
39+}
40+
41+export class NoteCache {
42+ private mem = new Map<string, Float32Array>();
43+ private memSamples = 0;
44+ private dbPromise: Promise<IDBDatabase | null> | null = null;
45+ /** Bookkeeping mirror of the 'meta' store, loaded once at open. */
46+ private meta = new Map<string, MetaRec>();
47+ private dbSamples = 0;
48+
49+ constructor(private readonly dbName: string) {}
50+
51+ private open(): Promise<IDBDatabase | null> {
52+ if (this.dbPromise) return this.dbPromise;
53+ this.dbPromise = (async () => {
54+ if (typeof indexedDB === 'undefined') return null;
55+ try {
56+ const req = indexedDB.open(this.dbName, 1);
57+ req.onupgradeneeded = () => {
58+ const db = req.result;
59+ if (!db.objectStoreNames.contains('data')) db.createObjectStore('data', { keyPath: 'key' });
60+ if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'key' });
61+ };
62+ const db = await new Promise<IDBDatabase>((resolve, reject) => {
63+ req.onsuccess = () => resolve(req.result);
64+ req.onerror = () => reject(req.error);
65+ req.onblocked = () => reject(new Error('blocked'));
66+ });
67+ db.onversionchange = () => db.close();
68+
69+ // Load the bookkeeping and drop entries unused for too long.
70+ const metas = (await reqDone(
71+ db.transaction('meta', 'readonly').objectStore('meta').getAll(),
72+ )) as MetaRec[];
73+ const now = Date.now();
74+ const stale: string[] = [];
75+ for (const m of metas) {
76+ if (now - m.lastUsedAt > MAX_AGE_MS) stale.push(m.key);
77+ else {
78+ this.meta.set(m.key, m);
79+ this.dbSamples += m.sampleCount;
80+ }
81+ }
82+ if (stale.length > 0) {
83+ const tx = db.transaction(['data', 'meta'], 'readwrite');
84+ for (const key of stale) {
85+ tx.objectStore('data').delete(key);
86+ tx.objectStore('meta').delete(key);
87+ }
88+ }
89+ return db;
90+ } catch {
91+ return null; // persistence unavailable; memory tier still works
92+ }
93+ })();
94+ return this.dbPromise;
95+ }
96+
97+ private memPut(key: string, samples: Float32Array): void {
98+ const old = this.mem.get(key);
99+ if (old) this.memSamples -= old.length;
100+ this.mem.delete(key);
101+ this.mem.set(key, samples);
102+ this.memSamples += samples.length;
103+ while (this.memSamples > MEM_MAX_SAMPLES && this.mem.size > 1) {
104+ const oldest = this.mem.keys().next().value as string;
105+ this.memSamples -= this.mem.get(oldest)!.length;
106+ this.mem.delete(oldest);
107+ }
108+ }
109+
110+ /** Record a use; fire-and-forget so hits never wait on a write. */
111+ private touch(key: string): void {
112+ const m = this.meta.get(key);
113+ if (!m) return;
114+ m.lastUsedAt = Date.now();
115+ void this.open().then((db) => {
116+ if (!db) return;
117+ try {
118+ db.transaction('meta', 'readwrite').objectStore('meta').put(m);
119+ } catch { /* persistence unavailable */ }
120+ });
121+ }
122+
123+ async get(key: string): Promise<Float32Array | undefined> {
124+ const hit = this.mem.get(key);
125+ if (hit) {
126+ this.mem.delete(key);
127+ this.mem.set(key, hit); // LRU refresh
128+ this.touch(key);
129+ return hit;
130+ }
131+ const db = await this.open();
132+ if (!db) return undefined;
133+ try {
134+ const rec = (await reqDone(
135+ db.transaction('data', 'readonly').objectStore('data').get(key),
136+ )) as DataRec | undefined;
137+ if (!rec) return undefined;
138+ const samples = rec.samples instanceof Float32Array ? rec.samples : new Float32Array(rec.samples as ArrayLike<number>);
139+ this.memPut(key, samples);
140+ this.touch(key);
141+ return samples;
142+ } catch {
143+ return undefined;
144+ }
145+ }
146+
147+ /** Store a fresh render: memory now, disk in the background. */
148+ put(key: string, samples: Float32Array): void {
149+ this.memPut(key, samples);
150+ void this.persist(key, samples);
151+ }
152+
153+ private async persist(key: string, samples: Float32Array): Promise<void> {
154+ const db = await this.open();
155+ if (!db) return;
156+ try {
157+ const prev = this.meta.get(key);
158+ if (prev) this.dbSamples -= prev.sampleCount;
159+ const m: MetaRec = { key, sampleCount: samples.length, lastUsedAt: Date.now() };
160+ const tx = db.transaction(['data', 'meta'], 'readwrite');
161+ tx.objectStore('data').put({ key, samples } satisfies DataRec);
162+ tx.objectStore('meta').put(m);
163+ this.meta.set(key, m);
164+ this.dbSamples += m.sampleCount;
165+
166+ // Trim least-recently-used until the disk tier is under budget.
167+ if (this.dbSamples > DB_MAX_SAMPLES) {
168+ const byAge = [...this.meta.values()].sort((a, b) => a.lastUsedAt - b.lastUsedAt);
169+ for (const victim of byAge) {
170+ if (this.dbSamples <= DB_MAX_SAMPLES) break;
171+ if (victim.key === key) continue; // never evict what was just written
172+ tx.objectStore('data').delete(victim.key);
173+ tx.objectStore('meta').delete(victim.key);
174+ this.meta.delete(victim.key);
175+ this.dbSamples -= victim.sampleCount;
176+ }
177+ }
178+ } catch { /* persistence unavailable; the memory tier holds it */ }
179+ }
180+}
instrument/package-lock.jsonadded+2301−0View file
This diff is 2,306 lines long and is not shown.
instrument/package.jsonadded+16−0View file
@@ -0,0 +1,16 @@
1+{
2+ "name": "maraca-instrument",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "The maraca model behind the track-render instrument interface",
7+ "scripts": {
8+ "test": "tsx test.ts"
9+ },
10+ "dependencies": {
11+ "numbl": "^0.4.19"
12+ },
13+ "devDependencies": {
14+ "tsx": "^4.19.0"
15+ }
16+}
instrument/test.tsadded+113−0View file
@@ -0,0 +1,113 @@
1+// Node-side test of the maraca track-render instrument: executes the
2+// actual MATLAB model through numbl (no browser, no worker) and checks the
3+// audio. Run with: npx tsx test.ts
4+import { readdirSync, readFileSync } from 'node:fs';
5+import { join, dirname } from 'node:path';
6+import { fileURLToPath } from 'node:url';
7+import { renderRequest, type RenderEnv } from './core.ts';
8+import type { NoteSpec, TrackRenderRequest } from './types.ts';
9+
10+const matlabDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'matlab');
11+const mfiles = readdirSync(matlabDir)
12+ .filter((f) => f.endsWith('.m'))
13+ .sort()
14+ .map((f) => ({ name: f, source: readFileSync(join(matlabDir, f), 'utf8') }));
15+
16+const env: RenderEnv = {
17+ mfiles,
18+ post: () => {},
19+ checkCancel: () => {},
20+ yieldToHost: async () => {},
21+};
22+
23+let failures = 0;
24+function check(label: string, ok: boolean, detail = ''): void {
25+ console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
26+ if (!ok) failures++;
27+}
28+
29+function stats(y: Float32Array) {
30+ let peak = 0;
31+ let finite = true;
32+ for (let i = 0; i < y.length; i++) {
33+ if (!Number.isFinite(y[i])) finite = false;
34+ peak = Math.max(peak, Math.abs(y[i]));
35+ }
36+ return { peak, finite };
37+}
38+
39+const SR = 22050;
40+
41+function req(notes: NoteSpec[], overrides: Partial<TrackRenderRequest> = {}): TrackRenderRequest {
42+ const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0);
43+ return {
44+ notes,
45+ sampleRate: SR,
46+ lengthSamples: Math.ceil((end + 1.0) * SR),
47+ quality: 'preview',
48+ settings: {},
49+ ...overrides,
50+ };
51+}
52+
53+const note = (timeSec: number, pitch: number, velocity: number): NoteSpec =>
54+ ({ timeSec, durationSec: 0.1, pitch, velocity });
55+
56+const t0 = performance.now();
57+
58+// One stroke: finite, audible, decaying.
59+const a = await renderRequest('maraca', req([note(0, 60, 0.7)]), env);
60+{
61+ const s = stats(a.audio);
62+ check('single stroke is finite', s.finite);
63+ check('single stroke is audible', s.peak > 1e-5, `peak ${s.peak.toExponential(2)}`);
64+ const n = a.audio.length;
65+ const head = stats(a.audio.slice(0, Math.floor(n / 3))).peak;
66+ const tail = stats(a.audio.slice(Math.floor((2 * n) / 3))).peak;
67+ check('single stroke decays', tail < head * 0.8, `head ${head.toExponential(2)} tail ${tail.toExponential(2)}`);
68+}
69+
70+// The note cache.
71+{
72+ const t = performance.now();
73+ await renderRequest('maraca', req([note(0, 60, 0.7)]), env);
74+ const ms = performance.now() - t;
75+ check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`);
76+}
77+
78+// Velocity changes the sound (normalize is off, so amplitude scales).
79+const soft = await renderRequest('maraca', req([note(0, 60, 0.2)]), env);
80+{
81+ const pa = stats(a.audio).peak;
82+ const ps = stats(soft.audio).peak;
83+ check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`);
84+}
85+
86+// Pitch and knobs change the sound.
87+const high = await renderRequest('maraca', req([note(0, 67, 0.7)]), env);
88+{
89+ let diff = high.audio.length !== a.audio.length;
90+ if (!diff) for (let i = 0; i < a.audio.length; i++) if (Math.abs(a.audio[i] - high.audio[i]) > 1e-9) { diff = true; break; }
91+ check('pitch changes the sound', diff);
92+}
93+const sparse = await renderRequest('maraca', req([note(0, 60, 0.7)], { settings: { grain_count: 60 } }), env);
94+{
95+ let diff = false;
96+ const n = Math.min(a.audio.length, sparse.audio.length);
97+ for (let i = 0; i < n; i++) if (Math.abs(a.audio[i] - sparse.audio[i]) > 1e-9) { diff = true; break; }
98+ check('grain_count knob changes the sound', diff);
99+}
100+
101+// A pattern: repeats from the cache, distinct sounds at their onsets.
102+const tr = await renderRequest('maraca', req([note(0, 60, 0.7), note(0.5, 60, 0.7), note(1.0, 67, 0.7)]), env);
103+{
104+ const s = stats(tr.audio);
105+ check('track render is finite', s.finite);
106+ const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * SR), Math.floor((sec + 0.25) * SR))).peak;
107+ check('energy at first onset', at(0.0) > 1e-5);
108+ check('energy at second onset', at(0.5) > 1e-5);
109+ check('energy at third onset', at(1.0) > 1e-5);
110+}
111+
112+console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
113+if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }
instrument/types.tsadded+91−0View file
@@ -0,0 +1,91 @@
1+// The track-render instrument contract shared between midiboot and the
2+// instrument web apps. Each simulated-instrument repo carries its own
3+// structurally identical copy of these types (there is no shared package;
4+// TypeScript's structural typing keeps the copies interchangeable), and
5+// midiboot's src/instruments/types.ts is the reference.
6+//
7+// An instrument receives an entire MIDI track per call: every note with
8+// times already in seconds. It owns its rendering strategy end to end,
9+// including any internal caching (per-note, per-stage, or none), progress
10+// reporting, and cancellation. The optional fields at the bottom exist for
11+// the instrument's own web app, which drives the full parameter surface
12+// and live-edited MATLAB sources; midiboot never sets them.
13+
14+export interface NoteSpec {
15+ timeSec: number;
16+ durationSec: number;
17+ pitch: number; // MIDI note number
18+ velocity: number; // 0..1
19+}
20+
21+/** A user-editable instrument parameter, declared by the instrument. */
22+export interface KnobSpec {
23+ key: string;
24+ label: string;
25+ min: number;
26+ max: number;
27+ default: number;
28+ step?: number;
29+ scale?: 'log';
30+}
31+
32+/** One MATLAB source file, as numbl consumes it. */
33+export interface MFile {
34+ name: string;
35+ source: string;
36+}
37+
38+export interface RenderProgress {
39+ /** 0..1 through the whole request. */
40+ frac: number;
41+ /** Short human-readable stage label, e.g. "note 3/8: solve". */
42+ stage: string;
43+}
44+
45+export interface TrackRenderRequest {
46+ /** Every note of the track, sorted by timeSec. */
47+ notes: NoteSpec[];
48+ sampleRate: number;
49+ /** Song length incl. tail, as a minimum: the returned buffer may be
50+ * longer, so a ring that outlives the last note (a cymbal crash, a
51+ * long bass tail) is kept in full; the host mixes buffers of unequal
52+ * lengths. */
53+ lengthSamples: number;
54+ /** Fast preview for playback, full quality for export. */
55+ quality: 'preview' | 'full';
56+ /** Values for the instrument's knobs; missing keys mean the default. */
57+ settings?: Record<string, number>;
58+ /** Cooperative cancellation; checked between stages and notes. */
59+ signal?: AbortSignal;
60+ onProgress?: (p: RenderProgress) => void;
61+
62+ // --- fields used only by the instrument's own web app ---
63+
64+ /** Full model parameter overrides, applied after the defaults (and after
65+ * any note mapping). midiboot never sets this. */
66+ params?: Record<string, number | boolean>;
67+ /** Live-edited MATLAB sources replacing the shipped ones. */
68+ mfiles?: MFile[];
69+ /** Also compute the instrument-specific diagnostics payload. */
70+ wantDiagnostics?: boolean;
71+}
72+
73+export interface TrackRenderResult {
74+ audio: Float32Array;
75+ /** Instrument-specific diagnostics; present only when requested. */
76+ diag?: unknown;
77+}
78+
79+export interface LiveVoice {
80+ noteOff(): void; // begin release
81+ cut(): void; // stop immediately
82+}
83+
84+export interface Instrument {
85+ id: string;
86+ name: string;
87+ /** Curated editable parameters; tracks store their values in settings. */
88+ knobs?: KnobSpec[];
89+ renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult>;
90+ noteOn?(ctx: BaseAudioContext, dest: AudioNode, pitch: number, velocity: number, whenSec: number): LiveVoice;
91+}
instrument/worker.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+// The render worker: numbl runs here so a seconds-long solve never
2+// blocks the UI. Requests are processed one at a time; the core yields to
3+// the macrotask queue between stages, which is when queued cancel messages
4+// are delivered, so cancellation is cooperative and the caches survive it.
5+import { renderRequest, type MaracaVariant } from './core.ts';
6+import { DEFAULT_MFILES } from './mfiles.ts';
7+import type { MFile, NoteSpec } from './types.ts';
8+
9+export interface WorkerRenderMsg {
10+ kind: 'render';
11+ id: number;
12+ variant: MaracaVariant;
13+ notes: NoteSpec[];
14+ sampleRate: number;
15+ lengthSamples: number;
16+ quality: 'preview' | 'full';
17+ settings?: Record<string, number>;
18+ params?: Record<string, number | boolean>;
19+ mfiles?: MFile[];
20+ wantDiagnostics?: boolean;
21+}
22+
23+export interface WorkerCancelMsg {
24+ kind: 'cancel';
25+ id: number;
26+}
27+
28+export type WorkerResponse =
29+ | { kind: 'progress'; id: number; frac: number; stage: string }
30+ | { kind: 'result'; id: number; audio: Float32Array; diag?: unknown }
31+ | { kind: 'cancelled'; id: number }
32+ | { kind: 'error'; id: number; message: string; file?: string; line?: number };
33+
34+class Cancelled extends Error {}
35+
36+const cancelled = new Set<number>();
37+const queue: WorkerRenderMsg[] = [];
38+let running = false;
39+
40+const post = (m: WorkerResponse, transfer?: Transferable[]) =>
41+ (self as unknown as Worker).postMessage(m, transfer ?? []);
42+
43+const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
44+
45+async function run(msg: WorkerRenderMsg): Promise<void> {
46+ // numbl reports a file it could not parse through console.warn and then
47+ // skips it, so the failure surfaces at the call site as "Undefined
48+ // function". Capture the warning to report the actual syntax error.
49+ const warnings: string[] = [];
50+ const origWarn = console.warn;
51+ console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); };
52+ try {
53+ const result = await renderRequest(msg.variant, {
54+ notes: msg.notes,
55+ sampleRate: msg.sampleRate,
56+ lengthSamples: msg.lengthSamples,
57+ quality: msg.quality,
58+ settings: msg.settings,
59+ params: msg.params,
60+ wantDiagnostics: msg.wantDiagnostics,
61+ }, {
62+ mfiles: msg.mfiles ?? DEFAULT_MFILES,
63+ post: (frac, stage) => post({ kind: 'progress', id: msg.id, frac, stage }),
64+ checkCancel: () => { if (cancelled.has(msg.id)) throw new Cancelled(); },
65+ yieldToHost: () => new Promise((res) => setTimeout(res, 0)),
66+ });
67+ const transfer: Transferable[] = [result.audio.buffer];
68+ if (result.diag) {
69+ for (const v of Object.values(result.diag as unknown as Record<string, unknown>)) {
70+ if (v instanceof Float32Array && v.buffer !== result.audio.buffer) transfer.push(v.buffer);
71+ }
72+ }
73+ post({ kind: 'result', id: msg.id, audio: result.audio, diag: result.diag }, transfer);
74+ } catch (err) {
75+ if (err instanceof Cancelled) {
76+ post({ kind: 'cancelled', id: msg.id });
77+ } else {
78+ const e = err as { message?: string; file?: string; line?: number };
79+ let m: WorkerResponse = {
80+ kind: 'error',
81+ id: msg.id,
82+ message: e?.message ? String(e.message) : String(err),
83+ file: typeof e?.file === 'string' ? e.file : undefined,
84+ line: typeof e?.line === 'number' ? e.line : undefined,
85+ };
86+ for (const w of warnings) {
87+ const hit = SKIP_WARNING.exec(w);
88+ if (hit) {
89+ m = { kind: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
90+ break;
91+ }
92+ }
93+ post(m);
94+ }
95+ } finally {
96+ console.warn = origWarn;
97+ cancelled.delete(msg.id);
98+ }
99+}
100+
101+async function pump(): Promise<void> {
102+ if (running) return;
103+ running = true;
104+ while (queue.length > 0) {
105+ const msg = queue.shift()!;
106+ if (cancelled.has(msg.id)) { post({ kind: 'cancelled', id: msg.id }); cancelled.delete(msg.id); continue; }
107+ await run(msg);
108+ }
109+ running = false;
110+}
111+
112+self.onmessage = (ev: MessageEvent<WorkerRenderMsg | WorkerCancelMsg>) => {
113+ const msg = ev.data;
114+ if (msg.kind === 'render') {
115+ queue.push(msg);
116+ void pump();
117+ } else if (msg.kind === 'cancel') {
118+ cancelled.add(msg.id);
119+ }
120+};
scripts/build-exports.mjsdeleted+0−79View file
@@ -1,79 +0,0 @@
1-#!/usr/bin/env node
2-// Build exported instrument bundles (format numbl-instrument-v1) for one
3-// instrument repository. Run from the repo root:
4-//
5-// node scripts/build-exports.mjs
6-//
7-// For each export-src/<name>/export.json, the bundle's files are that
8-// directory's .m sources (the drivers, render_note.m required) plus every
9-// matlab/*.m of the repo, and the result is written to exports/<id>.json.
10-// The canonical copy of this script lives in
11-// simulated_instruments/instrument-format; instrument repos carry a copy so
12-// they build standalone.
13-
14-import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
15-import { join } from 'node:path';
16-
17-const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
18-const SRC = 'export-src';
19-const OUT = 'exports';
20-
21-function fail(msg) {
22- console.error(`build-exports: ${msg}`);
23- process.exit(1);
24-}
25-
26-function checkKnobs(id, knobs) {
27- if (!Array.isArray(knobs)) fail(`${id}: 'knobs' must be an array`);
28- for (const k of knobs) {
29- if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(`${id}: knobs need key and label`);
30- if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
31- fail(`${id}: knob '${k.key}' needs min < max and a default`);
32- }
33- if (k.default < k.min || k.default > k.max) fail(`${id}: knob '${k.key}' default outside its range`);
34- if (k.step !== undefined && !(k.step > 0)) fail(`${id}: knob '${k.key}' step must be > 0`);
35- if (k.scale !== undefined && k.scale !== 'log') fail(`${id}: knob '${k.key}' has unknown scale`);
36- if (k.scale === 'log' && k.min <= 0) fail(`${id}: knob '${k.key}' log scale needs min > 0`);
37- }
38-}
39-
40-function mfilesIn(dir) {
41- if (!existsSync(dir)) return [];
42- return readdirSync(dir)
43- .filter((f) => f.endsWith('.m'))
44- .sort()
45- .map((name) => ({ name, content: readFileSync(join(dir, name), 'utf8') }));
46-}
47-
48-if (!existsSync(SRC)) fail(`no ${SRC}/ directory here; run from the instrument repo root`);
49-const shared = mfilesIn('matlab');
50-const dirs = readdirSync(SRC, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
51-if (dirs.length === 0) fail(`${SRC}/ has no instrument directories`);
52-mkdirSync(OUT, { recursive: true });
53-
54-for (const dir of dirs) {
55- const metaPath = join(SRC, dir, 'export.json');
56- if (!existsSync(metaPath)) fail(`${metaPath} is missing`);
57- const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
58- const id = meta.id;
59- if (typeof id !== 'string' || !ID_RE.test(id)) fail(`${metaPath}: 'id' must match ${ID_RE}`);
60- if (typeof meta.name !== 'string' || meta.name === '') fail(`${id}: missing 'name'`);
61- checkKnobs(id, meta.knobs ?? []);
62- const drivers = mfilesIn(join(SRC, dir));
63- if (!drivers.some((f) => f.name === 'render_note.m')) fail(`${id}: no render_note.m in ${SRC}/${dir}/`);
64- const names = new Set(drivers.map((f) => f.name));
65- const clash = shared.find((f) => names.has(f.name));
66- if (clash) fail(`${id}: driver file '${clash.name}' collides with matlab/${clash.name}`);
67- const bundle = {
68- format: 'numbl-instrument-v1',
69- id,
70- name: meta.name,
71- ...(meta.description ? { description: meta.description } : {}),
72- ...(meta.source ? { source: meta.source } : {}),
73- knobs: meta.knobs ?? [],
74- files: [...drivers, ...shared],
75- };
76- const outPath = join(OUT, `${id}.json`);
77- writeFileSync(outPath, JSON.stringify(bundle, null, 2) + '\n');
78- console.log(`wrote ${outPath} (${bundle.files.length} files, ${bundle.knobs.length} knobs)`);
79-}
webapp/README.mdmodified+1−1View file
@@ -36,7 +36,7 @@ The New seed button redraws the whole impact process without touching any other
3636 - `../matlab/` — the algorithm: `maraca_basis.m`, `maraca_impacts.m`, `maraca_radiate.m`, `maraca_output.m`, `maraca_hankel.m`, `maraca_rng.m`, plus `maraca_render.m` / `maraca_demo.m` / `maraca_validate.m` for CLI use
3737 - `src/params.ts` — parameter schema, defaults, tier assignment, persistence
3838 - `src/mfiles.ts` — the `.m` sources, bundled at build time and hashed for cache keying
39-- `src/worker/renderWorker.ts` — off-thread numbl execution with tiered caching
39+- `../instrument/` — off-thread numbl execution with tiered caching, behind the shared track-render instrument interface (also used by midiboot)
4040 - `src/components/` — 3D scene (three.js), parameter panel, code panel and editor, plots (impact raster, waveform, spectrum, spectrogram, shell mode map)
4141 - `src/selftest.ts` — Node harness for the MATLAB validation script
4242
webapp/src/components/DiagnosticsPanel.tsxmodified+2−2View file
@@ -1,6 +1,6 @@
1-import type { RenderResponse } from '../worker/renderWorker';
1+import type { RenderResult } from '../hooks/useRenderer';
22
3-export default function DiagnosticsPanel({ result }: { result: RenderResponse }) {
3+export default function DiagnosticsPanel({ result }: { result: RenderResult }) {
44 const d = result.diag;
55 const modes = Array.from(result.modeF);
66 const shown = modes.slice(0, 10);
webapp/src/hooks/useRenderer.tsmodified+72−65View file
@@ -1,12 +1,24 @@
1-// Hook owning the render worker. Renders are explicit: nothing starts until
2-// Render preview or Render full is pressed, so changing a parameter or a .m
3-// source never kicks off work on its own. A request in flight can be
4-// cancelled, which tears the worker down and builds a fresh one.
1+// Hook owning the render requests. Renders are explicit: nothing starts
2+// until Render preview or Render full is pressed, so changing a parameter
3+// or a .m source never kicks off work on its own. Rendering goes through
4+// the repo's instrument/ module (the same track-render interface midiboot
5+// uses), with the full parameter surface, the edited .m sources, and the
6+// diagnostics payload passed through the request's app-facing fields.
7+// Cancelling is cooperative: the worker checks between render stages, and
8+// the instrument module tears the worker down only if it never responds.
59
610 import { useCallback, useEffect, useRef, useState } from 'react';
711 import type { MaracaParams } from '../params';
812 import type { MFile } from '../mfiles';
9-import type { ErrorMessage, ProgressMessage, RenderResponse } from '../worker/renderWorker';
13+import { maracaInstrument } from '../../../instrument/index.ts';
14+import type { MaracaExploreDiag } from '../../../instrument/core.ts';
15+
16+/** The old worker response shape, reassembled from the instrument result
17+ * so the plot components are unchanged. */
18+export type RenderResult = MaracaExploreDiag & {
19+ y: Float32Array;
20+ sampleRate: number;
21+};
1022
1123 export interface RenderError {
1224 message: string;
@@ -15,7 +27,7 @@ export interface RenderError {
1527 }
1628
1729 export interface RenderState {
18- result: RenderResponse | null;
30+ result: RenderResult | null;
1931 error: RenderError | null;
2032 busy: boolean;
2133 stage: string;
@@ -24,9 +36,7 @@ export interface RenderState {
2436 }
2537
2638 export function useRenderer(params: MaracaParams, mfiles: MFile[]) {
27- const workerRef = useRef<Worker | null>(null);
28- const nextId = useRef(1);
29- const inFlight = useRef<number | null>(null);
39+ const abortRef = useRef<AbortController | null>(null);
3040 const startedAt = useRef(0);
3141 const [state, setState] = useState<RenderState>({
3242 result: null,
@@ -37,49 +47,7 @@ export function useRenderer(params: MaracaParams, mfiles: MFile[]) {
3747 elapsedS: 0,
3848 });
3949
40- const attachHandlers = useCallback((worker: Worker) => {
41- worker.onmessage = (ev: MessageEvent<RenderResponse | ProgressMessage | ErrorMessage>) => {
42- const msg = ev.data;
43- if (msg.type === 'progress') {
44- setState((s) => ({ ...s, stage: msg.stage, progress: msg.frac }));
45- return;
46- }
47- inFlight.current = null;
48- if (msg.type === 'error') {
49- // A failed render leaves nothing playable: the previous result is
50- // dropped, so Play and Download cannot hand back audio that no
51- // longer corresponds to the code or parameters on screen.
52- setState((s) => ({
53- ...s,
54- result: null,
55- error: { message: msg.message, file: msg.file, line: msg.line },
56- busy: false,
57- stage: '',
58- progress: 0,
59- }));
60- return;
61- }
62- setState((s) => ({ ...s, result: msg, error: null, busy: false, stage: '', progress: 0 }));
63- };
64- }, []);
65-
66- const makeWorker = useCallback(() => {
67- const worker = new Worker(new URL('../worker/renderWorker.ts', import.meta.url), {
68- type: 'module',
69- });
70- attachHandlers(worker);
71- workerRef.current = worker;
72- return worker;
73- }, [attachHandlers]);
74-
75- useEffect(() => {
76- const worker = makeWorker();
77- return () => {
78- worker.terminate();
79- workerRef.current = null;
80- inFlight.current = null;
81- };
82- }, [makeWorker]);
50+ useEffect(() => () => abortRef.current?.abort(), []);
8351
8452 // elapsed-time ticker while a render is in flight
8553 useEffect(() => {
@@ -93,27 +61,66 @@ export function useRenderer(params: MaracaParams, mfiles: MFile[]) {
9361
9462 const render = useCallback(
9563 (quality: 'preview' | 'full') => {
96- const worker = workerRef.current;
97- if (!worker || inFlight.current !== null) return;
98- const id = nextId.current++;
99- inFlight.current = id;
64+ if (abortRef.current) return; // a request is already in flight
65+ const ctrl = new AbortController();
66+ abortRef.current = ctrl;
10067 startedAt.current = performance.now();
10168 setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0, elapsedS: 0 }));
102- worker.postMessage({ type: 'render', id, params, mfiles, quality });
69+ const sampleRate = Number(params.sample_rate);
70+ void maracaInstrument.renderTrack({
71+ notes: [],
72+ sampleRate,
73+ lengthSamples: 1,
74+ quality,
75+ params: params as unknown as Record<string, number | boolean>,
76+ mfiles,
77+ wantDiagnostics: true,
78+ signal: ctrl.signal,
79+ onProgress: (p) => setState((s) => ({ ...s, stage: p.stage, progress: p.frac })),
80+ }).then((r) => {
81+ if (abortRef.current === ctrl) abortRef.current = null;
82+ if (ctrl.signal.aborted) return;
83+ const diag = r.diag as MaracaExploreDiag;
84+ setState((s) => ({
85+ ...s,
86+ result: { ...diag, y: r.audio, sampleRate },
87+ error: null,
88+ busy: false,
89+ stage: '',
90+ progress: 0,
91+ }));
92+ }).catch((err: unknown) => {
93+ if (abortRef.current === ctrl) abortRef.current = null;
94+ if (err instanceof DOMException && err.name === 'AbortError') {
95+ setState((s) => ({ ...s, busy: false, stage: '', progress: 0 }));
96+ return;
97+ }
98+ // A failed render leaves nothing playable: the previous result is
99+ // dropped, so Play and Download cannot hand back audio that no
100+ // longer corresponds to the code or parameters on screen.
101+ const e = err as { message?: string; file?: string; line?: number };
102+ setState((s) => ({
103+ ...s,
104+ result: null,
105+ error: {
106+ message: e?.message ? String(e.message) : String(err),
107+ file: typeof e?.file === 'string' ? e.file : undefined,
108+ line: typeof e?.line === 'number' ? e.line : undefined,
109+ },
110+ busy: false,
111+ stage: '',
112+ progress: 0,
113+ }));
114+ });
103115 },
104116 [params, mfiles],
105117 );
106118
107- // Cancelling terminates the worker outright: the .m interpreter runs a tight
108- // loop that never yields, so there is nothing to poll a flag from. The
109- // replacement worker starts with empty stage caches.
110119 const cancel = useCallback(() => {
111- const worker = workerRef.current;
112- if (worker) worker.terminate();
113- inFlight.current = null;
114- makeWorker();
120+ abortRef.current?.abort();
121+ abortRef.current = null;
115122 setState((s) => ({ ...s, busy: false, stage: '', progress: 0, elapsedS: 0 }));
116- }, [makeWorker]);
123+ }, []);
117124
118125 const renderPreview = useCallback(() => render('preview'), [render]);
119126 const renderFull = useCallback(() => render('full'), [render]);
webapp/src/worker/renderWorker.tsdeleted+0−429View file
@@ -1,429 +0,0 @@
1-// Web Worker that renders the maraca shake by executing the MATLAB
2-// implementation in ../matlab through numbl. The stages run as separate
3-// executeCode calls so intermediates can be cached and cheap parameter
4-// changes skip the expensive ones:
5-// solve tier -> maraca_basis + maraca_impacts (cached on solve-tier params)
6-// radiate tier -> maraca_radiate (cached on the listener and direct_gain)
7-// output tier -> maraca_output (always cheap, always re-run)
8-// Tensors cross the boundary as numbl RuntimeValues (column-major Float64).
9-
10-import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
11-import { hashMFiles, type MFile } from '../mfiles';
12-import { SOLVE_KEYS, RADIATE_KEYS, type MaracaParams } from '../params';
13-
14-export interface RenderRequest {
15- type: 'render';
16- id: number;
17- params: MaracaParams;
18- quality: 'preview' | 'full';
19- mfiles: MFile[];
20-}
21-
22-export interface RenderResponse {
23- type: 'result';
24- id: number;
25- quality: 'preview' | 'full';
26- y: Float32Array; // final signal, [-1, 1]
27- sampleRate: number;
28- impactT: Float32Array; // impact times, s
29- impactV: Float32Array; // impact speeds, m/s
30- impactPos: Float32Array; // 3 x I unit positions on the shell
31- rateT: Float32Array; // expected-rate envelope, s
32- rateHz: Float32Array; // expected impacts per second
33- modeF: Float32Array; // mode frequencies, Hz
34- modeL: Float32Array; // spherical-harmonic degree of each mode
35- modeZeta: Float32Array; // damping ratio of each mode
36- modeSigma: Float32Array; // radiation efficiency of each mode
37- diag: {
38- modesKept: number;
39- breathingHz: number;
40- breathingDryHz: number;
41- impactCount: number;
42- meanV: number;
43- maxV: number;
44- meanTauUs: number;
45- durationS: number;
46- peakPa: number;
47- peakSplDb: number;
48- directPeakPa: number;
49- listenerDistM: number;
50- warnings: string[];
51- solveMs: number; // 0 when the cached solve was reused
52- radiateMs: number; // 0 when the cached radiation was reused
53- solveCached: boolean;
54- radiateCached: boolean;
55- oversample: number;
56- };
57-}
58-
59-export interface ProgressMessage {
60- type: 'progress';
61- id: number;
62- stage: 'solve' | 'radiate' | 'output';
63- frac: number;
64-}
65-
66-// Raised by a fault in the .m code (the user can edit it), so it is reported
67-// rather than left to stall the request.
68-export interface ErrorMessage {
69- type: 'error';
70- id: number;
71- message: string;
72- file?: string;
73- line?: number;
74-}
75-
76-// Impacts drawn for the raster plot. The full process runs to thousands, and
77-// plotting every one of them is neither visible nor worth the transfer.
78-const MAX_PLOT_IMPACTS = 4000;
79-
80-interface SolveEntry {
81- timp: RuntimeValue;
82- jimp: RuntimeValue;
83- tauimp: RuntimeValue;
84- fimp: RuntimeValue;
85- ximp: RuntimeValue;
86- fsHi: number;
87- impactT: Float32Array;
88- impactV: Float32Array;
89- impactPos: Float32Array;
90- rateT: Float32Array;
91- rateHz: Float32Array;
92- modeF: Float32Array;
93- modeL: Float32Array;
94- modeZeta: Float32Array;
95- modeSigma: Float32Array;
96- breathingHz: number;
97- breathingDryHz: number;
98- impactCount: number;
99- meanV: number;
100- maxV: number;
101- meanTauUs: number;
102- durationS: number;
103-}
104-
105-interface RadiateEntry {
106- p: RuntimeValue;
107- directPeakPa: number;
108- listenerDistM: number;
109- listenerInside: boolean;
110-}
111-
112-// Small LRU caches so preview- and full-quality intermediates coexist. A
113-// radiate-tier change right after a full render then reuses the full solve,
114-// and the "preview" is silently upgraded to full quality.
115-const solveCache = new Map<string, SolveEntry>();
116-const radiateCache = new Map<string, RadiateEntry>();
117-const SOLVE_CACHE_MAX = 2;
118-const RADIATE_CACHE_MAX = 3;
119-
120-function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
121- const v = cache.get(key);
122- if (v !== undefined) {
123- cache.delete(key);
124- cache.set(key, v);
125- }
126- return v;
127-}
128-
129-function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
130- cache.delete(key);
131- cache.set(key, value);
132- while (cache.size > max) {
133- const oldest = cache.keys().next().value as string;
134- cache.delete(oldest);
135- }
136-}
137-
138-function keyFor(params: MaracaParams, keys: readonly (keyof MaracaParams)[]): string {
139- return keys.map((k) => String(params[k])).join('|');
140-}
141-
142-// MATLAB source lines assigning every parameter onto a struct P.
143-function pLiteral(params: MaracaParams): string {
144- const lines: string[] = [];
145- for (const [k, v] of Object.entries(params)) {
146- if (typeof v === 'number') lines.push(`P.${k} = ${v};`);
147- else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
148- }
149- return lines.join('\n');
150-}
151-
152-function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
153- return executeCode(
154- source,
155- { initialVariableValues: inputs, displayResults: false },
156- mfiles,
157- 'stage.m',
158- );
159-}
160-
161-function asTensor(v: RuntimeValue): RuntimeTensor {
162- const t = v as RuntimeTensor;
163- if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
164- return t;
165-}
166-
167-function asScalar(v: RuntimeValue): number {
168- if (typeof v === 'number') return v;
169- const t = v as RuntimeTensor;
170- if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
171- if (typeof v === 'boolean') return v ? 1 : 0;
172- throw new Error('expected scalar result from numbl stage');
173-}
174-
175-function toF32(v: RuntimeValue): Float32Array {
176- const t = asTensor(v);
177- const out = new Float32Array(t.data.length);
178- for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
179- return out;
180-}
181-
182-const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
183-
184-self.onmessage = (ev: MessageEvent<RenderRequest>) => {
185- const msg = ev.data;
186- if (msg.type !== 'render') return;
187-
188- // numbl reports a file it could not parse through console.warn and then
189- // skips it, so the failure surfaces at the call site as "Undefined
190- // function". Capture the warning to report the actual syntax error instead.
191- const warnings: string[] = [];
192- const origWarn = console.warn;
193- console.warn = (...args: unknown[]) => {
194- warnings.push(args.map(String).join(' '));
195- };
196-
197- try {
198- render(msg);
199- } catch (err) {
200- // Most likely a fault in the (user-editable) .m code. Report it so the
201- // request completes and the message reaches the code panel.
202- const e = err as { message?: string; file?: string; line?: number };
203- let m: ErrorMessage = {
204- type: 'error',
205- id: msg.id,
206- message: e?.message ? String(e.message) : String(err),
207- file: typeof e?.file === 'string' ? e.file : undefined,
208- line: typeof e?.line === 'number' ? e.line : undefined,
209- };
210- for (const w of warnings) {
211- const hit = SKIP_WARNING.exec(w);
212- if (hit) {
213- m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
214- break;
215- }
216- }
217- (self as unknown as Worker).postMessage(m);
218- } finally {
219- console.warn = origWarn;
220- }
221-};
222-
223-function render(msg: RenderRequest): void {
224- const { id, params, mfiles } = msg;
225- let quality = msg.quality;
226-
227- // The .m sources are part of the cache identity: editing the code must
228- // invalidate everything computed from the previous version.
229- const codeKey = hashMFiles(mfiles);
230- const solveParamsKey = codeKey + '|' + keyFor(params, SOLVE_KEYS);
231- const fullSolveKey = solveParamsKey + `|os=${params.oversample}`;
232- if (quality === 'preview' && solveCache.has(fullSolveKey)) {
233- quality = 'full'; // full-rate impact process already cached
234- }
235-
236- const oversample = quality === 'preview' ? Math.min(1, params.oversample) : params.oversample;
237- const fsHi = params.sample_rate * oversample;
238-
239- const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
240-
241- // ---- solve stage: mode basis and the stochastic impact process ----
242- const solveKey = solveParamsKey + `|os=${oversample}`;
243- let solveMs = 0;
244- let solveCached = true;
245- let solveEntry = lruGet(solveCache, solveKey);
246- if (!solveEntry) {
247- solveCached = false;
248- post({ type: 'progress', id, stage: 'solve', frac: 0.15 });
249- const t0 = performance.now();
250- const r = runStage(mfiles, `
251-${pLiteral(params)}
252-basis = maraca_basis(P);
253-[timp, jimp, tauimp, fimp, ximp, ginfo] = maraca_impacts(P);
254-modeF = basis.f;
255-modeL = basis.l;
256-modeZeta = basis.zeta;
257-modeSigma = basis.sigma;
258-breathingHz = basis.breathingHz;
259-breathingDryHz = basis.breathingDryHz;
260-impactCount = ginfo.count;
261-meanV = ginfo.meanV;
262-maxV = ginfo.maxV;
263-meanTauUs = ginfo.meanTauUs;
264-durationS = ginfo.durationS;
265-rateT = ginfo.rateT;
266-rateHz = ginfo.rateHz;
267-vimp = ginfo.vimp;
268-`);
269- solveMs = performance.now() - t0;
270-
271- const impactCount = asScalar(r.variableValues.impactCount);
272- const allT = toF32(r.variableValues.timp);
273- const allV = toF32(r.variableValues.vimp);
274- const allX = toF32(r.variableValues.ximp);
275- // thin uniformly for the raster plot, keeping the time ordering
276- const stride = Math.max(1, Math.ceil(impactCount / MAX_PLOT_IMPACTS));
277- const nPlot = Math.ceil(impactCount / stride);
278- const impactT = new Float32Array(nPlot);
279- const impactV = new Float32Array(nPlot);
280- const impactPos = new Float32Array(nPlot * 3);
281- for (let i = 0, j = 0; j < nPlot; i += stride, j++) {
282- impactT[j] = allT[i];
283- impactV[j] = allV[i];
284- impactPos[j * 3] = allX[i * 3];
285- impactPos[j * 3 + 1] = allX[i * 3 + 1];
286- impactPos[j * 3 + 2] = allX[i * 3 + 2];
287- }
288-
289- solveEntry = {
290- timp: r.variableValues.timp,
291- jimp: r.variableValues.jimp,
292- tauimp: r.variableValues.tauimp,
293- fimp: r.variableValues.fimp,
294- ximp: r.variableValues.ximp,
295- fsHi,
296- impactT,
297- impactV,
298- impactPos,
299- rateT: toF32(r.variableValues.rateT),
300- rateHz: toF32(r.variableValues.rateHz),
301- modeF: toF32(r.variableValues.modeF),
302- modeL: toF32(r.variableValues.modeL),
303- modeZeta: toF32(r.variableValues.modeZeta),
304- modeSigma: toF32(r.variableValues.modeSigma),
305- breathingHz: asScalar(r.variableValues.breathingHz),
306- breathingDryHz: asScalar(r.variableValues.breathingDryHz),
307- impactCount,
308- meanV: asScalar(r.variableValues.meanV),
309- maxV: asScalar(r.variableValues.maxV),
310- meanTauUs: asScalar(r.variableValues.meanTauUs),
311- durationS: asScalar(r.variableValues.durationS),
312- };
313- lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
314- }
315-
316- // ---- radiate stage ----
317- const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
318- let radiateMs = 0;
319- let radiateCached = true;
320- let radEntry = lruGet(radiateCache, radiateKey);
321- if (!radEntry) {
322- radiateCached = false;
323- post({ type: 'progress', id, stage: 'radiate', frac: 0.55 });
324- const t0 = performance.now();
325- const r = runStage(mfiles, `
326-${pLiteral(params)}
327-basis = maraca_basis(P);
328-[p, radinfo] = maraca_radiate(P, basis, timp, jimp, tauimp, fimp, ximp, ${solveEntry.fsHi});
329-directPeakPa = radinfo.directPeakPa;
330-listenerDistM = radinfo.listenerDistM;
331-listenerInside = radinfo.listenerInside;
332-`, {
333- timp: solveEntry.timp,
334- jimp: solveEntry.jimp,
335- tauimp: solveEntry.tauimp,
336- fimp: solveEntry.fimp,
337- ximp: solveEntry.ximp,
338- });
339- radiateMs = performance.now() - t0;
340- radEntry = {
341- p: r.variableValues.p,
342- directPeakPa: asScalar(r.variableValues.directPeakPa),
343- listenerDistM: asScalar(r.variableValues.listenerDistM),
344- listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
345- };
346- lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
347- }
348-
349- // ---- output stage ----
350- post({ type: 'progress', id, stage: 'output', frac: 0.9 });
351- const rOut = runStage(mfiles, `
352-${pLiteral(params)}
353-[y, peakPa] = maraca_output(P, p, ${oversample});
354-`, { p: radEntry.p });
355-
356- const y32 = toF32(rOut.variableValues.y);
357- const peakPa = asScalar(rOut.variableValues.peakPa);
358-
359- const warnings: string[] = [];
360- if (radEntry.listenerInside) {
361- warnings.push(
362- 'the listener is at or inside the shell surface; the multipole field is evaluated at the nearest valid radius instead',
363- );
364- }
365- if (!params.normalize) {
366- let peak = 0;
367- for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
368- if (peak > 1.0) {
369- warnings.push(
370- `clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`,
371- );
372- }
373- }
374- const topMode = solveEntry.modeF[solveEntry.modeF.length - 1];
375- if (topMode < 0.4 * params.max_mode_hz) {
376- warnings.push(
377- `the highest retained mode is only ${topMode.toFixed(0)} Hz; raise max degree l to keep the shell from sounding truncated`,
378- );
379- }
380-
381- const resp: RenderResponse = {
382- type: 'result',
383- id,
384- quality,
385- y: y32,
386- sampleRate: params.sample_rate,
387- impactT: solveEntry.impactT.slice(),
388- impactV: solveEntry.impactV.slice(),
389- impactPos: solveEntry.impactPos.slice(),
390- rateT: solveEntry.rateT.slice(),
391- rateHz: solveEntry.rateHz.slice(),
392- modeF: solveEntry.modeF.slice(),
393- modeL: solveEntry.modeL.slice(),
394- modeZeta: solveEntry.modeZeta.slice(),
395- modeSigma: solveEntry.modeSigma.slice(),
396- diag: {
397- modesKept: solveEntry.modeF.length,
398- breathingHz: solveEntry.breathingHz,
399- breathingDryHz: solveEntry.breathingDryHz,
400- impactCount: solveEntry.impactCount,
401- meanV: solveEntry.meanV,
402- maxV: solveEntry.maxV,
403- meanTauUs: solveEntry.meanTauUs,
404- durationS: solveEntry.durationS,
405- peakPa,
406- peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
407- directPeakPa: radEntry.directPeakPa,
408- listenerDistM: radEntry.listenerDistM,
409- warnings,
410- solveMs,
411- radiateMs,
412- solveCached,
413- radiateCached,
414- oversample,
415- },
416- };
417- (self as unknown as Worker).postMessage(resp, [
418- resp.y.buffer,
419- resp.impactT.buffer,
420- resp.impactV.buffer,
421- resp.impactPos.buffer,
422- resp.rateT.buffer,
423- resp.rateHz.buffer,
424- resp.modeF.buffer,
425- resp.modeL.buffer,
426- resp.modeZeta.buffer,
427- resp.modeSigma.buffer,
428- ]);
429-}
webapp/tsconfig.jsonmodified+1−0View file
@@ -6,6 +6,7 @@
66 "module": "ESNext",
77 "skipLibCheck": true,
88 "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
910 "resolveJsonModule": true,
1011 "isolatedModules": true,
1112 "noEmit": true,