Replace instrument.json with exported instrument bundles
The declarative manifest is superseded by the numbl-instrument-v1 format
specified in ../instrument-format: export-src/ holds per-bundle metadata
and numbl driver code (render_note, optionally prepare) that maps MIDI
notes onto the model, and scripts/build-exports.mjs inlines the drivers
and matlab/ sources into the committed exports/*.json bundles.
5 changed files+167−14
export-src/woodblock/export.jsonadded+10−0View file
@@ -0,0 +1,10 @@
1+{
2+ "id": "woodblock",
3+ "name": "Woodblock",
4+ "description": "Struck woodblock; unpitched, velocity onto mallet speed.",
5+ "source": "simulated_instruments/woodblock",
6+ "knobs": [
7+ { "key": "strike_position", "label": "strike position", "min": 0.05, "max": 0.95, "default": 0.42 },
8+ { "key": "mallet_stiffness", "label": "mallet stiffness", "min": 1e7, "max": 3e10, "default": 1e8, "scale": "log" }
9+ ]
10+}
export-src/woodblock/render_note.madded+16−0View file
@@ -0,0 +1,16 @@
1+function y = render_note(note)
2+% Unpitched: MIDI pitch is ignored, velocity drives the mallet speed.
3+% Fixed: normalize off so velocity survives in pascals; the preview tier
4+% caps oversampling at 4 (the model's eigensolve dominates either way).
5+P = woodblock_defaults();
6+P.sample_rate = note.sample_rate;
7+P.pre_delay_ms = 0;
8+P.normalize = 0;
9+if note.preview ~= 0
10+ P.oversample = 4;
11+end
12+P.mallet_velocity = 0.2 + (8 - 0.2) * note.velocity;
13+P.strike_position = note.params.strike_position;
14+P.mallet_stiffness = note.params.mallet_stiffness;
15+y = woodblock_render(P);
16+end
exports/woodblock.jsonadded+62−0View file
@@ -0,0 +1,62 @@
1+{
2+ "format": "numbl-instrument-v1",
3+ "id": "woodblock",
4+ "name": "Woodblock",
5+ "description": "Struck woodblock; unpitched, 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% Unpitched: MIDI pitch is ignored, velocity drives the mallet speed.\n% Fixed: normalize off so velocity survives in pascals; the preview tier\n% caps oversampling at 4 (the model's 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\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.jsondeleted+0−14View file
@@ -1,14 +0,0 @@
1-{
2- "format": "simulated-instrument-v1",
3- "name": "woodblock",
4- "label": "Woodblock",
5- "entry": "woodblock_render",
6- "defaults": "woodblock_defaults",
7- "fixed": { "pre_delay_ms": 0, "normalize": 0 },
8- "velocity": { "param": "mallet_velocity", "min": 0.2, "max": 8 },
9- "preview": { "oversample": 4 },
10- "knobs": [
11- { "key": "strike_position", "label": "strike position", "min": 0.05, "max": 0.95, "default": 0.42 },
12- { "key": "mallet_stiffness", "label": "mallet stiffness", "min": 1e7, "max": 3e10, "default": 1e8, "scale": "log" }
13- ]
14-}
scripts/build-exports.mjsadded+79−0View file
@@ -0,0 +1,79 @@
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+}