/ simulated_instruments / bass
Sign in
simulated_instruments / bass
Voice the defaults deeper and darker
The default pizzicato was thinner than a standup bass ought to sound. The pluck moves out to 38% of the string with a slower 2.6 ms fingertip release, A0 comes down to 58.5 Hz so the f-hole monopole sits just above the open-A fundamental and carries it, the corpus dipoles lengthen to 0.45 m, the body Qs run 20% hot, the broadband direct term drops to 0.7, and the high-frequency string loss rises to 4.0e-7. The tail grows to 8 s so the longer ring is actually rendered. Measured on the default open A1: energy below 80 Hz goes from 79% to 91% of the total and the attack spectral centroid falls from 106 Hz to 72 Hz. The trade-off is the fundamental's T60, 9.8 s to 8.0 s, since a well coupled A0 drains the string faster; 58.5 Hz was the compromise against 57 Hz, which weighed more but rang only 5.3 s. Pinned diagnostics in bass_validate.m re-recorded (peak 0.218467 Pa, fundamental 54.3180 Hz); all eight checks pass under MATLAB and numbl. Export knob defaults and both bundles rebuilt, README numbers updated.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit f456a3bebd9d parent 4152cc7 Browse files
7 changed files+31−31
README.mdmodified+2−2View file
@@ -32,7 +32,7 @@ pluck -> initial condition on the coupled modes
3232
3333 Three points are worth stating plainly, because they are where the model commits itself.
3434
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 17 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.
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 about 22 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.
3636
3737 **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.
3838
@@ -46,4 +46,4 @@ As in the sibling `maraca`, `triangle`, and `recorder` repositories, there is no
4646
4747 ## Defaults
4848
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 1.3 ms: 61 coupled modes (49 string, 12 body), a fundamental at 54.45 Hz that the body holds 17 cents flat of A1, a T60 of 9.8 s on that fundamental, and a peak of 0.270 Pa (82.6 dB SPL) at the 133 cm listener before normalization. The radiativity of the body table is deliberately bottom-heavy (A0 and the 97 and 113 Hz wood modes carry most of the output), which is what keeps the low band about 10 dB above the mids during the attack; flattening `rad_area_scale` against a lower `dipole_length_m` will thin it back out. Setting `note_count` to 8 walks a seeded quarter-note line in A major at 96 bpm, each note choked as the next lands.
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 38% of the string from the bridge, released over 2.6 ms: 61 coupled modes (49 string, 12 body), a fundamental at 54.32 Hz that the body holds about 22 cents flat of A1, a T60 of 8.0 s on that fundamental, and a peak of 0.218 Pa (80.8 dB SPL) at the 133 cm listener before normalization. The defaults are voiced dark and deep: the pluck sits near the end of the fingerboard with a fleshy release, A0 at 58.5 Hz sits just above the open-A fundamental so the f-holes carry it (the trade-off being that the body drains the string faster; moving `a0_hz` back to 60 buys about two seconds of T60 at the cost of low-end weight), the corpus dipoles are long (`dipole_length_m` 0.45), the body Qs run 20% hot, and the broadband direct term is pulled back to 0.7. Together with the deliberately bottom-heavy radiativity of the body table (A0 and the 97 and 113 Hz wood modes carry most of the output), this keeps over 90% of the radiated energy below 80 Hz. Flattening `rad_area_scale` against a lower `dipole_length_m`, or raising `direct_gain`, will thin and brighten it back out. Setting `note_count` to 8 walks a seeded quarter-note line in A major at 96 bpm, each note choked as the next lands.
export-src/bass/export.jsonmodified+3−3View file
@@ -4,8 +4,8 @@
44 "description": "Plucked standup bass, one note per MIDI event, E1 to G3.",
55 "source": "simulated_instruments/bass",
66 "knobs": [
7- { "key": "pluck_pos", "label": "pluck position", "min": 0.05, "max": 0.95, "default": 0.3 },
8- { "key": "finger_release_ms", "label": "fingertip release (ms)", "min": 0.2, "max": 8, "default": 1.3 },
9- { "key": "tail_s", "label": "ring-out (s)", "min": 1, "max": 8, "default": 6 }
7+ { "key": "pluck_pos", "label": "pluck position", "min": 0.05, "max": 0.95, "default": 0.38 },
8+ { "key": "finger_release_ms", "label": "fingertip release (ms)", "min": 0.2, "max": 8, "default": 2.6 },
9+ { "key": "tail_s", "label": "ring-out (s)", "min": 1, "max": 8, "default": 8 }
1010 ]
1111 }
exports/bass-muted.jsonmodified+2−2View file
@@ -32,7 +32,7 @@
3232 },
3333 {
3434 "name": "bass_defaults.m",
35- "content": "function P = bass_defaults()\n% BASS_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% (note_count - 1) * 60 / tempo_bpm + tail_s.\n\n% Strings (1 = G2, 2 = D2, 3 = A1, 4 = E1), a 3/4-size double bass.\n% Linear density is not a parameter: it follows from the tension and the\n% open pitch, mu = T / (2 L f0)^2, which lands on realistic values\n% (about 7 g/m for the G string, 42 g/m for the E).\nP.scale_length_m = 1.04;\nP.tension_g_n = 285.0;\nP.tension_d_n = 290.0;\nP.tension_a_n = 300.0;\nP.tension_e_n = 310.0;\nP.inharmonicity = 8.0e-5; % B of the open string: f_n ~ n f0 sqrt(1 + B n^2)\nP.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)\n\n% String damping\nP.loss_factor = 1.6e-3; % frequency-independent internal loss\nP.loss_slope = 2.5e-7; % extra high-frequency loss, s\nP.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)\nP.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg\n\n% Body (see bass_body.m for the mode table these trims act on)\nP.a0_hz = 60.0; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.0; % scales the body mode Qs\nP.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)\nP.rad_area_scale = 1.0; % scales the radiating areas of the body modes\nP.dipole_length_m = 0.35; % effective front-to-back path of the corpus dipoles\nP.radiation_loss = 1.0; % scales the radiation damping added to the body Qs\n\n% Pluck\nP.pluck_force_n = 15.0; % transverse pull at release\nP.pluck_pos = 0.30; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 1.3; % finite release time of the fingertip (spectral rolloff)\nP.choke_ms = 45.0; % decay time constant applied when the next note lands\n\n% Gesture\nP.root_midi = 33; % A1; the single note, and the key of a walking line\nP.note_count = 1;\nP.tempo_bpm = 96.0;\nP.force_spread = 0.20; % lognormal spread of the pluck force across a line\nP.timing_jitter_ms = 4.0;\nP.seed = 1;\n\n% Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)\nP.direct_gain = 1.0;\nP.listener_x_m = 0.50;\nP.listener_y_m = 0.30;\nP.listener_z_m = 1.20;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.max_mode_hz = 3000.0;\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 6.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
35+ "content": "function P = bass_defaults()\n% BASS_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% (note_count - 1) * 60 / tempo_bpm + tail_s.\n\n% Strings (1 = G2, 2 = D2, 3 = A1, 4 = E1), a 3/4-size double bass.\n% Linear density is not a parameter: it follows from the tension and the\n% open pitch, mu = T / (2 L f0)^2, which lands on realistic values\n% (about 7 g/m for the G string, 42 g/m for the E).\nP.scale_length_m = 1.04;\nP.tension_g_n = 285.0;\nP.tension_d_n = 290.0;\nP.tension_a_n = 300.0;\nP.tension_e_n = 310.0;\nP.inharmonicity = 8.0e-5; % B of the open string: f_n ~ n f0 sqrt(1 + B n^2)\nP.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)\n\n% String damping\nP.loss_factor = 1.6e-3; % frequency-independent internal loss\nP.loss_slope = 4.0e-7; % extra high-frequency loss, s\nP.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)\nP.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg\n\n% Body (see bass_body.m for the mode table these trims act on)\nP.a0_hz = 58.5; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.2; % scales the body mode Qs\nP.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)\nP.rad_area_scale = 1.0; % scales the radiating areas of the body modes\nP.dipole_length_m = 0.45; % effective front-to-back path of the corpus dipoles\nP.radiation_loss = 1.0; % scales the radiation damping added to the body Qs\n\n% Pluck\nP.pluck_force_n = 15.0; % transverse pull at release\nP.pluck_pos = 0.38; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 2.6; % finite release time of the fingertip (spectral rolloff)\nP.choke_ms = 45.0; % decay time constant applied when the next note lands\n\n% Gesture\nP.root_midi = 33; % A1; the single note, and the key of a walking line\nP.note_count = 1;\nP.tempo_bpm = 96.0;\nP.force_spread = 0.20; % lognormal spread of the pluck force across a line\nP.timing_jitter_ms = 4.0;\nP.seed = 1;\n\n% Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)\nP.direct_gain = 0.7;\nP.listener_x_m = 0.50;\nP.listener_y_m = 0.30;\nP.listener_z_m = 1.20;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.max_mode_hz = 3000.0;\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 8.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
3636 },
3737 {
3838 "name": "bass_demo.m",
@@ -60,7 +60,7 @@
6060 },
6161 {
6262 "name": "bass_validate.m",
63- "content": "% Validate the bass model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) structural identities of the coupled eigensolve, (c) exact\n% reproducibility of the seeded walking line, and (d) pinned end-to-end\n% diagnostics from the default configuration, recorded when the model was\n% built and treated as a regression reference since.\n% Run with: numbl run bass_validate.m\n\nfails = 0;\n\n% ---- 1. decoupled stiff string against the closed form ----\n% With the bridge mobility driven to zero the body cannot move, and the\n% coupled solve must hand back the textbook pinned-pinned stiff string,\n% f_k = sqrt((k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu) / 2 pi, including the\n% inharmonic stretch. Checked on the open A with an exaggerated B.\nP = bass_defaults();\nQ = P;\nQ.bridge_mobility = 1e-9;\nQ.inharmonicity = 2e-4;\nbasis = bass_basis(Q, 3, 0);\n[~, ~, omS, ~, meta] = bass_assemble(Q, 3, 0);\nerrS = 0;\nfor k = 1:10\n fClosed = omS(k) / (2 * pi);\n fGot = bv_nearest(basis.f, fClosed);\n errS = max(errS, abs(fGot - fClosed) / fClosed);\nend\nstretch = (omS(10) / (10 * omS(1))) - 1;\nok = errS < 1e-6 && stretch > 0.005;\nfails = fails + (~ok);\nfprintf('stiff string: err %.1e vs closed form, partial-10 stretch %.3f %s\\n', ...\n errS, stretch, bv_verdict(ok));\n\n% ---- 2. eigensolve identities on the full coupled system ----\n% The Cholesky reduction must hand back mass-orthonormal shapes that\n% diagonalize the stiffness: Phi' M Phi = I and Phi' K Phi = diag(om^2).\n% Checked on the open E, the largest system of the default chart.\n[M, Kmat, ~, ~, meta] = bass_assemble(P, 4, 0);\nRc = chol(M);\nAm = (Rc') \\ (Kmat / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nPhi = Rc \\ fliplr(Uv);\neOrth = max(max(abs(Phi' * M * Phi - eye(meta.n))));\nKP = Phi' * Kmat * Phi;\neDiag = max(max(abs(KP - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve: orthonormality %.1e, diagonalization %.1e (n = %d) %s\\n', ...\n eOrth, eDiag, meta.n, bv_verdict(ok));\n\n% ---- 3. a midpoint pluck silences the even partials ----\n% b_k ~ sin(k pi xp/Lv): plucking at the middle of the vibrating length\n% puts every even string mode at a node of the initial shape, so the\n% second partial must all but vanish from the rendered pressure, while an\n% ordinary pluck position leaves it strong. This exercises the projection,\n% the synthesis, and the radiation end to end.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 3.0;\nQ.pluck_pos = 0.5;\n[y1, b1, d1] = bass_render(Q);\nfs = Q.sample_rate;\nf1 = d1.fundHz;\nf2 = bv_nearest(b1.f, 2 * f1);\nrelMid = 20 * log10(bv_level(y1, fs, f2) / bv_level(y1, fs, f1) + 1e-15);\nQ.pluck_pos = 0.30;\n[y2, b2, d2] = bass_render(Q);\nf2b = bv_nearest(b2.f, 2 * d2.fundHz);\nrelOrd = 20 * log10(bv_level(y2, fs, f2b) / bv_level(y2, fs, d2.fundHz) + 1e-15);\nok = relMid < -35 && relOrd > -25;\nfails = fails + (~ok);\nfprintf('midpoint pluck: partial 2 at %.1f dB (ordinary pluck %.1f dB) %s\\n', ...\n relMid, relOrd, bv_verdict(ok));\n\n% ---- 4. two-way coupling: veering at a string-body coincidence ----\n% Tuning the strongest corpus mode onto the open G fundamental must split\n% it into a mixed pair (this is the wolf-note mechanism), and the splitting\n% must grow as the square root of the bridge mobility, which is what a\n% two-way coupled pair does and a one-way filter does not.\nd05 = bv_split(P, 0.5);\nd20 = bv_split(P, 2.0);\nratio = d20.split / d05.split;\nok = abs(ratio - 2) < 0.25 && d20.mix > 0.15;\nfails = fails + (~ok);\nfprintf('veering: split %.2f -> %.2f Hz for 4x mobility (ratio %.2f, mix %.2f) %s\\n', ...\n d05.split, d20.split, ratio, d20.mix, bv_verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the\n% bridge so the dipole directivity is held fixed. At the fundamental the\n% dipole near-field term still contributes a few hundredths of a dB at\n% 4 m, which is physics rather than error; the tolerance covers it.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 2.5;\nQ.listener_x_m = 1.2; Q.listener_y_m = 0.75; Q.listener_z_m = 3.0;\n[ya, ~, ~] = bass_render(Q);\nQ.listener_x_m = 2.4; Q.listener_y_m = 1.5; Q.listener_z_m = 6.0;\n[yb, ~, ~] = bass_render(Q);\ndrop = 10 * log10(sum(yb.^2) / sum(ya.^2));\nok = abs(drop + 6.02) < 0.35;\nfails = fails + (~ok);\nfprintf('falloff 3.3 m -> 6.6 m: %.2f dB rms (target -6.02) %s\\n', drop, bv_verdict(ok));\n\n% ---- 6. the seeded walking line is exactly reproducible ----\nQ = P;\nQ.note_count = 8;\n[t1, m1, s1, st1, fo1, g1] = bass_notes(Q);\n[t2, m2, s2, st2, fo2, g2] = bass_notes(Q);\nsame = max(abs(t1 - t2)) == 0 && max(abs(m1 - m2)) == 0 ...\n && max(abs(s1 - s2)) == 0 && max(abs(fo1 - fo2)) == 0;\nok = same ...\n && g1.count == 8 ...\n && sum(m1) == 293 ...\n && abs(g1.meanForce - 14.014948614) < 1e-6 ...\n && abs(t1(2) - 0.623242473696) < 1e-9;\nfails = fails + (~ok);\nfprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\\n', ...\n Q.seed, g1.count, sum(m1), g1.meanForce, bv_verdict(ok));\n\n% ---- 7. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = bass_render(P);\nok = abs(dinfo.peakPa - 0.270351) < 0.001 ...\n && abs(dinfo.fundHz - 54.4515) < 0.005 ...\n && basis.n == 61;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\\n', ...\n dinfo.peakPa, 0.270351, dinfo.fundHz, 54.4515, basis.n, bv_verdict(ok));\n\n% ---- 8. the note decays ----\n% An open-string pizzicato rings for seconds, so the tail is not silent;\n% but the energy in the last half second must sit well below the opening\n% half second, which carries the attack.\nfs = P.sample_rate;\nn = length(y);\nhead = y(1:round(0.5 * fs));\ntail = y(n - round(0.5 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -15;\nfails = fails + (~ok);\nfprintf('note decay: last/first half second %.1f dB %s\\n', dropE, bv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction fv = bv_nearest(fvec, target)\n% The entry of fvec nearest target.\nfv = fvec(1);\nbest = abs(fvec(1) - target);\nfor i = 2:length(fvec)\n d = abs(fvec(i) - target);\n if d < best\n best = d;\n fv = fvec(i);\n end\nend\nend\n\nfunction a = bv_level(y, fs, f)\n% Amplitude of y at frequency f over the first two seconds (plain\n% quadrature dot products; the window is long against 1/f).\nn = min(length(y), floor(2.0 * fs));\nt = (0:n - 1) / fs;\nseg = y(1:n);\na = sqrt((seg * (cos(2 * pi * f * t))')^2 + (seg * (sin(2 * pi * f * t))')^2);\nend\n\nfunction r = bv_split(P, mobility)\n% Frequency splitting of the coupled pair when the strongest corpus mode\n% is tuned onto the open G fundamental, plus how mixed the pair is.\nQ = P;\nQ.bridge_mobility = mobility;\nQ.body_trim = 97.9989 / 97.0;\nbasis = bass_basis(Q, 1, 0);\nf0 = 97.9989;\n% the two coupled modes nearest the coincidence\nd = abs(basis.f - f0);\n[~, order] = sort(d);\ni1 = order(1);\ni2 = order(2);\nr.split = abs(basis.f(i1) - basis.f(i2));\nr.mix = min(min(basis.bodyFrac(i1), 1 - basis.bodyFrac(i1)), ...\n min(basis.bodyFrac(i2), 1 - basis.bodyFrac(i2)));\nend\n\nfunction s = bv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
63+ "content": "% Validate the bass model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) structural identities of the coupled eigensolve, (c) exact\n% reproducibility of the seeded walking line, and (d) pinned end-to-end\n% diagnostics from the default configuration, recorded when the model was\n% built and treated as a regression reference since.\n% Run with: numbl run bass_validate.m\n\nfails = 0;\n\n% ---- 1. decoupled stiff string against the closed form ----\n% With the bridge mobility driven to zero the body cannot move, and the\n% coupled solve must hand back the textbook pinned-pinned stiff string,\n% f_k = sqrt((k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu) / 2 pi, including the\n% inharmonic stretch. Checked on the open A with an exaggerated B.\nP = bass_defaults();\nQ = P;\nQ.bridge_mobility = 1e-9;\nQ.inharmonicity = 2e-4;\nbasis = bass_basis(Q, 3, 0);\n[~, ~, omS, ~, meta] = bass_assemble(Q, 3, 0);\nerrS = 0;\nfor k = 1:10\n fClosed = omS(k) / (2 * pi);\n fGot = bv_nearest(basis.f, fClosed);\n errS = max(errS, abs(fGot - fClosed) / fClosed);\nend\nstretch = (omS(10) / (10 * omS(1))) - 1;\nok = errS < 1e-6 && stretch > 0.005;\nfails = fails + (~ok);\nfprintf('stiff string: err %.1e vs closed form, partial-10 stretch %.3f %s\\n', ...\n errS, stretch, bv_verdict(ok));\n\n% ---- 2. eigensolve identities on the full coupled system ----\n% The Cholesky reduction must hand back mass-orthonormal shapes that\n% diagonalize the stiffness: Phi' M Phi = I and Phi' K Phi = diag(om^2).\n% Checked on the open E, the largest system of the default chart.\n[M, Kmat, ~, ~, meta] = bass_assemble(P, 4, 0);\nRc = chol(M);\nAm = (Rc') \\ (Kmat / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nPhi = Rc \\ fliplr(Uv);\neOrth = max(max(abs(Phi' * M * Phi - eye(meta.n))));\nKP = Phi' * Kmat * Phi;\neDiag = max(max(abs(KP - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve: orthonormality %.1e, diagonalization %.1e (n = %d) %s\\n', ...\n eOrth, eDiag, meta.n, bv_verdict(ok));\n\n% ---- 3. a midpoint pluck silences the even partials ----\n% b_k ~ sin(k pi xp/Lv): plucking at the middle of the vibrating length\n% puts every even string mode at a node of the initial shape, so the\n% second partial must all but vanish from the rendered pressure, while an\n% ordinary pluck position leaves it strong. This exercises the projection,\n% the synthesis, and the radiation end to end.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 3.0;\nQ.pluck_pos = 0.5;\n[y1, b1, d1] = bass_render(Q);\nfs = Q.sample_rate;\nf1 = d1.fundHz;\nf2 = bv_nearest(b1.f, 2 * f1);\nrelMid = 20 * log10(bv_level(y1, fs, f2) / bv_level(y1, fs, f1) + 1e-15);\nQ.pluck_pos = 0.30;\n[y2, b2, d2] = bass_render(Q);\nf2b = bv_nearest(b2.f, 2 * d2.fundHz);\nrelOrd = 20 * log10(bv_level(y2, fs, f2b) / bv_level(y2, fs, d2.fundHz) + 1e-15);\nok = relMid < -35 && relOrd > -25;\nfails = fails + (~ok);\nfprintf('midpoint pluck: partial 2 at %.1f dB (ordinary pluck %.1f dB) %s\\n', ...\n relMid, relOrd, bv_verdict(ok));\n\n% ---- 4. two-way coupling: veering at a string-body coincidence ----\n% Tuning the strongest corpus mode onto the open G fundamental must split\n% it into a mixed pair (this is the wolf-note mechanism), and the splitting\n% must grow as the square root of the bridge mobility, which is what a\n% two-way coupled pair does and a one-way filter does not.\nd05 = bv_split(P, 0.5);\nd20 = bv_split(P, 2.0);\nratio = d20.split / d05.split;\nok = abs(ratio - 2) < 0.25 && d20.mix > 0.15;\nfails = fails + (~ok);\nfprintf('veering: split %.2f -> %.2f Hz for 4x mobility (ratio %.2f, mix %.2f) %s\\n', ...\n d05.split, d20.split, ratio, d20.mix, bv_verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the\n% bridge so the dipole directivity is held fixed. At the fundamental the\n% dipole near-field term still contributes a few hundredths of a dB at\n% 4 m, which is physics rather than error; the tolerance covers it.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 2.5;\nQ.listener_x_m = 1.2; Q.listener_y_m = 0.75; Q.listener_z_m = 3.0;\n[ya, ~, ~] = bass_render(Q);\nQ.listener_x_m = 2.4; Q.listener_y_m = 1.5; Q.listener_z_m = 6.0;\n[yb, ~, ~] = bass_render(Q);\ndrop = 10 * log10(sum(yb.^2) / sum(ya.^2));\nok = abs(drop + 6.02) < 0.35;\nfails = fails + (~ok);\nfprintf('falloff 3.3 m -> 6.6 m: %.2f dB rms (target -6.02) %s\\n', drop, bv_verdict(ok));\n\n% ---- 6. the seeded walking line is exactly reproducible ----\nQ = P;\nQ.note_count = 8;\n[t1, m1, s1, st1, fo1, g1] = bass_notes(Q);\n[t2, m2, s2, st2, fo2, g2] = bass_notes(Q);\nsame = max(abs(t1 - t2)) == 0 && max(abs(m1 - m2)) == 0 ...\n && max(abs(s1 - s2)) == 0 && max(abs(fo1 - fo2)) == 0;\nok = same ...\n && g1.count == 8 ...\n && sum(m1) == 293 ...\n && abs(g1.meanForce - 14.014948614) < 1e-6 ...\n && abs(t1(2) - 0.623242473696) < 1e-9;\nfails = fails + (~ok);\nfprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\\n', ...\n Q.seed, g1.count, sum(m1), g1.meanForce, bv_verdict(ok));\n\n% ---- 7. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = bass_render(P);\nok = abs(dinfo.peakPa - 0.218467) < 0.001 ...\n && abs(dinfo.fundHz - 54.3180) < 0.005 ...\n && basis.n == 61;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\\n', ...\n dinfo.peakPa, 0.218467, dinfo.fundHz, 54.3180, basis.n, bv_verdict(ok));\n\n% ---- 8. the note decays ----\n% An open-string pizzicato rings for seconds, so the tail is not silent;\n% but the energy in the last half second must sit well below the opening\n% half second, which carries the attack.\nfs = P.sample_rate;\nn = length(y);\nhead = y(1:round(0.5 * fs));\ntail = y(n - round(0.5 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -15;\nfails = fails + (~ok);\nfprintf('note decay: last/first half second %.1f dB %s\\n', dropE, bv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction fv = bv_nearest(fvec, target)\n% The entry of fvec nearest target.\nfv = fvec(1);\nbest = abs(fvec(1) - target);\nfor i = 2:length(fvec)\n d = abs(fvec(i) - target);\n if d < best\n best = d;\n fv = fvec(i);\n end\nend\nend\n\nfunction a = bv_level(y, fs, f)\n% Amplitude of y at frequency f over the first two seconds (plain\n% quadrature dot products; the window is long against 1/f).\nn = min(length(y), floor(2.0 * fs));\nt = (0:n - 1) / fs;\nseg = y(1:n);\na = sqrt((seg * (cos(2 * pi * f * t))')^2 + (seg * (sin(2 * pi * f * t))')^2);\nend\n\nfunction r = bv_split(P, mobility)\n% Frequency splitting of the coupled pair when the strongest corpus mode\n% is tuned onto the open G fundamental, plus how mixed the pair is.\nQ = P;\nQ.bridge_mobility = mobility;\nQ.body_trim = 97.9989 / 97.0;\nbasis = bass_basis(Q, 1, 0);\nf0 = 97.9989;\n% the two coupled modes nearest the coincidence\nd = abs(basis.f - f0);\n[~, order] = sort(d);\ni1 = order(1);\ni2 = order(2);\nr.split = abs(basis.f(i1) - basis.f(i2));\nr.mix = min(min(basis.bodyFrac(i1), 1 - basis.bodyFrac(i1)), ...\n min(basis.bodyFrac(i2), 1 - basis.bodyFrac(i2)));\nend\n\nfunction s = bv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
6464 }
6565 ]
6666 }
exports/bass.jsonmodified+5−5View file
@@ -10,21 +10,21 @@
1010 "label": "pluck position",
1111 "min": 0.05,
1212 "max": 0.95,
13- "default": 0.3
13+ "default": 0.38
1414 },
1515 {
1616 "key": "finger_release_ms",
1717 "label": "fingertip release (ms)",
1818 "min": 0.2,
1919 "max": 8,
20- "default": 1.3
20+ "default": 2.6
2121 },
2222 {
2323 "key": "tail_s",
2424 "label": "ring-out (s)",
2525 "min": 1,
2626 "max": 8,
27- "default": 6
27+ "default": 8
2828 }
2929 ],
3030 "files": [
@@ -46,7 +46,7 @@
4646 },
4747 {
4848 "name": "bass_defaults.m",
49- "content": "function P = bass_defaults()\n% BASS_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% (note_count - 1) * 60 / tempo_bpm + tail_s.\n\n% Strings (1 = G2, 2 = D2, 3 = A1, 4 = E1), a 3/4-size double bass.\n% Linear density is not a parameter: it follows from the tension and the\n% open pitch, mu = T / (2 L f0)^2, which lands on realistic values\n% (about 7 g/m for the G string, 42 g/m for the E).\nP.scale_length_m = 1.04;\nP.tension_g_n = 285.0;\nP.tension_d_n = 290.0;\nP.tension_a_n = 300.0;\nP.tension_e_n = 310.0;\nP.inharmonicity = 8.0e-5; % B of the open string: f_n ~ n f0 sqrt(1 + B n^2)\nP.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)\n\n% String damping\nP.loss_factor = 1.6e-3; % frequency-independent internal loss\nP.loss_slope = 2.5e-7; % extra high-frequency loss, s\nP.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)\nP.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg\n\n% Body (see bass_body.m for the mode table these trims act on)\nP.a0_hz = 60.0; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.0; % scales the body mode Qs\nP.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)\nP.rad_area_scale = 1.0; % scales the radiating areas of the body modes\nP.dipole_length_m = 0.35; % effective front-to-back path of the corpus dipoles\nP.radiation_loss = 1.0; % scales the radiation damping added to the body Qs\n\n% Pluck\nP.pluck_force_n = 15.0; % transverse pull at release\nP.pluck_pos = 0.30; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 1.3; % finite release time of the fingertip (spectral rolloff)\nP.choke_ms = 45.0; % decay time constant applied when the next note lands\n\n% Gesture\nP.root_midi = 33; % A1; the single note, and the key of a walking line\nP.note_count = 1;\nP.tempo_bpm = 96.0;\nP.force_spread = 0.20; % lognormal spread of the pluck force across a line\nP.timing_jitter_ms = 4.0;\nP.seed = 1;\n\n% Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)\nP.direct_gain = 1.0;\nP.listener_x_m = 0.50;\nP.listener_y_m = 0.30;\nP.listener_z_m = 1.20;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.max_mode_hz = 3000.0;\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 6.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
49+ "content": "function P = bass_defaults()\n% BASS_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% The output duration is not a parameter: it is\n% (note_count - 1) * 60 / tempo_bpm + tail_s.\n\n% Strings (1 = G2, 2 = D2, 3 = A1, 4 = E1), a 3/4-size double bass.\n% Linear density is not a parameter: it follows from the tension and the\n% open pitch, mu = T / (2 L f0)^2, which lands on realistic values\n% (about 7 g/m for the G string, 42 g/m for the E).\nP.scale_length_m = 1.04;\nP.tension_g_n = 285.0;\nP.tension_d_n = 290.0;\nP.tension_a_n = 300.0;\nP.tension_e_n = 310.0;\nP.inharmonicity = 8.0e-5; % B of the open string: f_n ~ n f0 sqrt(1 + B n^2)\nP.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)\n\n% String damping\nP.loss_factor = 1.6e-3; % frequency-independent internal loss\nP.loss_slope = 4.0e-7; % extra high-frequency loss, s\nP.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)\nP.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg\n\n% Body (see bass_body.m for the mode table these trims act on)\nP.a0_hz = 58.5; % f-hole Helmholtz resonance\nP.body_trim = 1.0; % scales the corpus mode frequencies\nP.body_q_scale = 1.2; % scales the body mode Qs\nP.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)\nP.rad_area_scale = 1.0; % scales the radiating areas of the body modes\nP.dipole_length_m = 0.45; % effective front-to-back path of the corpus dipoles\nP.radiation_loss = 1.0; % scales the radiation damping added to the body Qs\n\n% Pluck\nP.pluck_force_n = 15.0; % transverse pull at release\nP.pluck_pos = 0.38; % fraction of the vibrating length, measured from the bridge\nP.finger_release_ms = 2.6; % finite release time of the fingertip (spectral rolloff)\nP.choke_ms = 45.0; % decay time constant applied when the next note lands\n\n% Gesture\nP.root_midi = 33; % A1; the single note, and the key of a walking line\nP.note_count = 1;\nP.tempo_bpm = 96.0;\nP.force_spread = 0.20; % lognormal spread of the pluck force across a line\nP.timing_jitter_ms = 4.0;\nP.seed = 1;\n\n% Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)\nP.direct_gain = 0.7;\nP.listener_x_m = 0.50;\nP.listener_y_m = 0.30;\nP.listener_z_m = 1.20;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.max_mode_hz = 3000.0;\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 8.0;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 2.0;\nend\n"
5050 },
5151 {
5252 "name": "bass_demo.m",
@@ -74,7 +74,7 @@
7474 },
7575 {
7676 "name": "bass_validate.m",
77- "content": "% Validate the bass model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) structural identities of the coupled eigensolve, (c) exact\n% reproducibility of the seeded walking line, and (d) pinned end-to-end\n% diagnostics from the default configuration, recorded when the model was\n% built and treated as a regression reference since.\n% Run with: numbl run bass_validate.m\n\nfails = 0;\n\n% ---- 1. decoupled stiff string against the closed form ----\n% With the bridge mobility driven to zero the body cannot move, and the\n% coupled solve must hand back the textbook pinned-pinned stiff string,\n% f_k = sqrt((k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu) / 2 pi, including the\n% inharmonic stretch. Checked on the open A with an exaggerated B.\nP = bass_defaults();\nQ = P;\nQ.bridge_mobility = 1e-9;\nQ.inharmonicity = 2e-4;\nbasis = bass_basis(Q, 3, 0);\n[~, ~, omS, ~, meta] = bass_assemble(Q, 3, 0);\nerrS = 0;\nfor k = 1:10\n fClosed = omS(k) / (2 * pi);\n fGot = bv_nearest(basis.f, fClosed);\n errS = max(errS, abs(fGot - fClosed) / fClosed);\nend\nstretch = (omS(10) / (10 * omS(1))) - 1;\nok = errS < 1e-6 && stretch > 0.005;\nfails = fails + (~ok);\nfprintf('stiff string: err %.1e vs closed form, partial-10 stretch %.3f %s\\n', ...\n errS, stretch, bv_verdict(ok));\n\n% ---- 2. eigensolve identities on the full coupled system ----\n% The Cholesky reduction must hand back mass-orthonormal shapes that\n% diagonalize the stiffness: Phi' M Phi = I and Phi' K Phi = diag(om^2).\n% Checked on the open E, the largest system of the default chart.\n[M, Kmat, ~, ~, meta] = bass_assemble(P, 4, 0);\nRc = chol(M);\nAm = (Rc') \\ (Kmat / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nPhi = Rc \\ fliplr(Uv);\neOrth = max(max(abs(Phi' * M * Phi - eye(meta.n))));\nKP = Phi' * Kmat * Phi;\neDiag = max(max(abs(KP - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve: orthonormality %.1e, diagonalization %.1e (n = %d) %s\\n', ...\n eOrth, eDiag, meta.n, bv_verdict(ok));\n\n% ---- 3. a midpoint pluck silences the even partials ----\n% b_k ~ sin(k pi xp/Lv): plucking at the middle of the vibrating length\n% puts every even string mode at a node of the initial shape, so the\n% second partial must all but vanish from the rendered pressure, while an\n% ordinary pluck position leaves it strong. This exercises the projection,\n% the synthesis, and the radiation end to end.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 3.0;\nQ.pluck_pos = 0.5;\n[y1, b1, d1] = bass_render(Q);\nfs = Q.sample_rate;\nf1 = d1.fundHz;\nf2 = bv_nearest(b1.f, 2 * f1);\nrelMid = 20 * log10(bv_level(y1, fs, f2) / bv_level(y1, fs, f1) + 1e-15);\nQ.pluck_pos = 0.30;\n[y2, b2, d2] = bass_render(Q);\nf2b = bv_nearest(b2.f, 2 * d2.fundHz);\nrelOrd = 20 * log10(bv_level(y2, fs, f2b) / bv_level(y2, fs, d2.fundHz) + 1e-15);\nok = relMid < -35 && relOrd > -25;\nfails = fails + (~ok);\nfprintf('midpoint pluck: partial 2 at %.1f dB (ordinary pluck %.1f dB) %s\\n', ...\n relMid, relOrd, bv_verdict(ok));\n\n% ---- 4. two-way coupling: veering at a string-body coincidence ----\n% Tuning the strongest corpus mode onto the open G fundamental must split\n% it into a mixed pair (this is the wolf-note mechanism), and the splitting\n% must grow as the square root of the bridge mobility, which is what a\n% two-way coupled pair does and a one-way filter does not.\nd05 = bv_split(P, 0.5);\nd20 = bv_split(P, 2.0);\nratio = d20.split / d05.split;\nok = abs(ratio - 2) < 0.25 && d20.mix > 0.15;\nfails = fails + (~ok);\nfprintf('veering: split %.2f -> %.2f Hz for 4x mobility (ratio %.2f, mix %.2f) %s\\n', ...\n d05.split, d20.split, ratio, d20.mix, bv_verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the\n% bridge so the dipole directivity is held fixed. At the fundamental the\n% dipole near-field term still contributes a few hundredths of a dB at\n% 4 m, which is physics rather than error; the tolerance covers it.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 2.5;\nQ.listener_x_m = 1.2; Q.listener_y_m = 0.75; Q.listener_z_m = 3.0;\n[ya, ~, ~] = bass_render(Q);\nQ.listener_x_m = 2.4; Q.listener_y_m = 1.5; Q.listener_z_m = 6.0;\n[yb, ~, ~] = bass_render(Q);\ndrop = 10 * log10(sum(yb.^2) / sum(ya.^2));\nok = abs(drop + 6.02) < 0.35;\nfails = fails + (~ok);\nfprintf('falloff 3.3 m -> 6.6 m: %.2f dB rms (target -6.02) %s\\n', drop, bv_verdict(ok));\n\n% ---- 6. the seeded walking line is exactly reproducible ----\nQ = P;\nQ.note_count = 8;\n[t1, m1, s1, st1, fo1, g1] = bass_notes(Q);\n[t2, m2, s2, st2, fo2, g2] = bass_notes(Q);\nsame = max(abs(t1 - t2)) == 0 && max(abs(m1 - m2)) == 0 ...\n && max(abs(s1 - s2)) == 0 && max(abs(fo1 - fo2)) == 0;\nok = same ...\n && g1.count == 8 ...\n && sum(m1) == 293 ...\n && abs(g1.meanForce - 14.014948614) < 1e-6 ...\n && abs(t1(2) - 0.623242473696) < 1e-9;\nfails = fails + (~ok);\nfprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\\n', ...\n Q.seed, g1.count, sum(m1), g1.meanForce, bv_verdict(ok));\n\n% ---- 7. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = bass_render(P);\nok = abs(dinfo.peakPa - 0.270351) < 0.001 ...\n && abs(dinfo.fundHz - 54.4515) < 0.005 ...\n && basis.n == 61;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\\n', ...\n dinfo.peakPa, 0.270351, dinfo.fundHz, 54.4515, basis.n, bv_verdict(ok));\n\n% ---- 8. the note decays ----\n% An open-string pizzicato rings for seconds, so the tail is not silent;\n% but the energy in the last half second must sit well below the opening\n% half second, which carries the attack.\nfs = P.sample_rate;\nn = length(y);\nhead = y(1:round(0.5 * fs));\ntail = y(n - round(0.5 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -15;\nfails = fails + (~ok);\nfprintf('note decay: last/first half second %.1f dB %s\\n', dropE, bv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction fv = bv_nearest(fvec, target)\n% The entry of fvec nearest target.\nfv = fvec(1);\nbest = abs(fvec(1) - target);\nfor i = 2:length(fvec)\n d = abs(fvec(i) - target);\n if d < best\n best = d;\n fv = fvec(i);\n end\nend\nend\n\nfunction a = bv_level(y, fs, f)\n% Amplitude of y at frequency f over the first two seconds (plain\n% quadrature dot products; the window is long against 1/f).\nn = min(length(y), floor(2.0 * fs));\nt = (0:n - 1) / fs;\nseg = y(1:n);\na = sqrt((seg * (cos(2 * pi * f * t))')^2 + (seg * (sin(2 * pi * f * t))')^2);\nend\n\nfunction r = bv_split(P, mobility)\n% Frequency splitting of the coupled pair when the strongest corpus mode\n% is tuned onto the open G fundamental, plus how mixed the pair is.\nQ = P;\nQ.bridge_mobility = mobility;\nQ.body_trim = 97.9989 / 97.0;\nbasis = bass_basis(Q, 1, 0);\nf0 = 97.9989;\n% the two coupled modes nearest the coincidence\nd = abs(basis.f - f0);\n[~, order] = sort(d);\ni1 = order(1);\ni2 = order(2);\nr.split = abs(basis.f(i1) - basis.f(i2));\nr.mix = min(min(basis.bodyFrac(i1), 1 - basis.bodyFrac(i1)), ...\n min(basis.bodyFrac(i2), 1 - basis.bodyFrac(i2)));\nend\n\nfunction s = bv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
77+ "content": "% Validate the bass model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the physics must satisfy,\n% (b) structural identities of the coupled eigensolve, (c) exact\n% reproducibility of the seeded walking line, and (d) pinned end-to-end\n% diagnostics from the default configuration, recorded when the model was\n% built and treated as a regression reference since.\n% Run with: numbl run bass_validate.m\n\nfails = 0;\n\n% ---- 1. decoupled stiff string against the closed form ----\n% With the bridge mobility driven to zero the body cannot move, and the\n% coupled solve must hand back the textbook pinned-pinned stiff string,\n% f_k = sqrt((k pi/Lv)^2 T/mu + (k pi/Lv)^4 EI/mu) / 2 pi, including the\n% inharmonic stretch. Checked on the open A with an exaggerated B.\nP = bass_defaults();\nQ = P;\nQ.bridge_mobility = 1e-9;\nQ.inharmonicity = 2e-4;\nbasis = bass_basis(Q, 3, 0);\n[~, ~, omS, ~, meta] = bass_assemble(Q, 3, 0);\nerrS = 0;\nfor k = 1:10\n fClosed = omS(k) / (2 * pi);\n fGot = bv_nearest(basis.f, fClosed);\n errS = max(errS, abs(fGot - fClosed) / fClosed);\nend\nstretch = (omS(10) / (10 * omS(1))) - 1;\nok = errS < 1e-6 && stretch > 0.005;\nfails = fails + (~ok);\nfprintf('stiff string: err %.1e vs closed form, partial-10 stretch %.3f %s\\n', ...\n errS, stretch, bv_verdict(ok));\n\n% ---- 2. eigensolve identities on the full coupled system ----\n% The Cholesky reduction must hand back mass-orthonormal shapes that\n% diagonalize the stiffness: Phi' M Phi = I and Phi' K Phi = diag(om^2).\n% Checked on the open E, the largest system of the default chart.\n[M, Kmat, ~, ~, meta] = bass_assemble(P, 4, 0);\nRc = chol(M);\nAm = (Rc') \\ (Kmat / Rc);\nAm = (Am + Am') / 2;\n[Uv, Sv, ~] = svd(Am);\nom2 = flipud(diag(Sv));\nPhi = Rc \\ fliplr(Uv);\neOrth = max(max(abs(Phi' * M * Phi - eye(meta.n))));\nKP = Phi' * Kmat * Phi;\neDiag = max(max(abs(KP - diag(om2)))) / max(om2);\nok = eOrth < 1e-8 && eDiag < 1e-10;\nfails = fails + (~ok);\nfprintf('eigensolve: orthonormality %.1e, diagonalization %.1e (n = %d) %s\\n', ...\n eOrth, eDiag, meta.n, bv_verdict(ok));\n\n% ---- 3. a midpoint pluck silences the even partials ----\n% b_k ~ sin(k pi xp/Lv): plucking at the middle of the vibrating length\n% puts every even string mode at a node of the initial shape, so the\n% second partial must all but vanish from the rendered pressure, while an\n% ordinary pluck position leaves it strong. This exercises the projection,\n% the synthesis, and the radiation end to end.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 3.0;\nQ.pluck_pos = 0.5;\n[y1, b1, d1] = bass_render(Q);\nfs = Q.sample_rate;\nf1 = d1.fundHz;\nf2 = bv_nearest(b1.f, 2 * f1);\nrelMid = 20 * log10(bv_level(y1, fs, f2) / bv_level(y1, fs, f1) + 1e-15);\nQ.pluck_pos = 0.30;\n[y2, b2, d2] = bass_render(Q);\nf2b = bv_nearest(b2.f, 2 * d2.fundHz);\nrelOrd = 20 * log10(bv_level(y2, fs, f2b) / bv_level(y2, fs, d2.fundHz) + 1e-15);\nok = relMid < -35 && relOrd > -25;\nfails = fails + (~ok);\nfprintf('midpoint pluck: partial 2 at %.1f dB (ordinary pluck %.1f dB) %s\\n', ...\n relMid, relOrd, bv_verdict(ok));\n\n% ---- 4. two-way coupling: veering at a string-body coincidence ----\n% Tuning the strongest corpus mode onto the open G fundamental must split\n% it into a mixed pair (this is the wolf-note mechanism), and the splitting\n% must grow as the square root of the bridge mobility, which is what a\n% two-way coupled pair does and a one-way filter does not.\nd05 = bv_split(P, 0.5);\nd20 = bv_split(P, 2.0);\nratio = d20.split / d05.split;\nok = abs(ratio - 2) < 0.25 && d20.mix > 0.15;\nfails = fails + (~ok);\nfprintf('veering: split %.2f -> %.2f Hz for 4x mobility (ratio %.2f, mix %.2f) %s\\n', ...\n d05.split, d20.split, ratio, d20.mix, bv_verdict(ok));\n\n% ---- 5. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, with the two listeners on the same ray from the\n% bridge so the dipole directivity is held fixed. At the fundamental the\n% dipole near-field term still contributes a few hundredths of a dB at\n% 4 m, which is physics rather than error; the tolerance covers it.\nQ = P;\nQ.normalize = 0;\nQ.tail_s = 2.5;\nQ.listener_x_m = 1.2; Q.listener_y_m = 0.75; Q.listener_z_m = 3.0;\n[ya, ~, ~] = bass_render(Q);\nQ.listener_x_m = 2.4; Q.listener_y_m = 1.5; Q.listener_z_m = 6.0;\n[yb, ~, ~] = bass_render(Q);\ndrop = 10 * log10(sum(yb.^2) / sum(ya.^2));\nok = abs(drop + 6.02) < 0.35;\nfails = fails + (~ok);\nfprintf('falloff 3.3 m -> 6.6 m: %.2f dB rms (target -6.02) %s\\n', drop, bv_verdict(ok));\n\n% ---- 6. the seeded walking line is exactly reproducible ----\nQ = P;\nQ.note_count = 8;\n[t1, m1, s1, st1, fo1, g1] = bass_notes(Q);\n[t2, m2, s2, st2, fo2, g2] = bass_notes(Q);\nsame = max(abs(t1 - t2)) == 0 && max(abs(m1 - m2)) == 0 ...\n && max(abs(s1 - s2)) == 0 && max(abs(fo1 - fo2)) == 0;\nok = same ...\n && g1.count == 8 ...\n && sum(m1) == 293 ...\n && abs(g1.meanForce - 14.014948614) < 1e-6 ...\n && abs(t1(2) - 0.623242473696) < 1e-9;\nfails = fails + (~ok);\nfprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\\n', ...\n Q.seed, g1.count, sum(m1), g1.meanForce, bv_verdict(ok));\n\n% ---- 7. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = bass_render(P);\nok = abs(dinfo.peakPa - 0.218467) < 0.001 ...\n && abs(dinfo.fundHz - 54.3180) < 0.005 ...\n && basis.n == 61;\nfails = fails + (~ok);\nfprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\\n', ...\n dinfo.peakPa, 0.218467, dinfo.fundHz, 54.3180, basis.n, bv_verdict(ok));\n\n% ---- 8. the note decays ----\n% An open-string pizzicato rings for seconds, so the tail is not silent;\n% but the energy in the last half second must sit well below the opening\n% half second, which carries the attack.\nfs = P.sample_rate;\nn = length(y);\nhead = y(1:round(0.5 * fs));\ntail = y(n - round(0.5 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / sum(head.^2));\nok = dropE < -15;\nfails = fails + (~ok);\nfprintf('note decay: last/first half second %.1f dB %s\\n', dropE, bv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction fv = bv_nearest(fvec, target)\n% The entry of fvec nearest target.\nfv = fvec(1);\nbest = abs(fvec(1) - target);\nfor i = 2:length(fvec)\n d = abs(fvec(i) - target);\n if d < best\n best = d;\n fv = fvec(i);\n end\nend\nend\n\nfunction a = bv_level(y, fs, f)\n% Amplitude of y at frequency f over the first two seconds (plain\n% quadrature dot products; the window is long against 1/f).\nn = min(length(y), floor(2.0 * fs));\nt = (0:n - 1) / fs;\nseg = y(1:n);\na = sqrt((seg * (cos(2 * pi * f * t))')^2 + (seg * (sin(2 * pi * f * t))')^2);\nend\n\nfunction r = bv_split(P, mobility)\n% Frequency splitting of the coupled pair when the strongest corpus mode\n% is tuned onto the open G fundamental, plus how mixed the pair is.\nQ = P;\nQ.bridge_mobility = mobility;\nQ.body_trim = 97.9989 / 97.0;\nbasis = bass_basis(Q, 1, 0);\nf0 = 97.9989;\n% the two coupled modes nearest the coincidence\nd = abs(basis.f - f0);\n[~, order] = sort(d);\ni1 = order(1);\ni2 = order(2);\nr.split = abs(basis.f(i1) - basis.f(i2));\nr.mix = min(min(basis.bodyFrac(i1), 1 - basis.bodyFrac(i1)), ...\n min(basis.bodyFrac(i2), 1 - basis.bodyFrac(i2)));\nend\n\nfunction s = bv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
7878 }
7979 ]
8080 }
matlab/bass_defaults.mmodified+8−8View file
@@ -17,23 +17,23 @@ P.tuning_trim = 1.0; % overall pitch trim (1 = standard orchestral tuning)
1717
1818 % String damping
1919 P.loss_factor = 1.6e-3; % frequency-independent internal loss
20-P.loss_slope = 2.5e-7; % extra high-frequency loss, s
20+P.loss_slope = 4.0e-7; % extra high-frequency loss, s
2121 P.finger_loss = 6.0e-3; % extra loss factor on stopped notes (finger termination)
2222 P.residual_y = 0.015; % residual bridge conductance above the retained body modes, s/kg
2323
2424 % Body (see bass_body.m for the mode table these trims act on)
25-P.a0_hz = 60.0; % f-hole Helmholtz resonance
25+P.a0_hz = 58.5; % f-hole Helmholtz resonance
2626 P.body_trim = 1.0; % scales the corpus mode frequencies
27-P.body_q_scale = 1.0; % scales the body mode Qs
27+P.body_q_scale = 1.2; % scales the body mode Qs
2828 P.bridge_mobility = 1.0; % scales the body admittance seen at the bridge (1/masses)
2929 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
30+P.dipole_length_m = 0.45; % effective front-to-back path of the corpus dipoles
3131 P.radiation_loss = 1.0; % scales the radiation damping added to the body Qs
3232
3333 % Pluck
3434 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 = 1.3; % finite release time of the fingertip (spectral rolloff)
35+P.pluck_pos = 0.38; % fraction of the vibrating length, measured from the bridge
36+P.finger_release_ms = 2.6; % finite release time of the fingertip (spectral rolloff)
3737 P.choke_ms = 45.0; % decay time constant applied when the next note lands
3838
3939 % Gesture
@@ -45,7 +45,7 @@ P.timing_jitter_ms = 4.0;
4545 P.seed = 1;
4646
4747 % Radiation / listener (origin at the bridge, +y up the neck, +z out of the top plate)
48-P.direct_gain = 1.0;
48+P.direct_gain = 0.7;
4949 P.listener_x_m = 0.50;
5050 P.listener_y_m = 0.30;
5151 P.listener_z_m = 1.20;
@@ -56,7 +56,7 @@ P.air_density = 1.204;
5656 P.max_mode_hz = 3000.0;
5757 P.sample_rate = 48000;
5858 P.oversample = 2;
59-P.tail_s = 6.0;
59+P.tail_s = 8.0;
6060 P.pre_delay_ms = 3.0;
6161 P.normalize = 1;
6262 P.peak_dbfs = -3.0;
matlab/bass_validate.mmodified+3−3View file
@@ -122,12 +122,12 @@ fprintf('walk (seed %d): %d notes, sum midi %d, mean force %.6f N %s\n', ...
122122
123123 % ---- 7. pinned end-to-end diagnostics at the defaults ----
124124 [y, basis, dinfo] = bass_render(P);
125-ok = abs(dinfo.peakPa - 0.270351) < 0.001 ...
126- && abs(dinfo.fundHz - 54.4515) < 0.005 ...
125+ok = abs(dinfo.peakPa - 0.218467) < 0.001 ...
126+ && abs(dinfo.fundHz - 54.3180) < 0.005 ...
127127 && basis.n == 61;
128128 fails = fails + (~ok);
129129 fprintf('pinned render: peak %.6f Pa (target %.6f), fund %.4f Hz (target %.4f), %d modes %s\n', ...
130- dinfo.peakPa, 0.270351, dinfo.fundHz, 54.4515, basis.n, bv_verdict(ok));
130+ dinfo.peakPa, 0.218467, dinfo.fundHz, 54.3180, basis.n, bv_verdict(ok));
131131
132132 % ---- 8. the note decays ----
133133 % An open-string pizzicato rings for seconds, so the tail is not silent;
webapp/src/params.tsmodified+8−8View file
@@ -71,19 +71,19 @@ export const DEFAULTS: BassParams = {
7171 inharmonicity: 8.0e-5,
7272 tuning_trim: 1.0,
7373 loss_factor: 1.6e-3,
74- loss_slope: 2.5e-7,
74+ loss_slope: 4.0e-7,
7575 finger_loss: 6.0e-3,
7676 residual_y: 0.015,
77- a0_hz: 60.0,
77+ a0_hz: 58.5,
7878 body_trim: 1.0,
79- body_q_scale: 1.0,
79+ body_q_scale: 1.2,
8080 bridge_mobility: 1.0,
8181 rad_area_scale: 1.0,
82- dipole_length_m: 0.35,
82+ dipole_length_m: 0.45,
8383 radiation_loss: 1.0,
8484 pluck_force_n: 15.0,
85- pluck_pos: 0.3,
86- finger_release_ms: 1.3,
85+ pluck_pos: 0.38,
86+ finger_release_ms: 2.6,
8787 choke_ms: 45.0,
8888 root_midi: 33,
8989 note_count: 1,
@@ -91,7 +91,7 @@ export const DEFAULTS: BassParams = {
9191 force_spread: 0.2,
9292 timing_jitter_ms: 4.0,
9393 seed: 1,
94- direct_gain: 1.0,
94+ direct_gain: 0.7,
9595 listener_x_m: 0.5,
9696 listener_y_m: 0.3,
9797 listener_z_m: 1.2,
@@ -100,7 +100,7 @@ export const DEFAULTS: BassParams = {
100100 max_mode_hz: 3000.0,
101101 sample_rate: 48000,
102102 oversample: 2,
103- tail_s: 6.0,
103+ tail_s: 8.0,
104104 pre_delay_ms: 3.0,
105105 normalize: true,
106106 peak_dbfs: -3.0,
↑↓ move↵openescclose