/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
woodblock / exports / woodblock.json
62 lines · 17.6 KBBlameHistoryRaw
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 ]
↑↓ move↵openescclose