{ "format": "numbl-instrument-v1", "id": "bass", "name": "Standup Bass", "description": "Plucked standup bass, one note per MIDI event, E1 to G3.", "source": "simulated_instruments/bass", "knobs": [ { "key": "pluck_pos", "label": "pluck position", "min": 0.05, "max": 0.95, "default": 0.3 }, { "key": "finger_release_ms", "label": "fingertip release (ms)", "min": 0.2, "max": 8, "default": 1.3 }, { "key": "tail_s", "label": "ring-out (s)", "min": 1, "max": 8, "default": 6 } ], "files": [ { "name": "render_note.m", "content": "function y = render_note(note)\n% One MIDI note through the standup bass model: pitch onto the fingered\n% root, velocity onto pluck force, knobs onto the curated parameters.\n% Fixed: a single-pluck gesture, a fixed seed so renders are cacheable,\n% and normalize off so velocity survives in pascals.\nP = bass_defaults();\nP.sample_rate = note.sample_rate;\nP.note_count = 1;\nP.seed = 1;\nP.pre_delay_ms = 0;\nP.normalize = 0;\nif note.preview ~= 0\n P.oversample = 1;\nend\nP.root_midi = max(28, min(55, round(note.pitch)));\nP.pluck_force_n = 3 + (30 - 3) * note.velocity;\nP.pluck_pos = note.params.pluck_pos;\nP.finger_release_ms = note.params.finger_release_ms;\nP.tail_s = note.params.tail_s;\ny = bass_render(P);\nend\n" }, { "name": "bass_assemble.m", "content": "function [M, Kmat, omS, zS, meta] = bass_assemble(P, s, st)\n% BASS_ASSEMBLE Mass and stiffness matrices of one fingering's coupled\n% string-body system, plus the uncoupled string modes and damping.\n%\n% The displacement ansatz and the resulting matrices are documented in\n% bass_basis.m, which calls this per fingering; bass_validate.m calls it\n% directly to check the closed-form string limit and the eigensolve\n% identities. Coordinates are [q_1..q_ns, xi_1..xi_nb]: mass-normalized\n% pinned-pinned stiff-string modes, then the mass-normalized body modes of\n% bass_body.m. M and Kmat are symmetric and M is positive definite (the\n% coupling is weak: the off-diagonal mass terms are of order\n% sqrt(mu Lv / m_body) / k pi, a few percent here).\n\nf0open = [97.9989 73.4162 55.0000 41.2034] * P.tuning_trim;\ntension = [P.tension_g_n P.tension_d_n P.tension_a_n P.tension_e_n];\nL0 = P.scale_length_m;\n\nbody = bass_body(P);\nnb = body.n;\nomRes = 2 * pi * 1.25 * max(body.f); % residual admittance high-pass corner\n\nT = tension(s);\ncw = 2 * L0 * f0open(s); % transverse wave speed of the string\nmu = T / cw^2;\nLv = L0 * 2^(-st / 12);\nEI = P.inharmonicity * T * L0^2 / pi^2;\n\n% string modes up to max_mode_hz (at least one, at most 160)\nkEst = min(160, max(1, floor(2 * Lv * P.max_mode_hz / cw) + 2));\nkk = (1:kEst)' * pi / Lv;\nom2 = kk.^2 * (T / mu) + kk.^4 * (EI / mu);\nomAll = sqrt(om2);\nns = max(1, sum(omAll <= 2 * pi * P.max_mode_hz));\nns = min(ns, kEst);\nomS = omAll(1:ns);\nkv = (1:ns)';\n\n% uncoupled string damping: internal loss, the finger termination on\n% stopped notes, and the resistive residual bridge admittance (high-passed\n% above the retained body modes so the modal region is not double-counted;\n% a resistive end gives a constant decay RATE, zeta ~ Y T / (Lv om))\nhp = omS.^2 ./ (omS.^2 + omRes^2);\nzS = 0.5 * (P.loss_factor + P.loss_slope * omS) ...\n + 0.5 * P.residual_y * (T / Lv) * hp ./ omS;\nif st > 0\n zS = zS + 0.5 * P.finger_loss;\nend\n\n% ---- assemble ----\nn = ns + nb;\ngk = sqrt(2 * mu * Lv) * ((-1).^(kv + 1)) ./ (kv * pi);\nM = eye(n);\nKmat = zeros(n, n);\nfor k = 1:ns\n Kmat(k, k) = omS(k)^2;\nend\nfor j = 1:nb\n for k = 1:ns\n M(k, ns + j) = gk(k) * body.a(j);\n M(ns + j, k) = M(k, ns + j);\n end\n for i = 1:nb\n M(ns + i, ns + j) = M(ns + i, ns + j) + (mu * Lv / 3) * body.a(i) * body.a(j);\n Kmat(ns + i, ns + j) = (T / Lv) * body.a(i) * body.a(j);\n end\n Kmat(ns + j, ns + j) = Kmat(ns + j, ns + j) + body.omega(j)^2;\nend\n\nmeta.ns = ns;\nmeta.nb = nb;\nmeta.n = n;\nmeta.Lv = Lv;\nmeta.mu = mu;\nmeta.T = T;\nmeta.f0 = f0open(s) * 2^(st / 12);\nmeta.body = body;\nend\n" }, { "name": "bass_basis.m", "content": "function basis = bass_basis(P, str, stop)\n% BASS_BASIS Coupled string-body mode set, one eigensolve per fingering.\n%\n% For each fingering of the gesture (a string and a stop, deduplicated so a\n% walking line that revisits a note reuses its solve), the vibrating string\n% and the modal body of bass_body.m are assembled into ONE symmetric\n% generalized eigenproblem and diagonalized together, so the coupling\n% through the bridge is two-way: the string drives the body, and the moving\n% bridge detunes and damps the string in return. This is where wolf-note\n% physics lives; a note whose fundamental lands on a strong body resonance\n% comes out as a split, fast-beating pair of coupled modes rather than one.\n%\n% The displacement ansatz is\n% u(x) = sum_k q_k phi_k(x) + y_b x/Lv, y_b = sum_j a_j xi_j,\n% with phi_k the mass-normalized pinned-pinned modes of the stiff string\n% (om_k^2 = (k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu; the bending stiffness EI\n% comes from the inharmonicity parameter, EI = B T L0^2/pi^2, and is what\n% stretches the high partials sharp) and x/Lv the static shape the string\n% takes when the bridge end moves. With this split the elastic coupling\n% integrals vanish (the ramp has no curvature and phi_k vanishes at both\n% ends), so all the coupling sits in the mass matrix, plus the ramp's own\n% inertia and the tension load T/Lv on the body block; bass_assemble.m\n% builds the matrices. Both are symmetric and M is positive definite, so\n% the problem reduces through the Cholesky factor of M and is diagonalized\n% by svd, which keeps near-degenerate coupled pairs orthonormal where a\n% nonsymmetric eigensolver does not. The shapes come back mass-normalized.\n%\n% Damping is assigned in the uncoupled coordinates and projected,\n% zeta_m = (Phi' C0 Phi)_mm / (2 om_m): string internal loss (loss_factor +\n% loss_slope om), the finger termination on stopped notes (finger_loss), the\n% resistive residual bridge admittance above the retained body modes\n% (residual_y, high-passed so it does not double-count the modal region),\n% and the body Qs. Radiation damping from each coupled mode's own monopole\n% and dipole strengths is added on top, scaled by radiation_loss.\n%\n% Stored per coupled mode: frequency, damping, monopole volume Vm and dipole\n% volume Dd per unit modal coordinate (what bass_radiate.m radiates), the\n% bridge force coefficient Fcoef (what drives the direct term), and the\n% projection matrix PM = (M Phi)' restricted to the string block, which\n% turns a plucked initial shape into coupled initial amplitudes. Fields are\n% concatenated across fingerings with offset indexing (numbl has no struct\n% arrays).\n\nrho0 = P.air_density;\ncair = P.speed_of_sound;\n\n% ---- deduplicate fingerings ----\nK = length(str);\nfkey = str * 100 + stop;\nuKey = zeros(1, K);\nfIdx = zeros(1, K);\nnf = 0;\nfor i = 1:K\n hit = 0;\n for u = 1:nf\n if uKey(u) == fkey(i)\n hit = u;\n end\n end\n if hit == 0\n nf = nf + 1;\n uKey(nf) = fkey(i);\n hit = nf;\n end\n fIdx(i) = hit;\nend\n\n% ---- per-fingering eigensolve, concatenated storage ----\nf = [];\nzeta = [];\nVm = [];\nDd = [];\nFcoef = [];\nybAmp = [];\nbodyFrac = [];\nPMflat = [];\noff = zeros(nf, 1);\nnModes = zeros(nf, 1);\npmOff = zeros(nf, 1);\nnsArr = zeros(nf, 1);\nLvArr = zeros(nf, 1);\nmuArr = zeros(nf, 1);\nTArr = zeros(nf, 1);\nf0Arr = zeros(nf, 1);\nstrArr = zeros(nf, 1);\nstopArr = zeros(nf, 1);\nbody = bass_body(P);\n\nfor u = 1:nf\n s = floor(uKey(u) / 100);\n st = uKey(u) - 100 * s;\n [M, Kmat, omS, zS, meta] = bass_assemble(P, s, st);\n ns = meta.ns;\n nb = meta.nb;\n n = meta.n;\n Lv = meta.Lv;\n mu = meta.mu;\n T = meta.T;\n kv = (1:ns)';\n\n Rc = chol(M);\n Am = (Rc') \\ (Kmat / Rc);\n Am = (Am + Am') / 2;\n [Uv, Sv, ~] = svd(Am);\n omc2 = flipud(diag(Sv)); % ascending\n Vv = fliplr(Uv);\n omc = sqrt(max(1e-6, omc2));\n Phi = Rc \\ Vv; % mass-normalized coupled shapes, n x n\n\n % projected damping plus per-mode radiation damping\n cvec = 2 * [zS .* omS; body.zeta .* body.omega];\n zc = ((cvec') * (Phi.^2))' ./ (2 * omc);\n PhiB = Phi(ns + 1:n, :);\n bodyDisp = (body.a * ones(1, n)) .* PhiB; % nb x n physical\n vm = ((body.S .* body.mono)' * bodyDisp)'; % n x 1 monopole volume\n dd = P.dipole_length_m * ((body.S .* (1 - body.mono))' * bodyDisp)';\n zRad = P.radiation_loss * (rho0 * omc.^4 .* vm.^2 / (8 * pi * cair) ...\n + rho0 * omc.^6 .* dd.^2 / (24 * pi * cair^3)) ./ omc.^3;\n zc = zc + zRad;\n\n yb = ((body.a') * PhiB)'; % n x 1 bridge displacement\n phiSlope = sqrt(2 / (mu * Lv)) * (kv * pi / Lv) .* ((-1).^kv);\n fc = T * (((phiSlope') * Phi(1:ns, :))' + yb / Lv);\n bfrac = ((ones(1, nb) * (PhiB.^2)))';\n\n PM = (M * Phi);\n PM = (PM(1:ns, :))'; % n x ns projection\n\n off(u) = length(f);\n nModes(u) = n;\n pmOff(u) = length(PMflat);\n nsArr(u) = ns;\n LvArr(u) = Lv;\n muArr(u) = mu;\n TArr(u) = T;\n f0Arr(u) = meta.f0;\n strArr(u) = s;\n stopArr(u) = st;\n\n f = [f; omc / (2 * pi)];\n zeta = [zeta; zc];\n Vm = [Vm; vm];\n Dd = [Dd; dd];\n Fcoef = [Fcoef; fc];\n ybAmp = [ybAmp; yb];\n bodyFrac = [bodyFrac; bfrac];\n PMflat = [PMflat; reshape(PM, n * ns, 1)];\nend\n\nbasis.nf = nf;\nbasis.fIdx = fIdx;\nbasis.off = off;\nbasis.nModes = nModes;\nbasis.pmOff = pmOff;\nbasis.ns = nsArr;\nbasis.Lv = LvArr;\nbasis.mu = muArr;\nbasis.T = TArr;\nbasis.f0 = f0Arr;\nbasis.str = strArr;\nbasis.stop = stopArr;\nbasis.f = f;\nbasis.zeta = zeta;\nbasis.Vm = Vm;\nbasis.Dd = Dd;\nbasis.Fcoef = Fcoef;\nbasis.ybAmp = ybAmp;\nbasis.bodyFrac = bodyFrac;\nbasis.PMflat = PMflat;\nbasis.n = length(f);\nbasis.bodyF = body.f;\nbasis.bodyZeta = body.zeta;\nbasis.bodyA = body.a;\nbasis.bodyS = body.S;\nbasis.bodyMono = body.mono;\nend\n" }, { "name": "bass_body.m", "content": "function body = bass_body(P)\n% BASS_BODY The parameterized modal body seen at the bridge.\n%\n% This is the empirical part of the model: the corpus of a double bass is\n% not solved from plates and ribs here, it is a table of measured-plausible\n% resonances, each reduced to what the string and the listener can see of\n% it. Per mode: the frequency, the Q, the effective mass at the bridge foot\n% (which sets the admittance the string is loaded by), the radiating area\n% (the net volume the body displaces per unit bridge displacement in that\n% mode), and whether it radiates as a monopole or a dipole. The first row\n% is A0, the f-hole Helmholtz resonance: the one mode that moves net volume\n% through the f-holes and therefore the one monopole; every corpus mode\n% above it displaces the top plate against the surrounding air with no net\n% flow and radiates as a dipole across the body. The table follows the\n% published admittance and radiativity measurements of orchestral basses in\n% shape (A0 near 60 Hz, the main wood resonances between 90 and 250 Hz, a\n% falling effective area above), with the absolute levels set by the trims\n% in bass_defaults.m. Modes above the table are NOT ignored: their\n% collective resistive load enters as residual_y in bass_basis.m and their\n% radiation as the direct term in bass_radiate.m.\n%\n% Mass-normalized amplitude at the bridge: a_j = sqrt(bridge_mobility/m_j),\n% so bridge_mobility scales the whole admittance without retuning the table.\n\nfb = [60.0 97.0 113.0 135.0 167.0 205.0 252.0 310.0 382.0 470.0 578.0 711.0];\nQb = [28.0 30.0 30.0 30.0 28.0 26.0 24.0 22.0 20.0 19.0 18.0 17.0];\nmb = [1.6 1.2 1.1 1.0 0.95 0.90 0.90 1.0 1.1 1.3 1.5 1.8]; % kg at the bridge\nSb = [0.055 0.055 0.048 0.032 0.022 0.015 0.010 0.0075 0.0055 0.004 0.003 0.0022]; % m^2\nmono = [1 0 0 0 0 0 0 0 0 0 0 0];\n\nnb = length(fb);\nf = reshape(fb, nb, 1);\nf(1) = P.a0_hz;\nf(2:nb) = f(2:nb) * P.body_trim;\n\nbody.n = nb;\nbody.f = f;\nbody.omega = 2 * pi * f;\nbody.zeta = reshape(0.5 ./ (Qb * P.body_q_scale), nb, 1);\nbody.a = reshape(sqrt(P.bridge_mobility ./ mb), nb, 1); % 1/sqrt(kg)\nbody.S = reshape(Sb * P.rad_area_scale, nb, 1); % m^2\nbody.mono = reshape(mono, nb, 1);\nend\n" }, { "name": "bass_defaults.m", "content": "function P = bass_defaults()\n% BASS_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% (note_count - 1) * 60 / tempo_bpm + tail_s.\n\n% Strings (1 = G2, 2 = D2, 3 = A1, 4 = E1), a 3/4-size double bass.\n% Linear density is not a parameter: it follows from the tension and the\n% open pitch, mu = T / (2 L f0)^2, which lands on realistic values\n% (about 7 g/m for the G string, 42 g/m for the E).\nP.scale_length_m = 1.04;\nP.tension_g_n = 285.0;\nP.tension_d_n = 290.0;\nP.tension_a_n = 300.0;\nP.tension_e_n = 310.0;\nP.inharmonicity = 8.0e-5; % B of the open string: f_n ~ n f0 sqrt(1 + B n^2)\nP.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)\n\n% String damping\nP.loss_factor = 1.6e-3; % frequency-independent internal loss\nP.loss_slope = 2.5e-7; % extra high-frequency loss, s\nP.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)\nP.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg\n\n% Body (see bass_body.m for the mode table these trims act on)\nP.a0_hz = 60.0; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.0; % scales the body mode Qs\nP.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)\nP.rad_area_scale = 1.0; % scales the radiating areas of the body modes\nP.dipole_length_m = 0.35; % effective front-to-back path of the corpus dipoles\nP.radiation_loss = 1.0; % scales the radiation damping added to the body Qs\n\n% Pluck\nP.pluck_force_n = 15.0; % transverse pull at release\nP.pluck_pos = 0.30; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 1.3; % finite release time of the fingertip (spectral rolloff)\nP.choke_ms = 45.0; % decay time constant applied when the next note lands\n\n% Gesture\nP.root_midi = 33; % A1; the single note, and the key of a walking line\nP.note_count = 1;\nP.tempo_bpm = 96.0;\nP.force_spread = 0.20; % lognormal spread of the pluck force across a line\nP.timing_jitter_ms = 4.0;\nP.seed = 1;\n\n% Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)\nP.direct_gain = 1.0;\nP.listener_x_m = 0.50;\nP.listener_y_m = 0.30;\nP.listener_z_m = 1.20;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.max_mode_hz = 3000.0;\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 6.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n" }, { "name": "bass_demo.m", "content": "% Render a pizzicato bass note with default parameters and print diagnostics.\n% Set P.note_count = 8 for a walking line.\n% Run with: numbl run bass_demo.m\n\nP = bass_defaults();\ntic;\n[y, basis, dinfo] = bass_render(P);\nelapsed = toc;\n\nfprintf('notes : %d (midi ', dinfo.noteCount);\nfprintf('%d ', dinfo.midi);\nfprintf(')\\n');\nfprintf('fingerings : %d, %d coupled modes in total\\n', dinfo.fingerings, dinfo.modesTotal);\nfprintf('fundamental: %.2f Hz, T60 %.2f s (note 1)\\n', dinfo.fundHz, dinfo.fundT60);\nfprintf('body : A0 %.1f Hz, top corpus mode %.0f Hz\\n', basis.bodyF(1), max(basis.bodyF));\nfprintf('peak press.: %.4f Pa (%.1f dB SPL) at %.0f cm\\n', ...\n dinfo.peakPa, dinfo.peakSplDb, dinfo.listenerDistM * 100);\nfprintf('length : %.2f s @ %d Hz\\n', length(y) / P.sample_rate, P.sample_rate);\nfprintf('render time: %.2f s\\n', elapsed);\nif dinfo.listenerInside\n fprintf('WARNING : listener is at or inside the body\\n');\nend\n" }, { "name": "bass_notes.m", "content": "function [tOn, midi, str, stop, force, ninfo] = bass_notes(P)\n% BASS_NOTES The gesture: a single note, or a seeded walking line.\n%\n% note_count = 1 gives one deterministic note at root_midi, plucked with\n% pluck_force_n at t = 0; no random numbers are drawn. note_count > 1 gives\n% a walking line: quarter notes at tempo_bpm on the major scale of the root,\n% generated as a random walk with direction persistence (mostly stepwise,\n% an occasional third, an occasional leap home to the root), reflected off\n% the playable range E1 to G3. Pluck forces get a lognormal spread and the\n% onsets after the first a Gaussian jitter. All randomness comes from\n% bass_rng seeded with P.seed, so a given seed reproduces the identical\n% line in the CLI, Node, and the browser.\n%\n% Each pitch is assigned a fingering the way a player would in low\n% positions: the highest-tuned string whose open pitch does not exceed the\n% note, so the stop stays small. Open strings come out as stop = 0, which\n% bass_basis.m treats as an undamped (nut) termination.\n%\n% Returns row vectors sorted by time: tOn (s), midi (pitch), str (1 = G2,\n% 2 = D2, 3 = A1, 4 = E1), stop (semitones above the open string), force\n% (pluck force, N), and ninfo with summary statistics.\n\nK = max(1, round(P.note_count));\nbeat = 60 / max(20, P.tempo_bpm);\nroot = round(P.root_midi);\nopenMidi = [43 38 33 28]; % G2 D2 A1 E1\nloMidi = 28; % E1\nhiMidi = 55; % G3\n\n% ---- the scale grid: major-scale pitches of the key across the range ----\ndegs = [0 2 4 5 7 9 11];\ngrid = zeros(1, hiMidi - loMidi + 1);\nng = 0;\nfor m = loMidi:hiMidi\n rel = mod(m - root, 12);\n if sum(degs == rel) > 0\n ng = ng + 1;\n grid(ng) = m;\n end\nend\ngrid = grid(1:ng);\n\n% index of the grid note nearest the root (the walk starts and leaps here)\nrootIdx = 1;\nbest = 1e9;\nfor i = 1:ng\n d = abs(grid(i) - root);\n if d < best\n best = d;\n rootIdx = i;\n end\nend\n\n% ---- draw the walk ----\nstate = mod(floor(abs(P.seed)), 4294967296);\nuWalk = zeros(K, 1);\nz1 = zeros(K, 1);\nz2 = zeros(K, 1);\nif K > 1\n [u, state] = bass_rng(state, 5 * K);\n U = reshape(u, K, 5);\n uWalk = U(:, 1);\n z1 = sqrt(-2 * log(U(:, 2))) .* cos(2 * pi * U(:, 3));\n z2 = sqrt(-2 * log(U(:, 4))) .* cos(2 * pi * U(:, 5));\nend\n\ntOn = zeros(1, K);\nmidi = zeros(1, K);\nstr = zeros(1, K);\nstop = zeros(1, K);\nforce = zeros(1, K);\nidx = rootIdx;\ndir = 1;\nfor i = 1:K\n if i > 1\n u = uWalk(i);\n if u < 0.50\n idx = idx + dir; % continue stepwise\n elseif u < 0.75\n dir = -dir; % turn around\n idx = idx + dir;\n elseif u < 0.90\n idx = idx + 2 * dir; % skip a third\n else\n idx = rootIdx; % leap home\n dir = 1;\n end\n if idx > ng\n idx = ng - (idx - ng);\n dir = -1;\n end\n if idx < 1\n idx = 1 + (1 - idx);\n dir = 1;\n end\n idx = min(ng, max(1, idx));\n end\n m = grid(idx);\n if K == 1\n m = min(hiMidi, max(loMidi, root));\n end\n\n % fingering: highest-tuned string whose open pitch is at or below the note\n s = 4;\n for cand = 1:4\n if openMidi(cand) <= m\n s = cand;\n break;\n end\n end\n\n t = (i - 1) * beat;\n if i > 1\n t = max(0, t + P.timing_jitter_ms * 1e-3 * z1(i));\n end\n fpl = P.pluck_force_n;\n if K > 1\n fpl = fpl * exp(P.force_spread * z2(i));\n end\n\n tOn(i) = t;\n midi(i) = m;\n str(i) = s;\n stop(i) = m - openMidi(s);\n force(i) = max(0.1, fpl);\nend\n\n[tOn, order] = sort(tOn);\ntOn = reshape(tOn, 1, K);\nmidi = reshape(midi(order), 1, K);\nstr = reshape(str(order), 1, K);\nstop = reshape(stop(order), 1, K);\nforce = reshape(force(order), 1, K);\n\nninfo.count = K;\nninfo.durationS = (K - 1) * beat + P.tail_s;\nninfo.beatS = beat;\nninfo.meanForce = sum(force) / K;\nninfo.maxForce = max(force);\nninfo.loMidi = min(midi);\nninfo.hiMidi = max(midi);\nend\n" }, { "name": "bass_output.m", "content": "function [y, peakPa] = bass_output(P, p, oversample)\n% BASS_OUTPUT Anti-aliased decimation and output conditioning.\n% Kaiser-windowed-sinc FIR lowpass at 0.45 * fs_lo, decimate by oversample,\n% pre-roll, tail fade, then either normalize to peak_dbfs or divide by\n% full_scale_pa. Identical in structure to the sibling instruments' output\n% stages.\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 .* (bo_i0(beta * sqrt(max(0, 1 - r.^2))) / bo_i0(beta));\n h = h / sum(h); % unity DC gain\n pf = conv(p, h);\n outLen = floor(T / R);\n y = reshape(pf((0:outLen - 1) * R + M2 + 1), 1, outLen); % centered pick\nelse\n y = p;\nend\n\n% Pre-roll, then truncate to the nominal duration\npadN = floor(P.pre_delay_ms * 1e-3 * P.sample_rate);\nK = max(1, round(P.note_count));\nbeat = 60 / max(20, P.tempo_bpm);\nduration = (K - 1) * beat + P.tail_s;\ntotal = floor(duration * P.sample_rate);\nif padN > 0\n y = [zeros(1, padN) y];\nend\ny = y(1:min(length(y), total));\n\n% Squared fade on the tail\nfade = min(2048, 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 = bo_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" }, { "name": "bass_radiate.m", "content": "function [p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi)\n% BASS_RADIATE Pluck projection, closed-form synthesis, and free-field\n% pressure at the listener.\n%\n% The pluck is an initial condition, not a force history. Releasing a\n% string pulled with force F at fraction pluck_pos of the vibrating length\n% from the bridge leaves the classic triangular shape, whose sine\n% coefficients are b_k = 2 F Lv sin(k pi xp/Lv) / (T pi^2 k^2); projected\n% through PM = (M Phi)' from bass_basis.m these become initial amplitudes\n% of the coupled modes, with zero initial velocity. The finite release from\n% the fingertip is a first-order rolloff 1/sqrt(1 + (om tau)^2) applied to\n% the coupled amplitudes (finger_release_ms), which is what separates a\n% fleshy jazz pizzicato from a hard snap. From there every coupled mode is a\n% damped exponential; nothing is integrated. When the next note lands, the\n% previous note is choked: its poles pick up an extra decay 1/tau_choke\n% (choke_ms), standing in for the player stopping the string, and the last\n% note rings through the full tail.\n%\n% Radiation, per coupled mode at its own centre frequency (the modes are\n% narrow-band):\n% - the monopole volume Vm radiates from the two f-holes, half each, as\n% point monopoles with their own retardations;\n% - the dipole volume Dd radiates from the top-plate centre as a point\n% dipole along the plate normal (+z), with the near-field 1/r term kept;\n% - the direct term: above the retained body modes the bridge sees the\n% resistive residual admittance residual_y, so the bridge force (Fcoef\n% per unit modal amplitude) moves the plate broadband, and that motion\n% radiates as a small dipole. This is how the high partials reach the\n% listener at all, and it is the least principled term of the model;\n% direct_gain scales it and 0 turns it off.\n% The common carrier exp(-i k rc) to the bridge is stripped, so relative\n% phase between the f-holes, the plate, and the listener position is kept\n% while the overall propagation delay is not double counted.\n%\n% Returns p (1 x T, pascals at fsHi) and radinfo diagnostics.\n\nc = P.speed_of_sound;\nrho0 = P.air_density;\nK = length(tOn);\nbeat = 60 / max(20, P.tempo_bpm);\nduration = (K - 1) * beat + P.tail_s;\nT = floor(duration * fsHi);\ndt = 1 / fsHi;\ntauRel = P.finger_release_ms * 1e-3;\ntauChoke = max(1e-3, P.choke_ms * 1e-3);\n\n% ---- source and listener geometry (origin at the bridge) ----\nlx = P.listener_x_m;\nly = P.listener_y_m;\nlz = P.listener_z_m;\nrc = sqrt(lx^2 + ly^2 + lz^2);\nfhX = 0.095; fhY = -0.02; fhZ = 0.11; % the two f-holes\ndpX = 0.0; dpY = -0.10; dpZ = 0.11; % top-plate dipole centre\nr1 = max(0.05, sqrt((lx - fhX)^2 + (ly - fhY)^2 + (lz - fhZ)^2));\nr2 = max(0.05, sqrt((lx + fhX)^2 + (ly - fhY)^2 + (lz - fhZ)^2));\nrd = max(0.05, sqrt((lx - dpX)^2 + (ly - dpY)^2 + (lz - dpZ)^2));\ncosg = (lz - dpZ) / rd; % dipole axis is the plate normal +z\nlistenerInside = rc < 0.35;\nSdir = 0.004 * P.rad_area_scale;\n\n% ---- per-fingering radiation transfer, poles, and unit pluck amplitudes ----\nnf = basis.nf;\nntot = basis.n;\nG = zeros(ntot, 1) + 0i;\nlam = zeros(ntot, 1) + 0i;\neta0u = zeros(ntot, 1); % per unit pluck force\nfor u = 1:nf\n n = basis.nModes(u);\n ns = basis.ns(u);\n Lv = basis.Lv(u);\n Tt = basis.T(u);\n mu = basis.mu(u);\n rows = basis.off(u) + (1:n);\n om = 2 * pi * basis.f(rows);\n zt = basis.zeta(rows);\n\n ph1 = exp(-1i * (om / c) * (r1 - rc));\n ph2 = exp(-1i * (om / c) * (r2 - rc));\n phd = exp(-1i * (om / c) * (rd - rc));\n near = 1i * (om / c) + 1 / rd;\n Gm = -(om.^2 * rho0 / (4 * pi)) .* (basis.Vm(rows) / 2) .* (ph1 / r1 + ph2 / r2);\n Gd = -(om.^2 * rho0 / (4 * pi)) .* basis.Dd(rows) .* near .* phd * (cosg / rd);\n Gr = (1i * om * rho0 / (4 * pi)) * (Sdir * P.residual_y * P.dipole_length_m) ...\n .* basis.Fcoef(rows) .* near .* phd * (cosg / rd);\n G(rows) = Gm + Gd + P.direct_gain * Gr;\n\n omd = om .* sqrt(max(1e-12, 1 - zt.^2));\n lam(rows) = -zt .* om + 1i * omd;\n\n % unit-force pluck projection through the coupled shapes\n xp = Lv * (1 - min(0.95, max(0.05, P.pluck_pos)));\n kv = (1:ns)';\n bk = (2 * Lv / (Tt * pi^2)) * sin(kv * pi * xp / Lv) ./ kv.^2;\n q0 = bk * sqrt(mu * Lv / 2);\n PM = reshape(basis.PMflat(basis.pmOff(u) + (1:n * ns)), n, ns);\n e0 = PM * q0;\n e0 = e0 ./ sqrt(1 + (om * tauRel).^2); % finite finger release, first order\n eta0u(rows) = e0;\nend\n\n% ---- per-note synthesis: damped exponentials, choked at the next onset ----\nks = min(T, max(1, round(tOn * fsHi) + 1));\np = zeros(1, T);\nCH = 32768;\nchokeSpan = ceil(10 * tauChoke * fsHi);\npeakEta = 0;\nfor i = 1:K\n u = basis.fIdx(i);\n n = basis.nModes(u);\n rows = basis.off(u) + (1:n);\n om = 2 * pi * basis.f(rows);\n zt = basis.zeta(rows);\n omd = om .* sqrt(max(1e-12, 1 - zt.^2));\n cAmp = (force(i) * eta0u(rows)) .* (1 - 1i * (zt .* om ./ omd));\n s = G(rows) .* cAmp;\n lamN = lam(rows);\n peakEta = max(peakEta, force(i) * max(abs(eta0u(rows))));\n\n if i < K\n segEnd = max(ks(i), ks(i + 1));\n stopAt = min(T + 1, segEnd + chokeSpan);\n else\n segEnd = T + 1;\n stopAt = T + 1;\n end\n\n pos = ks(i);\n while pos < segEnd\n len = min(CH, segEnd - pos);\n seg = real((s.') * exp(lamN * ((0:len - 1) * dt)));\n p(pos:pos + len - 1) = p(pos:pos + len - 1) + seg;\n s = s .* exp(lamN * (len * dt));\n pos = pos + len;\n end\n if pos < stopAt\n lamC = lamN - 1 / tauChoke;\n while pos < stopAt\n len = min(CH, stopAt - pos);\n seg = real((s.') * exp(lamC * ((0:len - 1) * dt)));\n p(pos:pos + len - 1) = p(pos:pos + len - 1) + seg;\n s = s .* exp(lamC * (len * dt));\n pos = pos + len;\n end\n end\nend\n\n% ---- diagnostics: the fundamental of the first note ----\nu1 = basis.fIdx(1);\nrows1 = basis.off(u1) + (1:basis.nModes(u1));\na1 = abs(eta0u(rows1));\nbest = 1;\nbv = 0;\nfor m = 1:length(rows1)\n if a1(m) > bv\n bv = a1(m);\n best = m;\n end\nend\nf1 = basis.f(rows1(best));\nz1 = basis.zeta(rows1(best));\nradinfo.fundHz = f1;\nradinfo.fundT60 = log(1000) / max(1e-9, z1 * 2 * pi * f1);\nradinfo.listenerDistM = rc;\nradinfo.listenerInside = listenerInside;\nradinfo.gainAbs = abs(G);\nradinfo.peakEta = peakEta;\nend\n" }, { "name": "bass_render.m", "content": "function [y, basis, dinfo] = bass_render(P)\n% BASS_RENDER Full render: notes -> basis -> radiate -> output.\n% Convenience composition of the stage functions; the web app calls the\n% stages separately so it can cache intermediates (the per-fingering\n% eigensolves above all).\n\n[tOn, midi, str, stop, force, ninfo] = bass_notes(P);\nbasis = bass_basis(P, str, stop);\nfsHi = P.sample_rate * P.oversample;\n[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi);\n[y, peakPa] = bass_output(P, p, P.oversample);\n\ndinfo.noteCount = ninfo.count;\ndinfo.durationS = ninfo.durationS;\ndinfo.meanForce = ninfo.meanForce;\ndinfo.maxForce = ninfo.maxForce;\ndinfo.midi = midi;\ndinfo.tOn = tOn;\ndinfo.str = str;\ndinfo.stop = stop;\ndinfo.fingerings = basis.nf;\ndinfo.modesTotal = basis.n;\ndinfo.fundHz = radinfo.fundHz;\ndinfo.fundT60 = radinfo.fundT60;\ndinfo.peakPa = peakPa;\ndinfo.peakSplDb = 20 * log10(peakPa / 20e-6 + 1e-30);\ndinfo.listenerDistM = radinfo.listenerDistM;\ndinfo.listenerInside = radinfo.listenerInside;\ndinfo.ninfo = ninfo;\nend\n" }, { "name": "bass_rng.m", "content": "function [r, state] = bass_rng(state, n)\n% BASS_RNG Portable seeded uniform generator (32-bit LCG).\n% Returns n uniforms in (0, 1) as a column vector, plus the advanced state.\n% Deliberately not MATLAB's rand: the same seed must produce the identical\n% walking line in the numbl CLI, Node, and the browser, and every double\n% here stays below 2^53 so the arithmetic is exact everywhere.\nr = zeros(n, 1);\nfor i = 1:n\n state = mod(state * 1664525 + 1013904223, 4294967296);\n r(i) = (state + 0.5) / 4294967296;\nend\nend\n" }, { "name": "bass_validate.m", "content": "% Validate the bass model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) structural identities of the coupled eigensolve, (c) exact\n% reproducibility of the seeded walking line, and (d) pinned end-to-end\n% diagnostics from the default configuration, recorded when the model was\n% built and treated as a regression reference since.\n% Run with: numbl run bass_validate.m\n\nfails = 0;\n\n% ---- 1. decoupled stiff string against the closed form ----\n% With the bridge mobility driven to zero the body cannot move, and the\n% coupled solve must hand back the textbook pinned-pinned stiff string,\n% f_k = sqrt((k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu) / 2 pi, including the\n% inharmonic stretch. Checked on the open A with an exaggerated B.\nP = bass_defaults();\nQ = P;\nQ.bridge_mobility = 1e-9;\nQ.inharmonicity = 2e-4;\nbasis = bass_basis(Q, 3, 0);\n[~, ~, omS, ~, meta] = bass_assemble(Q, 3, 0);\nerrS = 0;\nfor k = 1:10\n fClosed = omS(k) / (2 * pi);\n fGot = bv_nearest(basis.f, fClosed);\n errS = max(errS, abs(fGot - fClosed) / fClosed);\nend\nstretch = (omS(10) / (10 * omS(1))) - 1;\nok = errS < 1e-6 && stretch > 0.005;\nfails = fails + (~ok);\nfprintf('stiff string: err %.1e vs closed form, partial-10 stretch %.3f %s\\n', ...\n errS, stretch, bv_verdict(ok));\n\n% ---- 2. eigensolve identities on the full coupled system ----\n% The Cholesky reduction must hand back mass-orthonormal shapes that\n% diagonalize the stiffness: Phi' M Phi = I and Phi' K Phi = diag(om^2).\n% Checked on the open E, the largest system of the default chart.\n[M, Kmat, ~, ~, meta] = bass_assemble(P, 4, 0);\nRc = chol(M);\nAm = (Rc') \\ (Kmat / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nPhi = Rc \\ fliplr(Uv);\neOrth = max(max(abs(Phi' * M * Phi - eye(meta.n))));\nKP = Phi' * Kmat * Phi;\neDiag = max(max(abs(KP - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve: orthonormality %.1e, diagonalization %.1e (n = %d) %s\\n', ...\n eOrth, eDiag, meta.n, bv_verdict(ok));\n\n% ---- 3. a midpoint pluck silences the even partials ----\n% b_k ~ sin(k pi xp/Lv): plucking at the middle of the vibrating length\n% puts every even string mode at a node of the initial shape, so the\n% second partial must all but vanish from the rendered pressure, while an\n% ordinary pluck position leaves it strong. This exercises the projection,\n% the synthesis, and the radiation end to end.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 3.0;\nQ.pluck_pos = 0.5;\n[y1, b1, d1] = bass_render(Q);\nfs = Q.sample_rate;\nf1 = d1.fundHz;\nf2 = bv_nearest(b1.f, 2 * f1);\nrelMid = 20 * log10(bv_level(y1, fs, f2) / bv_level(y1, fs, f1) + 1e-15);\nQ.pluck_pos = 0.30;\n[y2, b2, d2] = bass_render(Q);\nf2b = bv_nearest(b2.f, 2 * d2.fundHz);\nrelOrd = 20 * log10(bv_level(y2, fs, f2b) / bv_level(y2, fs, d2.fundHz) + 1e-15);\nok = relMid < -35 && relOrd > -25;\nfails = fails + (~ok);\nfprintf('midpoint pluck: partial 2 at %.1f dB (ordinary pluck %.1f dB) %s\\n', ...\n relMid, relOrd, bv_verdict(ok));\n\n% ---- 4. two-way coupling: veering at a string-body coincidence ----\n% Tuning the strongest corpus mode onto the open G fundamental must split\n% it into a mixed pair (this is the wolf-note mechanism), and the splitting\n% must grow as the square root of the bridge mobility, which is what a\n% two-way coupled pair does and a one-way filter does not.\nd05 = bv_split(P, 0.5);\nd20 = bv_split(P, 2.0);\nratio = d20.split / d05.split;\nok = abs(ratio - 2) < 0.25 && d20.mix > 0.15;\nfails = fails + (~ok);\nfprintf('veering: split %.2f -> %.2f Hz for 4x mobility (ratio %.2f, mix %.2f) %s\\n', ...\n d05.split, d20.split, ratio, d20.mix, bv_verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the\n% bridge so the dipole directivity is held fixed. At the fundamental the\n% dipole near-field term still contributes a few hundredths of a dB at\n% 4 m, which is physics rather than error; the tolerance covers it.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 2.5;\nQ.listener_x_m = 1.2; Q.listener_y_m = 0.75; Q.listener_z_m = 3.0;\n[ya, ~, ~] = bass_render(Q);\nQ.listener_x_m = 2.4; Q.listener_y_m = 1.5; Q.listener_z_m = 6.0;\n[yb, ~, ~] = bass_render(Q);\ndrop = 10 * log10(sum(yb.^2) / sum(ya.^2));\nok = abs(drop + 6.02) < 0.35;\nfails = fails + (~ok);\nfprintf('falloff 3.3 m -> 6.6 m: %.2f dB rms (target -6.02) %s\\n', drop, bv_verdict(ok));\n\n% ---- 6. the seeded walking line is exactly reproducible ----\nQ = P;\nQ.note_count = 8;\n[t1, m1, s1, st1, fo1, g1] = bass_notes(Q);\n[t2, m2, s2, st2, fo2, g2] = bass_notes(Q);\nsame = max(abs(t1 - t2)) == 0 && max(abs(m1 - m2)) == 0 ...\n && max(abs(s1 - s2)) == 0 && max(abs(fo1 - fo2)) == 0;\nok = same ...\n && g1.count == 8 ...\n && sum(m1) == 293 ...\n && abs(g1.meanForce - 14.014948614) < 1e-6 ...\n && abs(t1(2) - 0.623242473696) < 1e-9;\nfails = fails + (~ok);\nfprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\\n', ...\n Q.seed, g1.count, sum(m1), g1.meanForce, bv_verdict(ok));\n\n% ---- 7. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = bass_render(P);\nok = abs(dinfo.peakPa - 0.270351) < 0.001 ...\n && abs(dinfo.fundHz - 54.4515) < 0.005 ...\n && basis.n == 61;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\\n', ...\n dinfo.peakPa, 0.270351, dinfo.fundHz, 54.4515, basis.n, bv_verdict(ok));\n\n% ---- 8. the note decays ----\n% An open-string pizzicato rings for seconds, so the tail is not silent;\n% but the energy in the last half second must sit well below the opening\n% half second, which carries the attack.\nfs = P.sample_rate;\nn = length(y);\nhead = y(1:round(0.5 * fs));\ntail = y(n - round(0.5 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -15;\nfails = fails + (~ok);\nfprintf('note decay: last/first half second %.1f dB %s\\n', dropE, bv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction fv = bv_nearest(fvec, target)\n% The entry of fvec nearest target.\nfv = fvec(1);\nbest = abs(fvec(1) - target);\nfor i = 2:length(fvec)\n d = abs(fvec(i) - target);\n if d < best\n best = d;\n fv = fvec(i);\n end\nend\nend\n\nfunction a = bv_level(y, fs, f)\n% Amplitude of y at frequency f over the first two seconds (plain\n% quadrature dot products; the window is long against 1/f).\nn = min(length(y), floor(2.0 * fs));\nt = (0:n - 1) / fs;\nseg = y(1:n);\na = sqrt((seg * (cos(2 * pi * f * t))')^2 + (seg * (sin(2 * pi * f * t))')^2);\nend\n\nfunction r = bv_split(P, mobility)\n% Frequency splitting of the coupled pair when the strongest corpus mode\n% is tuned onto the open G fundamental, plus how mixed the pair is.\nQ = P;\nQ.bridge_mobility = mobility;\nQ.body_trim = 97.9989 / 97.0;\nbasis = bass_basis(Q, 1, 0);\nf0 = 97.9989;\n% the two coupled modes nearest the coincidence\nd = abs(basis.f - f0);\n[~, order] = sort(d);\ni1 = order(1);\ni2 = order(2);\nr.split = abs(basis.f(i1) - basis.f(i2));\nr.mix = min(min(basis.bodyFrac(i1), 1 - basis.bodyFrac(i1)), ...\n min(basis.bodyFrac(i2), 1 - basis.bodyFrac(i2)));\nend\n\nfunction s = bv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n" } ] }