/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
Add instrument/: the woodblock 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). Distinct strikes render once per pitch and velocity level, 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.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit a37be489a28a parent 4152426 Browse files
22 changed files+3508−620
.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
@@ -11,6 +11,7 @@ There is no microphone model: no directivity, no capsule response, no gain stagi
1111 | `woodblock.md` | The specification: physics, equations, state vector, and the traps |
1212 | `woodblock.py` | Reference implementation (numpy + scipy), and the source of the validation numbers |
1313 | `matlab/` | The algorithm in MATLAB syntax, run by [numbl](https://numbl.org) |
14+| `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) |
1415 | `webapp/` | Interactive browser app that executes the MATLAB code client-side |
1516
1617 The MATLAB code runs from the command line with `cd matlab && numbl run woodblock_demo.m`, or `woodblock_validate.m` to check it against the reference. The web app is a Vite project; see `webapp/README.md`.
export-src/woodblock/export.jsondeleted+0−23View file
@@ -1,23 +0,0 @@
1-{
2- "id": "woodblock",
3- "name": "Woodblock",
4- "description": "Struck woodblock; pitch scales the block around MIDI 76 (64..88), velocity onto mallet speed.",
5- "source": "simulated_instruments/woodblock",
6- "knobs": [
7- {
8- "key": "strike_position",
9- "label": "strike position",
10- "min": 0.05,
11- "max": 0.95,
12- "default": 0.42
13- },
14- {
15- "key": "mallet_stiffness",
16- "label": "mallet stiffness",
17- "min": 10000000.0,
18- "max": 30000000000.0,
19- "default": 100000000.0,
20- "scale": "log"
21- }
22- ]
23-}
export-src/woodblock/render_note.mdeleted+0−30View file
@@ -1,30 +0,0 @@
1-function y = render_note(note)
2-% MIDI pitch scales the block: every length by s = 2^-((pitch - 76)/12),
3-% the participating mass by s^3, and the resonant cavity consistently
4-% (volume s^3, slit area s^2, slit length s), so both the bar modes
5-% (thickness / length^2) and the Helmholtz resonance shift by the
6-% semitone ratio exactly. The mallet stays fixed. Pitch 76 is the
7-% default block, playable 64..88. Velocity drives the mallet speed.
8-% Fixed: normalize off so velocity survives in pascals; the preview tier
9-% caps oversampling at 4 (the eigensolve dominates either way).
10-P = woodblock_defaults();
11-P.sample_rate = note.sample_rate;
12-P.pre_delay_ms = 0;
13-P.normalize = 0;
14-if note.preview ~= 0
15- P.oversample = 4;
16-end
17-s = 2 ^ (-(max(64, min(88, note.pitch)) - 76) / 12);
18-P.length_m = P.length_m * s;
19-P.width_m = P.width_m * s;
20-P.wall_thickness_m = P.wall_thickness_m * s;
21-P.block_height_m = P.block_height_m * s;
22-P.participating_mass_kg = P.participating_mass_kg * s^3;
23-P.cavity_volume_m3 = P.cavity_volume_m3 * s^3;
24-P.slit_area_m2 = P.slit_area_m2 * s^2;
25-P.slit_length_m = P.slit_length_m * s;
26-P.mallet_velocity = 0.2 + (8 - 0.2) * note.velocity;
27-P.strike_position = note.params.strike_position;
28-P.mallet_stiffness = note.params.mallet_stiffness;
29-y = woodblock_render(P);
30-end
exports/woodblock.jsondeleted+0−62View file
@@ -1,62 +0,0 @@
1-{
2- "format": "numbl-instrument-v1",
3- "id": "woodblock",
4- "name": "Woodblock",
5- "description": "Struck woodblock; pitch scales the block around MIDI 76 (64..88), velocity onto mallet speed.",
6- "source": "simulated_instruments/woodblock",
7- "knobs": [
8- {
9- "key": "strike_position",
10- "label": "strike position",
11- "min": 0.05,
12- "max": 0.95,
13- "default": 0.42
14- },
15- {
16- "key": "mallet_stiffness",
17- "label": "mallet stiffness",
18- "min": 10000000,
19- "max": 30000000000,
20- "default": 100000000,
21- "scale": "log"
22- }
23- ],
24- "files": [
25- {
26- "name": "render_note.m",
27- "content": "function y = render_note(note)\n% MIDI pitch scales the block: every length by s = 2^-((pitch - 76)/12),\n% the participating mass by s^3, and the resonant cavity consistently\n% (volume s^3, slit area s^2, slit length s), so both the bar modes\n% (thickness / length^2) and the Helmholtz resonance shift by the\n% semitone ratio exactly. The mallet stays fixed. Pitch 76 is the\n% default block, playable 64..88. Velocity drives the mallet speed.\n% Fixed: normalize off so velocity survives in pascals; the preview tier\n% caps oversampling at 4 (the eigensolve dominates either way).\nP = woodblock_defaults();\nP.sample_rate = note.sample_rate;\nP.pre_delay_ms = 0;\nP.normalize = 0;\nif note.preview ~= 0\n P.oversample = 4;\nend\ns = 2 ^ (-(max(64, min(88, note.pitch)) - 76) / 12);\nP.length_m = P.length_m * s;\nP.width_m = P.width_m * s;\nP.wall_thickness_m = P.wall_thickness_m * s;\nP.block_height_m = P.block_height_m * s;\nP.participating_mass_kg = P.participating_mass_kg * s^3;\nP.cavity_volume_m3 = P.cavity_volume_m3 * s^3;\nP.slit_area_m2 = P.slit_area_m2 * s^2;\nP.slit_length_m = P.slit_length_m * s;\nP.mallet_velocity = 0.2 + (8 - 0.2) * note.velocity;\nP.strike_position = note.params.strike_position;\nP.mallet_stiffness = note.params.mallet_stiffness;\ny = woodblock_render(P);\nend\n"
28- },
29- {
30- "name": "woodblock_basis.m",
31- "content": "function basis = woodblock_basis(P)\n% WOODBLOCK_BASIS Modal basis: free-free Euler-Bernoulli bar.\n% Mass-normalized bending modes and their couplings (woodblock.md section 3).\n% basis.f modal frequencies (Hz), n x 1\n% basis.phiC mode shape at the strike point (1/sqrt(kg))\n% basis.A cavity volume coupling (m^2/sqrt(kg))\n% basis.shapes mass-normalized shapes sampled on basis.s, nGrid x n\n\nL = P.length_m; W = P.width_m; H = P.wall_thickness_m;\nmass = P.participating_mass_kg;\nif mass == 0\n mass = P.density * W * H * L;\nend\ncLong = sqrt(P.youngs_modulus / P.density);\n% f_n = (beta_n L)^2 / (2 pi L^2) * sqrt(E I / (rho A)), I = W H^3/12\nprefactor = cLong * H / (2 * pi * sqrt(12) * L^2);\n\nnGrid = 4096;\ns = linspace(0, 1, nGrid).';\n\n% Roots of cos(bL)cosh(bL) = 1; asymptotic beyond the 4th\nblTab = [4.73004074 7.85320462 10.99560784 14.13716549];\nf = zeros(P.n_modes, 1);\nphiC = zeros(P.n_modes, 1);\nA = zeros(P.n_modes, 1);\nshapes = zeros(nGrid, P.n_modes);\nf1 = prefactor * blTab(1)^2 * P.hollow_softening;\nn = 0;\nfor m = 1:P.n_modes\n if m <= 4\n bl = blTab(m);\n else\n bl = (2 * m + 1) * pi / 2;\n end\n fm = prefactor * bl^2 * P.hollow_softening;\n if m > 1 && P.inharmonicity ~= 1\n fm = f1 * (fm / f1)^P.inharmonicity;\n end\n if fm > P.max_mode_hz\n break;\n end\n n = n + 1;\n sigma = (cosh(bl) - cos(bl)) / (sinh(bl) - sin(bl));\n x = bl * s;\n shape = (cosh(x) + cos(x)) - sigma * (sinh(x) + sin(x));\n rmsval = sqrt(trapz(s, shape.^2));\n shape = shape / (rmsval * sqrt(mass));\n f(n) = fm;\n phiC(n) = interp1(s, shape, P.strike_position);\n mask = (s >= P.cavity_span_lo) & (s <= P.cavity_span_hi);\n A(n) = W * L * trapz(s, shape .* mask);\n shapes(:, n) = shape;\nend\nif n == 0\n error('No modes below max_mode_hz -- check geometry/material.');\nend\nf = f(1:n);\nomega = 2 * pi * f;\n% assemble by assignment (struct() with array values would make a struct array)\nbasis.n = n;\nbasis.f = f;\nbasis.omega = omega;\nbasis.zeta = 0.5 * (P.loss_factor + P.loss_slope * omega);\nbasis.phiC = phiC(1:n);\nbasis.A = A(1:n) * P.cavity_coupling;\nbasis.mass = mass;\nbasis.s = s;\nbasis.shapes = shapes(:, 1:n);\nend\n"
32- },
33- {
34- "name": "woodblock_defaults.m",
35- "content": "function P = woodblock_defaults()\n% WOODBLOCK_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% Note woodblock.py's reference default for mallet_stiffness is 3.0e8; the\n% app (and this file) default to a softer 1.0e8.\n\nP.length_m = 0.200;\nP.width_m = 0.055;\nP.wall_thickness_m = 0.010;\nP.block_height_m = 0.050;\n\nP.density = 850.0;\nP.youngs_modulus = 1.6e10;\nP.hollow_softening = 0.90;\nP.participating_mass_kg = 0.35;\nP.inharmonicity = 1.00;\nP.n_modes = 26;\nP.max_mode_hz = 20000.0;\n\nP.loss_factor = 0.010;\nP.loss_slope = 1.2e-7;\n\nP.strike_position = 0.42;\nP.mallet_mass = 0.025;\nP.mallet_velocity = 2.5;\nP.mallet_stiffness = 1.0e8;\nP.contact_exponent = 1.5;\nP.contact_damping = 0.8;\n\nP.cavity_volume_m3 = 2.6e-4;\nP.slit_area_m2 = 8.0e-4;\nP.slit_length_m = 0.012;\nP.cavity_damping = 0.06;\nP.cavity_coupling = 1.0;\nP.cavity_span_lo = 0.30;\nP.cavity_span_hi = 0.70;\n\nP.listener_x_m = 0.15;\nP.listener_y_m = 0.25;\nP.listener_z_m = 0.30;\nP.slit_x_m = 0.0;\nP.slit_y_m = 0.0275;\nP.slit_z_m = 0.0;\nP.radiating_elements = 48;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\nP.sample_rate = 48000;\nP.oversample = 16;\nP.duration_s = 0.45;\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": "woodblock_demo.m",
39- "content": "% Render a woodblock strike with default parameters and print diagnostics.\n% Run with: numbl run woodblock_demo.m\n\nP = woodblock_defaults();\ntic;\n[y, basis, dinfo] = woodblock_render(P);\nelapsed = toc;\n\nfprintf('modes kept : %d\\n', basis.n);\nfprintf('partials Hz: ');\nfprintf('%.0f ', basis.f);\nfprintf('\\nratios : ');\nfprintf('%.2f ', basis.f / basis.f(1));\nfprintf('\\nhelmholtz : %.0f Hz\\n', dinfo.helmholtzHz);\nfprintf('contact : %.3f ms, peak %.0f N\\n', dinfo.contactMs, dinfo.peakForceN);\nfprintf('peak press.: %.3f Pa (%.1f dB SPL)\\n', dinfo.peakPa, dinfo.peakSplDb);\nfprintf('length : %.3f s @ %d Hz\\n', length(y) / P.sample_rate, P.sample_rate);\nfprintf('render time: %.2f s\\n', elapsed);\nif dinfo.spacingNeed > 0\n fprintf('WARNING : element spacing is coarse; consider %d radiating elements\\n', dinfo.spacingNeed);\nend\nif dinfo.listenerInside\n fprintf('WARNING : listener is inside the source region\\n');\nend\n"
40- },
41- {
42- "name": "woodblock_output.m",
43- "content": "function [y, peakPa] = woodblock_output(P, p, oversample)\n% WOODBLOCK_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 (woodblock.md section 8).\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 .* (wb_i0(beta * sqrt(max(0, 1 - r.^2))) / wb_i0(beta));\n h = h / sum(h); % unity DC gain\n pf = conv(p, h); % pf(i) = sum_k p(k) h(i-k+1)\n outLen = floor(T / R);\n y = pf((0:outLen - 1) * R + M2 + 1); % centered polyphase 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);\ntotal = floor(P.duration_s * 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 = wb_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"
44- },
45- {
46- "name": "woodblock_radiate.m",
47- "content": "function [p, spacingNeed, listenerInside] = woodblock_radiate(P, basis, Qdd, Xdd, fsHi, M)\n% WOODBLOCK_RADIATE Retarded free-field pressure at the listener point.\n% The top and bottom faces move together, so each strip is a local dipole;\n% the strip surface accelerations are computed in one matrix product and\n% reused for both faces with opposite sign (woodblock.md section 7).\n% spacingNeed is 0 when the element spacing is fine enough, otherwise the\n% recommended element count; listenerInside flags |r| < L/2.\n\nN = basis.n;\nT = size(Qdd, 2);\nL = P.length_m; W = P.width_m;\nhalfH = 0.5 * P.block_height_m;\npatchA = W * L / M;\nc = P.speed_of_sound;\nrho0 = P.air_density;\n\nsElem = ((1:M).' - 0.5) / M;\nphiElem = zeros(M, N);\nfor n = 1:N\n phiElem(:, n) = interp1(basis.s, basis.shapes(:, n), sElem);\nend\nxElem = (sElem - 0.5) * L;\n\nlx = P.listener_x_m; ly = P.listener_y_m; lz = P.listener_z_m;\ndTop = sqrt((xElem - lx).^2 + ly^2 + (halfH - lz)^2);\ndBot = sqrt((xElem - lx).^2 + ly^2 + (-halfH - lz)^2);\nslitD = sqrt((P.slit_x_m - lx)^2 + (P.slit_y_m - ly)^2 + (P.slit_z_m - lz)^2);\n\nmaxDelay = ceil(max([dTop; dBot; slitD]) / c * fsHi) + 2;\nout = zeros(1, T + maxDelay);\n\nwddAll = phiElem * Qdd; % M x T strip surface accelerations\nfor j = 1:M\n aT = patchA * rho0 / (4 * pi * dTop(j));\n aB = -patchA * rho0 / (4 * pi * dBot(j));\n kT = round(dTop(j) / c * fsHi) + 1;\n kB = round(dBot(j) / c * fsHi) + 1;\n out(kT:kT + T - 1) = out(kT:kT + T - 1) + aT * wddAll(j, :);\n out(kB:kB + T - 1) = out(kB:kB + T - 1) + aB * wddAll(j, :);\nend\n\n% Slit monopole\naS = P.slit_area_m2 * rho0 / (4 * pi * slitD);\nkS = round(slitD / c * fsHi) + 1;\nout(kS:kS + T - 1) = out(kS:kS + T - 1) + aS * Xdd;\n\np = out(1:T);\n\n% Setup checks (check_setup in the reference)\nfMax = basis.f(N);\nif L / M > 0.25 * c / fMax\n spacingNeed = ceil(L / (0.25 * c / fMax));\nelse\n spacingNeed = 0;\nend\nlistenerInside = sqrt(lx^2 + ly^2 + lz^2) < 0.5 * L;\nend\n"
48- },
49- {
50- "name": "woodblock_render.m",
51- "content": "function [y, basis, dinfo] = woodblock_render(P)\n% WOODBLOCK_RENDER Full render: solve -> radiate -> output.\n% Convenience composition of the stage functions; the web app calls the\n% stages separately so it can cache intermediates.\n\nbasis = woodblock_basis(P);\nfsHi = P.sample_rate * P.oversample;\n[Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = ...\n woodblock_solve(P, basis, P.oversample);\n[p, spacingNeed, listenerInside] = ...\n woodblock_radiate(P, basis, Qdd, Xdd, fsHi, P.radiating_elements);\n[y, peakPa] = woodblock_output(P, p, P.oversample);\n\ndinfo.helmholtzHz = helmholtzHz;\ndinfo.contactMs = contactMs;\ndinfo.peakForceN = peakForceN;\ndinfo.maxRealLam = maxRealLam;\ndinfo.peakPa = peakPa;\ndinfo.peakSplDb = 20 * log10(peakPa / 20e-6 + 1e-30);\ndinfo.spacingNeed = spacingNeed;\ndinfo.listenerInside = listenerInside;\ndinfo.force = force;\nend\n"
52- },
53- {
54- "name": "woodblock_solve.m",
55- "content": "function [Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = woodblock_solve(P, basis, oversample)\n% WOODBLOCK_SOLVE Integrate the coupled mallet / body / cavity system.\n%\n% The system is nonlinear only while the mallet is in contact (a fraction of\n% a millisecond), so the solve is split in two:\n% contact phase: RK4 on the full nonlinear system, until the mallet has\n% rebounded far enough that no future contact is possible;\n% free phase: the remaining coupled body+cavity system is linear, so it is\n% evolved in closed form from one eigendecomposition of the (2N+2)\n% system matrix, evaluated vectorized over the whole time axis.\n% This departs from the reference woodblock.py, which runs RK4 throughout;\n% the free-phase eigen solution is exact in time, so the two agree to the\n% reference's own integration tolerance.\n%\n% Returns modal accelerations Qdd (N x T), slit-plug acceleration Xdd (1 x T),\n% contact force (1 x T), and scalar diagnostics. maxRealLam should be\n% strictly negative; anything else means the coupling was assembled wrongly.\n\nN = basis.n;\nfsHi = P.sample_rate * oversample;\ndt = 1 / fsHi;\nnSteps = floor(P.duration_s * fsHi);\n\nom = basis.omega;\nom2 = om.^2;\ntzo = 2 * basis.zeta .* om;\nphiC = basis.phiC;\nA = basis.A;\n\nrho0 = P.air_density; c = P.speed_of_sound;\nV = P.cavity_volume_m3; S = P.slit_area_m2;\n% End correction on the slit: ~0.85 * equivalent radius per open end.\nLeff = P.slit_length_m + 2 * 0.85 * sqrt(S / pi);\nomH = c * sqrt(S / (V * Leff));\ntzoH = 2 * P.cavity_damping * omH;\npScale = rho0 * c^2 / V;\ncavDrive = c^2 / (V * Leff);\nK = P.mallet_stiffness;\nalpha = P.contact_exponent;\nmu = P.contact_damping;\ninvMh = 1 / P.mallet_mass;\n\n% ---- contact phase: RK4 on [q; qd; xi; xid; y; yd] ----\nmaxContact = min(nSteps, ceil(0.02 * fsHi)); % 20 ms hard cap\nz = zeros(2 * N + 4, 1);\nz(2 * N + 4) = P.mallet_velocity;\nQc = zeros(N, maxContact);\nXc = zeros(1, maxContact);\nforceC = zeros(1, maxContact);\nTc = 0;\nfor k = 1:maxContact\n [d1, qdd1, xidd1, fc1] = wb_deriv(z, N, om2, tzo, phiC, A, S, pScale, omH, tzoH, cavDrive, K, alpha, mu, invMh);\n Qc(:, k) = qdd1;\n Xc(k) = xidd1;\n forceC(k) = fc1;\n Tc = k;\n [d2, ~, ~, ~] = wb_deriv(z + 0.5 * dt * d1, N, om2, tzo, phiC, A, S, pScale, omH, tzoH, cavDrive, K, alpha, mu, invMh);\n [d3, ~, ~, ~] = wb_deriv(z + 0.5 * dt * d2, N, om2, tzo, phiC, A, S, pScale, omH, tzoH, cavDrive, K, alpha, mu, invMh);\n [d4, ~, ~, ~] = wb_deriv(z + dt * d3, N, om2, tzo, phiC, A, S, pScale, omH, tzoH, cavDrive, K, alpha, mu, invMh);\n z = z + (dt / 6) * (d1 + 2 * d2 + 2 * d3 + d4);\n\n % No-recontact exit: after separation the mallet coasts at constant\n % velocity while the surface stays within B (a conservative bound from\n % the current modal energy), so once the mallet is receding and further\n % than 1.5*B below the surface rest plane, contact can never resume.\n y = z(2 * N + 3);\n yd = z(2 * N + 4);\n if fc1 == 0 && yd <= 0\n q = z(1:N);\n qd = z(N + 1:2 * N);\n B = sum(abs(phiC) .* sqrt(q.^2 + (qd ./ om).^2));\n if y <= -1.5 * B\n break;\n end\n end\nend\n\n% ---- free phase: eigendecomposition of the linear body+cavity system ----\n% state x = [q; qd; xi; xid], xdot = MM x\nT2 = nSteps - Tc;\nMM = zeros(2 * N + 2);\nMM(1:N, N + 1:2 * N) = eye(N);\nMM(N + 1:2 * N, 1:N) = -(diag(om2) + pScale * (A * A.'));\nMM(N + 1:2 * N, N + 1:2 * N) = -diag(tzo);\nMM(N + 1:2 * N, 2 * N + 1) = -pScale * S * A;\nMM(2 * N + 1, 2 * N + 2) = 1;\nMM(2 * N + 2, 1:N) = -cavDrive * A.';\nMM(2 * N + 2, 2 * N + 1) = -omH^2;\nMM(2 * N + 2, 2 * N + 2) = -tzoH;\n\n[VV, DD] = eig(MM);\nlam = diag(DD);\nmaxRealLam = max(real(lam));\ncvec = VV \\ z(1:2 * N + 2);\nw = lam .* cvec; % acceleration weights per eigenmode\nt2 = (0:T2 - 1) * dt; % t2 = 0 is the state right after contact\nE = exp(lam * t2); % (2N+2) x T2, complex\nQf = real((VV(N + 1:2 * N, :) * diag(w)) * E);\nXf = real((VV(2 * N + 2, :) * diag(w)) * E);\n\nQdd = [Qc(:, 1:Tc) Qf];\nXdd = [Xc(1:Tc) Xf];\nforce = [forceC(1:Tc) zeros(1, T2)];\n\nhelmholtzHz = omH / (2 * pi);\ncontactMs = sum(force > 0) * dt * 1e3;\npeakForceN = max(force);\nend\n\nfunction [d, qdd, xidd, fc] = wb_deriv(z, N, om2, tzo, phiC, A, S, pScale, omH, tzoH, cavDrive, K, alpha, mu, invMh)\n% Derivative of the coupled system with Hunt-Crossley mallet contact.\nq = z(1:N);\nqd = z(N + 1:2 * N);\nxi = z(2 * N + 1);\nxid = z(2 * N + 2);\ny = z(2 * N + 3);\nyd = z(2 * N + 4);\n\nwsurf = phiC.' * q;\nwdsurf = phiC.' * qd;\ndelta = y - wsurf;\nif delta > 0\n % f = K d^a (1 + lambda * ddot); lambda in s/m keeps the correction O(1)\n fc = K * delta^alpha * (1 + mu * (yd - wdsurf));\n if fc < 0\n fc = 0;\n end\nelse\n fc = 0;\nend\n\nAq = A.' * q;\npc = pScale * (S * xi + Aq);\n\nqdd = -tzo .* qd - om2 .* q + fc * phiC - A * pc;\nxidd = -tzoH * xid - omH^2 * xi - cavDrive * Aq;\n\nd = zeros(2 * N + 4, 1);\nd(1:N) = qd;\nd(N + 1:2 * N) = qdd;\nd(2 * N + 1) = xid;\nd(2 * N + 2) = xidd;\nd(2 * N + 3) = yd;\nd(2 * N + 4) = -fc * invMh;\nend\n"
56- },
57- {
58- "name": "woodblock_validate.m",
59- "content": "% Validate against woodblock.py reference diagnostics (its defaults use\n% mallet_stiffness = 3.0e8) and the listener targets in woodblock.md §7.\n% Run with: numbl run woodblock_validate.m\n\nP = woodblock_defaults();\nP.mallet_stiffness = 3.0e8;\n\n[y, basis, dinfo] = woodblock_render(P);\nfprintf('reference config:\\n');\nfprintf(' contact %.3f ms (target 0.301), peak %.0f N (target 794)\\n', ...\n dinfo.contactMs, dinfo.peakForceN);\nfprintf(' peak %.3f Pa (target 6.132), %.1f dB SPL (target 109.7)\\n', ...\n dinfo.peakPa, dinfo.peakSplDb);\nfprintf(' max real eigenvalue: %.1f (must be < 0)\\n', dinfo.maxRealLam);\n\ntargets = [0.15 0.25 0.30 109.7\n 0.00 0.00 0.40 100.9\n 0.00 0.40 0.00 90.9\n 0.00 0.05 0.02 125.4];\nfor i = 1:size(targets, 1)\n Q = P;\n Q.listener_x_m = targets(i, 1);\n Q.listener_y_m = targets(i, 2);\n Q.listener_z_m = targets(i, 3);\n [~, ~, di] = woodblock_render(Q);\n err = abs(di.peakSplDb - targets(i, 4));\n if err < 0.3\n verdict = 'ok';\n else\n verdict = 'MISMATCH';\n end\n fprintf('listener (%.2f, %.2f, %.2f): %.1f dB SPL (target %.1f) %s\\n', ...\n targets(i, 1), targets(i, 2), targets(i, 3), di.peakSplDb, targets(i, 4), verdict);\nend\n"
60- }
61- ]
62-}
instrument/.gitignoreadded+1−0View file
@@ -0,0 +1 @@
1+node_modules/
instrument/core.tsadded+474−0View file
@@ -0,0 +1,474 @@
1+// The woodblock 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 strike ignores
7+// note duration), each distinct strike renders once through
8+// woodblock_render, and copies are summed at the onsets. MIDI pitch
9+// scales the block geometry around pitch 76, exactly as the retired
10+// per-note export driver did.
11+//
12+// - the explorer path (the woodblock web app, wantDiagnostics set): the
13+// full parameter surface and live-edited .m sources drive one staged
14+// render (solve -> radiate -> output, intermediates cached per tier)
15+// with the diagnostics payload the app's plots consume.
16+
17+import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
18+import type { MFile, TrackRenderRequest } from './types.ts';
19+
20+import { WOODBLOCK_KNOBS, type WoodblockVariant } from './knobs.ts';
21+import { NoteCache } from './noteCache.ts';
22+
23+export { WOODBLOCK_KNOBS, type WoodblockVariant };
24+
25+/** The environment the host provides: sources, progress, cancellation, and
26+ * a macrotask yield so a browser worker can receive a cancel message. */
27+export interface RenderEnv {
28+ mfiles: MFile[];
29+ post: (frac: number, stage: string) => void;
30+ /** Throws to abandon the render; called between stages and notes. */
31+ checkCancel: () => void;
32+ yieldToHost: () => Promise<void>;
33+}
34+
35+// --- diagnostics payload for the explorer path (the web app's plots) ---
36+
37+export interface WoodblockExploreDiag {
38+ quality: 'preview' | 'full';
39+ force: Float32Array; // contact force, first forceMs at fsHi
40+ forceFsHz: number;
41+ modeShapes: Float32Array; // n x shapePts, concatenated
42+ shapePts: number;
43+ diag: {
44+ modesKept: number;
45+ partialsHz: number[];
46+ ratios: number[];
47+ helmholtzHz: number;
48+ contactMs: number;
49+ peakForceN: number;
50+ peakPa: number;
51+ peakSplDb: number;
52+ listenerDistM: number;
53+ warnings: string[];
54+ solveMs: number;
55+ radiateMs: number;
56+ solveCached: boolean;
57+ radiateCached: boolean;
58+ oversample: number;
59+ elements: number;
60+ };
61+}
62+
63+// --- shared helpers ---
64+
65+export function hashMFiles(files: MFile[]): string {
66+ let h = 0x811c9dc5;
67+ for (const f of files) {
68+ const s = f.name + ' ' + f.source;
69+ for (let i = 0; i < s.length; i++) {
70+ h ^= s.charCodeAt(i);
71+ h = Math.imul(h, 0x01000193);
72+ }
73+ }
74+ return (h >>> 0).toString(36);
75+}
76+
77+function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
78+ const v = cache.get(key);
79+ if (v !== undefined) {
80+ cache.delete(key);
81+ cache.set(key, v);
82+ }
83+ return v;
84+}
85+
86+function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
87+ cache.delete(key);
88+ cache.set(key, value);
89+ while (cache.size > max) {
90+ const oldest = cache.keys().next().value as string;
91+ cache.delete(oldest);
92+ }
93+}
94+
95+type Params = Record<string, number | boolean>;
96+
97+function pLiteral(params: Params): string {
98+ const lines: string[] = [];
99+ for (const [k, v] of Object.entries(params)) {
100+ if (typeof v === 'number') {
101+ if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
102+ lines.push(`P.${k} = ${v};`);
103+ } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
104+ }
105+ return lines.join('\n');
106+}
107+
108+function keyFor(params: Params, keys: readonly string[]): string {
109+ return keys.map((k) => String(params[k] ?? 'd')).join('|');
110+}
111+
112+function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
113+ return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
114+}
115+
116+function asTensor(v: RuntimeValue): RuntimeTensor {
117+ const t = v as RuntimeTensor;
118+ if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
119+ return t;
120+}
121+
122+function asScalar(v: RuntimeValue): number {
123+ if (typeof v === 'number') return v;
124+ const t = v as RuntimeTensor;
125+ if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
126+ if (typeof v === 'boolean') return v ? 1 : 0;
127+ throw new Error('expected scalar result from numbl stage');
128+}
129+
130+function toF32(v: RuntimeValue): Float32Array {
131+ const t = asTensor(v);
132+ const out = new Float32Array(t.data.length);
133+ for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
134+ return out;
135+}
136+
137+function knobValue(settings: Record<string, number>, key: string): number {
138+ const spec = WOODBLOCK_KNOBS.find((k) => k.key === key)!;
139+ const raw = settings[key];
140+ return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
141+}
142+
143+/** Velocity quantized to a few levels so identical strikes share a cached
144+ * render; 8 levels is inaudible next to the model's own dynamics. */
145+export function bucketVelocity(velocity01: number, levels = 8): number {
146+ return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
147+}
148+
149+const SOLVE_KEYS = [
150+ 'length_m', 'width_m', 'wall_thickness_m',
151+ 'density', 'youngs_modulus', 'hollow_softening', 'participating_mass_kg',
152+ 'inharmonicity', 'n_modes', 'max_mode_hz',
153+ 'loss_factor', 'loss_slope',
154+ 'strike_position', 'mallet_mass', 'mallet_velocity', 'mallet_stiffness',
155+ 'contact_exponent', 'contact_damping',
156+ 'cavity_volume_m3', 'slit_area_m2', 'slit_length_m', 'cavity_damping',
157+ 'cavity_coupling', 'cavity_span_lo', 'cavity_span_hi',
158+ 'speed_of_sound', 'air_density', 'sample_rate', 'duration_s',
159+];
160+
161+const RADIATE_KEYS = [
162+ 'block_height_m',
163+ 'listener_x_m', 'listener_y_m', 'listener_z_m',
164+ 'slit_x_m', 'slit_y_m', 'slit_z_m',
165+];
166+
167+const SHAPE_PTS = 128;
168+const FORCE_MS = 8;
169+
170+interface SolveEntry {
171+ Qdd: RuntimeValue;
172+ Xdd: RuntimeValue;
173+ fsHi: number;
174+ f: number[];
175+ modeShapes: Float32Array;
176+ force: Float32Array;
177+ helmholtzHz: number;
178+ contactMs: number;
179+ peakForceN: number;
180+}
181+
182+interface RadiateEntry {
183+ p: RuntimeValue;
184+ warnings: string[];
185+}
186+
187+// Explorer-tier caches, keyed under the code hash so editing the .m sources
188+// invalidates everything computed from the previous version.
189+const solveCache = new Map<string, SolveEntry>();
190+const radiateCache = new Map<string, RadiateEntry>();
191+const SOLVE_CACHE_MAX = 2;
192+const RADIATE_CACHE_MAX = 3;
193+
194+// Finished per-note audio: an in-memory LRU in front of an IndexedDB
195+// store, so renders survive page reloads (budgets and eviction live in
196+// noteCache.ts).
197+const noteCache = new NoteCache('woodblock-instrument-notes');
198+
199+// --- the explorer path (full params, staged, with diagnostics) ---
200+
201+async function renderExplore(
202+ req: TrackRenderRequest,
203+ env: RenderEnv,
204+): Promise<{ audio: Float32Array; diag: WoodblockExploreDiag }> {
205+ const params = req.params ?? {};
206+ const mfiles = env.mfiles;
207+ let quality = req.quality;
208+
209+ const codeKey = hashMFiles(mfiles);
210+ const solveParamsKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
211+ const fullOversample = Number(params.oversample ?? 16);
212+ const fullSolveKey = solveParamsKey + `|os=${fullOversample}`;
213+ if (quality === 'preview' && solveCache.has(fullSolveKey)) {
214+ quality = 'full'; // full solve already cached; only cheap stages remain
215+ }
216+
217+ const oversample = quality === 'preview' ? Math.min(8, fullOversample) : fullOversample;
218+ const fullElements = Number(params.radiating_elements ?? 24);
219+ const elements = quality === 'preview' ? Math.min(24, fullElements) : fullElements;
220+ const sampleRate = Number(params.sample_rate ?? req.sampleRate);
221+ const fsHi = sampleRate * oversample;
222+
223+ const base = `P = woodblock_defaults();\n${pLiteral(params)}`;
224+
225+ // ---- solve stage ----
226+ const solveKey = solveParamsKey + `|os=${oversample}`;
227+ let solveMs = 0;
228+ let solveCached = true;
229+ let solveEntry = lruGet(solveCache, solveKey);
230+ if (!solveEntry) {
231+ solveCached = false;
232+ env.post(0.15, 'solve');
233+ await env.yieldToHost();
234+ env.checkCancel();
235+ const t0 = performance.now();
236+ const r = runStage(mfiles, `
237+${base}
238+basis = woodblock_basis(P);
239+[Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = woodblock_solve(P, basis, ${oversample});
240+f = basis.f;
241+shapes = basis.shapes(1:32:end, :);
242+`);
243+ solveMs = performance.now() - t0;
244+
245+ const fT = asTensor(r.variableValues.f);
246+ const f = Array.from(fT.data);
247+ const n = f.length;
248+
249+ // decimated mode shapes, normalized per mode for the viewer
250+ const shapesT = asTensor(r.variableValues.shapes);
251+ const rows = shapesT.shape[0];
252+ const modeShapes = new Float32Array(n * SHAPE_PTS);
253+ for (let m = 0; m < n; m++) {
254+ let peak = 0;
255+ for (let i = 0; i < rows; i++) peak = Math.max(peak, Math.abs(shapesT.data[m * rows + i]));
256+ for (let i = 0; i < SHAPE_PTS; i++) {
257+ const j = Math.round((i / (SHAPE_PTS - 1)) * (rows - 1));
258+ modeShapes[m * SHAPE_PTS + i] = peak > 0 ? shapesT.data[m * rows + j] / peak : 0;
259+ }
260+ }
261+
262+ const forceT = asTensor(r.variableValues.force);
263+ const forceLen = Math.min(forceT.data.length, Math.round((FORCE_MS / 1000) * fsHi));
264+ const force = new Float32Array(forceLen);
265+ for (let i = 0; i < forceLen; i++) force[i] = forceT.data[i];
266+
267+ solveEntry = {
268+ Qdd: r.variableValues.Qdd,
269+ Xdd: r.variableValues.Xdd,
270+ fsHi,
271+ f,
272+ modeShapes,
273+ force,
274+ helmholtzHz: asScalar(r.variableValues.helmholtzHz),
275+ contactMs: asScalar(r.variableValues.contactMs),
276+ peakForceN: asScalar(r.variableValues.peakForceN),
277+ };
278+ lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
279+ }
280+
281+ // ---- radiate stage ----
282+ const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`;
283+ let radiateMs = 0;
284+ let radiateCached = true;
285+ let radEntry = lruGet(radiateCache, radiateKey);
286+ if (!radEntry) {
287+ radiateCached = false;
288+ env.post(0.55, 'radiate');
289+ await env.yieldToHost();
290+ env.checkCancel();
291+ const t0 = performance.now();
292+ const r = runStage(mfiles, `
293+${base}
294+basis = woodblock_basis(P);
295+[p, spacingNeed, listenerInside] = woodblock_radiate(P, basis, Qdd, Xdd, ${solveEntry.fsHi}, ${elements});
296+`, { Qdd: solveEntry.Qdd, Xdd: solveEntry.Xdd });
297+ radiateMs = performance.now() - t0;
298+
299+ const warnings: string[] = [];
300+ const spacingNeed = asScalar(r.variableValues.spacingNeed);
301+ if (spacingNeed > 0) {
302+ const fMax = solveEntry.f[solveEntry.f.length - 1];
303+ warnings.push(`element spacing is coarse at ${fMax.toFixed(0)} Hz; consider ${spacingNeed} radiating elements`);
304+ }
305+ if (asScalar(r.variableValues.listenerInside) !== 0) {
306+ const d = Math.hypot(Number(params.listener_x_m ?? 0), Number(params.listener_y_m ?? 0), Number(params.listener_z_m ?? 0));
307+ warnings.push(`listener is ${(d * 100).toFixed(0)} cm from the block centre, inside the source region; point-source elements are crude here`);
308+ }
309+ radEntry = { p: r.variableValues.p, warnings };
310+ lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
311+ }
312+
313+ // ---- output stage ----
314+ env.post(0.9, 'output');
315+ await env.yieldToHost();
316+ env.checkCancel();
317+ const rOut = runStage(mfiles, `
318+${base}
319+[y, peakPa] = woodblock_output(P, p, ${oversample});
320+`, { p: radEntry.p });
321+
322+ const y32 = toF32(rOut.variableValues.y);
323+ const peakPa = asScalar(rOut.variableValues.peakPa);
324+
325+ const warnings = [...radEntry.warnings];
326+ if (!params.normalize) {
327+ let peak = 0;
328+ for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
329+ if (peak > 1.0) {
330+ warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
331+ }
332+ }
333+ const lx = Number(params.listener_x_m ?? 0), ly = Number(params.listener_y_m ?? 0), lz = Number(params.listener_z_m ?? 0);
334+
335+ const diag: WoodblockExploreDiag = {
336+ quality,
337+ force: solveEntry.force.slice(),
338+ forceFsHz: solveEntry.fsHi,
339+ modeShapes: solveEntry.modeShapes.slice(),
340+ shapePts: SHAPE_PTS,
341+ diag: {
342+ modesKept: solveEntry.f.length,
343+ partialsHz: solveEntry.f,
344+ ratios: solveEntry.f.map((f) => f / solveEntry.f[0]),
345+ helmholtzHz: solveEntry.helmholtzHz,
346+ contactMs: solveEntry.contactMs,
347+ peakForceN: solveEntry.peakForceN,
348+ peakPa,
349+ peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
350+ listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz),
351+ warnings,
352+ solveMs,
353+ radiateMs,
354+ solveCached,
355+ radiateCached,
356+ oversample,
357+ elements,
358+ },
359+ };
360+ return { audio: y32, diag };
361+}
362+
363+// --- the track path (midiboot): per-strike rendering with caching ---
364+
365+function notePLines(note: { pitch: number; velocity: number }, req: TrackRenderRequest): string {
366+ // The note-to-parameter mapping: MIDI pitch scales the block, every
367+ // length by s = 2^-((pitch - 76)/12), the participating mass by s^3, and
368+ // the resonant cavity consistently (volume s^3, slit area s^2, slit
369+ // length s), so both the bar modes and the Helmholtz resonance shift by
370+ // the semitone ratio exactly. The mallet stays fixed; velocity drives
371+ // the mallet speed. Pitch 76 is the default block, playable 64..88.
372+ const settings = req.settings ?? {};
373+ const s = Math.pow(2, -(Math.max(64, Math.min(88, note.pitch)) - 76) / 12);
374+ const lines = [
375+ 'P = woodblock_defaults();',
376+ `P.sample_rate = ${req.sampleRate};`,
377+ 'P.pre_delay_ms = 0;',
378+ 'P.normalize = 0;',
379+ ];
380+ if (req.quality === 'preview') lines.push('P.oversample = 4;');
381+ lines.push(
382+ `P.length_m = P.length_m * ${s};`,
383+ `P.width_m = P.width_m * ${s};`,
384+ `P.wall_thickness_m = P.wall_thickness_m * ${s};`,
385+ `P.block_height_m = P.block_height_m * ${s};`,
386+ `P.participating_mass_kg = P.participating_mass_kg * ${s ** 3};`,
387+ `P.cavity_volume_m3 = P.cavity_volume_m3 * ${s ** 3};`,
388+ `P.slit_area_m2 = P.slit_area_m2 * ${s ** 2};`,
389+ `P.slit_length_m = P.slit_length_m * ${s};`,
390+ `P.mallet_velocity = ${0.2 + (8 - 0.2) * note.velocity};`,
391+ `P.strike_position = ${knobValue(settings, 'strike_position')};`,
392+ `P.mallet_stiffness = ${knobValue(settings, 'mallet_stiffness')};`,
393+ );
394+ return lines.join('\n');
395+}
396+
397+async function renderTrackNotes(
398+ req: TrackRenderRequest,
399+ env: RenderEnv,
400+): Promise<{ audio: Float32Array }> {
401+ if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
402+ const codeKey = hashMFiles(env.mfiles);
403+ const settings = req.settings ?? {};
404+ const knobsKey = WOODBLOCK_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
405+
406+ // Group notes by everything that affects their sound. A strike ignores
407+ // note duration, so a repeated pattern costs one render per distinct
408+ // pitch and velocity level.
409+ const groups = new Map<string, { pitch: number; velocity: number; onsets: number[] }>();
410+ for (const n of req.notes) {
411+ const pitch = Math.round(n.pitch);
412+ const vel = bucketVelocity(n.velocity);
413+ const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${pitch}|${vel}`;
414+ let g = groups.get(key);
415+ if (!g) groups.set(key, (g = { pitch, velocity: vel, onsets: [] }));
416+ g.onsets.push(n.timeSec);
417+ }
418+
419+ const placed: { buf: Float32Array; onsets: number[] }[] = [];
420+ // Resolve cache hits first (memory, then IndexedDB), so the progress
421+ // denominator counts only the sounds that actually need rendering.
422+ const resolved = new Map<string, Float32Array>();
423+ for (const key of groups.keys()) {
424+ const hit = await noteCache.get(key);
425+ if (hit) resolved.set(key, hit);
426+ }
427+ const misses = groups.size - resolved.size;
428+ let done = 0;
429+ for (const [key, g] of groups) {
430+ let buf = resolved.get(key);
431+ if (!buf) {
432+ env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: solve`);
433+ await env.yieldToHost();
434+ env.checkCancel();
435+ const r = runStage(env.mfiles, `
436+${notePLines(g, req)}
437+[y, basis, dinfo] = woodblock_render(P);
438+`);
439+ buf = toF32(r.variableValues.y);
440+ noteCache.put(key, buf);
441+ done++;
442+ }
443+ placed.push({ buf, onsets: g.onsets });
444+ }
445+
446+ // lengthSamples is a minimum: size the output to the longest tail so a
447+ // ring that outlives the last note is returned in full, and place every
448+ // copy.
449+ let length = req.lengthSamples;
450+ for (const p of placed) {
451+ for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
452+ }
453+ const out = new Float32Array(length);
454+ for (const p of placed) {
455+ for (const t of p.onsets) {
456+ const off = Math.round(t * req.sampleRate);
457+ const end = Math.min(out.length, off + p.buf.length);
458+ for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
459+ }
460+ }
461+ return { audio: out };
462+}
463+
464+/** The single entry point: explorer path when diagnostics are requested,
465+ * the per-strike track path otherwise. */
466+export async function renderRequest(
467+ variant: WoodblockVariant,
468+ req: TrackRenderRequest,
469+ env: RenderEnv,
470+): Promise<{ audio: Float32Array; diag?: WoodblockExploreDiag }> {
471+ void variant;
472+ if (req.wantDiagnostics) return renderExplore(req, env);
473+ return renderTrackNotes(req, env);
474+}
instrument/index.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+// The woodblock behind the generic track-render instrument interface, for
2+// direct import by midiboot and by the woodblock 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 strike 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 { WOODBLOCK_KNOBS, type WoodblockVariant } 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(`woodblock 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: WoodblockVariant, 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 WoodblockInstrument implements Instrument {
103+ readonly id = 'woodblock';
104+ readonly name = 'Woodblock';
105+ readonly knobs = WOODBLOCK_KNOBS;
106+ renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
107+ return renderOnWorker('woodblock', req);
108+ }
109+}
110+
111+export const woodblockInstrument: Instrument = new WoodblockInstrument();
112+export const woodblockInstruments: Instrument[] = [woodblockInstrument];
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 WOODBLOCK_KNOBS: KnobSpec[] = [
6+ { key: 'strike_position', label: 'strike position', min: 0.05, max: 0.95, default: 0.42 },
7+ { key: 'mallet_stiffness', label: 'mallet stiffness', min: 1e7, max: 3e10, default: 1e8, scale: 'log' },
8+];
9+
10+export type WoodblockVariant = 'woodblock';
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": "woodblock-instrument",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "The woodblock 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+108−0View file
@@ -0,0 +1,108 @@
1+// Node-side test of the woodblock 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+function req(notes: NoteSpec[], overrides: Partial<TrackRenderRequest> = {}): TrackRenderRequest {
40+ const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0);
41+ return {
42+ notes,
43+ sampleRate: 22050,
44+ lengthSamples: Math.ceil((end + 1.0) * 22050),
45+ quality: 'preview',
46+ settings: {},
47+ ...overrides,
48+ };
49+}
50+
51+const note = (timeSec: number, pitch: number, velocity: number): NoteSpec =>
52+ ({ timeSec, durationSec: 0.1, pitch, velocity });
53+
54+const t0 = performance.now();
55+
56+// One strike: finite, audible, decaying.
57+const a = await renderRequest('woodblock', req([note(0, 76, 0.7)]), env);
58+{
59+ const s = stats(a.audio);
60+ check('single strike is finite', s.finite);
61+ check('single strike is audible', s.peak > 1e-4, `peak ${s.peak.toExponential(2)}`);
62+ const n = a.audio.length;
63+ const head = stats(a.audio.slice(0, Math.floor(n / 3))).peak;
64+ const tail = stats(a.audio.slice(Math.floor((2 * n) / 3))).peak;
65+ check('single strike decays', tail < head * 0.8, `head ${head.toExponential(2)} tail ${tail.toExponential(2)}`);
66+}
67+
68+// Determinism and the note cache.
69+const b = await renderRequest('woodblock', req([note(0, 76, 0.7)]), env);
70+{
71+ let same = a.audio.length === b.audio.length;
72+ if (same) for (let i = 0; i < a.audio.length; i++) if (a.audio[i] !== b.audio[i]) { same = false; break; }
73+ check('render is deterministic', same);
74+}
75+{
76+ const t = performance.now();
77+ await renderRequest('woodblock', req([note(0, 76, 0.7)]), env);
78+ const ms = performance.now() - t;
79+ check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`);
80+}
81+
82+// Velocity and pitch change the sound.
83+const soft = await renderRequest('woodblock', req([note(0, 76, 0.2)]), env);
84+{
85+ const pa = stats(a.audio).peak;
86+ const ps = stats(soft.audio).peak;
87+ check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`);
88+}
89+const high = await renderRequest('woodblock', req([note(0, 83, 0.7)]), env);
90+{
91+ let diff = high.audio.length !== a.audio.length;
92+ 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; }
93+ check('pitch changes the sound', diff);
94+}
95+
96+// A pattern: the repeated strike is placed, not re-rendered.
97+const tr = await renderRequest('woodblock', req([note(0, 76, 0.7), note(0.5, 76, 0.7), note(1.0, 83, 0.7)]), env);
98+{
99+ const s = stats(tr.audio);
100+ check('track render is finite', s.finite);
101+ const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * 22050), Math.floor((sec + 0.2) * 22050))).peak;
102+ check('energy at first onset', at(0.0) > 1e-4);
103+ check('energy at second onset', at(0.5) > 1e-4);
104+ check('energy at third onset', at(1.0) > 1e-4);
105+}
106+
107+console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
108+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 modal 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 WoodblockVariant } 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: WoodblockVariant;
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,6 +36,6 @@ The Loop button repeats the last render continuously, swapping in each new rende
3636 - `../matlab/` — the algorithm: `woodblock_basis.m`, `woodblock_solve.m`, `woodblock_radiate.m`, `woodblock_output.m`, plus `woodblock_render.m` / `woodblock_demo.m` / `woodblock_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 (waveform, spectrum, spectrogram, contact force, animated mode shapes)
4141 - `src/selftest.ts` — Node harness for the MATLAB validation script
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 return (
66 <div className="diag">
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 { WoodblockParams } from '../params';
812 import type { MFile } from '../mfiles';
9-import type { ErrorMessage, ProgressMessage, RenderResponse } from '../worker/renderWorker';
13+import { woodblockInstrument } from '../../../instrument/index.ts';
14+import type { WoodblockExploreDiag } 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 = WoodblockExploreDiag & {
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: WoodblockParams, 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: WoodblockParams, 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: WoodblockParams, 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 woodblockInstrument.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 WoodblockExploreDiag;
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−358View file
@@ -1,358 +0,0 @@
1-// Web Worker that renders the woodblock strike 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 -> woodblock_solve (cached on solve-tier params + oversample)
6-// radiate tier -> woodblock_radiate (cached on radiate-tier params + M)
7-// output tier -> woodblock_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 WoodblockParams } from '../params';
13-
14-export interface RenderRequest {
15- type: 'render';
16- id: number;
17- params: WoodblockParams;
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- force: Float32Array; // contact force, first forceMs at fsHi
29- forceFsHz: number;
30- modeShapes: Float32Array; // n x shapePts, concatenated
31- shapePts: number;
32- diag: {
33- modesKept: number;
34- partialsHz: number[];
35- ratios: number[];
36- helmholtzHz: number;
37- contactMs: number;
38- peakForceN: number;
39- peakPa: number;
40- peakSplDb: number;
41- listenerDistM: number;
42- warnings: string[];
43- solveMs: number; // 0 when the cached solve was reused
44- radiateMs: number; // 0 when the cached radiation was reused
45- solveCached: boolean;
46- radiateCached: boolean;
47- oversample: number;
48- elements: number;
49- };
50-}
51-
52-export interface ProgressMessage {
53- type: 'progress';
54- id: number;
55- stage: 'solve' | 'radiate' | 'output';
56- frac: number;
57-}
58-
59-// Raised by a fault in the .m code (the user can edit it), so it is reported
60-// rather than left to stall the request.
61-export interface ErrorMessage {
62- type: 'error';
63- id: number;
64- message: string;
65- file?: string;
66- line?: number;
67-}
68-
69-const SHAPE_PTS = 128;
70-const FORCE_MS = 8;
71-
72-interface SolveEntry {
73- Qdd: RuntimeValue;
74- Xdd: RuntimeValue;
75- fsHi: number;
76- f: number[];
77- modeShapes: Float32Array;
78- force: Float32Array;
79- helmholtzHz: number;
80- contactMs: number;
81- peakForceN: number;
82-}
83-
84-interface RadiateEntry {
85- p: RuntimeValue;
86- warnings: string[];
87-}
88-
89-// Small LRU caches so preview- and full-quality intermediates coexist. A
90-// radiate-tier change right after a full render then reuses the full solve,
91-// and the "preview" is silently upgraded to full quality.
92-const solveCache = new Map<string, SolveEntry>();
93-const radiateCache = new Map<string, RadiateEntry>();
94-const SOLVE_CACHE_MAX = 2;
95-const RADIATE_CACHE_MAX = 3;
96-
97-function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
98- const v = cache.get(key);
99- if (v !== undefined) {
100- cache.delete(key);
101- cache.set(key, v);
102- }
103- return v;
104-}
105-
106-function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
107- cache.delete(key);
108- cache.set(key, value);
109- while (cache.size > max) {
110- const oldest = cache.keys().next().value as string;
111- cache.delete(oldest);
112- }
113-}
114-
115-function keyFor(params: WoodblockParams, keys: readonly (keyof WoodblockParams)[]): string {
116- return keys.map((k) => String(params[k])).join('|');
117-}
118-
119-// MATLAB source lines assigning every parameter onto a struct P.
120-function pLiteral(params: WoodblockParams): string {
121- const lines: string[] = [];
122- for (const [k, v] of Object.entries(params)) {
123- if (typeof v === 'number') lines.push(`P.${k} = ${v};`);
124- else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
125- }
126- return lines.join('\n');
127-}
128-
129-function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
130- return executeCode(
131- source,
132- { initialVariableValues: inputs, displayResults: false },
133- mfiles,
134- 'stage.m',
135- );
136-}
137-
138-function asTensor(v: RuntimeValue): RuntimeTensor {
139- const t = v as RuntimeTensor;
140- if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
141- return t;
142-}
143-
144-function asScalar(v: RuntimeValue): number {
145- if (typeof v === 'number') return v;
146- const t = v as RuntimeTensor;
147- if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
148- if (typeof v === 'boolean') return v ? 1 : 0;
149- throw new Error('expected scalar result from numbl stage');
150-}
151-
152-const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
153-
154-self.onmessage = (ev: MessageEvent<RenderRequest>) => {
155- const msg = ev.data;
156- if (msg.type !== 'render') return;
157-
158- // numbl reports a file it could not parse through console.warn and then
159- // skips it, so the failure surfaces at the call site as "Undefined
160- // function". Capture the warning to report the actual syntax error instead.
161- const warnings: string[] = [];
162- const origWarn = console.warn;
163- console.warn = (...args: unknown[]) => {
164- warnings.push(args.map(String).join(' '));
165- };
166-
167- try {
168- render(msg);
169- } catch (err) {
170- // Most likely a fault in the (user-editable) .m code. Report it so the
171- // request completes and the message reaches the code panel.
172- const e = err as { message?: string; file?: string; line?: number };
173- let m: ErrorMessage = {
174- type: 'error',
175- id: msg.id,
176- message: e?.message ? String(e.message) : String(err),
177- file: typeof e?.file === 'string' ? e.file : undefined,
178- line: typeof e?.line === 'number' ? e.line : undefined,
179- };
180- for (const w of warnings) {
181- const hit = SKIP_WARNING.exec(w);
182- if (hit) {
183- m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
184- break;
185- }
186- }
187- (self as unknown as Worker).postMessage(m);
188- } finally {
189- console.warn = origWarn;
190- }
191-};
192-
193-function render(msg: RenderRequest): void {
194- const { id, params, mfiles } = msg;
195- let quality = msg.quality;
196-
197- // The .m sources are part of the cache identity: editing the code must
198- // invalidate everything computed from the previous version.
199- const codeKey = hashMFiles(mfiles);
200- const solveParamsKey = codeKey + '|' + keyFor(params, SOLVE_KEYS);
201- const fullSolveKey = solveParamsKey + `|os=${params.oversample}`;
202- if (quality === 'preview' && solveCache.has(fullSolveKey)) {
203- quality = 'full'; // full solve already cached; only cheap stages remain
204- }
205-
206- const oversample = quality === 'preview' ? Math.min(8, params.oversample) : params.oversample;
207- const elements = quality === 'preview'
208- ? Math.min(24, params.radiating_elements)
209- : params.radiating_elements;
210- const fsHi = params.sample_rate * oversample;
211-
212- const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
213-
214- // ---- solve stage ----
215- const solveKey = solveParamsKey + `|os=${oversample}`;
216- let solveMs = 0;
217- let solveCached = true;
218- let solveEntry = lruGet(solveCache, solveKey);
219- if (!solveEntry) {
220- solveCached = false;
221- post({ type: 'progress', id, stage: 'solve', frac: 0.15 });
222- const t0 = performance.now();
223- const r = runStage(mfiles, `
224-${pLiteral(params)}
225-basis = woodblock_basis(P);
226-[Qdd, Xdd, force, helmholtzHz, contactMs, peakForceN, maxRealLam] = woodblock_solve(P, basis, ${oversample});
227-f = basis.f;
228-shapes = basis.shapes(1:32:end, :);
229-`);
230- solveMs = performance.now() - t0;
231-
232- const fT = asTensor(r.variableValues.f);
233- const f = Array.from(fT.data);
234- const n = f.length;
235-
236- // decimated mode shapes, normalized per mode for the viewer
237- const shapesT = asTensor(r.variableValues.shapes);
238- const rows = shapesT.shape[0];
239- const modeShapes = new Float32Array(n * SHAPE_PTS);
240- for (let m = 0; m < n; m++) {
241- let peak = 0;
242- for (let i = 0; i < rows; i++) peak = Math.max(peak, Math.abs(shapesT.data[m * rows + i]));
243- for (let i = 0; i < SHAPE_PTS; i++) {
244- const j = Math.round((i / (SHAPE_PTS - 1)) * (rows - 1));
245- modeShapes[m * SHAPE_PTS + i] = peak > 0 ? shapesT.data[m * rows + j] / peak : 0;
246- }
247- }
248-
249- const forceT = asTensor(r.variableValues.force);
250- const forceLen = Math.min(forceT.data.length, Math.round((FORCE_MS / 1000) * fsHi));
251- const force = new Float32Array(forceLen);
252- for (let i = 0; i < forceLen; i++) force[i] = forceT.data[i];
253-
254- solveEntry = {
255- Qdd: r.variableValues.Qdd,
256- Xdd: r.variableValues.Xdd,
257- fsHi,
258- f,
259- modeShapes,
260- force,
261- helmholtzHz: asScalar(r.variableValues.helmholtzHz),
262- contactMs: asScalar(r.variableValues.contactMs),
263- peakForceN: asScalar(r.variableValues.peakForceN),
264- };
265- lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
266- }
267-
268- // ---- radiate stage ----
269- const radiateKey = solveKey + '||' + keyFor(params, RADIATE_KEYS) + `|M=${elements}`;
270- let radiateMs = 0;
271- let radiateCached = true;
272- let radEntry = lruGet(radiateCache, radiateKey);
273- if (!radEntry) {
274- radiateCached = false;
275- post({ type: 'progress', id, stage: 'radiate', frac: 0.55 });
276- const t0 = performance.now();
277- const r = runStage(mfiles, `
278-${pLiteral(params)}
279-basis = woodblock_basis(P);
280-[p, spacingNeed, listenerInside] = woodblock_radiate(P, basis, Qdd, Xdd, ${solveEntry.fsHi}, ${elements});
281-`, { Qdd: solveEntry.Qdd, Xdd: solveEntry.Xdd });
282- radiateMs = performance.now() - t0;
283-
284- const warnings: string[] = [];
285- const spacingNeed = asScalar(r.variableValues.spacingNeed);
286- if (spacingNeed > 0) {
287- const fMax = solveEntry.f[solveEntry.f.length - 1];
288- warnings.push(
289- `element spacing is coarse at ${fMax.toFixed(0)} Hz; consider ${spacingNeed} radiating elements`,
290- );
291- }
292- if (asScalar(r.variableValues.listenerInside) !== 0) {
293- const d = Math.hypot(params.listener_x_m, params.listener_y_m, params.listener_z_m);
294- warnings.push(
295- `listener is ${(d * 100).toFixed(0)} cm from the block centre, inside the source region; point-source elements are crude here`,
296- );
297- }
298- radEntry = { p: r.variableValues.p, warnings };
299- lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
300- }
301-
302- // ---- output stage ----
303- post({ type: 'progress', id, stage: 'output', frac: 0.9 });
304- const rOut = runStage(mfiles, `
305-${pLiteral(params)}
306-[y, peakPa] = woodblock_output(P, p, ${oversample});
307-`, { p: radEntry.p });
308-
309- const yT = asTensor(rOut.variableValues.y);
310- const y32 = new Float32Array(yT.data.length);
311- for (let i = 0; i < yT.data.length; i++) y32[i] = yT.data[i];
312- const peakPa = asScalar(rOut.variableValues.peakPa);
313-
314- const warnings = [...radEntry.warnings];
315- if (!params.normalize) {
316- let peak = 0;
317- for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
318- if (peak > 1.0) {
319- warnings.push(
320- `clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`,
321- );
322- }
323- }
324- const lx = params.listener_x_m, ly = params.listener_y_m, lz = params.listener_z_m;
325-
326- const force = solveEntry.force.slice();
327- const modeShapes = solveEntry.modeShapes.slice();
328- const resp: RenderResponse = {
329- type: 'result',
330- id,
331- quality,
332- y: y32,
333- sampleRate: params.sample_rate,
334- force,
335- forceFsHz: solveEntry.fsHi,
336- modeShapes,
337- shapePts: SHAPE_PTS,
338- diag: {
339- modesKept: solveEntry.f.length,
340- partialsHz: solveEntry.f,
341- ratios: solveEntry.f.map((f) => f / solveEntry.f[0]),
342- helmholtzHz: solveEntry.helmholtzHz,
343- contactMs: solveEntry.contactMs,
344- peakForceN: solveEntry.peakForceN,
345- peakPa,
346- peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
347- listenerDistM: Math.sqrt(lx * lx + ly * ly + lz * lz),
348- warnings,
349- solveMs,
350- radiateMs,
351- solveCached,
352- radiateCached,
353- oversample,
354- elements,
355- },
356- };
357- (self as unknown as Worker).postMessage(resp, [y32.buffer, force.buffer, modeShapes.buffer]);
358-}
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,
↑↓ move↵openescclose