/ simulated_instruments / bass
Sign in
simulated_instruments / bass
Add instrument/: the bass 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). One coupled eigensolve per fingered pitch is shared across velocities, knobs, and both variants, and finished note 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 afd5157e4c3e parent 70a9b6f Browse files
25 changed files+3628−773
.github/workflows/deploy.ymlmodified+4−0View file
@@ -39,6 +39,10 @@ jobs:
3939 with:
4040 node-version: 22
4141
42+ - name: Install (instrument)
43+ working-directory: instrument
44+ run: npm ci
45+
4246 - name: Install
4347 working-directory: webapp
4448 run: npm ci
README.mdmodified+1−0View file
@@ -9,6 +9,7 @@ There is no microphone model: no directivity, no capsule response, no gain stagi
99 | Path | What it is |
1010 |---|---|
1111 | `matlab/` | The algorithm in MATLAB syntax, run by [numbl](https://numbl.org) |
12+| `instrument/` | The model behind the track-render instrument interface used by [midiboot](../../proof-of-concept/midiboot) and by the web app (worker, note mapping, caching; `npm test` there runs the model in node) |
1213 | `webapp/` | Interactive browser app that executes the MATLAB code client-side |
1314
1415 The MATLAB code runs from the command line with `cd matlab && numbl run bass_demo.m`, or `bass_validate.m` to run the checks. The web app is a Vite project; see `webapp/README.md`.
export-src/bass-muted/export.jsondeleted+0−9View file
@@ -1,9 +0,0 @@
1-{
2- "id": "bass-muted",
3- "name": "Muted Bass",
4- "description": "The same standup bass palm-muted: a slow fingertip release near the bridge and a short ring-out baked in.",
5- "source": "simulated_instruments/bass",
6- "knobs": [
7- { "key": "pluck_pos", "label": "pluck position", "min": 0.05, "max": 0.95, "default": 0.15 }
8- ]
9-}
export-src/bass-muted/render_note.mdeleted+0−19View file
@@ -1,19 +0,0 @@
1-function y = render_note(note)
2-% The muted variant: identical mapping to the standup export, with the
3-% damping character fixed rather than exposed (slow release, short tail).
4-P = bass_defaults();
5-P.sample_rate = note.sample_rate;
6-P.note_count = 1;
7-P.seed = 1;
8-P.pre_delay_ms = 0;
9-P.normalize = 0;
10-if note.preview ~= 0
11- P.oversample = 1;
12-end
13-P.root_midi = max(28, min(55, round(note.pitch)));
14-P.pluck_force_n = 3 + (30 - 3) * note.velocity;
15-P.pluck_pos = note.params.pluck_pos;
16-P.finger_release_ms = 6;
17-P.tail_s = 0.8;
18-y = bass_render(P);
19-end
export-src/bass/export.jsondeleted+0−11View file
@@ -1,11 +0,0 @@
1-{
2- "id": "bass",
3- "name": "Standup Bass",
4- "description": "Plucked standup bass, one note per MIDI event, E1 to G3.",
5- "source": "simulated_instruments/bass",
6- "knobs": [
7- { "key": "pluck_pos", "label": "pluck position", "min": 0.05, "max": 0.95, "default": 0.38 },
8- { "key": "finger_release_ms", "label": "fingertip release (ms)", "min": 0.2, "max": 8, "default": 2.6 },
9- { "key": "tail_s", "label": "ring-out (s)", "min": 1, "max": 8, "default": 8 }
10- ]
11-}
export-src/bass/render_note.mdeleted+0−21View file
@@ -1,21 +0,0 @@
1-function y = render_note(note)
2-% One MIDI note through the standup bass model: pitch onto the fingered
3-% root, velocity onto pluck force, knobs onto the curated parameters.
4-% Fixed: a single-pluck gesture, a fixed seed so renders are cacheable,
5-% and normalize off so velocity survives in pascals.
6-P = bass_defaults();
7-P.sample_rate = note.sample_rate;
8-P.note_count = 1;
9-P.seed = 1;
10-P.pre_delay_ms = 0;
11-P.normalize = 0;
12-if note.preview ~= 0
13- P.oversample = 1;
14-end
15-P.root_midi = max(28, min(55, round(note.pitch)));
16-P.pluck_force_n = 3 + (30 - 3) * note.velocity;
17-P.pluck_pos = note.params.pluck_pos;
18-P.finger_release_ms = note.params.finger_release_ms;
19-P.tail_s = note.params.tail_s;
20-y = bass_render(P);
21-end
exports/bass-muted.jsondeleted+0−66View file
@@ -1,66 +0,0 @@
1-{
2- "format": "numbl-instrument-v1",
3- "id": "bass-muted",
4- "name": "Muted Bass",
5- "description": "The same standup bass palm-muted: a slow fingertip release near the bridge and a short ring-out baked in.",
6- "source": "simulated_instruments/bass",
7- "knobs": [
8- {
9- "key": "pluck_pos",
10- "label": "pluck position",
11- "min": 0.05,
12- "max": 0.95,
13- "default": 0.15
14- }
15- ],
16- "files": [
17- {
18- "name": "render_note.m",
19- "content": "function y = render_note(note)\n% The muted variant: identical mapping to the standup export, with the\n% damping character fixed rather than exposed (slow release, short tail).\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 = 6;\nP.tail_s = 0.8;\ny = bass_render(P);\nend\n"
20- },
21- {
22- "name": "bass_assemble.m",
23- "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"
24- },
25- {
26- "name": "bass_basis.m",
27- "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"
28- },
29- {
30- "name": "bass_body.m",
31- "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"
32- },
33- {
34- "name": "bass_defaults.m",
35- "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 = 4.0e-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 = 58.5; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.2; % 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.45; % 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.38; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 2.6; % 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 = 0.7;\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 = 8.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
36- },
37- {
38- "name": "bass_demo.m",
39- "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"
40- },
41- {
42- "name": "bass_notes.m",
43- "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"
44- },
45- {
46- "name": "bass_output.m",
47- "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"
48- },
49- {
50- "name": "bass_radiate.m",
51- "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"
52- },
53- {
54- "name": "bass_render.m",
55- "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"
56- },
57- {
58- "name": "bass_rng.m",
59- "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"
60- },
61- {
62- "name": "bass_validate.m",
63- "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.218467) < 0.001 ...\n && abs(dinfo.fundHz - 54.3180) < 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.218467, dinfo.fundHz, 54.3180, 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"
64- }
65- ]
66-}
exports/bass.jsondeleted+0−80View file
@@ -1,80 +0,0 @@
1-{
2- "format": "numbl-instrument-v1",
3- "id": "bass",
4- "name": "Standup Bass",
5- "description": "Plucked standup bass, one note per MIDI event, E1 to G3.",
6- "source": "simulated_instruments/bass",
7- "knobs": [
8- {
9- "key": "pluck_pos",
10- "label": "pluck position",
11- "min": 0.05,
12- "max": 0.95,
13- "default": 0.38
14- },
15- {
16- "key": "finger_release_ms",
17- "label": "fingertip release (ms)",
18- "min": 0.2,
19- "max": 8,
20- "default": 2.6
21- },
22- {
23- "key": "tail_s",
24- "label": "ring-out (s)",
25- "min": 1,
26- "max": 8,
27- "default": 8
28- }
29- ],
30- "files": [
31- {
32- "name": "render_note.m",
33- "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"
34- },
35- {
36- "name": "bass_assemble.m",
37- "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"
38- },
39- {
40- "name": "bass_basis.m",
41- "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"
42- },
43- {
44- "name": "bass_body.m",
45- "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"
46- },
47- {
48- "name": "bass_defaults.m",
49- "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 = 4.0e-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 = 58.5; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.2; % 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.45; % 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.38; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 2.6; % 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 = 0.7;\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 = 8.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
50- },
51- {
52- "name": "bass_demo.m",
53- "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"
54- },
55- {
56- "name": "bass_notes.m",
57- "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"
58- },
59- {
60- "name": "bass_output.m",
61- "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"
62- },
63- {
64- "name": "bass_radiate.m",
65- "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"
66- },
67- {
68- "name": "bass_render.m",
69- "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"
70- },
71- {
72- "name": "bass_rng.m",
73- "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"
74- },
75- {
76- "name": "bass_validate.m",
77- "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.218467) < 0.001 ...\n && abs(dinfo.fundHz - 54.3180) < 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.218467, dinfo.fundHz, 54.3180, 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"
78- }
79- ]
80-}
instrument/.gitignoreadded+1−0View file
@@ -0,0 +1 @@
1+node_modules/
instrument/core.tsadded+571−0View file
@@ -0,0 +1,571 @@
1+// The standup bass as a track-render instrument: pure rendering logic with
2+// no 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; the bass ignores
7+// note duration, ring-out is the tail_s knob), each distinct sound is
8+// rendered once through the staged pipeline, and copies are summed at
9+// the onsets. The per-fingering eigensolve is cached separately, so two
10+// velocities of the same pitch share one solve.
11+//
12+// - the explorer path (the bass web app, wantDiagnostics set): the full
13+// parameter surface and live-edited .m sources drive one staged render
14+// with the diagnostics payload the app's plots consume, exactly the
15+// arrangement of the app's former render worker.
16+//
17+// Stage intermediates cross executeCode calls as numbl RuntimeValues
18+// (column-major Float64).
19+
20+import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
21+import type { KnobSpec, MFile, NoteSpec, TrackRenderRequest } from './types.ts';
22+
23+import { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant } from './knobs.ts';
24+import { NoteCache } from './noteCache.ts';
25+
26+export { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant };
27+
28+/** The environment the host provides: sources, progress, cancellation, and
29+ * a macrotask yield so a browser worker can receive a cancel message. */
30+export interface RenderEnv {
31+ mfiles: MFile[];
32+ post: (frac: number, stage: string) => void;
33+ /** Throws to abandon the render; called between stages and notes. */
34+ checkCancel: () => void;
35+ yieldToHost: () => Promise<void>;
36+}
37+
38+// --- diagnostics payload for the explorer path (the web app's plots) ---
39+
40+export interface BassExploreDiag {
41+ quality: 'preview' | 'full';
42+ noteT: Float32Array;
43+ noteMidi: Float32Array;
44+ noteStr: Float32Array;
45+ noteStop: Float32Array;
46+ noteForce: Float32Array;
47+ mode1F: Float32Array;
48+ mode1Zeta: Float32Array;
49+ mode1BodyFrac: Float32Array;
50+ bodyF: Float32Array;
51+ bodyZeta: Float32Array;
52+ bodyA: Float32Array;
53+ bodyMono: Float32Array;
54+ diag: {
55+ noteCount: number;
56+ fingerings: number;
57+ modesTotal: number;
58+ modesNote1: number;
59+ fundHz: number;
60+ fundT60: number;
61+ meanForce: number;
62+ durationS: number;
63+ peakPa: number;
64+ peakSplDb: number;
65+ listenerDistM: number;
66+ warnings: string[];
67+ solveMs: number;
68+ radiateMs: number;
69+ solveCached: boolean;
70+ radiateCached: boolean;
71+ oversample: number;
72+ };
73+}
74+
75+// --- shared helpers ---
76+
77+export function hashMFiles(files: MFile[]): string {
78+ let h = 0x811c9dc5;
79+ for (const f of files) {
80+ const s = f.name + ' ' + f.source;
81+ for (let i = 0; i < s.length; i++) {
82+ h ^= s.charCodeAt(i);
83+ h = Math.imul(h, 0x01000193);
84+ }
85+ }
86+ return (h >>> 0).toString(36);
87+}
88+
89+function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
90+ const v = cache.get(key);
91+ if (v !== undefined) {
92+ cache.delete(key);
93+ cache.set(key, v);
94+ }
95+ return v;
96+}
97+
98+function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
99+ cache.delete(key);
100+ cache.set(key, value);
101+ while (cache.size > max) {
102+ const oldest = cache.keys().next().value as string;
103+ cache.delete(oldest);
104+ }
105+}
106+
107+type Params = Record<string, number | boolean>;
108+
109+function pLiteral(params: Params): string {
110+ const lines: string[] = [];
111+ for (const [k, v] of Object.entries(params)) {
112+ if (typeof v === 'number') {
113+ if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
114+ lines.push(`P.${k} = ${v};`);
115+ } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
116+ }
117+ return lines.join('\n');
118+}
119+
120+function keyFor(params: Params, keys: readonly string[]): string {
121+ return keys.map((k) => String(params[k] ?? 'd')).join('|');
122+}
123+
124+function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
125+ return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
126+}
127+
128+function asTensor(v: RuntimeValue): RuntimeTensor {
129+ const t = v as RuntimeTensor;
130+ if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
131+ return t;
132+}
133+
134+function asScalar(v: RuntimeValue): number {
135+ if (typeof v === 'number') return v;
136+ const t = v as RuntimeTensor;
137+ if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
138+ if (typeof v === 'boolean') return v ? 1 : 0;
139+ throw new Error('expected scalar result from numbl stage');
140+}
141+
142+function toF32(v: RuntimeValue): Float32Array {
143+ const t = asTensor(v);
144+ const out = new Float32Array(t.data.length);
145+ for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
146+ return out;
147+}
148+
149+function knobValue(knobs: KnobSpec[], settings: Record<string, number>, key: string): number {
150+ const spec = knobs.find((k) => k.key === key)!;
151+ const raw = settings[key];
152+ return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
153+}
154+
155+/** Velocity quantized to a few levels so identical notes share a cached
156+ * render; 8 levels is inaudible next to the model's own dynamics. */
157+export function bucketVelocity(velocity01: number, levels = 8): number {
158+ return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
159+}
160+
161+// The basis fields handed from the solve stage into the radiate stage, in
162+// the order the reassembly script expects.
163+const BASIS_FIELDS = [
164+ 'fIdx', 'off', 'nModes', 'pmOff', 'ns', 'Lv', 'mu', 'T', 'f0', 'str', 'stop',
165+ 'f', 'zeta', 'Vm', 'Dd', 'Fcoef', 'ybAmp', 'bodyFrac', 'PMflat',
166+ 'bodyF', 'bodyZeta', 'bodyA', 'bodyS', 'bodyMono',
167+];
168+
169+const SOLVE_KEYS = [
170+ 'scale_length_m', 'tension_g_n', 'tension_d_n', 'tension_a_n', 'tension_e_n',
171+ 'inharmonicity', 'tuning_trim',
172+ 'loss_factor', 'loss_slope', 'finger_loss', 'residual_y',
173+ 'a0_hz', 'body_trim', 'body_q_scale', 'bridge_mobility',
174+ 'rad_area_scale', 'dipole_length_m', 'radiation_loss',
175+ 'root_midi', 'note_count', 'seed',
176+ 'max_mode_hz', 'speed_of_sound', 'air_density',
177+];
178+
179+const RADIATE_KEYS = [
180+ 'pluck_force_n', 'pluck_pos', 'finger_release_ms', 'choke_ms',
181+ 'tempo_bpm', 'force_spread', 'timing_jitter_ms',
182+ 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
183+ 'sample_rate', 'tail_s',
184+];
185+
186+interface SolveEntry {
187+ vars: Record<string, RuntimeValue>;
188+ noteT: Float32Array;
189+ noteMidi: Float32Array;
190+ noteStr: Float32Array;
191+ noteStop: Float32Array;
192+ noteForce: Float32Array;
193+ mode1F: Float32Array;
194+ mode1Zeta: Float32Array;
195+ mode1BodyFrac: Float32Array;
196+ bodyF: Float32Array;
197+ bodyZeta: Float32Array;
198+ bodyA: Float32Array;
199+ bodyMono: Float32Array;
200+ fingerings: number;
201+ modesTotal: number;
202+ meanForce: number;
203+ durationS: number;
204+}
205+
206+interface RadiateEntry {
207+ p: RuntimeValue;
208+ fundHz: number;
209+ fundT60: number;
210+ listenerDistM: number;
211+ listenerInside: boolean;
212+ fsHi: number;
213+ oversample: number;
214+}
215+
216+// Caches, all keyed under the code hash so editing the .m sources
217+// invalidates everything computed from the previous version. The explorer
218+// caches are small (the app compares a handful of settings); the note-solve
219+// cache holds one coupled eigensolve per fingered pitch, which is what lets
220+// a track reuse the expensive stage across velocities and repeats.
221+const solveCache = new Map<string, SolveEntry>();
222+const radiateCache = new Map<string, RadiateEntry>();
223+const SOLVE_CACHE_MAX = 2;
224+const RADIATE_CACHE_MAX = 3;
225+interface NoteSolveEntry { vars: Record<string, RuntimeValue> }
226+const noteSolveCache = new Map<string, NoteSolveEntry>();
227+const NOTE_SOLVE_CACHE_MAX = 16;
228+
229+// Finished per-note audio: an in-memory LRU in front of an IndexedDB
230+// store, so renders survive page reloads (budgets and eviction live in
231+// noteCache.ts).
232+const noteCache = new NoteCache('bass-instrument-notes');
233+
234+function midiHz(m: number): number {
235+ return 440 * Math.pow(2, (m - 69) / 12);
236+}
237+
238+// --- the explorer path (full params, staged, with diagnostics) ---
239+
240+async function renderExplore(
241+ req: TrackRenderRequest,
242+ env: RenderEnv,
243+): Promise<{ audio: Float32Array; diag: BassExploreDiag }> {
244+ const params = req.params ?? {};
245+ const mfiles = env.mfiles;
246+ let quality = req.quality;
247+
248+ const codeKey = hashMFiles(mfiles);
249+ const solveKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
250+ const fullOversample = Number(params.oversample ?? 2);
251+ const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
252+ if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) {
253+ quality = 'full'; // full-rate radiation already cached
254+ }
255+ const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
256+ const sampleRate = Number(params.sample_rate ?? req.sampleRate);
257+ const fsHi = sampleRate * oversample;
258+
259+ const base = `P = bass_defaults();\n${pLiteral(params)}`;
260+
261+ // ---- solve stage: the gesture and the coupled eigensolves ----
262+ let solveMs = 0;
263+ let solveCached = true;
264+ let solveEntry = lruGet(solveCache, solveKey);
265+ if (!solveEntry) {
266+ solveCached = false;
267+ env.post(0.15, 'solve');
268+ await env.yieldToHost();
269+ env.checkCancel();
270+ const t0 = performance.now();
271+ const r = runStage(mfiles, `
272+${base}
273+[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
274+basis = bass_basis(P, str, stop);
275+${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
276+n1 = basis.nModes(1);
277+mode1F = basis.f(1:n1);
278+mode1Zeta = basis.zeta(1:n1);
279+mode1BodyFrac = basis.bodyFrac(1:n1);
280+fingerings = basis.nf;
281+modesTotal = basis.n;
282+meanForce = ninfo.meanForce;
283+durationS = ninfo.durationS;
284+`);
285+ solveMs = performance.now() - t0;
286+ const vars: Record<string, RuntimeValue> = {};
287+ for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
288+ solveEntry = {
289+ vars,
290+ noteT: toF32(r.variableValues.tOn),
291+ noteMidi: toF32(r.variableValues.midi),
292+ noteStr: toF32(r.variableValues.str),
293+ noteStop: toF32(r.variableValues.stop),
294+ noteForce: toF32(r.variableValues.force),
295+ mode1F: toF32(r.variableValues.mode1F),
296+ mode1Zeta: toF32(r.variableValues.mode1Zeta),
297+ mode1BodyFrac: toF32(r.variableValues.mode1BodyFrac),
298+ bodyF: toF32(r.variableValues.b_bodyF),
299+ bodyZeta: toF32(r.variableValues.b_bodyZeta),
300+ bodyA: toF32(r.variableValues.b_bodyA),
301+ bodyMono: toF32(r.variableValues.b_bodyMono),
302+ fingerings: asScalar(r.variableValues.fingerings),
303+ modesTotal: asScalar(r.variableValues.modesTotal),
304+ meanForce: asScalar(r.variableValues.meanForce),
305+ durationS: asScalar(r.variableValues.durationS),
306+ };
307+ lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
308+ }
309+
310+ // ---- radiate stage: pluck projection + closed-form synthesis ----
311+ const radiateKey = radiateParamsKey + `|os=${oversample}`;
312+ let radiateMs = 0;
313+ let radiateCached = true;
314+ let radEntry = lruGet(radiateCache, radiateKey);
315+ if (!radEntry) {
316+ radiateCached = false;
317+ env.post(0.55, 'radiate');
318+ await env.yieldToHost();
319+ env.checkCancel();
320+ const t0 = performance.now();
321+ const r = runStage(mfiles, `
322+${base}
323+${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
324+basis.nf = length(basis.off);
325+basis.n = length(basis.f);
326+[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
327+[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, ${fsHi});
328+fundHz = radinfo.fundHz;
329+fundT60 = radinfo.fundT60;
330+listenerDistM = radinfo.listenerDistM;
331+listenerInside = radinfo.listenerInside;
332+`, solveEntry.vars);
333+ radiateMs = performance.now() - t0;
334+ radEntry = {
335+ p: r.variableValues.p,
336+ fundHz: asScalar(r.variableValues.fundHz),
337+ fundT60: asScalar(r.variableValues.fundT60),
338+ listenerDistM: asScalar(r.variableValues.listenerDistM),
339+ listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
340+ fsHi,
341+ oversample,
342+ };
343+ lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
344+ }
345+
346+ // ---- output stage ----
347+ env.post(0.9, 'output');
348+ await env.yieldToHost();
349+ env.checkCancel();
350+ const rOut = runStage(mfiles, `
351+${base}
352+[y, peakPa] = bass_output(P, p, ${radEntry.oversample});
353+`, { p: radEntry.p });
354+
355+ const y32 = toF32(rOut.variableValues.y);
356+ const peakPa = asScalar(rOut.variableValues.peakPa);
357+
358+ const warnings: string[] = [];
359+ if (radEntry.listenerInside) {
360+ warnings.push('the listener is within 35 cm of the bridge, inside or against the body; the field there is not free');
361+ }
362+ if (!params.normalize) {
363+ let peak = 0;
364+ for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
365+ if (peak > 1.0) {
366+ warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
367+ }
368+ }
369+ // wolf warning: the fundamental pulled well away from equal temperament
370+ const target = midiHz(solveEntry.noteMidi[0]) * Number(params.tuning_trim ?? 1);
371+ const cents = 1200 * Math.log2(radEntry.fundHz / target);
372+ if (Math.abs(cents) > 35) {
373+ warnings.push(`the first note sounds ${Math.abs(cents).toFixed(0)} cents ${cents > 0 ? 'sharp' : 'flat'}: its fundamental is being pulled by a body resonance (wolf territory)`);
374+ }
375+
376+ const diag: BassExploreDiag = {
377+ quality,
378+ noteT: solveEntry.noteT.slice(),
379+ noteMidi: solveEntry.noteMidi.slice(),
380+ noteStr: solveEntry.noteStr.slice(),
381+ noteStop: solveEntry.noteStop.slice(),
382+ noteForce: solveEntry.noteForce.slice(),
383+ mode1F: solveEntry.mode1F.slice(),
384+ mode1Zeta: solveEntry.mode1Zeta.slice(),
385+ mode1BodyFrac: solveEntry.mode1BodyFrac.slice(),
386+ bodyF: solveEntry.bodyF.slice(),
387+ bodyZeta: solveEntry.bodyZeta.slice(),
388+ bodyA: solveEntry.bodyA.slice(),
389+ bodyMono: solveEntry.bodyMono.slice(),
390+ diag: {
391+ noteCount: solveEntry.noteT.length,
392+ fingerings: solveEntry.fingerings,
393+ modesTotal: solveEntry.modesTotal,
394+ modesNote1: solveEntry.mode1F.length,
395+ fundHz: radEntry.fundHz,
396+ fundT60: radEntry.fundT60,
397+ meanForce: solveEntry.meanForce,
398+ durationS: solveEntry.durationS,
399+ peakPa,
400+ peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
401+ listenerDistM: radEntry.listenerDistM,
402+ warnings,
403+ solveMs,
404+ radiateMs,
405+ solveCached,
406+ radiateCached,
407+ oversample: radEntry.oversample,
408+ },
409+ };
410+ return { audio: y32, diag };
411+}
412+
413+// --- the track path (midiboot): per-note rendering with caching ---
414+
415+function notePLines(variant: BassVariant, note: { root: number; velocity: number }, req: TrackRenderRequest): string {
416+ // The note-to-parameter mapping: pitch onto the fingered root, velocity
417+ // onto pluck force, knobs onto the curated parameters. A single-pluck
418+ // gesture with a fixed seed so renders are cacheable, normalize off so
419+ // velocity survives in pascals; the muted variant bakes in a slow
420+ // fingertip release and a short ring-out.
421+ const knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS;
422+ const settings = req.settings ?? {};
423+ const lines = [
424+ 'P = bass_defaults();',
425+ `P.sample_rate = ${req.sampleRate};`,
426+ 'P.note_count = 1;',
427+ 'P.seed = 1;',
428+ 'P.pre_delay_ms = 0;',
429+ 'P.normalize = 0;',
430+ ];
431+ if (req.quality === 'preview') lines.push('P.oversample = 1;');
432+ lines.push(
433+ `P.root_midi = ${note.root};`,
434+ `P.pluck_force_n = ${3 + (30 - 3) * note.velocity};`,
435+ `P.pluck_pos = ${knobValue(knobs, settings, 'pluck_pos')};`,
436+ );
437+ if (variant === 'bass') {
438+ lines.push(
439+ `P.finger_release_ms = ${knobValue(knobs, settings, 'finger_release_ms')};`,
440+ `P.tail_s = ${knobValue(knobs, settings, 'tail_s')};`,
441+ );
442+ } else {
443+ lines.push('P.finger_release_ms = 6;', 'P.tail_s = 0.8;');
444+ }
445+ return lines.join('\n');
446+}
447+
448+async function renderNoteAudio(
449+ variant: BassVariant,
450+ note: { root: number; velocity: number },
451+ req: TrackRenderRequest,
452+ env: RenderEnv,
453+ codeKey: string,
454+ progress: (frac: number, stage: string) => void,
455+): Promise<Float32Array> {
456+ const pLines = notePLines(variant, note, req);
457+
458+ // solve: one coupled eigensolve per fingered pitch, shared across
459+ // velocities, knob values, and both variants (the knobs are all
460+ // radiate-tier parameters).
461+ const solveKey = `n|${codeKey}|${note.root}`;
462+ let entry = lruGet(noteSolveCache, solveKey);
463+ if (!entry) {
464+ progress(0.1, 'solve');
465+ await env.yieldToHost();
466+ env.checkCancel();
467+ const r = runStage(env.mfiles, `
468+${pLines}
469+[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
470+basis = bass_basis(P, str, stop);
471+${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
472+`);
473+ const vars: Record<string, RuntimeValue> = {};
474+ for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
475+ entry = { vars };
476+ lruSet(noteSolveCache, solveKey, entry, NOTE_SOLVE_CACHE_MAX);
477+ }
478+
479+ progress(0.5, 'radiate');
480+ await env.yieldToHost();
481+ env.checkCancel();
482+ const r = runStage(env.mfiles, `
483+${pLines}
484+${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
485+basis.nf = length(basis.off);
486+basis.n = length(basis.f);
487+[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
488+fsHi = P.sample_rate * P.oversample;
489+[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi);
490+[y, peakPa] = bass_output(P, p, P.oversample);
491+`, entry.vars);
492+ return toF32(r.variableValues.y);
493+}
494+
495+async function renderTrackNotes(
496+ variant: BassVariant,
497+ req: TrackRenderRequest,
498+ env: RenderEnv,
499+): Promise<{ audio: Float32Array }> {
500+ if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
501+ const codeKey = hashMFiles(env.mfiles);
502+ const knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS;
503+ const settings = req.settings ?? {};
504+ const knobsKey = knobs.map((k) => `${k.key}=${knobValue(knobs, settings, k.key)}`).join(',');
505+
506+ // Group notes by everything that affects their sound. The bass ignores
507+ // note duration (ring-out is the tail_s knob), so duration is not part
508+ // of the key and a walking line of even eighths costs one render per
509+ // distinct pitch and velocity level.
510+ const groups = new Map<string, { root: number; velocity: number; onsets: number[] }>();
511+ for (const n of req.notes) {
512+ const root = Math.max(28, Math.min(55, Math.round(n.pitch)));
513+ const vel = bucketVelocity(n.velocity);
514+ const key = `${variant}|${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${root}|${vel}`;
515+ let g = groups.get(key);
516+ if (!g) groups.set(key, (g = { root, velocity: vel, onsets: [] }));
517+ g.onsets.push(n.timeSec);
518+ }
519+
520+ const placed: { buf: Float32Array; onsets: number[] }[] = [];
521+ // Resolve cache hits first (memory, then IndexedDB), so the progress
522+ // denominator counts only the sounds that actually need rendering.
523+ const resolved = new Map<string, Float32Array>();
524+ for (const key of groups.keys()) {
525+ const hit = await noteCache.get(key);
526+ if (hit) resolved.set(key, hit);
527+ }
528+ const misses = groups.size - resolved.size;
529+ let done = 0;
530+ for (const [key, g] of groups) {
531+ let buf = resolved.get(key);
532+ if (!buf) {
533+ const i = done;
534+ buf = await renderNoteAudio(variant, g, req, env, codeKey, (frac, stage) =>
535+ env.post((i + frac) / Math.max(1, misses), `note ${i + 1}/${misses}: ${stage}`));
536+ noteCache.put(key, buf);
537+ done++;
538+ }
539+ placed.push({ buf, onsets: g.onsets });
540+ }
541+
542+ // lengthSamples is a minimum: size the output to the longest tail so a
543+ // ring that outlives the last note is returned in full, and place every
544+ // copy.
545+ let length = req.lengthSamples;
546+ for (const p of placed) {
547+ for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
548+ }
549+ const out = new Float32Array(length);
550+ for (const p of placed) {
551+ for (const t of p.onsets) {
552+ const off = Math.round(t * req.sampleRate);
553+ const end = Math.min(out.length, off + p.buf.length);
554+ for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
555+ }
556+ }
557+ return { audio: out };
558+}
559+
560+/** The single entry point: explorer path when diagnostics are requested,
561+ * the per-note track path otherwise. */
562+export async function renderRequest(
563+ variant: BassVariant,
564+ req: TrackRenderRequest,
565+ env: RenderEnv,
566+): Promise<{ audio: Float32Array; diag?: BassExploreDiag }> {
567+ if (req.wantDiagnostics) return renderExplore(req, env);
568+ return renderTrackNotes(variant, req, env);
569+}
570+
571+export type { NoteSpec };
instrument/index.tsadded+114−0View file
@@ -0,0 +1,114 @@
1+// The standup bass behind the generic track-render instrument interface,
2+// for direct import by midiboot and by the bass 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+// per-fingering eigensolves and finished note renders) persist. Aborting
6+// a 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 { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant } 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(`bass 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: BassVariant, 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 BassInstrument implements Instrument {
103+ readonly knobs;
104+ constructor(readonly id: string, readonly name: string, private readonly variant: BassVariant) {
105+ this.knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS;
106+ }
107+ renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
108+ return renderOnWorker(this.variant, req);
109+ }
110+}
111+
112+export const bassInstrument: Instrument = new BassInstrument('bass', 'Standup Bass', 'bass');
113+export const bassMutedInstrument: Instrument = new BassInstrument('bass-muted', 'Muted Bass', 'bass-muted');
114+export const bassInstruments: Instrument[] = [bassInstrument, bassMutedInstrument];
instrument/knobs.tsadded+15−0View file
@@ -0,0 +1,15 @@
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 BASS_KNOBS: KnobSpec[] = [
6+ { key: 'pluck_pos', label: 'pluck position', min: 0.05, max: 0.95, default: 0.38 },
7+ { key: 'finger_release_ms', label: 'fingertip release (ms)', min: 0.2, max: 8, default: 2.6 },
8+ { key: 'tail_s', label: 'ring-out (s)', min: 1, max: 8, default: 8 },
9+];
10+
11+export const BASS_MUTED_KNOBS: KnobSpec[] = [
12+ { key: 'pluck_pos', label: 'pluck position', min: 0.05, max: 0.95, default: 0.15 },
13+];
14+
15+export type BassVariant = 'bass' | 'bass-muted';
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": "bass-instrument",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "The standup bass 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+124−0View file
@@ -0,0 +1,124 @@
1+// Node-side test of the bass track-render instrument: executes the actual
2+// MATLAB model through numbl (no browser, no worker) and checks the audio.
3+// Run from webapp/ or here 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.5) * 22050),
45+ quality: 'preview',
46+ settings: { tail_s: 1 },
47+ ...overrides,
48+ };
49+}
50+
51+const note = (timeSec: number, pitch: number, velocity: number): NoteSpec =>
52+ ({ timeSec, durationSec: 0.5, pitch, velocity });
53+
54+const t0 = performance.now();
55+
56+// One note: finite, audible, decaying tail.
57+const a = await renderRequest('bass', req([note(0, 36, 0.7)]), env);
58+{
59+ const s = stats(a.audio);
60+ check('single note is finite', s.finite);
61+ check('single note 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 note decays', tail < head * 0.8, `head ${head.toExponential(2)} tail ${tail.toExponential(2)}`);
66+}
67+
68+// Determinism: the identical request renders identical audio.
69+const b = await renderRequest('bass', req([note(0, 36, 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+// The second identical render must come from the note cache (no numbl run).
77+{
78+ const t = performance.now();
79+ await renderRequest('bass', req([note(0, 36, 0.7)]), env);
80+ const ms = performance.now() - t;
81+ check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`);
82+}
83+
84+// Velocity and knobs change the sound; a repeated pitch shares the solve.
85+const soft = await renderRequest('bass', req([note(0, 36, 0.2)]), env);
86+{
87+ const pa = stats(a.audio).peak;
88+ const ps = stats(soft.audio).peak;
89+ check('velocity changes the sound', ps < pa, `soft ${ps.toExponential(2)} < hard ${pa.toExponential(2)}`);
90+}
91+const bridge = await renderRequest('bass', req([note(0, 36, 0.7)], { settings: { tail_s: 1, pluck_pos: 0.08 } }), env);
92+{
93+ let diff = false;
94+ const n = Math.min(a.audio.length, bridge.audio.length);
95+ for (let i = 0; i < n; i++) if (Math.abs(a.audio[i] - bridge.audio[i]) > 1e-9) { diff = true; break; }
96+ check('pluck_pos knob changes the sound', diff);
97+}
98+
99+// A track: two pitches and a repeat; the repeat is placed, not re-rendered,
100+// and the mixed buffer has energy at both onsets.
101+const tr = await renderRequest('bass', req([note(0, 36, 0.7), note(0.8, 43, 0.7), note(1.6, 36, 0.7)]), env);
102+{
103+ const s = stats(tr.audio);
104+ check('track render is finite', s.finite);
105+ const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * 22050), Math.floor((sec + 0.3) * 22050))).peak;
106+ check('energy at first onset', at(0.0) > 1e-4);
107+ check('energy at second onset', at(0.8) > 1e-4);
108+ check('energy at third onset', at(1.6) > 1e-4);
109+ const asked = req([note(0, 36, 0.7), note(0.8, 43, 0.7), note(1.6, 36, 0.7)]).lengthSamples;
110+ check('buffer covers the requested length', tr.audio.length >= asked, `${tr.audio.length} >= ${asked}`);
111+ const lastOnset = Math.round(1.6 * 22050);
112+ check('final tail is not clipped', tr.audio.length >= lastOnset, 'ring extends past the last onset');
113+}
114+
115+// The muted variant differs from the standup.
116+const muted = await renderRequest('bass-muted', req([note(0, 36, 0.7)], { settings: {} }), env);
117+{
118+ let diff = muted.audio.length !== a.audio.length;
119+ if (!diff) for (let i = 0; i < a.audio.length; i++) if (Math.abs(a.audio[i] - muted.audio[i]) > 1e-9) { diff = true; break; }
120+ check('muted variant differs', diff);
121+}
122+
123+console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
124+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 eigensolve 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 BassVariant } 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: BassVariant;
13+ notes: NoteSpec[];
14+ sampleRate: number;
15+ lengthSamples: number;
16+ quality: 'preview' | 'full';
17+ settings?: Record<string, number>;
18+ params?: Record<string, number | boolean>;
19+ mfiles?: MFile[];
20+ wantDiagnostics?: boolean;
21+}
22+
23+export interface WorkerCancelMsg {
24+ kind: 'cancel';
25+ id: number;
26+}
27+
28+export type WorkerResponse =
29+ | { kind: 'progress'; id: number; frac: number; stage: string }
30+ | { kind: 'result'; id: number; audio: Float32Array; diag?: unknown }
31+ | { kind: 'cancelled'; id: number }
32+ | { kind: 'error'; id: number; message: string; file?: string; line?: number };
33+
34+class Cancelled extends Error {}
35+
36+const cancelled = new Set<number>();
37+const queue: WorkerRenderMsg[] = [];
38+let running = false;
39+
40+const post = (m: WorkerResponse, transfer?: Transferable[]) =>
41+ (self as unknown as Worker).postMessage(m, transfer ?? []);
42+
43+const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
44+
45+async function run(msg: WorkerRenderMsg): Promise<void> {
46+ // numbl reports a file it could not parse through console.warn and then
47+ // skips it, so the failure surfaces at the call site as "Undefined
48+ // function". Capture the warning to report the actual syntax error.
49+ const warnings: string[] = [];
50+ const origWarn = console.warn;
51+ console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); };
52+ try {
53+ const result = await renderRequest(msg.variant, {
54+ notes: msg.notes,
55+ sampleRate: msg.sampleRate,
56+ lengthSamples: msg.lengthSamples,
57+ quality: msg.quality,
58+ settings: msg.settings,
59+ params: msg.params,
60+ wantDiagnostics: msg.wantDiagnostics,
61+ }, {
62+ mfiles: msg.mfiles ?? DEFAULT_MFILES,
63+ post: (frac, stage) => post({ kind: 'progress', id: msg.id, frac, stage }),
64+ checkCancel: () => { if (cancelled.has(msg.id)) throw new Cancelled(); },
65+ yieldToHost: () => new Promise((res) => setTimeout(res, 0)),
66+ });
67+ const transfer: Transferable[] = [result.audio.buffer];
68+ if (result.diag) {
69+ for (const v of Object.values(result.diag as unknown as Record<string, unknown>)) {
70+ if (v instanceof Float32Array && v.buffer !== result.audio.buffer) transfer.push(v.buffer);
71+ }
72+ }
73+ post({ kind: 'result', id: msg.id, audio: result.audio, diag: result.diag }, transfer);
74+ } catch (err) {
75+ if (err instanceof Cancelled) {
76+ post({ kind: 'cancelled', id: msg.id });
77+ } else {
78+ const e = err as { message?: string; file?: string; line?: number };
79+ let m: WorkerResponse = {
80+ kind: 'error',
81+ id: msg.id,
82+ message: e?.message ? String(e.message) : String(err),
83+ file: typeof e?.file === 'string' ? e.file : undefined,
84+ line: typeof e?.line === 'number' ? e.line : undefined,
85+ };
86+ for (const w of warnings) {
87+ const hit = SKIP_WARNING.exec(w);
88+ if (hit) {
89+ m = { kind: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
90+ break;
91+ }
92+ }
93+ post(m);
94+ }
95+ } finally {
96+ console.warn = origWarn;
97+ cancelled.delete(msg.id);
98+ }
99+}
100+
101+async function pump(): Promise<void> {
102+ if (running) return;
103+ running = true;
104+ while (queue.length > 0) {
105+ const msg = queue.shift()!;
106+ if (cancelled.has(msg.id)) { post({ kind: 'cancelled', id: msg.id }); cancelled.delete(msg.id); continue; }
107+ await run(msg);
108+ }
109+ running = false;
110+}
111+
112+self.onmessage = (ev: MessageEvent<WorkerRenderMsg | WorkerCancelMsg>) => {
113+ const msg = ev.data;
114+ if (msg.kind === 'render') {
115+ queue.push(msg);
116+ void pump();
117+ } else if (msg.kind === 'cancel') {
118+ cancelled.add(msg.id);
119+ }
120+};
scripts/build-exports.mjsdeleted+0−79View file
@@ -1,79 +0,0 @@
1-#!/usr/bin/env node
2-// Build exported instrument bundles (format numbl-instrument-v1) for one
3-// instrument repository. Run from the repo root:
4-//
5-// node scripts/build-exports.mjs
6-//
7-// For each export-src/<name>/export.json, the bundle's files are that
8-// directory's .m sources (the drivers, render_note.m required) plus every
9-// matlab/*.m of the repo, and the result is written to exports/<id>.json.
10-// The canonical copy of this script lives in
11-// simulated_instruments/instrument-format; instrument repos carry a copy so
12-// they build standalone.
13-
14-import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
15-import { join } from 'node:path';
16-
17-const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
18-const SRC = 'export-src';
19-const OUT = 'exports';
20-
21-function fail(msg) {
22- console.error(`build-exports: ${msg}`);
23- process.exit(1);
24-}
25-
26-function checkKnobs(id, knobs) {
27- if (!Array.isArray(knobs)) fail(`${id}: 'knobs' must be an array`);
28- for (const k of knobs) {
29- if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(`${id}: knobs need key and label`);
30- if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
31- fail(`${id}: knob '${k.key}' needs min < max and a default`);
32- }
33- if (k.default < k.min || k.default > k.max) fail(`${id}: knob '${k.key}' default outside its range`);
34- if (k.step !== undefined && !(k.step > 0)) fail(`${id}: knob '${k.key}' step must be > 0`);
35- if (k.scale !== undefined && k.scale !== 'log') fail(`${id}: knob '${k.key}' has unknown scale`);
36- if (k.scale === 'log' && k.min <= 0) fail(`${id}: knob '${k.key}' log scale needs min > 0`);
37- }
38-}
39-
40-function mfilesIn(dir) {
41- if (!existsSync(dir)) return [];
42- return readdirSync(dir)
43- .filter((f) => f.endsWith('.m'))
44- .sort()
45- .map((name) => ({ name, content: readFileSync(join(dir, name), 'utf8') }));
46-}
47-
48-if (!existsSync(SRC)) fail(`no ${SRC}/ directory here; run from the instrument repo root`);
49-const shared = mfilesIn('matlab');
50-const dirs = readdirSync(SRC, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
51-if (dirs.length === 0) fail(`${SRC}/ has no instrument directories`);
52-mkdirSync(OUT, { recursive: true });
53-
54-for (const dir of dirs) {
55- const metaPath = join(SRC, dir, 'export.json');
56- if (!existsSync(metaPath)) fail(`${metaPath} is missing`);
57- const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
58- const id = meta.id;
59- if (typeof id !== 'string' || !ID_RE.test(id)) fail(`${metaPath}: 'id' must match ${ID_RE}`);
60- if (typeof meta.name !== 'string' || meta.name === '') fail(`${id}: missing 'name'`);
61- checkKnobs(id, meta.knobs ?? []);
62- const drivers = mfilesIn(join(SRC, dir));
63- if (!drivers.some((f) => f.name === 'render_note.m')) fail(`${id}: no render_note.m in ${SRC}/${dir}/`);
64- const names = new Set(drivers.map((f) => f.name));
65- const clash = shared.find((f) => names.has(f.name));
66- if (clash) fail(`${id}: driver file '${clash.name}' collides with matlab/${clash.name}`);
67- const bundle = {
68- format: 'numbl-instrument-v1',
69- id,
70- name: meta.name,
71- ...(meta.description ? { description: meta.description } : {}),
72- ...(meta.source ? { source: meta.source } : {}),
73- knobs: meta.knobs ?? [],
74- files: [...drivers, ...shared],
75- };
76- const outPath = join(OUT, `${id}.json`);
77- writeFileSync(outPath, JSON.stringify(bundle, null, 2) + '\n');
78- console.log(`wrote ${outPath} (${bundle.files.length} files, ${bundle.knobs.length} knobs)`);
79-}
webapp/README.mdmodified+1−1View file
@@ -36,7 +36,7 @@ The New seed button redraws the walking line without touching any other paramete
3636 - `../matlab/` — the algorithm: `bass_notes.m`, `bass_body.m`, `bass_assemble.m`, `bass_basis.m`, `bass_radiate.m`, `bass_output.m`, `bass_rng.m`, plus `bass_render.m` / `bass_demo.m` / `bass_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 (notes, waveform, spectrum, spectrogram, bridge admittance, coupled-mode map)
4141 - `src/selftest.ts` — Node harness for the MATLAB validation script
4242
webapp/src/components/DiagnosticsPanel.tsxmodified+2−2View file
@@ -1,7 +1,7 @@
11 import { midiHz, midiName } from '../params';
2-import type { RenderResponse } from '../worker/renderWorker';
2+import type { RenderResult } from '../hooks/useRenderer';
33
4-export default function DiagnosticsPanel({ result }: { result: RenderResponse }) {
4+export default function DiagnosticsPanel({ result }: { result: RenderResult }) {
55 const d = result.diag;
66 const pitches = Array.from(result.noteMidi).map((m) => midiName(m));
77 const shown = pitches.slice(0, 12);
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 { BassParams } from '../params';
812 import type { MFile } from '../mfiles';
9-import type { ErrorMessage, ProgressMessage, RenderResponse } from '../worker/renderWorker';
13+import { bassInstrument } from '../../../instrument/index.ts';
14+import type { BassExploreDiag } 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 = BassExploreDiag & {
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: BassParams, 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: BassParams, 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: BassParams, 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 bassInstrument.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 BassExploreDiag;
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−420View file
@@ -1,420 +0,0 @@
1-// Web Worker that renders the pizzicato bass 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 -> bass_notes + bass_basis (one coupled eigensolve per
6-// fingering; the expensive stage, cached on the strings,
7-// the body, and the gesture's pitches)
8-// radiate tier -> bass_radiate (pluck projection + closed-form synthesis +
9-// radiation; cached on the pluck, the tempo, the listener,
10-// and the synthesis rate)
11-// output tier -> bass_output (always cheap, always re-run)
12-// Tensors cross the boundary as numbl RuntimeValues (column-major Float64).
13-
14-import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
15-import { hashMFiles, type MFile } from '../mfiles';
16-import { SOLVE_KEYS, RADIATE_KEYS, midiHz, type BassParams } from '../params';
17-
18-export interface RenderRequest {
19- type: 'render';
20- id: number;
21- params: BassParams;
22- quality: 'preview' | 'full';
23- mfiles: MFile[];
24-}
25-
26-export interface RenderResponse {
27- type: 'result';
28- id: number;
29- quality: 'preview' | 'full';
30- y: Float32Array; // final signal, [-1, 1]
31- sampleRate: number;
32- noteT: Float32Array; // note onsets, s
33- noteMidi: Float32Array; // pitches
34- noteStr: Float32Array; // string index, 1 = G2 .. 4 = E1
35- noteStop: Float32Array; // semitones above the open string
36- noteForce: Float32Array; // pluck forces, N
37- mode1F: Float32Array; // coupled modes of the first fingering, Hz
38- mode1Zeta: Float32Array; // their damping ratios
39- mode1BodyFrac: Float32Array; // their body-energy fraction (0 string .. 1 body)
40- bodyF: Float32Array; // the body mode table, Hz
41- bodyZeta: Float32Array;
42- bodyA: Float32Array; // mass-normalized bridge amplitudes, 1/sqrt(kg)
43- bodyMono: Float32Array; // 1 = monopole (A0), 0 = dipole
44- diag: {
45- noteCount: number;
46- fingerings: number;
47- modesTotal: number;
48- modesNote1: number;
49- fundHz: number;
50- fundT60: number;
51- meanForce: number;
52- durationS: number;
53- peakPa: number;
54- peakSplDb: number;
55- listenerDistM: number;
56- warnings: string[];
57- solveMs: number; // 0 when the cached eigensolves were reused
58- radiateMs: number; // 0 when the cached radiation was reused
59- solveCached: boolean;
60- radiateCached: boolean;
61- oversample: number;
62- };
63-}
64-
65-export interface ProgressMessage {
66- type: 'progress';
67- id: number;
68- stage: 'solve' | 'radiate' | 'output';
69- frac: number;
70-}
71-
72-// Raised by a fault in the .m code (the user can edit it), so it is reported
73-// rather than left to stall the request.
74-export interface ErrorMessage {
75- type: 'error';
76- id: number;
77- message: string;
78- file?: string;
79- line?: number;
80-}
81-
82-// Basis fields that cross from the solve stage into the radiate stage as
83-// numbl RuntimeValues, plus their Float32 copies for the plots.
84-interface SolveEntry {
85- vars: Record<string, RuntimeValue>;
86- noteT: Float32Array;
87- noteMidi: Float32Array;
88- noteStr: Float32Array;
89- noteStop: Float32Array;
90- noteForce: Float32Array;
91- mode1F: Float32Array;
92- mode1Zeta: Float32Array;
93- mode1BodyFrac: Float32Array;
94- bodyF: Float32Array;
95- bodyZeta: Float32Array;
96- bodyA: Float32Array;
97- bodyMono: Float32Array;
98- fingerings: number;
99- modesTotal: number;
100- meanForce: number;
101- durationS: number;
102-}
103-
104-interface RadiateEntry {
105- p: RuntimeValue;
106- fundHz: number;
107- fundT60: number;
108- listenerDistM: number;
109- listenerInside: boolean;
110- fsHi: number;
111- oversample: number;
112-}
113-
114-// Small LRU caches. The solve cache does not depend on the oversample rate,
115-// so a preview render right after a full one reuses the same eigensolves.
116-const solveCache = new Map<string, SolveEntry>();
117-const radiateCache = new Map<string, RadiateEntry>();
118-const SOLVE_CACHE_MAX = 2;
119-const RADIATE_CACHE_MAX = 3;
120-
121-function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
122- const v = cache.get(key);
123- if (v !== undefined) {
124- cache.delete(key);
125- cache.set(key, v);
126- }
127- return v;
128-}
129-
130-function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
131- cache.delete(key);
132- cache.set(key, value);
133- while (cache.size > max) {
134- const oldest = cache.keys().next().value as string;
135- cache.delete(oldest);
136- }
137-}
138-
139-function keyFor(params: BassParams, keys: readonly (keyof BassParams)[]): string {
140- return keys.map((k) => String(params[k])).join('|');
141-}
142-
143-// MATLAB source lines assigning every parameter onto a struct P.
144-function pLiteral(params: BassParams): string {
145- const lines: string[] = [];
146- for (const [k, v] of Object.entries(params)) {
147- if (typeof v === 'number') lines.push(`P.${k} = ${v};`);
148- else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
149- }
150- return lines.join('\n');
151-}
152-
153-function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
154- return executeCode(
155- source,
156- { initialVariableValues: inputs, displayResults: false },
157- mfiles,
158- 'stage.m',
159- );
160-}
161-
162-function asTensor(v: RuntimeValue): RuntimeTensor {
163- const t = v as RuntimeTensor;
164- if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
165- return t;
166-}
167-
168-function asScalar(v: RuntimeValue): number {
169- if (typeof v === 'number') return v;
170- const t = v as RuntimeTensor;
171- if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
172- if (typeof v === 'boolean') return v ? 1 : 0;
173- throw new Error('expected scalar result from numbl stage');
174-}
175-
176-function toF32(v: RuntimeValue): Float32Array {
177- const t = asTensor(v);
178- const out = new Float32Array(t.data.length);
179- for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
180- return out;
181-}
182-
183-// The basis fields handed from the solve stage into the radiate stage, in the
184-// order the reassembly script expects.
185-const BASIS_FIELDS = [
186- 'fIdx', 'off', 'nModes', 'pmOff', 'ns', 'Lv', 'mu', 'T', 'f0', 'str', 'stop',
187- 'f', 'zeta', 'Vm', 'Dd', 'Fcoef', 'ybAmp', 'bodyFrac', 'PMflat',
188- 'bodyF', 'bodyZeta', 'bodyA', 'bodyS', 'bodyMono',
189-];
190-
191-const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
192-
193-self.onmessage = (ev: MessageEvent<RenderRequest>) => {
194- const msg = ev.data;
195- if (msg.type !== 'render') return;
196-
197- // numbl reports a file it could not parse through console.warn and then
198- // skips it, so the failure surfaces at the call site as "Undefined
199- // function". Capture the warning to report the actual syntax error instead.
200- const warnings: string[] = [];
201- const origWarn = console.warn;
202- console.warn = (...args: unknown[]) => {
203- warnings.push(args.map(String).join(' '));
204- };
205-
206- try {
207- render(msg);
208- } catch (err) {
209- // Most likely a fault in the (user-editable) .m code. Report it so the
210- // request completes and the message reaches the code panel.
211- const e = err as { message?: string; file?: string; line?: number };
212- let m: ErrorMessage = {
213- type: 'error',
214- id: msg.id,
215- message: e?.message ? String(e.message) : String(err),
216- file: typeof e?.file === 'string' ? e.file : undefined,
217- line: typeof e?.line === 'number' ? e.line : undefined,
218- };
219- for (const w of warnings) {
220- const hit = SKIP_WARNING.exec(w);
221- if (hit) {
222- m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
223- break;
224- }
225- }
226- (self as unknown as Worker).postMessage(m);
227- } finally {
228- console.warn = origWarn;
229- }
230-};
231-
232-function render(msg: RenderRequest): void {
233- const { id, params, mfiles } = msg;
234- let quality = msg.quality;
235-
236- // The .m sources are part of the cache identity: editing the code must
237- // invalidate everything computed from the previous version.
238- const codeKey = hashMFiles(mfiles);
239- const solveKey = codeKey + '|' + keyFor(params, SOLVE_KEYS);
240-
241- const fullOversample = params.oversample;
242- const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
243- if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) {
244- quality = 'full'; // full-rate radiation already cached
245- }
246- const oversample = quality === 'preview' ? Math.min(1, params.oversample) : params.oversample;
247- const fsHi = params.sample_rate * oversample;
248-
249- const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
250-
251- // ---- solve stage: the gesture and the coupled eigensolves ----
252- let solveMs = 0;
253- let solveCached = true;
254- let solveEntry = lruGet(solveCache, solveKey);
255- if (!solveEntry) {
256- solveCached = false;
257- post({ type: 'progress', id, stage: 'solve', frac: 0.15 });
258- const t0 = performance.now();
259- const r = runStage(mfiles, `
260-${pLiteral(params)}
261-[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
262-basis = bass_basis(P, str, stop);
263-${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
264-n1 = basis.nModes(1);
265-mode1F = basis.f(1:n1);
266-mode1Zeta = basis.zeta(1:n1);
267-mode1BodyFrac = basis.bodyFrac(1:n1);
268-fingerings = basis.nf;
269-modesTotal = basis.n;
270-meanForce = ninfo.meanForce;
271-durationS = ninfo.durationS;
272-`);
273- solveMs = performance.now() - t0;
274-
275- const vars: Record<string, RuntimeValue> = {};
276- for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
277- solveEntry = {
278- vars,
279- noteT: toF32(r.variableValues.tOn),
280- noteMidi: toF32(r.variableValues.midi),
281- noteStr: toF32(r.variableValues.str),
282- noteStop: toF32(r.variableValues.stop),
283- noteForce: toF32(r.variableValues.force),
284- mode1F: toF32(r.variableValues.mode1F),
285- mode1Zeta: toF32(r.variableValues.mode1Zeta),
286- mode1BodyFrac: toF32(r.variableValues.mode1BodyFrac),
287- bodyF: toF32(r.variableValues.b_bodyF),
288- bodyZeta: toF32(r.variableValues.b_bodyZeta),
289- bodyA: toF32(r.variableValues.b_bodyA),
290- bodyMono: toF32(r.variableValues.b_bodyMono),
291- fingerings: asScalar(r.variableValues.fingerings),
292- modesTotal: asScalar(r.variableValues.modesTotal),
293- meanForce: asScalar(r.variableValues.meanForce),
294- durationS: asScalar(r.variableValues.durationS),
295- };
296- lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
297- }
298-
299- // ---- radiate stage: pluck projection + closed-form synthesis ----
300- const radiateKey = radiateParamsKey + `|os=${oversample}`;
301- let radiateMs = 0;
302- let radiateCached = true;
303- let radEntry = lruGet(radiateCache, radiateKey);
304- if (!radEntry) {
305- radiateCached = false;
306- post({ type: 'progress', id, stage: 'radiate', frac: 0.55 });
307- const t0 = performance.now();
308- const r = runStage(mfiles, `
309-${pLiteral(params)}
310-${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
311-basis.nf = length(basis.off);
312-basis.n = length(basis.f);
313-[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
314-[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, ${fsHi});
315-fundHz = radinfo.fundHz;
316-fundT60 = radinfo.fundT60;
317-listenerDistM = radinfo.listenerDistM;
318-listenerInside = radinfo.listenerInside;
319-`, solveEntry.vars);
320- radiateMs = performance.now() - t0;
321- radEntry = {
322- p: r.variableValues.p,
323- fundHz: asScalar(r.variableValues.fundHz),
324- fundT60: asScalar(r.variableValues.fundT60),
325- listenerDistM: asScalar(r.variableValues.listenerDistM),
326- listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
327- fsHi,
328- oversample,
329- };
330- lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
331- }
332-
333- // ---- output stage ----
334- post({ type: 'progress', id, stage: 'output', frac: 0.9 });
335- const rOut = runStage(mfiles, `
336-${pLiteral(params)}
337-[y, peakPa] = bass_output(P, p, ${radEntry.oversample});
338-`, { p: radEntry.p });
339-
340- const y32 = toF32(rOut.variableValues.y);
341- const peakPa = asScalar(rOut.variableValues.peakPa);
342-
343- const warnings: string[] = [];
344- if (radEntry.listenerInside) {
345- warnings.push(
346- 'the listener is within 35 cm of the bridge, inside or against the body; the field there is not free',
347- );
348- }
349- if (!params.normalize) {
350- let peak = 0;
351- for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
352- if (peak > 1.0) {
353- warnings.push(
354- `clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`,
355- );
356- }
357- }
358- // wolf warning: the fundamental pulled well away from equal temperament
359- const target = midiHz(solveEntry.noteMidi[0]) * params.tuning_trim;
360- const cents = 1200 * Math.log2(radEntry.fundHz / target);
361- if (Math.abs(cents) > 35) {
362- warnings.push(
363- `the first note sounds ${Math.abs(cents).toFixed(0)} cents ${cents > 0 ? 'sharp' : 'flat'}: its fundamental is being pulled by a body resonance (wolf territory)`,
364- );
365- }
366-
367- const resp: RenderResponse = {
368- type: 'result',
369- id,
370- quality,
371- y: y32,
372- sampleRate: params.sample_rate,
373- noteT: solveEntry.noteT.slice(),
374- noteMidi: solveEntry.noteMidi.slice(),
375- noteStr: solveEntry.noteStr.slice(),
376- noteStop: solveEntry.noteStop.slice(),
377- noteForce: solveEntry.noteForce.slice(),
378- mode1F: solveEntry.mode1F.slice(),
379- mode1Zeta: solveEntry.mode1Zeta.slice(),
380- mode1BodyFrac: solveEntry.mode1BodyFrac.slice(),
381- bodyF: solveEntry.bodyF.slice(),
382- bodyZeta: solveEntry.bodyZeta.slice(),
383- bodyA: solveEntry.bodyA.slice(),
384- bodyMono: solveEntry.bodyMono.slice(),
385- diag: {
386- noteCount: solveEntry.noteT.length,
387- fingerings: solveEntry.fingerings,
388- modesTotal: solveEntry.modesTotal,
389- modesNote1: solveEntry.mode1F.length,
390- fundHz: radEntry.fundHz,
391- fundT60: radEntry.fundT60,
392- meanForce: solveEntry.meanForce,
393- durationS: solveEntry.durationS,
394- peakPa,
395- peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
396- listenerDistM: radEntry.listenerDistM,
397- warnings,
398- solveMs,
399- radiateMs,
400- solveCached,
401- radiateCached,
402- oversample: radEntry.oversample,
403- },
404- };
405- (self as unknown as Worker).postMessage(resp, [
406- resp.y.buffer,
407- resp.noteT.buffer,
408- resp.noteMidi.buffer,
409- resp.noteStr.buffer,
410- resp.noteStop.buffer,
411- resp.noteForce.buffer,
412- resp.mode1F.buffer,
413- resp.mode1Zeta.buffer,
414- resp.mode1BodyFrac.buffer,
415- resp.bodyF.buffer,
416- resp.bodyZeta.buffer,
417- resp.bodyA.buffer,
418- resp.bodyMono.buffer,
419- ]);
420-}
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