Pizzicato standup bass: coupled string-body modal model, numbl MATLAB reference, and interactive web app
43 changed files+8485−0
.github/workflows/deploy.ymladded+69−0View file
@@ -0,0 +1,69 @@
1+name: Deploy to Pages
2+
3+# Builds webapp/ and publishes it as this repository's site.
4+#
5+# Two notes on the vault:
6+# - there is no cache service, so setup-node runs without `cache: npm`;
7+# - a mochi site is served at /<collection>/<repo>/site/ or at the root of an
8+# origin of its own. The Vite build uses a relative base (`base: './'`), so
9+# it works at either without being told the deploy path, and configure-pages
10+# is therefore only here for parity with the deploy step.
11+
12+on:
13+ push:
14+ branches: [main]
15+ workflow_dispatch:
16+
17+permissions:
18+ contents: read
19+ pages: write
20+ id-token: write
21+
22+concurrency:
23+ group: pages
24+ cancel-in-progress: false
25+
26+jobs:
27+ build:
28+ runs-on: ubuntu-latest
29+ steps:
30+ - uses: actions/checkout@v4
31+
32+ - uses: actions/setup-node@v4
33+ with:
34+ node-version: 22
35+
36+ - name: Install
37+ working-directory: webapp
38+ run: npm ci
39+
40+ # Runs matlab/bass_validate.m through numbl: the closed-form stiff
41+ # string, the coupled eigensolve identities, the veering scaling,
42+ # reproducibility of the seeded walking line, and the pinned
43+ # end-to-end diagnostics. Fails the build on any mismatch.
44+ - name: Validate the model
45+ working-directory: webapp
46+ run: npm run selftest
47+
48+ - name: Build
49+ working-directory: webapp
50+ run: npm run build
51+
52+ - name: Setup Pages
53+ uses: actions/configure-pages@v5
54+
55+ - name: Upload artifact
56+ uses: actions/upload-pages-artifact@v3
57+ with:
58+ path: webapp/dist
59+
60+ deploy:
61+ needs: build
62+ runs-on: ubuntu-latest
63+ environment:
64+ name: github-pages
65+ url: ${{ steps.deployment.outputs.page_url }}
66+ steps:
67+ - name: Deploy to Pages
68+ id: deployment
69+ uses: actions/deploy-pages@v4
README.mdadded+49−0View file
@@ -0,0 +1,49 @@
1+# Standup Bass
2+
3+A physically-based simulation of a pizzicato standup bass (double bass), playing a single note or a seeded walking line, sampled as acoustic pressure at a point in space. Each note is a stiff, lossy string two-way coupled at the bridge to a modal model of the corpus; the pluck is a released initial condition, so between events nothing is integrated, and the coupled modes radiate to a listener point through the f-hole Helmholtz monopole and the top-plate dipoles.
4+
5+There is no microphone model: no directivity, no capsule response, no gain staging. The output is p(r, t) in pascals.
6+
7+## Contents
8+
9+| Path | What it is |
10+|---|---|
11+| `matlab/` | The algorithm in MATLAB syntax, run by [numbl](https://numbl.org) |
12+| `webapp/` | Interactive browser app that executes the MATLAB code client-side |
13+
14+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`.
15+
16+## The model
17+
18+The signal chain, with each stage in its own `.m` file:
19+
20+```
21+single note or seeded walking line bass_notes.m
22+ -> body mode table seen at the bridge bass_body.m
23+ -> string + body assembled per fingering bass_assemble.m
24+ -> one coupled eigensolve per fingering bass_basis.m
25+pluck -> initial condition on the coupled modes
26+ -> closed-form damped exponentials, choked at
27+ the next note
28+ -> f-hole monopoles + top-plate dipoles bass_radiate.m
29+ -> free-field pressure at the listener
30+ -> anti-aliased decimation -> PCM bass_output.m
31+```
32+
33+Three points are worth stating plainly, because they are where the model commits itself.
34+
35+**The string and the body are diagonalized together.** Each fingering (a string and a stop) is one symmetric generalized eigenproblem: the mass-normalized modes of the stiff pinned-pinned string, the modal body, and a bridge-ramp shape that lets the string's end move with the bridge. With that ansatz the elastic coupling integrals vanish and the whole coupling sits in the mass matrix, so the problem reduces through the Cholesky factor of M and is diagonalized by svd, which keeps near-degenerate coupled pairs orthonormal where a nonsymmetric eigensolver does not. The coupling is two-way: the body pulls the open A fundamental 14 cents flat at the defaults, and a note whose fundamental lands on a strong body resonance comes out as a split, fast-beating pair, which is the wolf-note mechanism, checked in the validation by its square-root-of-mobility splitting. The body itself is the empirical part: a table of twelve measured-plausible resonances (A0 near 60 Hz, the main wood modes from 97 Hz up) with effective bridge masses, Qs, and radiating areas, not a plate solve; above the table the dense modal region enters only as a resistive residual admittance. The bending stiffness of the wound string comes from a single inharmonicity parameter and stretches the high partials sharp, more so on stopped notes, as it should.
36+
37+**The pluck is an initial condition, not a force history.** Releasing a string pulled with force F leaves the classic triangular shape, whose projection through the coupled shapes gives every mode an initial amplitude, rolled off by a first-order finite-release filter standing in for the fingertip (the difference between a fleshy jazz pizzicato and a hard snap). From there every coupled mode is a damped exponential, so a note renders in closed form, faster than real time, unlike the sibling `recorder` repository whose oscillation must be integrated sample by sample. When the next note of a walking line lands, the previous note is choked with an extra decay standing in for the player stopping the string. What this gives up is the contact detail: there is no fingerboard collision, so the rattle of a very hard pluck is outside the model, and notes are otherwise independent, which is wrong for true legato slides. Damping is assigned in the uncoupled coordinates and projected: string internal loss, a finger termination on stopped notes (open strings ring longer, as on the instrument), the residual bridge conductance (a resistive end gives a constant decay rate, so the high partials die in under a second while the fundamental rings for ten), and the body Qs.
38+
39+**Radiation is a monopole pair plus dipoles, and one confessed shortcut.** A0 is the one body mode that moves net volume through the f-holes, and it radiates as a monopole split between the two f-hole points with their own retardations; every corpus mode displaces the top plate against the air with no net flow and radiates as a point dipole along the plate normal, near-field term kept, so walking behind the instrument flips their phase. The string itself is acoustically invisible. Above the retained body table, the bridge force acting on the resistive residual admittance moves the plate broadband, and that motion radiates as a small dipole: this is how the high partials reach the listener at all, it is the least principled term of the model, and `direct_gain` turns it off.
40+
41+## Validation
42+
43+`matlab/bass_validate.m` (also `npm run selftest` in `webapp/`) checks the decoupled solve against the closed-form stiff string (including the inharmonic stretch), the mass-orthonormality and stiffness-diagonalization identities of the coupled eigensolve, that a midpoint pluck silences the even partials end to end, that tuning a body mode onto a string fundamental splits it with the square-root-of-mobility scaling of a genuine two-way coupling, the far-field -6 dB per doubling of distance, exact reproducibility of the seeded walking line, and pinned end-to-end diagnostics.
44+
45+As in the sibling `maraca`, `triangle`, and `recorder` repositories, there is no separate Python reference implementation: the MATLAB code is the reference, and the pinned numbers were recorded from it when the model was built.
46+
47+## Defaults
48+
49+A 3/4-size bass with a 1.04 m scale, strung G2 D2 A1 E1 at 285 to 310 N (the linear densities follow from tension and pitch, about 7 g/m for the G and 42 g/m for the E). The default gesture is a single open A1 plucked with 15 N at 30% of the string from the bridge, released over 0.9 ms: 61 coupled modes (49 string, 12 body), a fundamental at 54.57 Hz that the body holds 14 cents flat of A1, a T60 of 10.7 s on that fundamental, and a peak of 0.371 Pa (85.4 dB SPL) at the 133 cm listener before normalization. Setting `note_count` to 8 walks a seeded quarter-note line in A major at 96 bpm, each note choked as the next lands.
matlab/bass_assemble.madded+77−0View file
@@ -0,0 +1,77 @@
1+function [M, Kmat, omS, zS, meta] = bass_assemble(P, s, st)
2+% BASS_ASSEMBLE Mass and stiffness matrices of one fingering's coupled
3+% string-body system, plus the uncoupled string modes and damping.
4+%
5+% The displacement ansatz and the resulting matrices are documented in
6+% bass_basis.m, which calls this per fingering; bass_validate.m calls it
7+% directly to check the closed-form string limit and the eigensolve
8+% identities. Coordinates are [q_1..q_ns, xi_1..xi_nb]: mass-normalized
9+% pinned-pinned stiff-string modes, then the mass-normalized body modes of
10+% bass_body.m. M and Kmat are symmetric and M is positive definite (the
11+% coupling is weak: the off-diagonal mass terms are of order
12+% sqrt(mu Lv / m_body) / k pi, a few percent here).
13+
14+f0open = [97.9989 73.4162 55.0000 41.2034] * P.tuning_trim;
15+tension = [P.tension_g_n P.tension_d_n P.tension_a_n P.tension_e_n];
16+L0 = P.scale_length_m;
17+
18+body = bass_body(P);
19+nb = body.n;
20+omRes = 2 * pi * 1.25 * max(body.f); % residual admittance high-pass corner
21+
22+T = tension(s);
23+cw = 2 * L0 * f0open(s); % transverse wave speed of the string
24+mu = T / cw^2;
25+Lv = L0 * 2^(-st / 12);
26+EI = P.inharmonicity * T * L0^2 / pi^2;
27+
28+% string modes up to max_mode_hz (at least one, at most 160)
29+kEst = min(160, max(1, floor(2 * Lv * P.max_mode_hz / cw) + 2));
30+kk = (1:kEst)' * pi / Lv;
31+om2 = kk.^2 * (T / mu) + kk.^4 * (EI / mu);
32+omAll = sqrt(om2);
33+ns = max(1, sum(omAll <= 2 * pi * P.max_mode_hz));
34+ns = min(ns, kEst);
35+omS = omAll(1:ns);
36+kv = (1:ns)';
37+
38+% uncoupled string damping: internal loss, the finger termination on
39+% stopped notes, and the resistive residual bridge admittance (high-passed
40+% above the retained body modes so the modal region is not double-counted;
41+% a resistive end gives a constant decay RATE, zeta ~ Y T / (Lv om))
42+hp = omS.^2 ./ (omS.^2 + omRes^2);
43+zS = 0.5 * (P.loss_factor + P.loss_slope * omS) ...
44+ + 0.5 * P.residual_y * (T / Lv) * hp ./ omS;
45+if st > 0
46+ zS = zS + 0.5 * P.finger_loss;
47+end
48+
49+% ---- assemble ----
50+n = ns + nb;
51+gk = sqrt(2 * mu * Lv) * ((-1).^(kv + 1)) ./ (kv * pi);
52+M = eye(n);
53+Kmat = zeros(n, n);
54+for k = 1:ns
55+ Kmat(k, k) = omS(k)^2;
56+end
57+for j = 1:nb
58+ for k = 1:ns
59+ M(k, ns + j) = gk(k) * body.a(j);
60+ M(ns + j, k) = M(k, ns + j);
61+ end
62+ for i = 1:nb
63+ M(ns + i, ns + j) = M(ns + i, ns + j) + (mu * Lv / 3) * body.a(i) * body.a(j);
64+ Kmat(ns + i, ns + j) = (T / Lv) * body.a(i) * body.a(j);
65+ end
66+ Kmat(ns + j, ns + j) = Kmat(ns + j, ns + j) + body.omega(j)^2;
67+end
68+
69+meta.ns = ns;
70+meta.nb = nb;
71+meta.n = n;
72+meta.Lv = Lv;
73+meta.mu = mu;
74+meta.T = T;
75+meta.f0 = f0open(s) * 2^(st / 12);
76+meta.body = body;
77+end
matlab/bass_basis.madded+176−0View file
@@ -0,0 +1,176 @@
1+function basis = bass_basis(P, str, stop)
2+% BASS_BASIS Coupled string-body mode set, one eigensolve per fingering.
3+%
4+% For each fingering of the gesture (a string and a stop, deduplicated so a
5+% walking line that revisits a note reuses its solve), the vibrating string
6+% and the modal body of bass_body.m are assembled into ONE symmetric
7+% generalized eigenproblem and diagonalized together, so the coupling
8+% through the bridge is two-way: the string drives the body, and the moving
9+% bridge detunes and damps the string in return. This is where wolf-note
10+% physics lives; a note whose fundamental lands on a strong body resonance
11+% comes out as a split, fast-beating pair of coupled modes rather than one.
12+%
13+% The displacement ansatz is
14+% u(x) = sum_k q_k phi_k(x) + y_b x/Lv, y_b = sum_j a_j xi_j,
15+% with phi_k the mass-normalized pinned-pinned modes of the stiff string
16+% (om_k^2 = (k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu; the bending stiffness EI
17+% comes from the inharmonicity parameter, EI = B T L0^2/pi^2, and is what
18+% stretches the high partials sharp) and x/Lv the static shape the string
19+% takes when the bridge end moves. With this split the elastic coupling
20+% integrals vanish (the ramp has no curvature and phi_k vanishes at both
21+% ends), so all the coupling sits in the mass matrix, plus the ramp's own
22+% inertia and the tension load T/Lv on the body block; bass_assemble.m
23+% builds the matrices. Both are symmetric and M is positive definite, so
24+% the problem reduces through the Cholesky factor of M and is diagonalized
25+% by svd, which keeps near-degenerate coupled pairs orthonormal where a
26+% nonsymmetric eigensolver does not. The shapes come back mass-normalized.
27+%
28+% Damping is assigned in the uncoupled coordinates and projected,
29+% zeta_m = (Phi' C0 Phi)_mm / (2 om_m): string internal loss (loss_factor +
30+% loss_slope om), the finger termination on stopped notes (finger_loss), the
31+% resistive residual bridge admittance above the retained body modes
32+% (residual_y, high-passed so it does not double-count the modal region),
33+% and the body Qs. Radiation damping from each coupled mode's own monopole
34+% and dipole strengths is added on top, scaled by radiation_loss.
35+%
36+% Stored per coupled mode: frequency, damping, monopole volume Vm and dipole
37+% volume Dd per unit modal coordinate (what bass_radiate.m radiates), the
38+% bridge force coefficient Fcoef (what drives the direct term), and the
39+% projection matrix PM = (M Phi)' restricted to the string block, which
40+% turns a plucked initial shape into coupled initial amplitudes. Fields are
41+% concatenated across fingerings with offset indexing (numbl has no struct
42+% arrays).
43+
44+rho0 = P.air_density;
45+cair = P.speed_of_sound;
46+
47+% ---- deduplicate fingerings ----
48+K = length(str);
49+fkey = str * 100 + stop;
50+uKey = zeros(1, K);
51+fIdx = zeros(1, K);
52+nf = 0;
53+for i = 1:K
54+ hit = 0;
55+ for u = 1:nf
56+ if uKey(u) == fkey(i)
57+ hit = u;
58+ end
59+ end
60+ if hit == 0
61+ nf = nf + 1;
62+ uKey(nf) = fkey(i);
63+ hit = nf;
64+ end
65+ fIdx(i) = hit;
66+end
67+
68+% ---- per-fingering eigensolve, concatenated storage ----
69+f = [];
70+zeta = [];
71+Vm = [];
72+Dd = [];
73+Fcoef = [];
74+ybAmp = [];
75+bodyFrac = [];
76+PMflat = [];
77+off = zeros(nf, 1);
78+nModes = zeros(nf, 1);
79+pmOff = zeros(nf, 1);
80+nsArr = zeros(nf, 1);
81+LvArr = zeros(nf, 1);
82+muArr = zeros(nf, 1);
83+TArr = zeros(nf, 1);
84+f0Arr = zeros(nf, 1);
85+strArr = zeros(nf, 1);
86+stopArr = zeros(nf, 1);
87+body = bass_body(P);
88+
89+for u = 1:nf
90+ s = floor(uKey(u) / 100);
91+ st = uKey(u) - 100 * s;
92+ [M, Kmat, omS, zS, meta] = bass_assemble(P, s, st);
93+ ns = meta.ns;
94+ nb = meta.nb;
95+ n = meta.n;
96+ Lv = meta.Lv;
97+ mu = meta.mu;
98+ T = meta.T;
99+ kv = (1:ns)';
100+
101+ Rc = chol(M);
102+ Am = (Rc') \ (Kmat / Rc);
103+ Am = (Am + Am') / 2;
104+ [Uv, Sv, ~] = svd(Am);
105+ omc2 = flipud(diag(Sv)); % ascending
106+ Vv = fliplr(Uv);
107+ omc = sqrt(max(1e-6, omc2));
108+ Phi = Rc \ Vv; % mass-normalized coupled shapes, n x n
109+
110+ % projected damping plus per-mode radiation damping
111+ cvec = 2 * [zS .* omS; body.zeta .* body.omega];
112+ zc = ((cvec') * (Phi.^2))' ./ (2 * omc);
113+ PhiB = Phi(ns + 1:n, :);
114+ bodyDisp = (body.a * ones(1, n)) .* PhiB; % nb x n physical
115+ vm = ((body.S .* body.mono)' * bodyDisp)'; % n x 1 monopole volume
116+ dd = P.dipole_length_m * ((body.S .* (1 - body.mono))' * bodyDisp)';
117+ zRad = P.radiation_loss * (rho0 * omc.^4 .* vm.^2 / (8 * pi * cair) ...
118+ + rho0 * omc.^6 .* dd.^2 / (24 * pi * cair^3)) ./ omc.^3;
119+ zc = zc + zRad;
120+
121+ yb = ((body.a') * PhiB)'; % n x 1 bridge displacement
122+ phiSlope = sqrt(2 / (mu * Lv)) * (kv * pi / Lv) .* ((-1).^kv);
123+ fc = T * (((phiSlope') * Phi(1:ns, :))' + yb / Lv);
124+ bfrac = ((ones(1, nb) * (PhiB.^2)))';
125+
126+ PM = (M * Phi);
127+ PM = (PM(1:ns, :))'; % n x ns projection
128+
129+ off(u) = length(f);
130+ nModes(u) = n;
131+ pmOff(u) = length(PMflat);
132+ nsArr(u) = ns;
133+ LvArr(u) = Lv;
134+ muArr(u) = mu;
135+ TArr(u) = T;
136+ f0Arr(u) = meta.f0;
137+ strArr(u) = s;
138+ stopArr(u) = st;
139+
140+ f = [f; omc / (2 * pi)];
141+ zeta = [zeta; zc];
142+ Vm = [Vm; vm];
143+ Dd = [Dd; dd];
144+ Fcoef = [Fcoef; fc];
145+ ybAmp = [ybAmp; yb];
146+ bodyFrac = [bodyFrac; bfrac];
147+ PMflat = [PMflat; reshape(PM, n * ns, 1)];
148+end
149+
150+basis.nf = nf;
151+basis.fIdx = fIdx;
152+basis.off = off;
153+basis.nModes = nModes;
154+basis.pmOff = pmOff;
155+basis.ns = nsArr;
156+basis.Lv = LvArr;
157+basis.mu = muArr;
158+basis.T = TArr;
159+basis.f0 = f0Arr;
160+basis.str = strArr;
161+basis.stop = stopArr;
162+basis.f = f;
163+basis.zeta = zeta;
164+basis.Vm = Vm;
165+basis.Dd = Dd;
166+basis.Fcoef = Fcoef;
167+basis.ybAmp = ybAmp;
168+basis.bodyFrac = bodyFrac;
169+basis.PMflat = PMflat;
170+basis.n = length(f);
171+basis.bodyF = body.f;
172+basis.bodyZeta = body.zeta;
173+basis.bodyA = body.a;
174+basis.bodyS = body.S;
175+basis.bodyMono = body.mono;
176+end
matlab/bass_body.madded+43−0View file
@@ -0,0 +1,43 @@
1+function body = bass_body(P)
2+% BASS_BODY The parameterized modal body seen at the bridge.
3+%
4+% This is the empirical part of the model: the corpus of a double bass is
5+% not solved from plates and ribs here, it is a table of measured-plausible
6+% resonances, each reduced to what the string and the listener can see of
7+% it. Per mode: the frequency, the Q, the effective mass at the bridge foot
8+% (which sets the admittance the string is loaded by), the radiating area
9+% (the net volume the body displaces per unit bridge displacement in that
10+% mode), and whether it radiates as a monopole or a dipole. The first row
11+% is A0, the f-hole Helmholtz resonance: the one mode that moves net volume
12+% through the f-holes and therefore the one monopole; every corpus mode
13+% above it displaces the top plate against the surrounding air with no net
14+% flow and radiates as a dipole across the body. The table follows the
15+% published admittance and radiativity measurements of orchestral basses in
16+% shape (A0 near 60 Hz, the main wood resonances between 90 and 250 Hz, a
17+% falling effective area above), with the absolute levels set by the trims
18+% in bass_defaults.m. Modes above the table are NOT ignored: their
19+% collective resistive load enters as residual_y in bass_basis.m and their
20+% radiation as the direct term in bass_radiate.m.
21+%
22+% Mass-normalized amplitude at the bridge: a_j = sqrt(bridge_mobility/m_j),
23+% so bridge_mobility scales the whole admittance without retuning the table.
24+
25+fb = [60.0 97.0 113.0 135.0 167.0 205.0 252.0 310.0 382.0 470.0 578.0 711.0];
26+Qb = [25.0 28.0 30.0 30.0 28.0 26.0 24.0 22.0 20.0 19.0 18.0 17.0];
27+mb = [2.2 1.6 1.3 1.1 0.95 0.85 0.80 0.80 0.85 0.95 1.1 1.3]; % kg at the bridge
28+Sb = [0.040 0.032 0.028 0.024 0.020 0.016 0.013 0.011 0.009 0.0075 0.006 0.005]; % m^2
29+mono = [1 0 0 0 0 0 0 0 0 0 0 0];
30+
31+nb = length(fb);
32+f = reshape(fb, nb, 1);
33+f(1) = P.a0_hz;
34+f(2:nb) = f(2:nb) * P.body_trim;
35+
36+body.n = nb;
37+body.f = f;
38+body.omega = 2 * pi * f;
39+body.zeta = reshape(0.5 ./ (Qb * P.body_q_scale), nb, 1);
40+body.a = reshape(sqrt(P.bridge_mobility ./ mb), nb, 1); % 1/sqrt(kg)
41+body.S = reshape(Sb * P.rad_area_scale, nb, 1); % m^2
42+body.mono = reshape(mono, nb, 1);
43+end
matlab/bass_defaults.madded+64−0View file
@@ -0,0 +1,64 @@
1+function P = bass_defaults()
2+% BASS_DEFAULTS Default parameters, mirroring the web app's params.ts.
3+% The output duration is not a parameter: it is
4+% (note_count - 1) * 60 / tempo_bpm + tail_s.
5+
6+% Strings (1 = G2, 2 = D2, 3 = A1, 4 = E1), a 3/4-size double bass.
7+% Linear density is not a parameter: it follows from the tension and the
8+% open pitch, mu = T / (2 L f0)^2, which lands on realistic values
9+% (about 7 g/m for the G string, 42 g/m for the E).
10+P.scale_length_m = 1.04;
11+P.tension_g_n = 285.0;
12+P.tension_d_n = 290.0;
13+P.tension_a_n = 300.0;
14+P.tension_e_n = 310.0;
15+P.inharmonicity = 8.0e-5; % B of the open string: f_n ~ n f0 sqrt(1 + B n^2)
16+P.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)
17+
18+% String damping
19+P.loss_factor = 1.6e-3; % frequency-independent internal loss
20+P.loss_slope = 1.2e-7; % extra high-frequency loss, s
21+P.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)
22+P.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg
23+
24+% Body (see bass_body.m for the mode table these trims act on)
25+P.a0_hz = 60.0; % f-hole Helmholtz resonance
26+P.body_trim = 1.0; % scales the corpus mode frequencies
27+P.body_q_scale = 1.0; % scales the body mode Qs
28+P.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)
29+P.rad_area_scale = 1.0; % scales the radiating areas of the body modes
30+P.dipole_length_m = 0.35; % effective front-to-back path of the corpus dipoles
31+P.radiation_loss = 1.0; % scales the radiation damping added to the body Qs
32+
33+% Pluck
34+P.pluck_force_n = 15.0; % transverse pull at release
35+P.pluck_pos = 0.30; % fraction of the vibrating length, measured from the bridge
36+P.finger_release_ms = 0.9; % finite release time of the fingertip (spectral rolloff)
37+P.choke_ms = 45.0; % decay time constant applied when the next note lands
38+
39+% Gesture
40+P.root_midi = 33; % A1; the single note, and the key of a walking line
41+P.note_count = 1;
42+P.tempo_bpm = 96.0;
43+P.force_spread = 0.20; % lognormal spread of the pluck force across a line
44+P.timing_jitter_ms = 4.0;
45+P.seed = 1;
46+
47+% Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)
48+P.direct_gain = 1.0;
49+P.listener_x_m = 0.50;
50+P.listener_y_m = 0.30;
51+P.listener_z_m = 1.20;
52+P.speed_of_sound = 343.0;
53+P.air_density = 1.204;
54+
55+% Rendering / output
56+P.max_mode_hz = 3000.0;
57+P.sample_rate = 48000;
58+P.oversample = 2;
59+P.tail_s = 6.0;
60+P.pre_delay_ms = 3.0;
61+P.normalize = 1;
62+P.peak_dbfs = -3.0;
63+P.full_scale_pa = 2.0;
64+end
matlab/bass_demo.madded+22−0View file
@@ -0,0 +1,22 @@
1+% Render a pizzicato bass note with default parameters and print diagnostics.
2+% Set P.note_count = 8 for a walking line.
3+% Run with: numbl run bass_demo.m
4+
5+P = bass_defaults();
6+tic;
7+[y, basis, dinfo] = bass_render(P);
8+elapsed = toc;
9+
10+fprintf('notes : %d (midi ', dinfo.noteCount);
11+fprintf('%d ', dinfo.midi);
12+fprintf(')\n');
13+fprintf('fingerings : %d, %d coupled modes in total\n', dinfo.fingerings, dinfo.modesTotal);
14+fprintf('fundamental: %.2f Hz, T60 %.2f s (note 1)\n', dinfo.fundHz, dinfo.fundT60);
15+fprintf('body : A0 %.1f Hz, top corpus mode %.0f Hz\n', basis.bodyF(1), max(basis.bodyF));
16+fprintf('peak press.: %.4f Pa (%.1f dB SPL) at %.0f cm\n', ...
17+ dinfo.peakPa, dinfo.peakSplDb, dinfo.listenerDistM * 100);
18+fprintf('length : %.2f s @ %d Hz\n', length(y) / P.sample_rate, P.sample_rate);
19+fprintf('render time: %.2f s\n', elapsed);
20+if dinfo.listenerInside
21+ fprintf('WARNING : listener is at or inside the body\n');
22+end
matlab/bass_notes.madded+142−0View file
@@ -0,0 +1,142 @@
1+function [tOn, midi, str, stop, force, ninfo] = bass_notes(P)
2+% BASS_NOTES The gesture: a single note, or a seeded walking line.
3+%
4+% note_count = 1 gives one deterministic note at root_midi, plucked with
5+% pluck_force_n at t = 0; no random numbers are drawn. note_count > 1 gives
6+% a walking line: quarter notes at tempo_bpm on the major scale of the root,
7+% generated as a random walk with direction persistence (mostly stepwise,
8+% an occasional third, an occasional leap home to the root), reflected off
9+% the playable range E1 to G3. Pluck forces get a lognormal spread and the
10+% onsets after the first a Gaussian jitter. All randomness comes from
11+% bass_rng seeded with P.seed, so a given seed reproduces the identical
12+% line in the CLI, Node, and the browser.
13+%
14+% Each pitch is assigned a fingering the way a player would in low
15+% positions: the highest-tuned string whose open pitch does not exceed the
16+% note, so the stop stays small. Open strings come out as stop = 0, which
17+% bass_basis.m treats as an undamped (nut) termination.
18+%
19+% Returns row vectors sorted by time: tOn (s), midi (pitch), str (1 = G2,
20+% 2 = D2, 3 = A1, 4 = E1), stop (semitones above the open string), force
21+% (pluck force, N), and ninfo with summary statistics.
22+
23+K = max(1, round(P.note_count));
24+beat = 60 / max(20, P.tempo_bpm);
25+root = round(P.root_midi);
26+openMidi = [43 38 33 28]; % G2 D2 A1 E1
27+loMidi = 28; % E1
28+hiMidi = 55; % G3
29+
30+% ---- the scale grid: major-scale pitches of the key across the range ----
31+degs = [0 2 4 5 7 9 11];
32+grid = zeros(1, hiMidi - loMidi + 1);
33+ng = 0;
34+for m = loMidi:hiMidi
35+ rel = mod(m - root, 12);
36+ if sum(degs == rel) > 0
37+ ng = ng + 1;
38+ grid(ng) = m;
39+ end
40+end
41+grid = grid(1:ng);
42+
43+% index of the grid note nearest the root (the walk starts and leaps here)
44+rootIdx = 1;
45+best = 1e9;
46+for i = 1:ng
47+ d = abs(grid(i) - root);
48+ if d < best
49+ best = d;
50+ rootIdx = i;
51+ end
52+end
53+
54+% ---- draw the walk ----
55+state = mod(floor(abs(P.seed)), 4294967296);
56+uWalk = zeros(K, 1);
57+z1 = zeros(K, 1);
58+z2 = zeros(K, 1);
59+if K > 1
60+ [u, state] = bass_rng(state, 5 * K);
61+ U = reshape(u, K, 5);
62+ uWalk = U(:, 1);
63+ z1 = sqrt(-2 * log(U(:, 2))) .* cos(2 * pi * U(:, 3));
64+ z2 = sqrt(-2 * log(U(:, 4))) .* cos(2 * pi * U(:, 5));
65+end
66+
67+tOn = zeros(1, K);
68+midi = zeros(1, K);
69+str = zeros(1, K);
70+stop = zeros(1, K);
71+force = zeros(1, K);
72+idx = rootIdx;
73+dir = 1;
74+for i = 1:K
75+ if i > 1
76+ u = uWalk(i);
77+ if u < 0.50
78+ idx = idx + dir; % continue stepwise
79+ elseif u < 0.75
80+ dir = -dir; % turn around
81+ idx = idx + dir;
82+ elseif u < 0.90
83+ idx = idx + 2 * dir; % skip a third
84+ else
85+ idx = rootIdx; % leap home
86+ dir = 1;
87+ end
88+ if idx > ng
89+ idx = ng - (idx - ng);
90+ dir = -1;
91+ end
92+ if idx < 1
93+ idx = 1 + (1 - idx);
94+ dir = 1;
95+ end
96+ idx = min(ng, max(1, idx));
97+ end
98+ m = grid(idx);
99+ if K == 1
100+ m = min(hiMidi, max(loMidi, root));
101+ end
102+
103+ % fingering: highest-tuned string whose open pitch is at or below the note
104+ s = 4;
105+ for cand = 1:4
106+ if openMidi(cand) <= m
107+ s = cand;
108+ break;
109+ end
110+ end
111+
112+ t = (i - 1) * beat;
113+ if i > 1
114+ t = max(0, t + P.timing_jitter_ms * 1e-3 * z1(i));
115+ end
116+ fpl = P.pluck_force_n;
117+ if K > 1
118+ fpl = fpl * exp(P.force_spread * z2(i));
119+ end
120+
121+ tOn(i) = t;
122+ midi(i) = m;
123+ str(i) = s;
124+ stop(i) = m - openMidi(s);
125+ force(i) = max(0.1, fpl);
126+end
127+
128+[tOn, order] = sort(tOn);
129+tOn = reshape(tOn, 1, K);
130+midi = reshape(midi(order), 1, K);
131+str = reshape(str(order), 1, K);
132+stop = reshape(stop(order), 1, K);
133+force = reshape(force(order), 1, K);
134+
135+ninfo.count = K;
136+ninfo.durationS = (K - 1) * beat + P.tail_s;
137+ninfo.beatS = beat;
138+ninfo.meanForce = sum(force) / K;
139+ninfo.maxForce = max(force);
140+ninfo.loMidi = min(midi);
141+ninfo.hiMidi = max(midi);
142+end
matlab/bass_output.madded+66−0View file
@@ -0,0 +1,66 @@
1+function [y, peakPa] = bass_output(P, p, oversample)
2+% BASS_OUTPUT Anti-aliased decimation and output conditioning.
3+% Kaiser-windowed-sinc FIR lowpass at 0.45 * fs_lo, decimate by oversample,
4+% pre-roll, tail fade, then either normalize to peak_dbfs or divide by
5+% full_scale_pa. Identical in structure to the sibling instruments' output
6+% stages.
7+
8+peakPa = max(abs(p));
9+R = oversample;
10+T = length(p);
11+
12+if R > 1
13+ taps = 12 * R + 1;
14+ M2 = (taps - 1) / 2;
15+ fcNorm = 0.45 / R; % cycles per input sample
16+ m = (0:taps - 1) - M2;
17+ h = sin(2 * pi * fcNorm * m) ./ (pi * m);
18+ h(M2 + 1) = 2 * fcNorm; % sinc value at m = 0
19+ r = m / M2;
20+ beta = 8;
21+ h = h .* (bo_i0(beta * sqrt(max(0, 1 - r.^2))) / bo_i0(beta));
22+ h = h / sum(h); % unity DC gain
23+ pf = conv(p, h);
24+ outLen = floor(T / R);
25+ y = reshape(pf((0:outLen - 1) * R + M2 + 1), 1, outLen); % centered pick
26+else
27+ y = p;
28+end
29+
30+% Pre-roll, then truncate to the nominal duration
31+padN = floor(P.pre_delay_ms * 1e-3 * P.sample_rate);
32+K = max(1, round(P.note_count));
33+beat = 60 / max(20, P.tempo_bpm);
34+duration = (K - 1) * beat + P.tail_s;
35+total = floor(duration * P.sample_rate);
36+if padN > 0
37+ y = [zeros(1, padN) y];
38+end
39+y = y(1:min(length(y), total));
40+
41+% Squared fade on the tail
42+fade = min(2048, floor(length(y) / 8));
43+if fade > 1
44+ y(end - fade + 1:end) = y(end - fade + 1:end) .* linspace(1, 0, fade).^2;
45+end
46+
47+if P.normalize ~= 0
48+ pk = max(abs(y));
49+ if pk > 0
50+ y = y / pk * 10^(P.peak_dbfs / 20);
51+ end
52+else
53+ y = y / P.full_scale_pa;
54+end
55+end
56+
57+function v = bo_i0(x)
58+% Modified Bessel function of the first kind, order zero (series expansion).
59+v = ones(size(x));
60+term = ones(size(x));
61+hx = x / 2;
62+for k = 1:40
63+ term = term .* (hx / k).^2;
64+ v = v + term;
65+end
66+end
matlab/bass_radiate.madded+167−0View file
@@ -0,0 +1,167 @@
1+function [p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi)
2+% BASS_RADIATE Pluck projection, closed-form synthesis, and free-field
3+% pressure at the listener.
4+%
5+% The pluck is an initial condition, not a force history. Releasing a
6+% string pulled with force F at fraction pluck_pos of the vibrating length
7+% from the bridge leaves the classic triangular shape, whose sine
8+% coefficients are b_k = 2 F Lv sin(k pi xp/Lv) / (T pi^2 k^2); projected
9+% through PM = (M Phi)' from bass_basis.m these become initial amplitudes
10+% of the coupled modes, with zero initial velocity. The finite release from
11+% the fingertip is a first-order rolloff 1/sqrt(1 + (om tau)^2) applied to
12+% the coupled amplitudes (finger_release_ms), which is what separates a
13+% fleshy jazz pizzicato from a hard snap. From there every coupled mode is a
14+% damped exponential; nothing is integrated. When the next note lands, the
15+% previous note is choked: its poles pick up an extra decay 1/tau_choke
16+% (choke_ms), standing in for the player stopping the string, and the last
17+% note rings through the full tail.
18+%
19+% Radiation, per coupled mode at its own centre frequency (the modes are
20+% narrow-band):
21+% - the monopole volume Vm radiates from the two f-holes, half each, as
22+% point monopoles with their own retardations;
23+% - the dipole volume Dd radiates from the top-plate centre as a point
24+% dipole along the plate normal (+z), with the near-field 1/r term kept;
25+% - the direct term: above the retained body modes the bridge sees the
26+% resistive residual admittance residual_y, so the bridge force (Fcoef
27+% per unit modal amplitude) moves the plate broadband, and that motion
28+% radiates as a small dipole. This is how the high partials reach the
29+% listener at all, and it is the least principled term of the model;
30+% direct_gain scales it and 0 turns it off.
31+% The common carrier exp(-i k rc) to the bridge is stripped, so relative
32+% phase between the f-holes, the plate, and the listener position is kept
33+% while the overall propagation delay is not double counted.
34+%
35+% Returns p (1 x T, pascals at fsHi) and radinfo diagnostics.
36+
37+c = P.speed_of_sound;
38+rho0 = P.air_density;
39+K = length(tOn);
40+beat = 60 / max(20, P.tempo_bpm);
41+duration = (K - 1) * beat + P.tail_s;
42+T = floor(duration * fsHi);
43+dt = 1 / fsHi;
44+tauRel = P.finger_release_ms * 1e-3;
45+tauChoke = max(1e-3, P.choke_ms * 1e-3);
46+
47+% ---- source and listener geometry (origin at the bridge) ----
48+lx = P.listener_x_m;
49+ly = P.listener_y_m;
50+lz = P.listener_z_m;
51+rc = sqrt(lx^2 + ly^2 + lz^2);
52+fhX = 0.095; fhY = -0.02; fhZ = 0.11; % the two f-holes
53+dpX = 0.0; dpY = -0.10; dpZ = 0.11; % top-plate dipole centre
54+r1 = max(0.05, sqrt((lx - fhX)^2 + (ly - fhY)^2 + (lz - fhZ)^2));
55+r2 = max(0.05, sqrt((lx + fhX)^2 + (ly - fhY)^2 + (lz - fhZ)^2));
56+rd = max(0.05, sqrt((lx - dpX)^2 + (ly - dpY)^2 + (lz - dpZ)^2));
57+cosg = (lz - dpZ) / rd; % dipole axis is the plate normal +z
58+listenerInside = rc < 0.35;
59+Sdir = 0.006 * P.rad_area_scale;
60+
61+% ---- per-fingering radiation transfer, poles, and unit pluck amplitudes ----
62+nf = basis.nf;
63+ntot = basis.n;
64+G = zeros(ntot, 1) + 0i;
65+lam = zeros(ntot, 1) + 0i;
66+eta0u = zeros(ntot, 1); % per unit pluck force
67+for u = 1:nf
68+ n = basis.nModes(u);
69+ ns = basis.ns(u);
70+ Lv = basis.Lv(u);
71+ Tt = basis.T(u);
72+ mu = basis.mu(u);
73+ rows = basis.off(u) + (1:n);
74+ om = 2 * pi * basis.f(rows);
75+ zt = basis.zeta(rows);
76+
77+ ph1 = exp(-1i * (om / c) * (r1 - rc));
78+ ph2 = exp(-1i * (om / c) * (r2 - rc));
79+ phd = exp(-1i * (om / c) * (rd - rc));
80+ near = 1i * (om / c) + 1 / rd;
81+ Gm = -(om.^2 * rho0 / (4 * pi)) .* (basis.Vm(rows) / 2) .* (ph1 / r1 + ph2 / r2);
82+ Gd = -(om.^2 * rho0 / (4 * pi)) .* basis.Dd(rows) .* near .* phd * (cosg / rd);
83+ Gr = (1i * om * rho0 / (4 * pi)) * (Sdir * P.residual_y * P.dipole_length_m) ...
84+ .* basis.Fcoef(rows) .* near .* phd * (cosg / rd);
85+ G(rows) = Gm + Gd + P.direct_gain * Gr;
86+
87+ omd = om .* sqrt(max(1e-12, 1 - zt.^2));
88+ lam(rows) = -zt .* om + 1i * omd;
89+
90+ % unit-force pluck projection through the coupled shapes
91+ xp = Lv * (1 - min(0.95, max(0.05, P.pluck_pos)));
92+ kv = (1:ns)';
93+ bk = (2 * Lv / (Tt * pi^2)) * sin(kv * pi * xp / Lv) ./ kv.^2;
94+ q0 = bk * sqrt(mu * Lv / 2);
95+ PM = reshape(basis.PMflat(basis.pmOff(u) + (1:n * ns)), n, ns);
96+ e0 = PM * q0;
97+ e0 = e0 ./ sqrt(1 + (om * tauRel).^2); % finite finger release, first order
98+ eta0u(rows) = e0;
99+end
100+
101+% ---- per-note synthesis: damped exponentials, choked at the next onset ----
102+ks = min(T, max(1, round(tOn * fsHi) + 1));
103+p = zeros(1, T);
104+CH = 32768;
105+chokeSpan = ceil(10 * tauChoke * fsHi);
106+peakEta = 0;
107+for i = 1:K
108+ u = basis.fIdx(i);
109+ n = basis.nModes(u);
110+ rows = basis.off(u) + (1:n);
111+ om = 2 * pi * basis.f(rows);
112+ zt = basis.zeta(rows);
113+ omd = om .* sqrt(max(1e-12, 1 - zt.^2));
114+ cAmp = (force(i) * eta0u(rows)) .* (1 - 1i * (zt .* om ./ omd));
115+ s = G(rows) .* cAmp;
116+ lamN = lam(rows);
117+ peakEta = max(peakEta, force(i) * max(abs(eta0u(rows))));
118+
119+ if i < K
120+ segEnd = max(ks(i), ks(i + 1));
121+ stopAt = min(T + 1, segEnd + chokeSpan);
122+ else
123+ segEnd = T + 1;
124+ stopAt = T + 1;
125+ end
126+
127+ pos = ks(i);
128+ while pos < segEnd
129+ len = min(CH, segEnd - pos);
130+ seg = real((s.') * exp(lamN * ((0:len - 1) * dt)));
131+ p(pos:pos + len - 1) = p(pos:pos + len - 1) + seg;
132+ s = s .* exp(lamN * (len * dt));
133+ pos = pos + len;
134+ end
135+ if pos < stopAt
136+ lamC = lamN - 1 / tauChoke;
137+ while pos < stopAt
138+ len = min(CH, stopAt - pos);
139+ seg = real((s.') * exp(lamC * ((0:len - 1) * dt)));
140+ p(pos:pos + len - 1) = p(pos:pos + len - 1) + seg;
141+ s = s .* exp(lamC * (len * dt));
142+ pos = pos + len;
143+ end
144+ end
145+end
146+
147+% ---- diagnostics: the fundamental of the first note ----
148+u1 = basis.fIdx(1);
149+rows1 = basis.off(u1) + (1:basis.nModes(u1));
150+a1 = abs(eta0u(rows1));
151+best = 1;
152+bv = 0;
153+for m = 1:length(rows1)
154+ if a1(m) > bv
155+ bv = a1(m);
156+ best = m;
157+ end
158+end
159+f1 = basis.f(rows1(best));
160+z1 = basis.zeta(rows1(best));
161+radinfo.fundHz = f1;
162+radinfo.fundT60 = log(1000) / max(1e-9, z1 * 2 * pi * f1);
163+radinfo.listenerDistM = rc;
164+radinfo.listenerInside = listenerInside;
165+radinfo.gainAbs = abs(G);
166+radinfo.peakEta = peakEta;
167+end
matlab/bass_render.madded+30−0View file
@@ -0,0 +1,30 @@
1+function [y, basis, dinfo] = bass_render(P)
2+% BASS_RENDER Full render: notes -> basis -> radiate -> output.
3+% Convenience composition of the stage functions; the web app calls the
4+% stages separately so it can cache intermediates (the per-fingering
5+% eigensolves above all).
6+
7+[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
8+basis = bass_basis(P, str, stop);
9+fsHi = P.sample_rate * P.oversample;
10+[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi);
11+[y, peakPa] = bass_output(P, p, P.oversample);
12+
13+dinfo.noteCount = ninfo.count;
14+dinfo.durationS = ninfo.durationS;
15+dinfo.meanForce = ninfo.meanForce;
16+dinfo.maxForce = ninfo.maxForce;
17+dinfo.midi = midi;
18+dinfo.tOn = tOn;
19+dinfo.str = str;
20+dinfo.stop = stop;
21+dinfo.fingerings = basis.nf;
22+dinfo.modesTotal = basis.n;
23+dinfo.fundHz = radinfo.fundHz;
24+dinfo.fundT60 = radinfo.fundT60;
25+dinfo.peakPa = peakPa;
26+dinfo.peakSplDb = 20 * log10(peakPa / 20e-6 + 1e-30);
27+dinfo.listenerDistM = radinfo.listenerDistM;
28+dinfo.listenerInside = radinfo.listenerInside;
29+dinfo.ninfo = ninfo;
30+end
matlab/bass_rng.madded+12−0View file
@@ -0,0 +1,12 @@
1+function [r, state] = bass_rng(state, n)
2+% BASS_RNG Portable seeded uniform generator (32-bit LCG).
3+% Returns n uniforms in (0, 1) as a column vector, plus the advanced state.
4+% Deliberately not MATLAB's rand: the same seed must produce the identical
5+% walking line in the numbl CLI, Node, and the browser, and every double
6+% here stays below 2^53 so the arithmetic is exact everywhere.
7+r = zeros(n, 1);
8+for i = 1:n
9+ state = mod(state * 1664525 + 1013904223, 4294967296);
10+ r(i) = (state + 0.5) / 4294967296;
11+end
12+end
matlab/bass_validate.madded+197−0View file
@@ -0,0 +1,197 @@
1+% Validate the bass model. There is no external reference implementation,
2+% so the checks are (a) closed-form identities the physics must satisfy,
3+% (b) structural identities of the coupled eigensolve, (c) exact
4+% reproducibility of the seeded walking line, and (d) pinned end-to-end
5+% diagnostics from the default configuration, recorded when the model was
6+% built and treated as a regression reference since.
7+% Run with: numbl run bass_validate.m
8+
9+fails = 0;
10+
11+% ---- 1. decoupled stiff string against the closed form ----
12+% With the bridge mobility driven to zero the body cannot move, and the
13+% coupled solve must hand back the textbook pinned-pinned stiff string,
14+% f_k = sqrt((k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu) / 2 pi, including the
15+% inharmonic stretch. Checked on the open A with an exaggerated B.
16+P = bass_defaults();
17+Q = P;
18+Q.bridge_mobility = 1e-9;
19+Q.inharmonicity = 2e-4;
20+basis = bass_basis(Q, 3, 0);
21+[~, ~, omS, ~, meta] = bass_assemble(Q, 3, 0);
22+errS = 0;
23+for k = 1:10
24+ fClosed = omS(k) / (2 * pi);
25+ fGot = bv_nearest(basis.f, fClosed);
26+ errS = max(errS, abs(fGot - fClosed) / fClosed);
27+end
28+stretch = (omS(10) / (10 * omS(1))) - 1;
29+ok = errS < 1e-6 && stretch > 0.005;
30+fails = fails + (~ok);
31+fprintf('stiff string: err %.1e vs closed form, partial-10 stretch %.3f %s\n', ...
32+ errS, stretch, bv_verdict(ok));
33+
34+% ---- 2. eigensolve identities on the full coupled system ----
35+% The Cholesky reduction must hand back mass-orthonormal shapes that
36+% diagonalize the stiffness: Phi' M Phi = I and Phi' K Phi = diag(om^2).
37+% Checked on the open E, the largest system of the default chart.
38+[M, Kmat, ~, ~, meta] = bass_assemble(P, 4, 0);
39+Rc = chol(M);
40+Am = (Rc') \ (Kmat / Rc);
41+Am = (Am + Am') / 2;
42+[Uv, Sv, ~] = svd(Am);
43+om2 = flipud(diag(Sv));
44+Phi = Rc \ fliplr(Uv);
45+eOrth = max(max(abs(Phi' * M * Phi - eye(meta.n))));
46+KP = Phi' * Kmat * Phi;
47+eDiag = max(max(abs(KP - diag(om2)))) / max(om2);
48+ok = eOrth < 1e-8 && eDiag < 1e-10;
49+fails = fails + (~ok);
50+fprintf('eigensolve: orthonormality %.1e, diagonalization %.1e (n = %d) %s\n', ...
51+ eOrth, eDiag, meta.n, bv_verdict(ok));
52+
53+% ---- 3. a midpoint pluck silences the even partials ----
54+% b_k ~ sin(k pi xp/Lv): plucking at the middle of the vibrating length
55+% puts every even string mode at a node of the initial shape, so the
56+% second partial must all but vanish from the rendered pressure, while an
57+% ordinary pluck position leaves it strong. This exercises the projection,
58+% the synthesis, and the radiation end to end.
59+Q = P;
60+Q.normalize = 0;
61+Q.tail_s = 3.0;
62+Q.pluck_pos = 0.5;
63+[y1, b1, d1] = bass_render(Q);
64+fs = Q.sample_rate;
65+f1 = d1.fundHz;
66+f2 = bv_nearest(b1.f, 2 * f1);
67+relMid = 20 * log10(bv_level(y1, fs, f2) / bv_level(y1, fs, f1) + 1e-15);
68+Q.pluck_pos = 0.30;
69+[y2, b2, d2] = bass_render(Q);
70+f2b = bv_nearest(b2.f, 2 * d2.fundHz);
71+relOrd = 20 * log10(bv_level(y2, fs, f2b) / bv_level(y2, fs, d2.fundHz) + 1e-15);
72+ok = relMid < -35 && relOrd > -25;
73+fails = fails + (~ok);
74+fprintf('midpoint pluck: partial 2 at %.1f dB (ordinary pluck %.1f dB) %s\n', ...
75+ relMid, relOrd, bv_verdict(ok));
76+
77+% ---- 4. two-way coupling: veering at a string-body coincidence ----
78+% Tuning the strongest corpus mode onto the open G fundamental must split
79+% it into a mixed pair (this is the wolf-note mechanism), and the splitting
80+% must grow as the square root of the bridge mobility, which is what a
81+% two-way coupled pair does and a one-way filter does not.
82+d05 = bv_split(P, 0.5);
83+d20 = bv_split(P, 2.0);
84+ratio = d20.split / d05.split;
85+ok = abs(ratio - 2) < 0.25 && d20.mix > 0.15;
86+fails = fails + (~ok);
87+fprintf('veering: split %.2f -> %.2f Hz for 4x mobility (ratio %.2f, mix %.2f) %s\n', ...
88+ d05.split, d20.split, ratio, d20.mix, bv_verdict(ok));
89+
90+% ---- 5. far-field falloff: -6 dB per doubling of distance ----
91+% RMS over the render, with the two listeners on the same ray from the
92+% bridge so the dipole directivity is held fixed. At the fundamental the
93+% dipole near-field term still contributes a few hundredths of a dB at
94+% 4 m, which is physics rather than error; the tolerance covers it.
95+Q = P;
96+Q.normalize = 0;
97+Q.tail_s = 2.5;
98+Q.listener_x_m = 1.2; Q.listener_y_m = 0.75; Q.listener_z_m = 3.0;
99+[ya, ~, ~] = bass_render(Q);
100+Q.listener_x_m = 2.4; Q.listener_y_m = 1.5; Q.listener_z_m = 6.0;
101+[yb, ~, ~] = bass_render(Q);
102+drop = 10 * log10(sum(yb.^2) / sum(ya.^2));
103+ok = abs(drop + 6.02) < 0.35;
104+fails = fails + (~ok);
105+fprintf('falloff 3.3 m -> 6.6 m: %.2f dB rms (target -6.02) %s\n', drop, bv_verdict(ok));
106+
107+% ---- 6. the seeded walking line is exactly reproducible ----
108+Q = P;
109+Q.note_count = 8;
110+[t1, m1, s1, st1, fo1, g1] = bass_notes(Q);
111+[t2, m2, s2, st2, fo2, g2] = bass_notes(Q);
112+same = max(abs(t1 - t2)) == 0 && max(abs(m1 - m2)) == 0 ...
113+ && max(abs(s1 - s2)) == 0 && max(abs(fo1 - fo2)) == 0;
114+ok = same ...
115+ && g1.count == 8 ...
116+ && sum(m1) == 293 ...
117+ && abs(g1.meanForce - 14.014948614) < 1e-6 ...
118+ && abs(t1(2) - 0.623242473696) < 1e-9;
119+fails = fails + (~ok);
120+fprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\n', ...
121+ Q.seed, g1.count, sum(m1), g1.meanForce, bv_verdict(ok));
122+
123+% ---- 7. pinned end-to-end diagnostics at the defaults ----
124+[y, basis, dinfo] = bass_render(P);
125+ok = abs(dinfo.peakPa - 0.370774) < 0.001 ...
126+ && abs(dinfo.fundHz - 54.5678) < 0.005 ...
127+ && basis.n == 61;
128+fails = fails + (~ok);
129+fprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\n', ...
130+ dinfo.peakPa, 0.370774, dinfo.fundHz, 54.5678, basis.n, bv_verdict(ok));
131+
132+% ---- 8. the note decays ----
133+% An open-string pizzicato rings for seconds, so the tail is not silent;
134+% but the energy in the last half second must sit well below the opening
135+% half second, which carries the attack.
136+fs = P.sample_rate;
137+n = length(y);
138+head = y(1:round(0.5 * fs));
139+tail = y(n - round(0.5 * fs):n);
140+dropE = 10 * log10(sum(tail.^2) / sum(head.^2));
141+ok = dropE < -15;
142+fails = fails + (~ok);
143+fprintf('note decay: last/first half second %.1f dB %s\n', dropE, bv_verdict(ok));
144+
145+if fails > 0
146+ fprintf('%d CHECK(S) FAILED\n', fails);
147+else
148+ fprintf('all checks passed\n');
149+end
150+
151+function fv = bv_nearest(fvec, target)
152+% The entry of fvec nearest target.
153+fv = fvec(1);
154+best = abs(fvec(1) - target);
155+for i = 2:length(fvec)
156+ d = abs(fvec(i) - target);
157+ if d < best
158+ best = d;
159+ fv = fvec(i);
160+ end
161+end
162+end
163+
164+function a = bv_level(y, fs, f)
165+% Amplitude of y at frequency f over the first two seconds (plain
166+% quadrature dot products; the window is long against 1/f).
167+n = min(length(y), floor(2.0 * fs));
168+t = (0:n - 1) / fs;
169+seg = y(1:n);
170+a = sqrt((seg * (cos(2 * pi * f * t))')^2 + (seg * (sin(2 * pi * f * t))')^2);
171+end
172+
173+function r = bv_split(P, mobility)
174+% Frequency splitting of the coupled pair when the strongest corpus mode
175+% is tuned onto the open G fundamental, plus how mixed the pair is.
176+Q = P;
177+Q.bridge_mobility = mobility;
178+Q.body_trim = 97.9989 / 97.0;
179+basis = bass_basis(Q, 1, 0);
180+f0 = 97.9989;
181+% the two coupled modes nearest the coincidence
182+d = abs(basis.f - f0);
183+[~, order] = sort(d);
184+i1 = order(1);
185+i2 = order(2);
186+r.split = abs(basis.f(i1) - basis.f(i2));
187+r.mix = min(min(basis.bodyFrac(i1), 1 - basis.bodyFrac(i1)), ...
188+ min(basis.bodyFrac(i2), 1 - basis.bodyFrac(i2)));
189+end
190+
191+function s = bv_verdict(ok)
192+if ok
193+ s = 'ok';
194+else
195+ s = 'MISMATCH';
196+end
197+end
webapp/.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules/
2+dist/
webapp/README.mdadded+45−0View file
@@ -0,0 +1,45 @@
1+# Standup Bass Simulator — web app
2+
3+Interactive browser implementation of the physical model described in `../README.md`. The algorithm itself is written in MATLAB syntax, in `../matlab/*.m`, and executed client-side by [numbl](https://numbl.org) inside a Web Worker; the TypeScript here is only the UI, the plots, the WAV encoder, and the marshalling of parameters and tensors in and out of the numbl runtime.
4+
5+The signal chain: a single pizzicato note or a seeded walking line (`Notes` above 1), one coupled string-body eigensolve per fingering, a pluck released as an initial condition on the coupled modes, closed-form damped-exponential synthesis with each note choked as the next lands, radiation through the f-hole monopoles and top-plate dipoles, and Kaiser-windowed anti-aliased decimation to 48 kHz.
6+
7+## Usage
8+
9+```
10+npm install
11+npm run dev # development server
12+npm run build # production build in dist/
13+npm run selftest # run ../matlab/bass_validate.m through numbl in Node
14+```
15+
16+The self-test runs the closed-form and reproducibility checks and the pinned diagnostics. The MATLAB code can also be run directly: `cd ../matlab && numbl run bass_demo.m` (or `bass_validate.m`).
17+
18+## Why it renders fast
19+
20+Nothing is integrated. A pluck is an initial condition on the coupled modes, so the synthesis evaluates `exp(lambda t)` over each note's span in closed form (chunked only to bound memory) and applies the choke as a change of pole when the next note lands. The cost that remains is one eigensolve per fingering of the gesture, a fraction of a second each, and that is exactly the piece the worker caches: plucking harder, moving the pluck point, changing the tempo, or moving the microphone reuses the cached modes.
21+
22+## Reading and editing the code in the app
23+
24+The Code button opens the MATLAB source in a CodeMirror editor with MATLAB syntax highlighting. Every `.m` file in `../matlab` is listed, tagged by whether the app executes it or it is a CLI entry point. The sources are editable: an edit re-renders after the usual debounce, so changing the physics in the editor is heard and plotted the same way moving a slider is. Zeroing the mass-coupling term `gk` in `bass_assemble.m`, for instance, decouples the string from the body and leaves the note quiet, harmonic, and endless.
25+
26+Edits live in memory only. They are deliberately not persisted, so a reload restores the code the app shipped with; "Revert file" and "Revert all" undo them sooner, and a dot marks the files that differ from the shipped source. A fault in edited code is reported with its file and line (numbl skips a file it cannot parse and then fails at the call site, so the worker captures the parse warning and reports that instead), the last successful render stays on screen, and reverting recovers.
27+
28+## How rendering is tiered
29+
30+Changing any parameter triggers a debounced fast preview (oversample 1); the Render button runs the full-quality pass. While the current result is a preview, the Play and Download buttons and the status badge say so. The worker runs each stage as a separate numbl execution and keys its caches by tier: the strings, the body, the damping, and the gesture's pitches invalidate the coupled eigensolves; the pluck, the tempo, and the listener only re-run the projection, the synthesis, and the radiation; pre-delay, normalization, and bit depth only re-run the output stage. Intermediate tensors stay inside the worker as numbl RuntimeValues. Because the eigensolves do not depend on the synthesis rate, previews reuse the full-quality mode basis.
31+
32+The New seed button redraws the walking line without touching any other parameter (a single note is deterministic, so the button is disabled there). The Loop button repeats the render continuously, swapping in each new render as parameters change. Parameters persist in localStorage; "Reset to defaults" restores the values in `src/params.ts` (which `../matlab/bass_defaults.m` mirrors).
33+
34+## Layout
35+
36+- `../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
37+- `src/params.ts` — parameter schema, defaults, tier assignment, persistence
38+- `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
40+- `src/components/` — 3D scene (three.js), parameter panel, code panel and editor, plots (notes, waveform, spectrum, spectrogram, bridge admittance, coupled-mode map)
41+- `src/selftest.ts` — Node harness for the MATLAB validation script
42+
43+## A note on the seeded generator
44+
45+The walking line draws from a 32-bit LCG in `bass_rng.m` rather than MATLAB's `rand`, so a given seed produces the identical line in the numbl CLI, in Node, and in the browser. That is what lets the self-test pin exact onset times and pluck forces.
webapp/index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎻</text></svg>" />
7+ <title>Standup Bass Simulator</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
webapp/package-lock.jsonadded+4230−0View file
This diff is 4,235 lines long and is not shown.
webapp/package.jsonadded+34−0View file
@@ -0,0 +1,34 @@
1+{
2+ "name": "bass-webapp",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc --noEmit && vite build",
9+ "preview": "vite preview",
10+ "selftest": "tsx src/selftest.ts"
11+ },
12+ "dependencies": {
13+ "@codemirror/commands": "^6.11.0",
14+ "@codemirror/language": "^6.12.4",
15+ "@codemirror/legacy-modes": "^6.5.3",
16+ "@codemirror/state": "^6.7.1",
17+ "@codemirror/theme-one-dark": "^6.1.3",
18+ "@codemirror/view": "^6.43.9",
19+ "codemirror": "^6.0.2",
20+ "numbl": "^0.4.19",
21+ "react": "^18.3.1",
22+ "react-dom": "^18.3.1",
23+ "three": "^0.168.0"
24+ },
25+ "devDependencies": {
26+ "@types/react": "^18.3.5",
27+ "@types/react-dom": "^18.3.0",
28+ "@types/three": "^0.168.0",
29+ "@vitejs/plugin-react": "^4.3.1",
30+ "tsx": "^4.19.0",
31+ "typescript": "^5.5.4",
32+ "vite": "^5.4.3"
33+ }
34+}
webapp/src/App.tsxadded+279−0View file
@@ -0,0 +1,279 @@
1+import { useCallback, useEffect, useRef, useState } from 'react';
2+import { DEFAULTS, loadParams, saveParams, type BassParams } from './params';
3+import { DEFAULT_MFILES, type MFile } from './mfiles';
4+import { useRenderer } from './hooks/useRenderer';
5+import { encodeWav } from './lib/wav';
6+import ParamPanel from './components/ParamPanel';
7+import CodePanel from './components/CodePanel';
8+import Scene3D from './components/Scene3D';
9+import DiagnosticsPanel from './components/DiagnosticsPanel';
10+import WaveformPlot from './components/plots/WaveformPlot';
11+import SpectrumPlot from './components/plots/SpectrumPlot';
12+import SpectrogramPlot from './components/plots/SpectrogramPlot';
13+import NotesPlot from './components/plots/NotesPlot';
14+import ModeMap from './components/plots/ModeMap';
15+import AdmittancePlot from './components/plots/AdmittancePlot';
16+
17+export default function App() {
18+ const [params, setParams] = useState<BassParams>(() => loadParams());
19+ // Editable copy of the MATLAB implementation. Deliberately not persisted:
20+ // a reload restores the code the app shipped with.
21+ const [mfiles, setMfiles] = useState<MFile[]>(() => DEFAULT_MFILES.map((f) => ({ ...f })));
22+ const [codeOpen, setCodeOpen] = useState(false);
23+ const { result, error, busy, stage, progress, renderFull } = useRenderer(params, mfiles);
24+ const audioCtxRef = useRef<AudioContext | null>(null);
25+
26+ useEffect(() => saveParams(params), [params]);
27+
28+ const onChange = useCallback((key: keyof BassParams, value: number | boolean) => {
29+ setParams((p) => ({ ...p, [key]: value }));
30+ }, []);
31+
32+ const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
33+
34+ // A new seed redraws the walking line without touching anything else. For
35+ // a single note nothing is random, so the button is disabled there.
36+ const reseed = useCallback(
37+ () => setParams((p) => ({ ...p, seed: 1 + Math.floor(Math.random() * 9999) })),
38+ [],
39+ );
40+
41+ const onChangeFile = useCallback((name: string, source: string) => {
42+ setMfiles((fs) => fs.map((f) => (f.name === name ? { ...f, source } : f)));
43+ }, []);
44+
45+ const onResetFile = useCallback((name: string) => {
46+ setMfiles((fs) =>
47+ fs.map((f) =>
48+ f.name === name
49+ ? { ...f, source: DEFAULT_MFILES.find((d) => d.name === name)?.source ?? f.source }
50+ : f,
51+ ),
52+ );
53+ }, []);
54+
55+ const onResetAllFiles = useCallback(() => setMfiles(DEFAULT_MFILES.map((f) => ({ ...f }))), []);
56+ const codeEdited = mfiles.some(
57+ (f) => f.source !== DEFAULT_MFILES.find((d) => d.name === f.name)?.source,
58+ );
59+
60+ const [looping, setLooping] = useState(false);
61+ const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
62+
63+ const getAudioCtx = useCallback((sampleRate: number) => {
64+ let ctx = audioCtxRef.current;
65+ if (!ctx || ctx.state === 'closed') {
66+ ctx = new AudioContext({ sampleRate });
67+ audioCtxRef.current = ctx;
68+ }
69+ void ctx.resume();
70+ return ctx;
71+ }, []);
72+
73+ const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
74+ const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
75+ buf.copyToChannel(new Float32Array(r.y), 0);
76+ return buf;
77+ }, []);
78+
79+ const play = useCallback(() => {
80+ if (!result) return;
81+ const ctx = getAudioCtx(result.sampleRate);
82+ const src = ctx.createBufferSource();
83+ src.buffer = makeBuffer(ctx, result);
84+ src.connect(ctx.destination);
85+ src.start();
86+ }, [result, getAudioCtx, makeBuffer]);
87+
88+ // Loop mode: repeat the note or line continuously, swapping in each new
89+ // render as parameters change so edits are audible without pressing Play.
90+ useEffect(() => {
91+ if (!looping || !result) return;
92+ const ctx = getAudioCtx(result.sampleRate);
93+ const src = ctx.createBufferSource();
94+ src.buffer = makeBuffer(ctx, result);
95+ src.loop = true;
96+ src.connect(ctx.destination);
97+ src.start();
98+ loopSrcRef.current = src;
99+ return () => {
100+ loopSrcRef.current = null;
101+ try {
102+ src.stop();
103+ } catch {
104+ // already stopped
105+ }
106+ src.disconnect();
107+ };
108+ }, [looping, result, getAudioCtx, makeBuffer]);
109+
110+ const download = useCallback(() => {
111+ if (!result) return;
112+ const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
113+ const url = URL.createObjectURL(blob);
114+ const a = document.createElement('a');
115+ a.href = url;
116+ a.download = result.quality === 'full' ? 'bass.wav' : 'bass_preview.wav';
117+ a.click();
118+ setTimeout(() => URL.revokeObjectURL(url), 5000);
119+ }, [result, params.bit_depth]);
120+
121+ return (
122+ <div className="app">
123+ <div className="topbar">
124+ <h1>Standup Bass Simulator</h1>
125+ <button className="primary" onClick={renderFull} disabled={busy}>
126+ Render
127+ </button>
128+ <button
129+ className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
130+ onClick={play}
131+ disabled={!result}
132+ title={
133+ result?.quality === 'preview'
134+ ? 'Playing the fast preview render; press Render for full quality'
135+ : 'Play the rendered note or walking line'
136+ }
137+ >
138+ ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
139+ </button>
140+ <button
141+ className={`loop ${looping ? 'active' : ''}`}
142+ onClick={() => setLooping((v) => !v)}
143+ disabled={!result && !looping}
144+ title="Repeat the render continuously so parameter changes are audible immediately"
145+ >
146+ ⟳ Loop{looping ? ' on' : ''}
147+ </button>
148+ <button
149+ onClick={reseed}
150+ disabled={params.note_count < 2}
151+ title="Draw a new walking line from a new random seed (lines only)"
152+ >
153+ ⚄ New seed
154+ </button>
155+ <button onClick={download} disabled={!result}>
156+ Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
157+ </button>
158+ <button onClick={reset}>Reset to defaults</button>
159+ <button
160+ className={codeOpen ? 'code active' : 'code'}
161+ onClick={() => setCodeOpen((v) => !v)}
162+ title="View and edit the MATLAB source that numbl executes"
163+ >
164+ {'{ }'} Code{codeEdited ? ' •' : ''}
165+ </button>
166+ <div className="status">
167+ {error && <span className="badge err">code error</span>}
168+ {result && (
169+ <span className={`badge ${result.quality}`}>
170+ {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
171+ </span>
172+ )}
173+ {busy ? (
174+ <>
175+ <span>{stage}…</span>
176+ <div className="progressbar">
177+ <div style={{ width: `${Math.round(progress * 100)}%` }} />
178+ </div>
179+ </>
180+ ) : (
181+ <span>ready</span>
182+ )}
183+ </div>
184+ </div>
185+ <div className="main">
186+ <div className="sidebar">
187+ <ParamPanel params={params} onChange={onChange} />
188+ </div>
189+ <div className="content">
190+ {error && (
191+ <div className="card wide error-card">
192+ <h2>MATLAB error</h2>
193+ <div>
194+ <strong>
195+ {error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}
196+ </strong>{' '}
197+ {error.message}
198+ </div>
199+ <div className="code-hint">
200+ Showing the last successful render. Open Code to fix the source, or revert it.
201+ </div>
202+ </div>
203+ )}
204+ <div className="card wide">
205+ <h2>Geometry — bass, pluck, microphone</h2>
206+ <Scene3D params={params} />
207+ </div>
208+ {result && (
209+ <>
210+ <div className="card">
211+ <h2>Diagnostics</h2>
212+ <DiagnosticsPanel result={result} />
213+ </div>
214+ <div className="card">
215+ <h2>Notes</h2>
216+ <NotesPlot
217+ noteT={result.noteT}
218+ noteMidi={result.noteMidi}
219+ noteStr={result.noteStr}
220+ noteForce={result.noteForce}
221+ durationS={result.diag.durationS}
222+ />
223+ </div>
224+ <div className="card">
225+ <h2>Waveform</h2>
226+ <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
227+ </div>
228+ <div className="card">
229+ <h2>Spectrum</h2>
230+ <SpectrumPlot
231+ y={result.y}
232+ sampleRate={result.sampleRate}
233+ modeF={result.mode1F}
234+ modeBodyFrac={result.mode1BodyFrac}
235+ />
236+ </div>
237+ <div className="card">
238+ <h2>Spectrogram</h2>
239+ <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
240+ </div>
241+ <div className="card">
242+ <h2>Bridge admittance</h2>
243+ <AdmittancePlot
244+ bodyF={result.bodyF}
245+ bodyZeta={result.bodyZeta}
246+ bodyA={result.bodyA}
247+ bodyMono={result.bodyMono}
248+ noteMidi={result.noteMidi}
249+ tuningTrim={params.tuning_trim}
250+ residualY={params.residual_y}
251+ />
252+ </div>
253+ <div className="card wide">
254+ <h2>Coupled modes</h2>
255+ <ModeMap
256+ modeF={result.mode1F}
257+ modeZeta={result.mode1Zeta}
258+ modeBodyFrac={result.mode1BodyFrac}
259+ />
260+ </div>
261+ </>
262+ )}
263+ </div>
264+ </div>
265+ {codeOpen && (
266+ <CodePanel
267+ mfiles={mfiles}
268+ onChangeFile={onChangeFile}
269+ onResetFile={onResetFile}
270+ onResetAll={onResetAllFiles}
271+ onClose={() => setCodeOpen(false)}
272+ onRenderFull={renderFull}
273+ error={error}
274+ busy={busy}
275+ />
276+ )}
277+ </div>
278+ );
279+}
webapp/src/components/CodeEditor.tsxadded+91−0View file
@@ -0,0 +1,91 @@
1+// CodeMirror 6 editor for one .m file. The parent gives each instance a key
2+// of the file name, so switching files remounts rather than syncing docs.
3+
4+import { useEffect, useRef } from 'react';
5+import { EditorState } from '@codemirror/state';
6+import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view';
7+import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
8+import { StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language';
9+import type { StreamParser } from '@codemirror/language';
10+import { octave } from '@codemirror/legacy-modes/mode/octave';
11+import { tags as t } from '@lezer/highlight';
12+
13+// The octave mode's keyword list is missing these MATLAB keywords, so promote
14+// them after it has tokenized the word.
15+const EXTRA_KEYWORDS = new Set(['function', 'break', 'parfor', 'spmd']);
16+
17+const matlabMode: StreamParser<unknown> = {
18+ ...octave,
19+ token(stream, state) {
20+ const start = stream.pos;
21+ const tok = octave.token(stream, state);
22+ if (EXTRA_KEYWORDS.has(stream.string.slice(start, stream.pos))) return 'keyword';
23+ return tok;
24+ },
25+};
26+
27+const matlabHighlight = HighlightStyle.define([
28+ { tag: t.keyword, color: '#e8a33d' },
29+ { tag: t.comment, color: '#6b7a86', fontStyle: 'italic' },
30+ { tag: t.string, color: '#a5d6a7' },
31+ { tag: t.number, color: '#4dd0e1' },
32+ { tag: t.operator, color: '#c9a0dc' },
33+ { tag: t.variableName, color: '#d6dae2' },
34+ { tag: t.standard(t.variableName), color: '#82b1ff' }, // builtins: zeros, sqrt, ...
35+ { tag: t.function(t.variableName), color: '#82b1ff' },
36+ { tag: t.atom, color: '#4dd0e1' },
37+ { tag: t.meta, color: '#c9a0dc' },
38+ { tag: t.invalid, color: '#e57373' },
39+]);
40+
41+const editorTheme = EditorView.theme(
42+ {
43+ '&': { color: '#d6dae2', backgroundColor: '#181b20', height: '100%' },
44+ '.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '12.5px' },
45+ '.cm-gutters': { backgroundColor: '#181b20', color: '#4d5560', border: 'none' },
46+ '.cm-activeLine': { backgroundColor: '#20242b' },
47+ '.cm-cursor': { borderLeftColor: '#e8a33d' },
48+ '&.cm-focused': { outline: 'none' },
49+ '.cm-scroller': { overflow: 'auto' },
50+ '.cm-selectionBackground, ::selection': { backgroundColor: '#31465a' },
51+ '&.cm-focused .cm-selectionBackground': { backgroundColor: '#31465a' },
52+ },
53+ { dark: true },
54+);
55+
56+interface Props {
57+ source: string;
58+ onChange: (source: string) => void;
59+}
60+
61+export default function CodeEditor({ source, onChange }: Props) {
62+ const hostRef = useRef<HTMLDivElement | null>(null);
63+ const onChangeRef = useRef(onChange);
64+ onChangeRef.current = onChange;
65+
66+ useEffect(() => {
67+ const host = hostRef.current!;
68+ const view = new EditorView({
69+ parent: host,
70+ state: EditorState.create({
71+ doc: source,
72+ extensions: [
73+ lineNumbers(),
74+ highlightActiveLine(),
75+ history(),
76+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
77+ StreamLanguage.define(matlabMode),
78+ syntaxHighlighting(matlabHighlight),
79+ editorTheme,
80+ EditorView.updateListener.of((u) => {
81+ if (u.docChanged) onChangeRef.current(u.state.doc.toString());
82+ }),
83+ ],
84+ }),
85+ });
86+ return () => view.destroy();
87+ // eslint-disable-next-line react-hooks/exhaustive-deps
88+ }, []);
89+
90+ return <div className="code-editor" ref={hostRef} />;
91+}
webapp/src/components/CodePanel.tsxadded+109−0View file
@@ -0,0 +1,109 @@
1+// Overlay for reading and editing the MATLAB implementation. Edits take
2+// effect on the next debounced render and live only as long as the page.
3+
4+import { useState } from 'react';
5+import { DEFAULT_MFILES, EXECUTED_BY_APP, type MFile } from '../mfiles';
6+import type { RenderError } from '../hooks/useRenderer';
7+import CodeEditor from './CodeEditor';
8+
9+interface Props {
10+ mfiles: MFile[];
11+ onChangeFile: (name: string, source: string) => void;
12+ onResetFile: (name: string) => void;
13+ onResetAll: () => void;
14+ onClose: () => void;
15+ onRenderFull: () => void;
16+ error: RenderError | null;
17+ busy: boolean;
18+}
19+
20+function defaultSourceOf(name: string): string {
21+ return DEFAULT_MFILES.find((f) => f.name === name)?.source ?? '';
22+}
23+
24+export default function CodePanel({
25+ mfiles,
26+ onChangeFile,
27+ onResetFile,
28+ onResetAll,
29+ onClose,
30+ onRenderFull,
31+ error,
32+ busy,
33+}: Props) {
34+ const [selected, setSelected] = useState(
35+ mfiles.find((f) => EXECUTED_BY_APP.has(f.name))?.name ?? mfiles[0]?.name ?? '',
36+ );
37+ const file = mfiles.find((f) => f.name === selected) ?? mfiles[0];
38+ const dirty = (f: MFile) => f.source !== defaultSourceOf(f.name);
39+ const anyDirty = mfiles.some(dirty);
40+
41+ return (
42+ <div className="modal-backdrop" onClick={onClose}>
43+ <div className="code-panel" onClick={(e) => e.stopPropagation()}>
44+ <div className="code-header">
45+ <h2>MATLAB source</h2>
46+ <span className="code-note">
47+ Executed by numbl. Edits re-render as you type and are discarded on reload.
48+ </span>
49+ <button onClick={onRenderFull} disabled={busy}>
50+ Render full quality
51+ </button>
52+ <button onClick={onResetAll} disabled={!anyDirty}>
53+ Revert all
54+ </button>
55+ <button onClick={onClose}>Close</button>
56+ </div>
57+ <div className="code-body">
58+ <div className="code-files">
59+ {mfiles.map((f) => (
60+ <button
61+ key={f.name}
62+ className={f.name === file?.name ? 'active' : ''}
63+ onClick={() => setSelected(f.name)}
64+ >
65+ <span className="fname">
66+ {f.name}
67+ {dirty(f) ? <span className="dot" title="edited" /> : null}
68+ </span>
69+ <span className="ftag">{EXECUTED_BY_APP.has(f.name) ? 'in app' : 'CLI'}</span>
70+ </button>
71+ ))}
72+ </div>
73+ <div className="code-main">
74+ {file && (
75+ <>
76+ <div className="code-toolbar">
77+ <span>{file.name}</span>
78+ {!EXECUTED_BY_APP.has(file.name) && (
79+ <span className="code-hint">
80+ entry point for the numbl CLI; the app does not run this file
81+ </span>
82+ )}
83+ <button
84+ onClick={() => onResetFile(file.name)}
85+ disabled={!dirty(file)}
86+ style={{ marginLeft: 'auto' }}
87+ >
88+ Revert file
89+ </button>
90+ </div>
91+ <CodeEditor
92+ key={file.name}
93+ source={file.source}
94+ onChange={(src) => onChangeFile(file.name, src)}
95+ />
96+ </>
97+ )}
98+ </div>
99+ </div>
100+ {error && (
101+ <div className="code-error">
102+ <strong>{error.file ? `${error.file}${error.line ? `:${error.line}` : ''}` : 'error'}</strong>{' '}
103+ {error.message}
104+ </div>
105+ )}
106+ </div>
107+ </div>
108+ );
109+}
webapp/src/components/DiagnosticsPanel.tsxadded+69−0View file
@@ -0,0 +1,69 @@
1+import { midiHz, midiName } from '../params';
2+import type { RenderResponse } from '../worker/renderWorker';
3+
4+export default function DiagnosticsPanel({ result }: { result: RenderResponse }) {
5+ const d = result.diag;
6+ const pitches = Array.from(result.noteMidi).map((m) => midiName(m));
7+ const shown = pitches.slice(0, 12);
8+ const cents = 1200 * Math.log2(d.fundHz / midiHz(result.noteMidi[0]));
9+ return (
10+ <div className="diag">
11+ <table>
12+ <tbody>
13+ <tr>
14+ <th>Notes</th>
15+ <td className="partials">
16+ {shown.join(', ')}
17+ {pitches.length > shown.length ? `, … (${pitches.length} total)` : ''}
18+ {` · mean pluck ${d.meanForce.toFixed(1)} N`}
19+ </td>
20+ </tr>
21+ <tr>
22+ <th>Fundamental</th>
23+ <td>
24+ {d.fundHz.toFixed(2)} Hz ({cents >= 0 ? '+' : ''}{cents.toFixed(0)} cents) · T60{' '}
25+ {d.fundT60.toFixed(1)} s
26+ </td>
27+ </tr>
28+ <tr>
29+ <th>Modes</th>
30+ <td>
31+ {d.modesNote1} coupled (note 1) · {d.modesTotal} across {d.fingerings} fingering
32+ {d.fingerings === 1 ? '' : 's'}
33+ </td>
34+ </tr>
35+ <tr>
36+ <th>Body</th>
37+ <td>
38+ A0 {result.bodyF[0]?.toFixed(1)} Hz · {result.bodyF.length} modes to{' '}
39+ {result.bodyF[result.bodyF.length - 1]?.toFixed(0)} Hz
40+ </td>
41+ </tr>
42+ <tr>
43+ <th>Listener</th>
44+ <td>{(d.listenerDistM * 100).toFixed(0)} cm from the bridge</td>
45+ </tr>
46+ <tr>
47+ <th>Peak pressure</th>
48+ <td>
49+ {d.peakPa.toFixed(3)} Pa ({d.peakSplDb.toFixed(1)} dB SPL)
50+ </td>
51+ </tr>
52+ <tr>
53+ <th>Length</th>
54+ <td>
55+ {d.durationS.toFixed(2)} s · {result.quality} · oversample {d.oversample}
56+ </td>
57+ </tr>
58+ </tbody>
59+ </table>
60+ <div className="cachenote">
61+ eigensolves: {d.solveCached ? 'cached' : `${(d.solveMs / 1000).toFixed(2)} s`} · radiation:{' '}
62+ {d.radiateCached ? 'cached' : `${(d.radiateMs / 1000).toFixed(2)} s`}
63+ </div>
64+ {d.warnings.map((w, i) => (
65+ <div key={i} className="warning">⚠ {w}</div>
66+ ))}
67+ </div>
68+ );
69+}
webapp/src/components/ParamPanel.tsxadded+123−0View file
@@ -0,0 +1,123 @@
1+import { PARAM_GROUPS, type ParamSpec, type BassParams } from '../params';
2+
3+interface Props {
4+ params: BassParams;
5+ onChange: (key: keyof BassParams, value: number | boolean) => void;
6+}
7+
8+const SLIDER_STEPS = 1000;
9+
10+function sliderFromValue(spec: ParamSpec, v: number): number {
11+ if (spec.log) {
12+ const t = (Math.log(v) - Math.log(spec.min)) / (Math.log(spec.max) - Math.log(spec.min));
13+ return Math.round(t * SLIDER_STEPS);
14+ }
15+ return Math.round(((v - spec.min) / (spec.max - spec.min)) * SLIDER_STEPS);
16+}
17+
18+function valueFromSlider(spec: ParamSpec, s: number): number {
19+ const t = s / SLIDER_STEPS;
20+ let v: number;
21+ if (spec.log) {
22+ v = Math.exp(Math.log(spec.min) + t * (Math.log(spec.max) - Math.log(spec.min)));
23+ // keep three significant digits so the displayed number stays readable
24+ const mag = 10 ** Math.floor(Math.log10(Math.abs(v)));
25+ v = Math.round((v / mag) * 100) / 100 * mag;
26+ } else {
27+ v = spec.min + t * (spec.max - spec.min);
28+ if (spec.step) v = Math.round(v / spec.step) * spec.step;
29+ }
30+ if (spec.integer) v = Math.round(v);
31+ return Math.min(spec.max, Math.max(spec.min, v));
32+}
33+
34+function formatValue(v: number): string {
35+ if (v === 0) return '0';
36+ const a = Math.abs(v);
37+ if (a >= 1e5 || a < 1e-3) return v.toExponential(2);
38+ return String(Math.round(v * 1e6) / 1e6);
39+}
40+
41+function ParamRow({ spec, params, onChange }: Props & { spec: ParamSpec }) {
42+ const v = params[spec.key] as number;
43+ return (
44+ <div className="param-row" title={spec.help ?? ''}>
45+ <div className="param-label">
46+ <span className="name">
47+ {spec.label}
48+ {spec.unit ? <span style={{ color: 'var(--muted)' }}> ({spec.unit})</span> : null}
49+ </span>
50+ </div>
51+ <div className="param-controls">
52+ <input
53+ type="range"
54+ min={0}
55+ max={SLIDER_STEPS}
56+ value={sliderFromValue(spec, v)}
57+ onChange={(e) => onChange(spec.key, valueFromSlider(spec, Number(e.target.value)))}
58+ />
59+ <input
60+ type="number"
61+ value={formatValue(v)}
62+ step={spec.step ?? 'any'}
63+ onChange={(e) => {
64+ const nv = Number(e.target.value);
65+ if (Number.isFinite(nv)) onChange(spec.key, spec.integer ? Math.round(nv) : nv);
66+ }}
67+ />
68+ </div>
69+ </div>
70+ );
71+}
72+
73+export default function ParamPanel({ params, onChange }: Props) {
74+ return (
75+ <div>
76+ {PARAM_GROUPS.map((g, gi) => (
77+ <details key={g.name} className="group" open={gi < 2}>
78+ <summary>{g.name}</summary>
79+ <div className="group-body">
80+ {g.specs.map((spec) => (
81+ <ParamRow key={spec.key} spec={spec} params={params} onChange={onChange} />
82+ ))}
83+ {g.name === 'Output' && (
84+ <>
85+ <div className="param-row">
86+ <div className="param-label">
87+ <span className="name">Normalize peak</span>
88+ </div>
89+ <div className="param-controls">
90+ <input
91+ type="checkbox"
92+ checked={params.normalize}
93+ onChange={(e) => onChange('normalize', e.target.checked)}
94+ />
95+ <span className="param-help">
96+ {params.normalize
97+ ? `scaled to ${params.peak_dbfs} dBFS`
98+ : `absolute: ${params.full_scale_pa} Pa = 0 dBFS`}
99+ </span>
100+ </div>
101+ </div>
102+ <div className="param-row">
103+ <div className="param-label">
104+ <span className="name">WAV bit depth</span>
105+ </div>
106+ <div className="param-controls">
107+ <select
108+ value={params.bit_depth}
109+ onChange={(e) => onChange('bit_depth', Number(e.target.value))}
110+ >
111+ <option value={16}>16-bit</option>
112+ <option value={24}>24-bit</option>
113+ </select>
114+ </div>
115+ </div>
116+ </>
117+ )}
118+ </div>
119+ </details>
120+ ))}
121+ </div>
122+ );
123+}
webapp/src/components/Scene3D.tsxadded+315−0View file
@@ -0,0 +1,315 @@
1+// 3D view of the geometry: a stylized double bass standing on its endpin,
2+// the plucking finger at the pluck point of the first note's string, the
3+// stopping finger when that note is fingered, and the listener. Model
4+// coordinates put the origin at the bridge, +y up the neck, and +z out of
5+// the top plate; three.js is y-up too, so the mapping is the identity. The
6+// body outline is a visual stand-in: the acoustics use only the modal table
7+// of bass_body.m, the two f-hole points, and the plate-normal dipole.
8+
9+import { useEffect, useRef } from 'react';
10+import * as THREE from 'three';
11+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
12+import type { BassParams } from '../params';
13+
14+function makeLabel(text: string, color: string, scale = 1): THREE.Sprite {
15+ const canvas = document.createElement('canvas');
16+ canvas.width = 320;
17+ canvas.height = 64;
18+ const ctx = canvas.getContext('2d')!;
19+ ctx.font = '600 36px system-ui, sans-serif';
20+ ctx.fillStyle = color;
21+ ctx.textAlign = 'center';
22+ ctx.textBaseline = 'middle';
23+ ctx.fillText(text, 160, 32);
24+ const tex = new THREE.CanvasTexture(canvas);
25+ const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
26+ const sprite = new THREE.Sprite(mat);
27+ sprite.scale.set(0.24 * scale, 0.048 * scale, 1);
28+ return sprite;
29+}
30+
31+// The fingering of the first note, mirroring bass_notes.m: the highest-tuned
32+// string whose open pitch does not exceed the note.
33+function fingeringOf(rootMidi: number): { str: number; stop: number } {
34+ const open = [43, 38, 33, 28];
35+ const m = Math.min(55, Math.max(28, Math.round(rootMidi)));
36+ for (let s = 0; s < 4; s++) {
37+ if (open[s] <= m) return { str: s, stop: m - open[s] };
38+ }
39+ return { str: 3, stop: 0 };
40+}
41+
42+// Right half of the body outline (gamba-ish sloped shoulders), mirrored.
43+function bodyShape(): THREE.Shape {
44+ const sh = new THREE.Shape();
45+ sh.moveTo(0, -0.61);
46+ sh.bezierCurveTo(0.24, -0.61, 0.345, -0.48, 0.33, -0.30);
47+ sh.bezierCurveTo(0.315, -0.16, 0.245, -0.105, 0.205, -0.06);
48+ sh.bezierCurveTo(0.158, -0.005, 0.158, 0.10, 0.20, 0.155);
49+ sh.bezierCurveTo(0.238, 0.205, 0.258, 0.245, 0.245, 0.30);
50+ sh.bezierCurveTo(0.222, 0.40, 0.125, 0.475, 0, 0.50);
51+ sh.bezierCurveTo(-0.125, 0.475, -0.222, 0.40, -0.245, 0.30);
52+ sh.bezierCurveTo(-0.258, 0.245, -0.238, 0.205, -0.20, 0.155);
53+ sh.bezierCurveTo(-0.158, 0.10, -0.158, -0.005, -0.205, -0.06);
54+ sh.bezierCurveTo(-0.245, -0.105, -0.315, -0.16, -0.33, -0.30);
55+ sh.bezierCurveTo(-0.345, -0.48, -0.24, -0.61, 0, -0.61);
56+ return sh;
57+}
58+
59+interface Props {
60+ params: BassParams;
61+}
62+
63+export default function Scene3D({ params }: Props) {
64+ const mountRef = useRef<HTMLDivElement | null>(null);
65+ const sceneRef = useRef<{
66+ scene: THREE.Scene;
67+ group: THREE.Group | null;
68+ renderer: THREE.WebGLRenderer;
69+ camera: THREE.PerspectiveCamera;
70+ } | null>(null);
71+
72+ // one-time scene setup
73+ useEffect(() => {
74+ const mount = mountRef.current!;
75+ const renderer = new THREE.WebGLRenderer({ antialias: true });
76+ renderer.setPixelRatio(window.devicePixelRatio);
77+ renderer.setClearColor(0x14161a);
78+ mount.appendChild(renderer.domElement);
79+
80+ const scene = new THREE.Scene();
81+ const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 40);
82+ camera.position.set(1.35, 1.05, 2.3);
83+
84+ const controls = new OrbitControls(camera, renderer.domElement);
85+ controls.target.set(0, 0.35, 0);
86+ controls.enableDamping = true;
87+
88+ scene.add(new THREE.AmbientLight(0xffffff, 0.5));
89+ const dir = new THREE.DirectionalLight(0xffffff, 1.1);
90+ dir.position.set(1.2, 2.2, 1.6);
91+ scene.add(dir);
92+ const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3);
93+ dir2.position.set(-1.4, 0.8, -1.2);
94+ scene.add(dir2);
95+
96+ const grid = new THREE.GridHelper(3.2, 32, 0x3a4048, 0x262b33);
97+ grid.position.y = -0.78;
98+ scene.add(grid);
99+
100+ // model axes: x red (across), y green (up the neck), z blue (plate normal)
101+ const axisLen = 0.22;
102+ const axes: [THREE.Vector3, number, string][] = [
103+ [new THREE.Vector3(axisLen, 0, 0), 0xe57373, '+x'],
104+ [new THREE.Vector3(0, axisLen, 0), 0x81c784, '+y'],
105+ [new THREE.Vector3(0, 0, axisLen), 0x64b5f6, '+z'],
106+ ];
107+ for (const [v, color, label] of axes) {
108+ const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]);
109+ scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color })));
110+ const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0'), 0.6);
111+ s.position.copy(v).multiplyScalar(1.15);
112+ scene.add(s);
113+ }
114+
115+ sceneRef.current = { scene, group: null, renderer, camera };
116+
117+ let raf = 0;
118+ const animate = () => {
119+ raf = requestAnimationFrame(animate);
120+ controls.update();
121+ renderer.render(scene, camera);
122+ };
123+ animate();
124+
125+ const resize = () => {
126+ const w = mount.clientWidth;
127+ const h = mount.clientHeight;
128+ renderer.setSize(w, h, false);
129+ camera.aspect = w / h;
130+ camera.updateProjectionMatrix();
131+ };
132+ resize();
133+ const ro = new ResizeObserver(resize);
134+ ro.observe(mount);
135+
136+ return () => {
137+ cancelAnimationFrame(raf);
138+ ro.disconnect();
139+ controls.dispose();
140+ renderer.dispose();
141+ mount.removeChild(renderer.domElement);
142+ sceneRef.current = null;
143+ };
144+ }, []);
145+
146+ // rebuild the geometry group whenever relevant parameters change
147+ useEffect(() => {
148+ const ctx = sceneRef.current;
149+ if (!ctx) return;
150+ if (ctx.group) {
151+ ctx.scene.remove(ctx.group);
152+ ctx.group.traverse((o) => {
153+ const mesh = o as THREE.Mesh;
154+ if (mesh.geometry) mesh.geometry.dispose();
155+ });
156+ }
157+
158+ const g = new THREE.Group();
159+ const yNut = params.scale_length_m;
160+ const spruce = new THREE.MeshStandardMaterial({ color: 0xc79b56, roughness: 0.55, metalness: 0.05 });
161+ const maple = new THREE.MeshStandardMaterial({ color: 0x8a5a2b, roughness: 0.6, metalness: 0.05 });
162+ const ebony = new THREE.MeshStandardMaterial({ color: 0x17130f, roughness: 0.45, metalness: 0.1 });
163+
164+ // body: extruded outline, top plate at z ~ +0.11
165+ const body = new THREE.Mesh(
166+ new THREE.ExtrudeGeometry(bodyShape(), {
167+ depth: 0.2,
168+ bevelEnabled: true,
169+ bevelThickness: 0.006,
170+ bevelSize: 0.006,
171+ bevelSegments: 2,
172+ curveSegments: 24,
173+ }),
174+ spruce,
175+ );
176+ body.position.z = -0.106;
177+ g.add(body);
178+
179+ // f-holes: dark slots flanking the bridge (the monopole points sit here)
180+ for (const sx of [-1, 1]) {
181+ const fh = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.17, 0.004), ebony);
182+ fh.position.set(sx * 0.095, -0.02, 0.111);
183+ fh.rotation.z = sx * 0.18;
184+ g.add(fh);
185+ }
186+
187+ // neck, fingerboard, pegbox, scroll hint
188+ const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.03, yNut - 0.42, 12), maple);
189+ neck.position.set(0, 0.44 + (yNut - 0.42) / 2, 0.075);
190+ g.add(neck);
191+ const fbLen = yNut - 0.28;
192+ const fb = new THREE.Mesh(new THREE.BoxGeometry(0.075, fbLen, 0.016), ebony);
193+ fb.position.set(0, 0.28 + fbLen / 2, 0.118);
194+ g.add(fb);
195+ const pegbox = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.17, 0.05), maple);
196+ pegbox.position.set(0, yNut + 0.085, 0.095);
197+ g.add(pegbox);
198+ const scroll = new THREE.Mesh(new THREE.TorusGeometry(0.032, 0.014, 8, 20), maple);
199+ scroll.position.set(0, yNut + 0.185, 0.095);
200+ g.add(scroll);
201+
202+ // bridge, tailpiece, endpin
203+ const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.012, 0.075), maple);
204+ bridge.position.set(0, 0, 0.148);
205+ g.add(bridge);
206+ const tail = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.22, 0.018), ebony);
207+ tail.position.set(0, -0.46, 0.128);
208+ g.add(tail);
209+ const endpin = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, 0.17, 8),
210+ new THREE.MeshStandardMaterial({ color: 0x777d88, roughness: 0.4, metalness: 0.8 }));
211+ endpin.position.set(0, -0.695, 0);
212+ g.add(endpin);
213+
214+ // strings: G D A E left to right at the bridge, converging at the nut
215+ const bridgeX = [-0.045, -0.015, 0.015, 0.045];
216+ const nutX = [-0.0165, -0.0055, 0.0055, 0.0165];
217+ const rad = [0.0011, 0.0013, 0.0016, 0.002];
218+ const zBridge = 0.187;
219+ const zNut = 0.132;
220+ const zTail = 0.135;
221+ const steel = new THREE.MeshStandardMaterial({ color: 0xb8bec8, roughness: 0.35, metalness: 0.85 });
222+ const addSeg = (a: THREE.Vector3, b: THREE.Vector3, r: number) => {
223+ const len = a.distanceTo(b);
224+ const cyl = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 8), steel);
225+ cyl.position.copy(a).add(b).multiplyScalar(0.5);
226+ cyl.quaternion.setFromUnitVectors(
227+ new THREE.Vector3(0, 1, 0),
228+ b.clone().sub(a).normalize(),
229+ );
230+ g.add(cyl);
231+ };
232+ for (let s = 0; s < 4; s++) {
233+ const pB = new THREE.Vector3(bridgeX[s], 0, zBridge);
234+ const pN = new THREE.Vector3(nutX[s], yNut, zNut);
235+ const pT = new THREE.Vector3(bridgeX[s] * 0.6, -0.42, zTail);
236+ addSeg(pB, pN, rad[s]);
237+ addSeg(pT, pB, rad[s]);
238+ }
239+
240+ // the first note: plucking finger at the pluck point, stopping finger at
241+ // the stop (both on the string bass_notes.m would choose for the root)
242+ const { str, stop } = fingeringOf(params.root_midi);
243+ const Lv = yNut * 2 ** (-stop / 12);
244+ const along = (t: number) =>
245+ new THREE.Vector3(
246+ bridgeX[str] + (nutX[str] - bridgeX[str]) * t,
247+ yNut * t,
248+ zBridge + (zNut - zBridge) * t,
249+ );
250+ const tPluck = (params.pluck_pos * Lv) / yNut;
251+ const pluck = new THREE.Mesh(
252+ new THREE.SphereGeometry(0.016, 16, 12),
253+ new THREE.MeshStandardMaterial({ color: 0xe8a33d, roughness: 0.4 }),
254+ );
255+ pluck.position.copy(along(tPluck)).add(new THREE.Vector3(0, 0, 0.012));
256+ g.add(pluck);
257+ const pluckLbl = makeLabel('pluck', '#e8a33d', 0.7);
258+ pluckLbl.position.copy(pluck.position).add(new THREE.Vector3(0.05, 0.045, 0.03));
259+ g.add(pluckLbl);
260+ if (stop > 0) {
261+ const fingerPos = along(Lv / yNut);
262+ const finger = new THREE.Mesh(
263+ new THREE.SphereGeometry(0.013, 16, 12),
264+ new THREE.MeshStandardMaterial({ color: 0xd98a7a, roughness: 0.6 }),
265+ );
266+ finger.position.copy(fingerPos).add(new THREE.Vector3(0, 0, 0.01));
267+ g.add(finger);
268+ const stopLbl = makeLabel('stop', '#d98a7a', 0.6);
269+ stopLbl.position.copy(fingerPos).add(new THREE.Vector3(-0.05, 0.045, 0.03));
270+ g.add(stopLbl);
271+ }
272+
273+ // listener: microphone marker + line from the bridge
274+ const lis = new THREE.Vector3(params.listener_x_m, params.listener_y_m, params.listener_z_m);
275+ const micHead = new THREE.Mesh(
276+ new THREE.SphereGeometry(0.035, 20, 16),
277+ new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
278+ );
279+ micHead.position.copy(lis);
280+ g.add(micHead);
281+ const micBody = new THREE.Mesh(
282+ new THREE.CylinderGeometry(0.016, 0.016, 0.1, 12),
283+ new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
284+ );
285+ micBody.position.copy(lis).add(new THREE.Vector3(0, -0.068, 0));
286+ g.add(micBody);
287+ const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0.12), lis]);
288+ const line = new THREE.Line(
289+ lineGeo,
290+ new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.03, gapSize: 0.022 }),
291+ );
292+ line.computeLineDistances();
293+ g.add(line);
294+ const dist = lis.length();
295+ const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1', 0.8);
296+ micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.1, 0));
297+ g.add(micLabel);
298+
299+ ctx.scene.add(g);
300+ ctx.group = g;
301+ }, [
302+ params.scale_length_m,
303+ params.root_midi,
304+ params.pluck_pos,
305+ params.listener_x_m,
306+ params.listener_y_m,
307+ params.listener_z_m,
308+ ]);
309+
310+ return (
311+ <div className="scene3d" ref={mountRef}>
312+ <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
313+ </div>
314+ );
315+}
webapp/src/components/plots/AdmittancePlot.tsxadded+125−0View file
@@ -0,0 +1,125 @@
1+// The bridge admittance the string is loaded by: the modal sum over the body
2+// table, |Y(om)| = |sum_j i om a_j^2 / (om_j^2 - om^2 + 2 i zeta_j om_j om)|,
3+// plus the resistive residual floor. This is the wolf-hunting plot: the
4+// fundamentals of the gesture are overlaid, and a fundamental sitting on a
5+// tall peak is a note the body will pull, split, and eat quickly.
6+
7+import { midiHz } from '../../params';
8+import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
9+
10+interface Props {
11+ bodyF: Float32Array;
12+ bodyZeta: Float32Array;
13+ bodyA: Float32Array;
14+ bodyMono: Float32Array;
15+ noteMidi: Float32Array;
16+ tuningTrim: number;
17+ residualY: number;
18+}
19+
20+const F_LO = 30;
21+const F_HI = 2000;
22+const Y_LO = 1e-4;
23+const Y_HI = 1e-1;
24+
25+export default function AdmittancePlot({
26+ bodyF, bodyZeta, bodyA, bodyMono, noteMidi, tuningTrim, residualY,
27+}: Props) {
28+ const ref = useCanvas(
29+ (ctx, w, h) => {
30+ drawFrame(ctx, w, h);
31+ const padL = 44;
32+ const padB = 18;
33+ const pw = w - padL - 8;
34+ const ph = h - padB - 10;
35+ const top = 10;
36+
37+ const fx = (f: number) =>
38+ padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw;
39+ const yy = (v: number) =>
40+ top + ph - ((Math.log10(v) - Math.log10(Y_LO)) / (Math.log10(Y_HI) - Math.log10(Y_LO))) * ph;
41+
42+ ctx.font = '10px system-ui';
43+ ctx.textAlign = 'center';
44+ for (const f of [30, 50, 100, 200, 500, 1000, 2000]) {
45+ const x = fx(f);
46+ ctx.strokeStyle = PLOT_COLORS.grid;
47+ ctx.beginPath();
48+ ctx.moveTo(x, top);
49+ ctx.lineTo(x, top + ph);
50+ ctx.stroke();
51+ ctx.fillStyle = PLOT_COLORS.axis;
52+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5);
53+ }
54+ ctx.textAlign = 'right';
55+ for (let e = -4; e <= -1; e++) {
56+ const v = 10 ** e;
57+ const y = yy(v);
58+ ctx.strokeStyle = PLOT_COLORS.grid;
59+ ctx.beginPath();
60+ ctx.moveTo(padL, y);
61+ ctx.lineTo(padL + pw, y);
62+ ctx.stroke();
63+ ctx.fillStyle = PLOT_COLORS.axis;
64+ ctx.fillText(`1e${e}`, padL - 4, y + 3);
65+ }
66+
67+ // fundamentals of the gesture (deduplicated)
68+ const seen = new Set<number>();
69+ ctx.setLineDash([4, 3]);
70+ for (let i = 0; i < noteMidi.length; i++) {
71+ const m = Math.round(noteMidi[i]);
72+ if (seen.has(m)) continue;
73+ seen.add(m);
74+ const f = midiHz(m) * tuningTrim;
75+ if (f < F_LO || f > F_HI) continue;
76+ ctx.strokeStyle = 'rgba(111, 191, 115, 0.55)';
77+ ctx.beginPath();
78+ ctx.moveTo(fx(f), top);
79+ ctx.lineTo(fx(f), top + ph);
80+ ctx.stroke();
81+ }
82+ ctx.setLineDash([]);
83+
84+ // |Y| trace
85+ ctx.strokeStyle = PLOT_COLORS.accent;
86+ ctx.beginPath();
87+ for (let px = 0; px <= pw; px++) {
88+ const f = 10 ** (Math.log10(F_LO) + (px / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
89+ const om = 2 * Math.PI * f;
90+ let re = residualY;
91+ let im = 0;
92+ for (let j = 0; j < bodyF.length; j++) {
93+ const omj = 2 * Math.PI * bodyF[j];
94+ const a2 = bodyA[j] * bodyA[j];
95+ const dr = omj * omj - om * om;
96+ const di = 2 * bodyZeta[j] * omj * om;
97+ // i om a^2 / (dr + i di)
98+ const den = dr * dr + di * di;
99+ re += (om * a2 * di) / den;
100+ im += (om * a2 * dr) / den;
101+ }
102+ const mag = Math.max(Y_LO, Math.min(Y_HI, Math.hypot(re, im)));
103+ const x = padL + px;
104+ const y = yy(mag);
105+ if (px === 0) ctx.moveTo(x, y);
106+ else ctx.lineTo(x, y);
107+ }
108+ ctx.stroke();
109+
110+ // label the peaks: A0 and the first corpus mode
111+ ctx.fillStyle = PLOT_COLORS.axis;
112+ ctx.textAlign = 'left';
113+ if (bodyF.length > 0 && bodyMono[0] > 0) {
114+ ctx.fillText('A0', fx(bodyF[0]) + 3, top + 12);
115+ }
116+ ctx.fillStyle = PLOT_COLORS.accent;
117+ ctx.fillText('|Y| at the bridge (s/kg)', padL + 6, top + 2);
118+ ctx.fillStyle = 'rgba(111, 191, 115, 0.9)';
119+ ctx.fillText('· note fundamentals', padL + 130, top + 2);
120+ },
121+ [bodyF, bodyZeta, bodyA, bodyMono, noteMidi, tuningTrim, residualY],
122+ 190,
123+ );
124+ return <canvas ref={ref} />;
125+}
webapp/src/components/plots/ModeMap.tsxadded+101−0View file
@@ -0,0 +1,101 @@
1+// The coupled modes of the first note: frequency against ring time
2+// T60 = 6.91 / (zeta om), colored by body-energy fraction. String-dominated
3+// modes form the long-ringing partial ladder; body-dominated modes sit low
4+// and damped; a mixed pair straddling one frequency is the wolf signature.
5+
6+import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
7+
8+interface Props {
9+ modeF: Float32Array;
10+ modeZeta: Float32Array;
11+ modeBodyFrac: Float32Array;
12+}
13+
14+const F_LO = 30;
15+const F_HI = 8000;
16+
17+function mixColor(t: number): string {
18+ const r = Math.round(77 + t * (232 - 77));
19+ const g = Math.round(208 + t * (163 - 208));
20+ const b = Math.round(225 + t * (61 - 225));
21+ return `rgb(${r}, ${g}, ${b})`;
22+}
23+
24+export default function ModeMap({ modeF, modeZeta, modeBodyFrac }: Props) {
25+ const ref = useCanvas(
26+ (ctx, w, h) => {
27+ drawFrame(ctx, w, h);
28+ const padL = 40;
29+ const padB = 20;
30+ const pw = w - padL - 10;
31+ const ph = h - padB - 14;
32+ const top = 12;
33+ const n = modeF.length;
34+ if (n === 0) return;
35+
36+ const t60 = new Float64Array(n);
37+ let tMin = Infinity;
38+ let tMax = 0;
39+ for (let i = 0; i < n; i++) {
40+ t60[i] = 6.91 / (Math.max(1e-9, modeZeta[i]) * 2 * Math.PI * modeF[i]);
41+ tMin = Math.min(tMin, t60[i]);
42+ tMax = Math.max(tMax, t60[i]);
43+ }
44+ tMin = Math.max(1e-3, tMin / 1.5);
45+ tMax = tMax * 1.5;
46+
47+ const fx = (f: number) =>
48+ padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw;
49+ const ty = (t: number) =>
50+ top + ph - ((Math.log10(t) - Math.log10(tMin)) / (Math.log10(tMax) - Math.log10(tMin))) * ph;
51+
52+ ctx.font = '10px system-ui';
53+ ctx.textAlign = 'center';
54+ for (const f of [30, 50, 100, 200, 500, 1000, 2000, 5000]) {
55+ const x = fx(f);
56+ ctx.strokeStyle = PLOT_COLORS.grid;
57+ ctx.beginPath();
58+ ctx.moveTo(x, top);
59+ ctx.lineTo(x, top + ph);
60+ ctx.stroke();
61+ ctx.fillStyle = PLOT_COLORS.axis;
62+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 7);
63+ }
64+ ctx.textAlign = 'right';
65+ for (let e = Math.ceil(Math.log10(tMin)); e <= Math.floor(Math.log10(tMax)); e++) {
66+ const t = 10 ** e;
67+ const y = ty(t);
68+ ctx.strokeStyle = PLOT_COLORS.grid;
69+ ctx.beginPath();
70+ ctx.moveTo(padL, y);
71+ ctx.lineTo(padL + pw, y);
72+ ctx.stroke();
73+ ctx.fillStyle = PLOT_COLORS.axis;
74+ ctx.fillText(t >= 1 ? `${t}s` : `${t * 1000}ms`, padL - 4, y + 3);
75+ }
76+
77+ for (let i = 0; i < n; i++) {
78+ const x = fx(Math.min(F_HI, Math.max(F_LO, modeF[i])));
79+ const y = ty(t60[i]);
80+ const bf = Math.min(1, Math.max(0, modeBodyFrac[i]));
81+ ctx.fillStyle = mixColor(bf);
82+ ctx.globalAlpha = 0.85;
83+ ctx.beginPath();
84+ ctx.arc(x, y, 2.5 + 2.5 * bf, 0, 2 * Math.PI);
85+ ctx.fill();
86+ ctx.globalAlpha = 1;
87+ }
88+
89+ ctx.textAlign = 'left';
90+ ctx.fillStyle = mixColor(0);
91+ ctx.fillText('string-dominated', padL + 6, top + 2);
92+ ctx.fillStyle = mixColor(1);
93+ ctx.fillText('body-dominated', padL + 96, top + 2);
94+ ctx.fillStyle = PLOT_COLORS.axis;
95+ ctx.fillText(`· ${n} coupled modes of the first note · ring time T60`, padL + 182, top + 2);
96+ },
97+ [modeF, modeZeta, modeBodyFrac],
98+ 210,
99+ );
100+ return <canvas ref={ref} />;
101+}
webapp/src/components/plots/NotesPlot.tsxadded+105−0View file
@@ -0,0 +1,105 @@
1+// The gesture as a piano roll: each note at its onset and pitch, colored by
2+// the string it is fingered on, with the marker height proportional to the
3+// pluck force. For a single note this is one mark; for a walking line the
4+// jitter and force spread are what keep it from sounding like a sequencer.
5+
6+import { midiName } from '../../params';
7+import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
8+
9+interface Props {
10+ noteT: Float32Array;
11+ noteMidi: Float32Array;
12+ noteStr: Float32Array;
13+ noteForce: Float32Array;
14+ durationS: number;
15+}
16+
17+const STRING_COLORS = ['#4dd0e1', '#6fbf73', '#e8a33d', '#e57373']; // G D A E
18+const STRING_NAMES = ['G', 'D', 'A', 'E'];
19+
20+export default function NotesPlot({ noteT, noteMidi, noteStr, noteForce, durationS }: Props) {
21+ const ref = useCanvas(
22+ (ctx, w, h) => {
23+ drawFrame(ctx, w, h);
24+ const padL = 40;
25+ const padB = 16;
26+ const pw = w - padL - 8;
27+ const ph = h - padB - 14;
28+ const top = 12;
29+ const n = noteT.length;
30+ if (n === 0) return;
31+
32+ let mLo = Infinity, mHi = -Infinity, fMax = 0;
33+ for (let i = 0; i < n; i++) {
34+ mLo = Math.min(mLo, noteMidi[i]);
35+ mHi = Math.max(mHi, noteMidi[i]);
36+ fMax = Math.max(fMax, noteForce[i]);
37+ }
38+ mLo -= 2;
39+ mHi += 2;
40+ if (fMax <= 0) fMax = 1;
41+
42+ const tx = (t: number) => padL + (t / Math.max(1e-6, durationS)) * pw;
43+ const my = (m: number) => top + ph - ((m - mLo) / (mHi - mLo)) * ph;
44+
45+ ctx.font = '10px system-ui';
46+ ctx.textAlign = 'center';
47+ const tStep = durationS > 8 ? 2.0 : durationS > 4 ? 1.0 : durationS > 1.5 ? 0.5 : 0.1;
48+ for (let t = 0; t <= durationS + 1e-9; t += tStep) {
49+ const x = tx(t);
50+ ctx.strokeStyle = PLOT_COLORS.grid;
51+ ctx.beginPath();
52+ ctx.moveTo(x, top);
53+ ctx.lineTo(x, top + ph);
54+ ctx.stroke();
55+ ctx.fillStyle = PLOT_COLORS.axis;
56+ ctx.fillText(`${t.toFixed(1)}s`, x, h - 4);
57+ }
58+ // pitch gridlines on the octave Cs and the open strings
59+ ctx.textAlign = 'right';
60+ for (const m of [28, 33, 36, 38, 43, 48]) {
61+ if (m < mLo || m > mHi) continue;
62+ const yy = my(m);
63+ ctx.strokeStyle = PLOT_COLORS.grid;
64+ ctx.beginPath();
65+ ctx.moveTo(padL, yy);
66+ ctx.lineTo(padL + pw, yy);
67+ ctx.stroke();
68+ ctx.fillStyle = PLOT_COLORS.axis;
69+ ctx.fillText(midiName(m), padL - 4, yy + 3);
70+ }
71+
72+ // note marks: a bar from onset toward the next onset, sized by force
73+ for (let i = 0; i < n; i++) {
74+ const x0 = tx(noteT[i]);
75+ const x1 = i + 1 < n ? tx(noteT[i + 1]) - 2 : x0 + Math.min(40, pw * 0.06);
76+ const yy = my(noteMidi[i]);
77+ const s = Math.round(noteStr[i]) - 1;
78+ const th = 2 + 4 * (noteForce[i] / fMax);
79+ const bw = Math.max(4, x1 - x0);
80+ ctx.fillStyle = STRING_COLORS[Math.min(3, Math.max(0, s))];
81+ ctx.globalAlpha = 0.9;
82+ ctx.fillRect(x0, yy - th / 2, bw, th);
83+ ctx.globalAlpha = 1;
84+ if (n <= 16) {
85+ ctx.fillStyle = PLOT_COLORS.axis;
86+ ctx.textAlign = 'center';
87+ ctx.fillText(midiName(noteMidi[i]), x0 + bw / 2, yy - 7);
88+ }
89+ }
90+
91+ ctx.textAlign = 'left';
92+ let lx = padL + 6;
93+ for (let s = 0; s < 4; s++) {
94+ ctx.fillStyle = STRING_COLORS[s];
95+ ctx.fillText(`${STRING_NAMES[s]} string`, lx, top + 2);
96+ lx += 52;
97+ }
98+ ctx.fillStyle = PLOT_COLORS.axis;
99+ ctx.fillText('· bar height = pluck force', lx + 4, top + 2);
100+ },
101+ [noteT, noteMidi, noteStr, noteForce, durationS],
102+ 160,
103+ );
104+ return <canvas ref={ref} />;
105+}
webapp/src/components/plots/SpectrogramPlot.tsxadded+71−0View file
@@ -0,0 +1,71 @@
1+// Time-frequency view of the render, cropped to the band the bass actually
2+// occupies; per-partial decay is visible as horizontal streaks, and a choked
3+// walking-bass note as a streak that ends where the next note lands.
4+
5+import { stft } from '../../lib/fft';
6+import { useCanvas, drawFrame, PLOT_COLORS, colormap } from './canvasUtils';
7+
8+interface Props {
9+ y: Float32Array;
10+ sampleRate: number;
11+}
12+
13+const F_TOP = 4000; // display band, Hz
14+
15+export default function SpectrogramPlot({ y, sampleRate }: Props) {
16+ const ref = useCanvas(
17+ (ctx, w, h) => {
18+ drawFrame(ctx, w, h);
19+ const padL = 34;
20+ const padB = 16;
21+ const pw = w - padL - 6;
22+ const ph = h - padB - 6;
23+
24+ const win = 2048; // 23 Hz bins at 48 kHz: enough to separate the low partials
25+ const { frames, bins, db, floorDb } = stft(y, win, 512);
26+ const nyq = sampleRate / 2;
27+ const binsShown = Math.min(bins, Math.ceil((F_TOP / nyq) * bins));
28+
29+ const img = ctx.createImageData(frames, binsShown);
30+ for (let f = 0; f < frames; f++) {
31+ for (let b = 0; b < binsShown; b++) {
32+ const v = db[f * bins + b];
33+ const t = Math.min(1, Math.max(0, (v - floorDb) / -floorDb));
34+ const [r, g, bl] = colormap(t);
35+ // flip vertically: low frequency at the bottom
36+ const row = binsShown - 1 - b;
37+ const idx = (row * frames + f) * 4;
38+ img.data[idx] = r;
39+ img.data[idx + 1] = g;
40+ img.data[idx + 2] = bl;
41+ img.data[idx + 3] = 255;
42+ }
43+ }
44+ const off = new OffscreenCanvas(frames, binsShown);
45+ const octx = off.getContext('2d')!;
46+ octx.putImageData(img, 0, 0);
47+ ctx.imageSmoothingEnabled = true;
48+ ctx.drawImage(off, padL, 6, pw, ph);
49+
50+ // axes
51+ ctx.fillStyle = PLOT_COLORS.axis;
52+ ctx.font = '10px system-ui';
53+ ctx.textAlign = 'right';
54+ const fTop = (binsShown / bins) * nyq;
55+ for (let kf = 0; kf <= fTop; kf += 1000) {
56+ const yy = 6 + ph - (kf / fTop) * ph;
57+ ctx.fillText(kf >= 1000 ? `${kf / 1000}k` : `${kf}`, padL - 4, yy + 3);
58+ }
59+ ctx.textAlign = 'center';
60+ const durS = y.length / sampleRate;
61+ const tStep = durS > 8 ? 2.0 : durS > 4 ? 1.0 : durS > 1.5 ? 0.5 : 0.1;
62+ for (let t = 0; t <= durS + 1e-9; t += tStep) {
63+ const x = padL + (t / durS) * pw;
64+ ctx.fillText(`${t.toFixed(1)}s`, x, h - 4);
65+ }
66+ },
67+ [y, sampleRate],
68+ 200,
69+ );
70+ return <canvas ref={ref} />;
71+}
webapp/src/components/plots/SpectrumPlot.tsxadded+119−0View file
@@ -0,0 +1,119 @@
1+// Magnitude spectrum of the render, log-frequency axis, with the coupled
2+// modes of the first note overlaid, colored by how much of each mode's
3+// energy lives in the body. A pizzicato bass spectrum is a comb of slightly
4+// stretched partials standing on the body resonances; where a string
5+// partial meets a body mode the overlay shows the pair trading color.
6+
7+import { magnitudeSpectrum } from '../../lib/fft';
8+import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
9+
10+interface Props {
11+ y: Float32Array;
12+ sampleRate: number;
13+ modeF: Float32Array;
14+ modeBodyFrac: Float32Array;
15+}
16+
17+const F_LO = 30;
18+const F_HI = 8000;
19+const DB_RANGE = 80;
20+
21+function mixColor(t: number, alpha: number): string {
22+ // cyan (string) -> amber (body)
23+ const r = Math.round(77 + t * (232 - 77));
24+ const g = Math.round(208 + t * (163 - 208));
25+ const b = Math.round(225 + t * (61 - 225));
26+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
27+}
28+
29+export default function SpectrumPlot({ y, sampleRate, modeF, modeBodyFrac }: Props) {
30+ const ref = useCanvas(
31+ (ctx, w, h) => {
32+ drawFrame(ctx, w, h);
33+ const padL = 34;
34+ const padB = 18;
35+ const pw = w - padL - 6;
36+ const ph = h - padB - 8;
37+ const fx = (f: number) =>
38+ padL + ((Math.log10(f) - Math.log10(F_LO)) / (Math.log10(F_HI) - Math.log10(F_LO))) * pw;
39+
40+ ctx.font = '10px system-ui';
41+ ctx.textAlign = 'center';
42+ for (const f of [30, 50, 100, 200, 500, 1000, 2000, 5000]) {
43+ const x = fx(f);
44+ ctx.strokeStyle = PLOT_COLORS.grid;
45+ ctx.beginPath();
46+ ctx.moveTo(x, 8);
47+ ctx.lineTo(x, 8 + ph);
48+ ctx.stroke();
49+ ctx.fillStyle = PLOT_COLORS.axis;
50+ ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, x, h - 5);
51+ }
52+ ctx.textAlign = 'right';
53+ for (let db = 0; db >= -DB_RANGE; db -= 20) {
54+ const yy = 8 + (-db / DB_RANGE) * ph;
55+ ctx.strokeStyle = PLOT_COLORS.grid;
56+ ctx.beginPath();
57+ ctx.moveTo(padL, yy);
58+ ctx.lineTo(padL + pw, yy);
59+ ctx.stroke();
60+ ctx.fillStyle = PLOT_COLORS.axis;
61+ ctx.fillText(`${db}`, padL - 4, yy + 3);
62+ }
63+
64+ const { mag, size } = magnitudeSpectrum(y, 65536);
65+ let peak = 0;
66+ for (let i = 1; i < mag.length; i++) peak = Math.max(peak, mag[i]);
67+ if (peak <= 0) return;
68+
69+ // overlays first so the trace draws on top
70+ ctx.setLineDash([4, 3]);
71+ for (let i = 0; i < modeF.length; i++) {
72+ const f = modeF[i];
73+ if (f < F_LO || f > F_HI) continue;
74+ ctx.strokeStyle = mixColor(Math.min(1, modeBodyFrac[i]), 0.45);
75+ ctx.beginPath();
76+ ctx.moveTo(fx(f), 8);
77+ ctx.lineTo(fx(f), 8 + ph);
78+ ctx.stroke();
79+ }
80+ ctx.setLineDash([]);
81+
82+ ctx.strokeStyle = PLOT_COLORS.trace;
83+ ctx.beginPath();
84+ let started = false;
85+ for (let px = 0; px <= pw; px++) {
86+ const f = 10 ** (Math.log10(F_LO) + (px / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
87+ const bin = Math.min(mag.length - 1, Math.round((f / sampleRate) * size));
88+ const fNext =
89+ 10 ** (Math.log10(F_LO) + ((px + 1) / pw) * (Math.log10(F_HI) - Math.log10(F_LO)));
90+ const binNext = Math.min(
91+ mag.length - 1,
92+ Math.max(bin + 1, Math.round((fNext / sampleRate) * size)),
93+ );
94+ let m = 0;
95+ for (let b = bin; b < binNext; b++) m = Math.max(m, mag[b]);
96+ const db = 20 * Math.log10(m / peak + 1e-12);
97+ const yy = 8 + Math.min(ph, (-db / DB_RANGE) * ph);
98+ if (!started) {
99+ ctx.moveTo(padL + px, yy);
100+ started = true;
101+ } else {
102+ ctx.lineTo(padL + px, yy);
103+ }
104+ }
105+ ctx.stroke();
106+
107+ ctx.textAlign = 'left';
108+ ctx.fillStyle = mixColor(0, 0.9);
109+ ctx.fillText('string-dominated modes', padL + 4, 18);
110+ ctx.fillStyle = mixColor(1, 0.9);
111+ ctx.fillText('body-dominated modes', padL + 134, 18);
112+ ctx.fillStyle = PLOT_COLORS.axis;
113+ ctx.fillText('(first note)', padL + 258, 18);
114+ },
115+ [y, sampleRate, modeF, modeBodyFrac],
116+ 200,
117+ );
118+ return <canvas ref={ref} />;
119+}
webapp/src/components/plots/WaveformPlot.tsxadded+65−0View file
@@ -0,0 +1,65 @@
1+import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils';
2+
3+interface Props {
4+ y: Float32Array;
5+ sampleRate: number;
6+}
7+
8+export default function WaveformPlot({ y, sampleRate }: Props) {
9+ const ref = useCanvas(
10+ (ctx, w, h) => {
11+ drawFrame(ctx, w, h);
12+ const padL = 34;
13+ const padB = 16;
14+ const pw = w - padL - 6;
15+ const ph = h - padB - 6;
16+ const midY = 6 + ph / 2;
17+
18+ ctx.strokeStyle = PLOT_COLORS.grid;
19+ ctx.beginPath();
20+ ctx.moveTo(padL, midY);
21+ ctx.lineTo(padL + pw, midY);
22+ ctx.stroke();
23+
24+ // adaptive time gridlines
25+ const durS = y.length / sampleRate;
26+ const tStep = durS > 8 ? 2.0 : durS > 4 ? 1.0 : durS > 1.5 ? 0.5 : 0.1;
27+ ctx.fillStyle = PLOT_COLORS.axis;
28+ ctx.font = '10px system-ui';
29+ ctx.textAlign = 'center';
30+ for (let t = 0; t <= durS + 1e-9; t += tStep) {
31+ const x = padL + (t / durS) * pw;
32+ ctx.strokeStyle = PLOT_COLORS.grid;
33+ ctx.beginPath();
34+ ctx.moveTo(x, 6);
35+ ctx.lineTo(x, 6 + ph);
36+ ctx.stroke();
37+ ctx.fillText(`${t.toFixed(1)}s`, x, h - 4);
38+ }
39+ ctx.textAlign = 'right';
40+ ctx.fillText('+1', padL - 4, 12);
41+ ctx.fillText('-1', padL - 4, 6 + ph);
42+
43+ // min/max envelope per pixel column
44+ ctx.strokeStyle = PLOT_COLORS.trace;
45+ ctx.beginPath();
46+ const perPx = y.length / pw;
47+ for (let px = 0; px < pw; px++) {
48+ const i0 = Math.floor(px * perPx);
49+ const i1 = Math.min(y.length, Math.max(i0 + 1, Math.floor((px + 1) * perPx)));
50+ let mn = Infinity, mx = -Infinity;
51+ for (let i = i0; i < i1; i++) {
52+ if (y[i] < mn) mn = y[i];
53+ if (y[i] > mx) mx = y[i];
54+ }
55+ if (!Number.isFinite(mn)) continue;
56+ ctx.moveTo(padL + px + 0.5, midY - mx * (ph / 2));
57+ ctx.lineTo(padL + px + 0.5, midY - mn * (ph / 2) + 0.5);
58+ }
59+ ctx.stroke();
60+ },
61+ [y, sampleRate],
62+ 170,
63+ );
64+ return <canvas ref={ref} />;
65+}
webapp/src/components/plots/canvasUtils.tsadded+66−0View file
@@ -0,0 +1,66 @@
1+import { useEffect, useRef } from 'react';
2+
3+// Canvas hook: resizes to element width at devicePixelRatio and calls draw.
4+export function useCanvas(
5+ draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void,
6+ deps: unknown[],
7+ height: number,
8+) {
9+ const ref = useRef<HTMLCanvasElement | null>(null);
10+
11+ useEffect(() => {
12+ const canvas = ref.current;
13+ if (!canvas) return;
14+ const render = () => {
15+ const dpr = window.devicePixelRatio || 1;
16+ const w = canvas.clientWidth;
17+ canvas.width = Math.max(1, Math.round(w * dpr));
18+ canvas.height = Math.max(1, Math.round(height * dpr));
19+ canvas.style.height = `${height}px`;
20+ const ctx = canvas.getContext('2d')!;
21+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
22+ ctx.clearRect(0, 0, w, height);
23+ draw(ctx, w, height);
24+ };
25+ render();
26+ const ro = new ResizeObserver(render);
27+ ro.observe(canvas);
28+ return () => ro.disconnect();
29+ // eslint-disable-next-line react-hooks/exhaustive-deps
30+ }, deps);
31+
32+ return ref;
33+}
34+
35+export const PLOT_COLORS = {
36+ bg: '#181b20',
37+ grid: '#2a2f38',
38+ axis: '#8b93a1',
39+ trace: '#4dd0e1',
40+ accent: '#e8a33d',
41+ good: '#6fbf73',
42+ warn: '#e57373',
43+};
44+
45+export function drawFrame(ctx: CanvasRenderingContext2D, w: number, h: number) {
46+ ctx.fillStyle = PLOT_COLORS.bg;
47+ ctx.fillRect(0, 0, w, h);
48+}
49+
50+// Viridis-like colormap for the spectrogram, t in [0,1].
51+export function colormap(t: number): [number, number, number] {
52+ const stops: [number, number, number][] = [
53+ [13, 8, 60], [84, 39, 143], [140, 41, 129], [196, 66, 90],
54+ [237, 105, 37], [251, 155, 6], [247, 209, 61], [252, 255, 164],
55+ ];
56+ const x = Math.min(0.9999, Math.max(0, t)) * (stops.length - 1);
57+ const i = Math.floor(x);
58+ const f = x - i;
59+ const a = stops[i];
60+ const b = stops[i + 1];
61+ return [
62+ Math.round(a[0] + f * (b[0] - a[0])),
63+ Math.round(a[1] + f * (b[1] - a[1])),
64+ Math.round(a[2] + f * (b[2] - a[2])),
65+ ];
66+}
webapp/src/hooks/useRenderer.tsadded+114−0View file
@@ -0,0 +1,114 @@
1+// Hook owning the render worker: debounced auto-preview whenever the
2+// parameters or the .m sources change, explicit full-quality render, and a
3+// latest-wins request queue.
4+
5+import { useCallback, useEffect, useRef, useState } from 'react';
6+import type { BassParams } from '../params';
7+import type { MFile } from '../mfiles';
8+import type { ErrorMessage, ProgressMessage, RenderResponse } from '../worker/renderWorker';
9+
10+export interface RenderError {
11+ message: string;
12+ file?: string;
13+ line?: number;
14+}
15+
16+export interface RenderState {
17+ result: RenderResponse | null;
18+ error: RenderError | null;
19+ busy: boolean;
20+ stage: string;
21+ progress: number; // 0..1 of the current stage
22+}
23+
24+interface Request {
25+ params: BassParams;
26+ mfiles: MFile[];
27+ quality: 'preview' | 'full';
28+}
29+
30+export function useRenderer(params: BassParams, mfiles: MFile[]) {
31+ const workerRef = useRef<Worker | null>(null);
32+ const nextId = useRef(1);
33+ const inFlight = useRef<number | null>(null);
34+ const queued = useRef<Request | null>(null);
35+ const sendRef = useRef<(r: Request) => void>(() => {});
36+ const [state, setState] = useState<RenderState>({
37+ result: null,
38+ error: null,
39+ busy: false,
40+ stage: '',
41+ progress: 0,
42+ });
43+
44+ const send = useCallback((req: Request) => {
45+ const worker = workerRef.current;
46+ if (!worker) return;
47+ if (inFlight.current !== null) {
48+ queued.current = req; // latest wins
49+ return;
50+ }
51+ const id = nextId.current++;
52+ inFlight.current = id;
53+ setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0 }));
54+ worker.postMessage({ type: 'render', id, ...req });
55+ }, []);
56+ sendRef.current = send;
57+
58+ useEffect(() => {
59+ const worker = new Worker(new URL('../worker/renderWorker.ts', import.meta.url), {
60+ type: 'module',
61+ });
62+ workerRef.current = worker;
63+
64+ const finish = (update: (s: RenderState) => RenderState) => {
65+ inFlight.current = null;
66+ const next = queued.current;
67+ queued.current = null;
68+ setState((s) => {
69+ const done = update(s);
70+ return next ? { ...done, busy: true, stage: 'queued', progress: 0 } : done;
71+ });
72+ if (next) sendRef.current(next);
73+ };
74+
75+ worker.onmessage = (ev: MessageEvent<RenderResponse | ProgressMessage | ErrorMessage>) => {
76+ const msg = ev.data;
77+ if (msg.type === 'progress') {
78+ setState((s) => ({ ...s, stage: msg.stage, progress: msg.frac }));
79+ return;
80+ }
81+ if (msg.type === 'error') {
82+ // Keep the last good result on screen; the code panel shows the fault.
83+ finish((s) => ({
84+ ...s,
85+ error: { message: msg.message, file: msg.file, line: msg.line },
86+ busy: false,
87+ stage: '',
88+ progress: 0,
89+ }));
90+ return;
91+ }
92+ finish(() => ({ result: msg, error: null, busy: false, stage: '', progress: 0 }));
93+ };
94+ return () => {
95+ worker.terminate();
96+ workerRef.current = null;
97+ inFlight.current = null;
98+ queued.current = null;
99+ };
100+ }, []);
101+
102+ const renderFull = useCallback(
103+ () => send({ params, mfiles, quality: 'full' }),
104+ [params, mfiles, send],
105+ );
106+
107+ // Debounced auto-preview on any change to the parameters or the code.
108+ useEffect(() => {
109+ const t = setTimeout(() => send({ params, mfiles, quality: 'preview' }), 250);
110+ return () => clearTimeout(t);
111+ }, [params, mfiles, send]);
112+
113+ return { ...state, renderFull };
114+}
webapp/src/lib/fft.tsadded+91−0View file
@@ -0,0 +1,91 @@
1+// Minimal iterative radix-2 complex FFT, in-place on separate re/im arrays.
2+
3+export function fft(re: Float64Array, im: Float64Array): void {
4+ const n = re.length;
5+ if ((n & (n - 1)) !== 0) throw new Error('FFT length must be a power of 2');
6+ // bit-reversal permutation
7+ for (let i = 1, j = 0; i < n; i++) {
8+ let bit = n >> 1;
9+ for (; j & bit; bit >>= 1) j ^= bit;
10+ j ^= bit;
11+ if (i < j) {
12+ const tr = re[i]; re[i] = re[j]; re[j] = tr;
13+ const ti = im[i]; im[i] = im[j]; im[j] = ti;
14+ }
15+ }
16+ for (let len = 2; len <= n; len <<= 1) {
17+ const ang = (-2 * Math.PI) / len;
18+ const wr = Math.cos(ang);
19+ const wi = Math.sin(ang);
20+ for (let i = 0; i < n; i += len) {
21+ let cwr = 1, cwi = 0;
22+ for (let k = 0; k < len / 2; k++) {
23+ const ur = re[i + k], ui = im[i + k];
24+ const vr = re[i + k + len / 2] * cwr - im[i + k + len / 2] * cwi;
25+ const vi = re[i + k + len / 2] * cwi + im[i + k + len / 2] * cwr;
26+ re[i + k] = ur + vr;
27+ im[i + k] = ui + vi;
28+ re[i + k + len / 2] = ur - vr;
29+ im[i + k + len / 2] = ui - vi;
30+ const nwr = cwr * wr - cwi * wi;
31+ cwi = cwr * wi + cwi * wr;
32+ cwr = nwr;
33+ }
34+ }
35+ }
36+}
37+
38+// Magnitude spectrum of a real signal with a Hann window, zero-padded to the
39+// next power of two (at least minSize). Returns bins 0..N/2.
40+export function magnitudeSpectrum(x: Float32Array | Float64Array, minSize = 16384): {
41+ mag: Float64Array;
42+ size: number;
43+} {
44+ let size = minSize;
45+ while (size < x.length) size <<= 1;
46+ const re = new Float64Array(size);
47+ const im = new Float64Array(size);
48+ const n = x.length;
49+ for (let i = 0; i < n; i++) {
50+ const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1)));
51+ re[i] = x[i] * w;
52+ }
53+ fft(re, im);
54+ const half = size / 2;
55+ const mag = new Float64Array(half + 1);
56+ for (let i = 0; i <= half; i++) mag[i] = Math.hypot(re[i], im[i]);
57+ return { mag, size };
58+}
59+
60+// Simple STFT magnitude in dB for the spectrogram, frames x bins.
61+export function stft(
62+ x: Float32Array,
63+ win = 512,
64+ hop = 128,
65+): { frames: number; bins: number; db: Float32Array; floorDb: number } {
66+ const nFrames = Math.max(1, Math.floor((x.length - win) / hop) + 1);
67+ const bins = win / 2 + 1;
68+ const db = new Float32Array(nFrames * bins);
69+ const re = new Float64Array(win);
70+ const im = new Float64Array(win);
71+ const hann = new Float64Array(win);
72+ for (let i = 0; i < win; i++) hann[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (win - 1)));
73+ let maxDb = -Infinity;
74+ for (let f = 0; f < nFrames; f++) {
75+ const off = f * hop;
76+ for (let i = 0; i < win; i++) {
77+ re[i] = (off + i < x.length ? x[off + i] : 0) * hann[i];
78+ im[i] = 0;
79+ }
80+ fft(re, im);
81+ for (let b = 0; b < bins; b++) {
82+ const m = Math.hypot(re[b], im[b]);
83+ const v = 20 * Math.log10(m + 1e-12);
84+ db[f * bins + b] = v;
85+ if (v > maxDb) maxDb = v;
86+ }
87+ }
88+ // normalize so 0 dB is the frame-bin maximum
89+ for (let i = 0; i < db.length; i++) db[i] -= maxDb;
90+ return { frames: nFrames, bins, db, floorDb: -90 };
91+}
webapp/src/lib/wav.tsadded+40−0View file
@@ -0,0 +1,40 @@
1+// Mono PCM WAV encoding (16- or 24-bit).
2+
3+export function encodeWav(y: Float32Array, sampleRate: number, bitDepth: 16 | 24): Blob {
4+ const bytesPerSample = bitDepth / 8;
5+ const dataSize = y.length * bytesPerSample;
6+ const buf = new ArrayBuffer(44 + dataSize);
7+ const dv = new DataView(buf);
8+ const writeStr = (off: number, s: string) => {
9+ for (let i = 0; i < s.length; i++) dv.setUint8(off + i, s.charCodeAt(i));
10+ };
11+ writeStr(0, 'RIFF');
12+ dv.setUint32(4, 36 + dataSize, true);
13+ writeStr(8, 'WAVE');
14+ writeStr(12, 'fmt ');
15+ dv.setUint32(16, 16, true);
16+ dv.setUint16(20, 1, true); // PCM
17+ dv.setUint16(22, 1, true); // mono
18+ dv.setUint32(24, sampleRate, true);
19+ dv.setUint32(28, sampleRate * bytesPerSample, true);
20+ dv.setUint16(32, bytesPerSample, true);
21+ dv.setUint16(34, bitDepth, true);
22+ writeStr(36, 'data');
23+ dv.setUint32(40, dataSize, true);
24+
25+ let off = 44;
26+ for (let i = 0; i < y.length; i++) {
27+ const v = Math.max(-1, Math.min(1, y[i]));
28+ if (bitDepth === 16) {
29+ dv.setInt16(off, Math.round(v * 32767), true);
30+ off += 2;
31+ } else {
32+ const iv = Math.round(v * 8388607);
33+ dv.setUint8(off, iv & 0xff);
34+ dv.setUint8(off + 1, (iv >> 8) & 0xff);
35+ dv.setUint8(off + 2, (iv >> 16) & 0xff);
36+ off += 3;
37+ }
38+ }
39+ return new Blob([buf], { type: 'audio/wav' });
40+}
webapp/src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import React from 'react';
2+import { createRoot } from 'react-dom/client';
3+import App from './App';
4+import './style.css';
5+
6+createRoot(document.getElementById('root')!).render(
7+ <React.StrictMode>
8+ <App />
9+ </React.StrictMode>,
10+);
webapp/src/mfiles.tsadded+44−0View file
@@ -0,0 +1,44 @@
1+// The MATLAB implementation, loaded from ../matlab at build time. The app
2+// keeps an editable in-memory copy of these sources (see App.tsx); edits feed
3+// straight into the render worker and are deliberately not persisted, so a
4+// reload restores the shipped code.
5+
6+export interface MFile {
7+ name: string;
8+ source: string;
9+}
10+
11+const modules = import.meta.glob('../../matlab/*.m', {
12+ eager: true,
13+ query: '?raw',
14+ import: 'default',
15+}) as Record<string, string>;
16+
17+export const DEFAULT_MFILES: MFile[] = Object.entries(modules)
18+ .map(([path, source]) => ({ name: path.split('/').pop()!, source }))
19+ .sort((a, b) => a.name.localeCompare(b.name));
20+
21+// Files the app itself executes. The others are entry points for running the
22+// model from the numbl CLI, and editing them changes nothing in the browser.
23+export const EXECUTED_BY_APP = new Set([
24+ 'bass_notes.m',
25+ 'bass_rng.m',
26+ 'bass_body.m',
27+ 'bass_assemble.m',
28+ 'bass_basis.m',
29+ 'bass_radiate.m',
30+ 'bass_output.m',
31+]);
32+
33+// Cheap content hash (FNV-1a) so the worker can key its caches on the code.
34+export function hashMFiles(files: MFile[]): string {
35+ let h = 0x811c9dc5;
36+ for (const f of files) {
37+ const s = f.name + ' ' + f.source + '';
38+ for (let i = 0; i < s.length; i++) {
39+ h ^= s.charCodeAt(i);
40+ h = Math.imul(h, 0x01000193);
41+ }
42+ }
43+ return (h >>> 0).toString(36);
44+}
webapp/src/params.tsadded+276−0View file
@@ -0,0 +1,276 @@
1+// Parameter definitions for the bass model, mirroring bass_defaults.m.
2+// Each parameter is assigned a tier that determines how much recomputation a
3+// change requires:
4+// solve -> gesture + per-fingering coupled eigensolves + radiation + output
5+// radiate -> pluck projection + synthesis + radiation + output only (the
6+// cached eigensolves are reused; they are the expensive part)
7+// output -> output stage only
8+//
9+// Note that the render duration is not a parameter: it is
10+// (note_count - 1) * 60 / tempo_bpm + tail_s.
11+
12+export interface BassParams {
13+ // Strings
14+ scale_length_m: number;
15+ tension_g_n: number;
16+ tension_d_n: number;
17+ tension_a_n: number;
18+ tension_e_n: number;
19+ inharmonicity: number;
20+ tuning_trim: number;
21+ // String damping
22+ loss_factor: number;
23+ loss_slope: number;
24+ finger_loss: number;
25+ residual_y: number;
26+ // Body
27+ a0_hz: number;
28+ body_trim: number;
29+ body_q_scale: number;
30+ bridge_mobility: number;
31+ rad_area_scale: number;
32+ dipole_length_m: number;
33+ radiation_loss: number;
34+ // Pluck
35+ pluck_force_n: number;
36+ pluck_pos: number;
37+ finger_release_ms: number;
38+ choke_ms: number;
39+ // Gesture
40+ root_midi: number;
41+ note_count: number;
42+ tempo_bpm: number;
43+ force_spread: number;
44+ timing_jitter_ms: number;
45+ seed: number;
46+ // Radiation / listener
47+ direct_gain: number;
48+ listener_x_m: number;
49+ listener_y_m: number;
50+ listener_z_m: number;
51+ speed_of_sound: number;
52+ air_density: number;
53+ // Rendering
54+ max_mode_hz: number;
55+ sample_rate: number;
56+ oversample: number;
57+ tail_s: number;
58+ pre_delay_ms: number;
59+ normalize: boolean;
60+ peak_dbfs: number;
61+ full_scale_pa: number;
62+ bit_depth: number;
63+}
64+
65+export const DEFAULTS: BassParams = {
66+ scale_length_m: 1.04,
67+ tension_g_n: 285.0,
68+ tension_d_n: 290.0,
69+ tension_a_n: 300.0,
70+ tension_e_n: 310.0,
71+ inharmonicity: 8.0e-5,
72+ tuning_trim: 1.0,
73+ loss_factor: 1.6e-3,
74+ loss_slope: 1.2e-7,
75+ finger_loss: 6.0e-3,
76+ residual_y: 0.015,
77+ a0_hz: 60.0,
78+ body_trim: 1.0,
79+ body_q_scale: 1.0,
80+ bridge_mobility: 1.0,
81+ rad_area_scale: 1.0,
82+ dipole_length_m: 0.35,
83+ radiation_loss: 1.0,
84+ pluck_force_n: 15.0,
85+ pluck_pos: 0.3,
86+ finger_release_ms: 0.9,
87+ choke_ms: 45.0,
88+ root_midi: 33,
89+ note_count: 1,
90+ tempo_bpm: 96.0,
91+ force_spread: 0.2,
92+ timing_jitter_ms: 4.0,
93+ seed: 1,
94+ direct_gain: 1.0,
95+ listener_x_m: 0.5,
96+ listener_y_m: 0.3,
97+ listener_z_m: 1.2,
98+ speed_of_sound: 343.0,
99+ air_density: 1.204,
100+ max_mode_hz: 3000.0,
101+ sample_rate: 48000,
102+ oversample: 2,
103+ tail_s: 6.0,
104+ pre_delay_ms: 3.0,
105+ normalize: true,
106+ peak_dbfs: -3.0,
107+ full_scale_pa: 2.0,
108+ bit_depth: 24,
109+};
110+
111+export type Tier = 'solve' | 'radiate' | 'output';
112+
113+// Keys whose change invalidates the cached coupled eigensolves. The gesture
114+// keys that pick the pitches (root, count, seed) are here because a new set
115+// of fingerings needs new solves; the pluck, the tempo, and the listener are
116+// deliberately absent: plucking harder or moving the microphone reuses the
117+// cached modes.
118+export const SOLVE_KEYS: (keyof BassParams)[] = [
119+ 'scale_length_m', 'tension_g_n', 'tension_d_n', 'tension_a_n', 'tension_e_n',
120+ 'inharmonicity', 'tuning_trim',
121+ 'loss_factor', 'loss_slope', 'finger_loss', 'residual_y',
122+ 'a0_hz', 'body_trim', 'body_q_scale', 'bridge_mobility',
123+ 'rad_area_scale', 'dipole_length_m', 'radiation_loss',
124+ 'root_midi', 'note_count', 'seed',
125+ 'max_mode_hz', 'speed_of_sound', 'air_density',
126+];
127+
128+export const RADIATE_KEYS: (keyof BassParams)[] = [
129+ 'pluck_force_n', 'pluck_pos', 'finger_release_ms', 'choke_ms',
130+ 'tempo_bpm', 'force_spread', 'timing_jitter_ms',
131+ 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
132+ 'sample_rate', 'tail_s',
133+];
134+
135+export const OUTPUT_KEYS: (keyof BassParams)[] = [
136+ 'pre_delay_ms', 'normalize', 'peak_dbfs', 'full_scale_pa',
137+];
138+
139+export interface ParamSpec {
140+ key: keyof BassParams;
141+ label: string;
142+ unit?: string;
143+ min: number;
144+ max: number;
145+ step?: number;
146+ log?: boolean;
147+ integer?: boolean;
148+ help?: string;
149+}
150+
151+export interface ParamGroup {
152+ name: string;
153+ specs: ParamSpec[];
154+}
155+
156+export const PARAM_GROUPS: ParamGroup[] = [
157+ {
158+ name: 'Pluck',
159+ specs: [
160+ { key: 'pluck_force_n', label: 'Pluck force', unit: 'N', min: 1, max: 60, step: 0.5, help: 'Transverse pull at release. Dynamics: the initial displacement is F xp (Lv - xp) / (T Lv), about 11 mm at the default.' },
161+ { key: 'pluck_pos', label: 'Pluck position', min: 0.08, max: 0.6, step: 0.005, help: 'Fraction of the vibrating length from the bridge. ~0.3 is the end of the fingerboard, jazz territory; partials with a node there vanish.' },
162+ { key: 'finger_release_ms', label: 'Finger release', unit: 'ms', min: 0.1, max: 4.0, step: 0.05, help: 'How long the string takes to leave the fingertip. Short = bright snap, long = fleshy thump.' },
163+ { key: 'choke_ms', label: 'Choke time', unit: 'ms', min: 5, max: 200, step: 1, help: 'Extra decay applied to a note when the next one lands, standing in for the player stopping the string.' },
164+ ],
165+ },
166+ {
167+ name: 'Gesture',
168+ specs: [
169+ { key: 'root_midi', label: 'Root note', unit: 'midi', min: 28, max: 50, step: 1, integer: true, help: '33 = A1 (open A string). The single note, and the key a walking line walks in. E1 is 28.' },
170+ { key: 'note_count', label: 'Notes', min: 1, max: 32, step: 1, integer: true, help: '1 is a single deterministic pluck; more gives a seeded walking line on the major scale of the root.' },
171+ { key: 'tempo_bpm', label: 'Tempo', unit: 'bpm', min: 40, max: 200, step: 1, help: 'Quarter-note tempo of the walking line.' },
172+ { key: 'force_spread', label: 'Force spread', min: 0, max: 0.6, step: 0.01, help: 'Lognormal spread of the pluck force across a line. 0 sounds mechanical.' },
173+ { key: 'timing_jitter_ms', label: 'Timing jitter', unit: 'ms', min: 0, max: 30, step: 0.5, help: 'Gaussian jitter on each onset after the first.' },
174+ { key: 'seed', label: 'Random seed', min: 1, max: 9999, step: 1, integer: true, help: 'Any change redraws the walking line; the same seed always reproduces it.' },
175+ ],
176+ },
177+ {
178+ name: 'Strings',
179+ specs: [
180+ { key: 'scale_length_m', label: 'Scale length', unit: 'm', min: 0.9, max: 1.15, step: 0.005, help: '1.04 m is a 3/4 bass, the common orchestral size. The linear density follows from tension and pitch.' },
181+ { key: 'tension_g_n', label: 'G string tension', unit: 'N', min: 150, max: 400, step: 5, help: 'Higher tension = heavier string at the same pitch = louder and slower-speaking.' },
182+ { key: 'tension_d_n', label: 'D string tension', unit: 'N', min: 150, max: 400, step: 5 },
183+ { key: 'tension_a_n', label: 'A string tension', unit: 'N', min: 150, max: 400, step: 5 },
184+ { key: 'tension_e_n', label: 'E string tension', unit: 'N', min: 150, max: 400, step: 5 },
185+ { key: 'inharmonicity', label: 'Inharmonicity B', min: 1e-5, max: 1e-3, log: true, help: 'Bending stiffness of the wound string: f_n ~ n f0 sqrt(1 + B n^2). Stretches the high partials sharp; grows as the stop shortens the string.' },
186+ { key: 'tuning_trim', label: 'Tuning trim', min: 0.9, max: 1.1, step: 0.001, help: 'Overall pitch trim; 1 is standard orchestral tuning.' },
187+ ],
188+ },
189+ {
190+ name: 'String damping',
191+ specs: [
192+ { key: 'loss_factor', label: 'Loss factor η₀', min: 1e-4, max: 2e-2, log: true, help: 'Frequency-independent internal loss of the string.' },
193+ { key: 'loss_slope', label: 'Loss slope η₁', unit: 's', min: 1e-8, max: 2e-6, log: true, help: 'Extra high-frequency damping; controls how fast the attack brightness fades into the ring.' },
194+ { key: 'finger_loss', label: 'Finger loss', min: 1e-4, max: 5e-2, log: true, help: 'Extra loss on stopped notes from the fingertip termination. Open strings ring longer, as on the instrument.' },
195+ { key: 'residual_y', label: 'Residual admittance', unit: 's/kg', min: 0, max: 0.06, step: 0.001, help: 'Resistive bridge conductance standing in for the dense body modes above the retained table. Damps the high partials and feeds the direct term.' },
196+ ],
197+ },
198+ {
199+ name: 'Body',
200+ specs: [
201+ { key: 'a0_hz', label: 'A0 (f-holes)', unit: 'Hz', min: 45, max: 80, step: 0.5, help: 'The f-hole Helmholtz resonance, the one monopole of the body. Move it onto a fundamental to hear it take over.' },
202+ { key: 'body_trim', label: 'Corpus pitch trim', min: 0.7, max: 1.4, step: 0.005, help: 'Scales the corpus modes (97 Hz up) together; a bigger body is a lower trim.' },
203+ { key: 'body_q_scale', label: 'Body Q scale', min: 0.3, max: 3.0, step: 0.05, help: 'Scales all body Qs. Low = a dull, damped box; high = ringing, wolf-prone resonances.' },
204+ { key: 'bridge_mobility', label: 'Bridge mobility', min: 0.05, max: 4.0, log: true, help: 'Scales the admittance the string sees. High mobility = loud, fast decay, split wolf pairs; low = quiet and long.' },
205+ { key: 'rad_area_scale', label: 'Radiating area', min: 0.2, max: 3.0, step: 0.05, help: 'Scales how much air each body mode moves per unit bridge motion.' },
206+ { key: 'dipole_length_m', label: 'Dipole length', unit: 'm', min: 0.1, max: 0.7, step: 0.01, help: 'Effective front-to-back path of the corpus dipoles; longer radiates the low corpus modes better.' },
207+ { key: 'radiation_loss', label: 'Radiation loss', min: 0.0, max: 3.0, step: 0.05, help: 'Scales the radiation damping each coupled mode gets from its own monopole and dipole strength.' },
208+ ],
209+ },
210+ {
211+ name: 'Listener',
212+ specs: [
213+ { key: 'listener_x_m', label: 'Listener x', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Across the instrument. Radiation-only: no re-solve needed.' },
214+ { key: 'listener_y_m', label: 'Listener y', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Up the neck.' },
215+ { key: 'listener_z_m', label: 'Listener z', unit: 'm', min: -3.0, max: 3.0, step: 0.01, help: 'Out of the top plate: where the corpus dipoles radiate best. Behind the bass they flip phase.' },
216+ { key: 'direct_gain', label: 'Direct term gain', min: 0.0, max: 3.0, step: 0.05, help: 'Broadband radiation through the residual bridge admittance, standing in for the body modes above the table. 0 leaves only the modal body.' },
217+ ],
218+ },
219+ {
220+ name: 'Output',
221+ specs: [
222+ { key: 'tail_s', label: 'Tail', unit: 's', min: 1.0, max: 12.0, step: 0.1, help: 'Ring time rendered after the last note. An open-string pizzicato wants seconds.' },
223+ { key: 'pre_delay_ms', label: 'Pre-delay', unit: 'ms', min: 0, max: 50, step: 0.5 },
224+ { key: 'peak_dbfs', label: 'Peak level', unit: 'dBFS', min: -24, max: 0, step: 0.5, help: 'Used when normalize is on.' },
225+ { key: 'full_scale_pa', label: 'Full scale', unit: 'Pa', min: 0.1, max: 20, step: 0.1, help: 'Used when normalize is off: this many pascals maps to 0 dBFS.' },
226+ ],
227+ },
228+ {
229+ name: 'Advanced / quality',
230+ specs: [
231+ { key: 'max_mode_hz', label: 'Max string mode', unit: 'Hz', min: 500, max: 6000, step: 100, help: 'String partials above this are truncated; their radiation falls on the direct term.' },
232+ { key: 'oversample', label: 'Oversample', min: 1, max: 4, step: 1, integer: true, help: 'Synthesis rate multiple; 2 for render, 1 for preview.' },
233+ { key: 'speed_of_sound', label: 'Speed of sound', unit: 'm/s', min: 300, max: 400, step: 1 },
234+ { key: 'air_density', label: 'Air density', unit: 'kg/m³', min: 0.9, max: 1.5, step: 0.01 },
235+ ],
236+ },
237+];
238+
239+const STORAGE_KEY = 'bass-webapp-params-v1';
240+
241+export function loadParams(): BassParams {
242+ try {
243+ const raw = localStorage.getItem(STORAGE_KEY);
244+ if (!raw) return { ...DEFAULTS };
245+ const parsed = JSON.parse(raw);
246+ const out = { ...DEFAULTS };
247+ for (const k of Object.keys(DEFAULTS) as (keyof BassParams)[]) {
248+ if (typeof parsed[k] === typeof DEFAULTS[k]) {
249+ (out as Record<string, unknown>)[k] = parsed[k];
250+ }
251+ }
252+ return out;
253+ } catch {
254+ return { ...DEFAULTS };
255+ }
256+}
257+
258+export function saveParams(p: BassParams): void {
259+ try {
260+ localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
261+ } catch {
262+ // storage unavailable; persistence is a convenience only
263+ }
264+}
265+
266+// MIDI helpers shared by the plots and panels.
267+const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
268+
269+export function midiName(m: number): string {
270+ const n = Math.round(m);
271+ return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
272+}
273+
274+export function midiHz(m: number): number {
275+ return 440 * 2 ** ((m - 69) / 12);
276+}
webapp/src/selftest.tsadded+26−0View file
@@ -0,0 +1,26 @@
1+// Runs the MATLAB validation script (../matlab/bass_validate.m) through
2+// numbl in Node: closed-form identities, the coupled eigensolve identities,
3+// exact reproducibility of the seeded walking line, and the pinned
4+// end-to-end diagnostics.
5+// Run with: npm run selftest
6+
7+import { readFileSync, readdirSync } from 'fs';
8+import { join, dirname } from 'path';
9+import { fileURLToPath } from 'url';
10+import { executeCode } from 'numbl';
11+
12+const matlabDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'matlab');
13+const workspaceFiles = readdirSync(matlabDir)
14+ .filter((f) => f.endsWith('.m'))
15+ .map((f) => ({ name: f, source: readFileSync(join(matlabDir, f), 'utf8') }));
16+
17+const main = workspaceFiles.find((f) => f.name === 'bass_validate.m')!;
18+const t0 = performance.now();
19+const result = executeCode(main.source, { displayResults: false }, workspaceFiles, 'bass_validate.m');
20+const text = result.output.join('');
21+process.stdout.write(text);
22+console.log(`total time : ${((performance.now() - t0) / 1000).toFixed(2)} s`);
23+
24+if (text.includes('MISMATCH') || text.includes('FAILED')) {
25+ process.exit(1);
26+}
webapp/src/style.cssadded+358−0View file
@@ -0,0 +1,358 @@
1+:root {
2+ --bg: #14161a;
3+ --panel: #1d2026;
4+ --panel2: #23272f;
5+ --border: #31363f;
6+ --text: #d6dae2;
7+ --muted: #8b93a1;
8+ --accent: #e8a33d;
9+ --accent2: #4dd0e1;
10+ --good: #6fbf73;
11+ --warn: #e57373;
12+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
13+}
14+
15+* { box-sizing: border-box; }
16+
17+html, body, #root {
18+ margin: 0;
19+ height: 100%;
20+ background: var(--bg);
21+ color: var(--text);
22+}
23+
24+.app {
25+ display: flex;
26+ flex-direction: column;
27+ height: 100vh;
28+}
29+
30+.topbar {
31+ display: flex;
32+ align-items: center;
33+ gap: 10px;
34+ padding: 8px 14px;
35+ background: var(--panel);
36+ border-bottom: 1px solid var(--border);
37+ flex-wrap: wrap;
38+}
39+
40+.topbar h1 {
41+ font-size: 16px;
42+ margin: 0 12px 0 0;
43+ font-weight: 600;
44+ color: var(--accent);
45+ white-space: nowrap;
46+}
47+
48+button {
49+ background: var(--panel2);
50+ color: var(--text);
51+ border: 1px solid var(--border);
52+ border-radius: 6px;
53+ padding: 6px 14px;
54+ font-size: 13px;
55+ cursor: pointer;
56+}
57+button:hover { border-color: var(--muted); }
58+button:disabled { opacity: 0.45; cursor: default; }
59+button.primary {
60+ background: var(--accent);
61+ color: #1a1204;
62+ border-color: var(--accent);
63+ font-weight: 600;
64+}
65+button.play {
66+ background: var(--good);
67+ color: #06210a;
68+ border-color: var(--good);
69+ font-weight: 600;
70+}
71+button.play.preview {
72+ background: transparent;
73+ color: var(--accent);
74+ border-color: var(--accent);
75+}
76+button.loop.active {
77+ background: var(--accent2);
78+ color: #062a2f;
79+ border-color: var(--accent2);
80+ font-weight: 600;
81+}
82+button.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
83+button.code.active { border-color: var(--accent2); color: var(--accent2); }
84+
85+.status {
86+ margin-left: auto;
87+ font-size: 12px;
88+ color: var(--muted);
89+ display: flex;
90+ align-items: center;
91+ gap: 8px;
92+ min-width: 180px;
93+ justify-content: flex-end;
94+}
95+
96+.badge {
97+ font-size: 11px;
98+ border-radius: 10px;
99+ padding: 2px 8px;
100+ border: 1px solid var(--border);
101+}
102+.badge.preview { color: var(--accent); border-color: var(--accent); }
103+.badge.full { color: var(--good); border-color: var(--good); }
104+.badge.err { color: var(--warn); border-color: var(--warn); }
105+
106+.progressbar {
107+ width: 90px;
108+ height: 6px;
109+ background: var(--panel2);
110+ border-radius: 3px;
111+ overflow: hidden;
112+}
113+.progressbar > div {
114+ height: 100%;
115+ background: var(--accent2);
116+ transition: width 0.1s linear;
117+}
118+
119+.main {
120+ display: flex;
121+ flex: 1;
122+ min-height: 0;
123+}
124+
125+.sidebar {
126+ width: 340px;
127+ min-width: 340px;
128+ overflow-y: auto;
129+ background: var(--panel);
130+ border-right: 1px solid var(--border);
131+ padding: 8px;
132+}
133+
134+.content {
135+ flex: 1;
136+ overflow-y: auto;
137+ padding: 10px;
138+ display: grid;
139+ grid-template-columns: 1fr 1fr;
140+ gap: 10px;
141+ align-content: start;
142+}
143+
144+.card {
145+ background: var(--panel);
146+ border: 1px solid var(--border);
147+ border-radius: 8px;
148+ padding: 8px 10px 10px;
149+ min-width: 0;
150+}
151+.card.wide { grid-column: 1 / -1; }
152+.card h2 {
153+ font-size: 12px;
154+ text-transform: uppercase;
155+ letter-spacing: 0.06em;
156+ color: var(--muted);
157+ margin: 0 0 6px;
158+ font-weight: 600;
159+}
160+.card canvas { display: block; width: 100%; }
161+
162+.scene3d { height: 380px; position: relative; }
163+.scene3d canvas { height: 100%; border-radius: 6px; }
164+.scene-hint {
165+ position: absolute;
166+ bottom: 10px;
167+ right: 14px;
168+ font-size: 11px;
169+ color: var(--muted);
170+ pointer-events: none;
171+}
172+
173+/* Parameter panel */
174+details.group {
175+ border: 1px solid var(--border);
176+ border-radius: 6px;
177+ margin-bottom: 6px;
178+ background: var(--panel2);
179+}
180+details.group summary {
181+ cursor: pointer;
182+ padding: 6px 10px;
183+ font-size: 13px;
184+ font-weight: 600;
185+ user-select: none;
186+}
187+details.group[open] summary { border-bottom: 1px solid var(--border); }
188+.group-body { padding: 6px 10px 8px; }
189+
190+.param-row { margin-bottom: 8px; }
191+.param-label {
192+ display: flex;
193+ justify-content: space-between;
194+ font-size: 12px;
195+ margin-bottom: 2px;
196+ align-items: baseline;
197+}
198+.param-label .name { color: var(--text); }
199+.param-controls { display: flex; gap: 8px; align-items: center; }
200+.param-controls input[type='range'] { flex: 1; accent-color: var(--accent2); }
201+.param-controls input[type='number'] {
202+ width: 88px;
203+ background: var(--bg);
204+ color: var(--text);
205+ border: 1px solid var(--border);
206+ border-radius: 4px;
207+ padding: 3px 5px;
208+ font-size: 12px;
209+}
210+.param-controls select, .param-controls input[type='checkbox'] {
211+ background: var(--bg);
212+ color: var(--text);
213+ border: 1px solid var(--border);
214+ border-radius: 4px;
215+ font-size: 12px;
216+}
217+.param-help { font-size: 11px; color: var(--muted); margin-top: 1px; }
218+
219+/* Diagnostics */
220+.diag table { border-collapse: collapse; font-size: 12px; width: 100%; }
221+.diag td, .diag th {
222+ padding: 2px 8px 2px 0;
223+ text-align: left;
224+ color: var(--text);
225+ font-weight: 400;
226+}
227+.diag th { color: var(--muted); white-space: nowrap; }
228+.diag .partials { font-variant-numeric: tabular-nums; }
229+.warning { color: var(--warn); font-size: 12px; margin-top: 4px; }
230+.cachenote { color: var(--muted); font-size: 11px; margin-top: 6px; }
231+
232+.mode-list { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
233+.mode-list button { padding: 3px 10px; font-size: 12px; }
234+.mode-list button.active { border-color: var(--accent2); color: var(--accent2); }
235+
236+/* Code panel */
237+.modal-backdrop {
238+ position: fixed;
239+ inset: 0;
240+ background: rgba(8, 10, 13, 0.72);
241+ display: flex;
242+ align-items: center;
243+ justify-content: center;
244+ z-index: 50;
245+}
246+
247+.code-panel {
248+ width: min(1180px, 94vw);
249+ height: min(860px, 92vh);
250+ background: var(--panel);
251+ border: 1px solid var(--border);
252+ border-radius: 10px;
253+ display: flex;
254+ flex-direction: column;
255+ overflow: hidden;
256+}
257+
258+.code-header {
259+ display: flex;
260+ align-items: center;
261+ gap: 10px;
262+ padding: 9px 12px;
263+ border-bottom: 1px solid var(--border);
264+}
265+.code-header h2 {
266+ margin: 0;
267+ font-size: 13px;
268+ text-transform: uppercase;
269+ letter-spacing: 0.06em;
270+ color: var(--accent);
271+}
272+.code-header button:last-child { margin-left: 0; }
273+.code-note {
274+ font-size: 11.5px;
275+ color: var(--muted);
276+ margin-right: auto;
277+}
278+
279+.code-body { display: flex; flex: 1; min-height: 0; }
280+
281+.code-files {
282+ width: 236px;
283+ min-width: 236px;
284+ border-right: 1px solid var(--border);
285+ overflow-y: auto;
286+ padding: 6px;
287+}
288+.code-files button {
289+ display: flex;
290+ align-items: center;
291+ justify-content: space-between;
292+ gap: 6px;
293+ width: 100%;
294+ text-align: left;
295+ background: transparent;
296+ border: 1px solid transparent;
297+ border-radius: 5px;
298+ padding: 5px 8px;
299+ margin-bottom: 2px;
300+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
301+ font-size: 12px;
302+}
303+.code-files button:hover { background: var(--panel2); }
304+.code-files button.active {
305+ background: var(--panel2);
306+ border-color: var(--accent2);
307+ color: var(--accent2);
308+}
309+.code-files .fname { display: flex; align-items: center; gap: 5px; }
310+.code-files .dot {
311+ width: 6px;
312+ height: 6px;
313+ border-radius: 50%;
314+ background: var(--accent);
315+}
316+.code-files .ftag {
317+ font-size: 9.5px;
318+ color: var(--muted);
319+ text-transform: uppercase;
320+ letter-spacing: 0.05em;
321+ font-family: system-ui, sans-serif;
322+}
323+
324+.code-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
325+.code-toolbar {
326+ display: flex;
327+ align-items: center;
328+ gap: 10px;
329+ padding: 6px 10px;
330+ border-bottom: 1px solid var(--border);
331+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
332+ font-size: 12px;
333+ color: var(--text);
334+}
335+.code-hint {
336+ font-size: 11px;
337+ color: var(--muted);
338+ font-family: system-ui, sans-serif;
339+}
340+.code-editor { flex: 1; min-height: 0; overflow: hidden; }
341+
342+.code-error {
343+ border-top: 1px solid var(--warn);
344+ background: #2a1a1a;
345+ color: #ffb4a9;
346+ padding: 7px 12px;
347+ font-size: 12px;
348+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
349+}
350+
351+.error-card { border-color: var(--warn); }
352+.error-card h2 { color: var(--warn); }
353+.error-card div { font-size: 12px; }
354+.error-card strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
355+
356+@media (max-width: 1100px) {
357+ .content { grid-template-columns: 1fr; }
358+}
webapp/src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
webapp/src/worker/renderWorker.tsadded+420−0View file
@@ -0,0 +1,420 @@
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.jsonadded+20−0View file
@@ -0,0 +1,20 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+ "moduleResolution": "bundler",
9+ "resolveJsonModule": true,
10+ "isolatedModules": true,
11+ "noEmit": true,
12+ "jsx": "react-jsx",
13+ "strict": true,
14+ "noUnusedLocals": true,
15+ "noUnusedParameters": true,
16+ "noFallthroughCasesInSwitch": true
17+ },
18+ "include": ["src"],
19+ "exclude": ["src/selftest.ts"]
20+}
webapp/vite.config.tsadded+9−0View file
@@ -0,0 +1,9 @@
1+import { defineConfig } from 'vite';
2+import react from '@vitejs/plugin-react';
3+
4+export default defineConfig({
5+ plugins: [react()],
6+ base: './',
7+ worker: { format: 'es' },
8+ server: { fs: { allow: ['..'] } }, // the .m sources live in ../matlab
9+});