/ simulated_instruments / recorder
Sign in
simulated_instruments / recorder
Add instrument/: the recorder behind the track-render interface
The instrument/ module implements the whole-track instrument interface used by midiboot and now by the web app (one render path; the webapp worker is gone). The chart fold of MIDI pitch onto the baroque fingering rows lives here, note length drives blown duration, and finished renders persist in IndexedDB with LRU eviction and a 30-day age-out. Replaces the exported bundle; export-src/ and exports/ are removed.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 80e74e9e70e1 parent 3a84d42 Browse files
23 changed files+3600−728
.github/workflows/deploy.ymlmodified+4−0View file
@@ -39,6 +39,10 @@ jobs:
3939 with:
4040 node-version: 22
4141
42+ - name: Install (instrument)
43+ working-directory: instrument
44+ run: npm ci
45+
4246 - name: Install
4347 working-directory: webapp
4448 run: npm ci
README.mdmodified+1−0View file
@@ -9,6 +9,7 @@ There is no microphone model: no directivity, no capsule response, no gain stagi
99 | Path | What it is |
1010 |---|---|
1111 | `matlab/` | The algorithm in MATLAB syntax, run by [numbl](https://numbl.org) |
12+| `instrument/` | The model behind the track-render instrument interface used by [midiboot](../../proof-of-concept/midiboot) and by the web app (worker, note mapping, caching; `npm test` there runs the model in node) |
1213 | `webapp/` | Interactive browser app that executes the MATLAB code client-side |
1314
1415 The MATLAB code runs from the command line with `cd matlab && numbl run recorder_demo.m`, or `recorder_validate.m` to run the checks. The web app is a Vite project; see `webapp/README.md`.
export-src/recorder/export.jsondeleted+0−22View file
@@ -1,22 +0,0 @@
1-{
2- "id": "recorder",
3- "name": "Recorder",
4- "description": "Soprano recorder, C5 to A6 on the baroque fingering chart; out-of-range pitches fold in by octaves, velocity onto breath pressure, note length onto blown duration.",
5- "source": "simulated_instruments/recorder",
6- "knobs": [
7- {
8- "key": "attack_ms",
9- "label": "attack (ms)",
10- "min": 2,
11- "max": 60,
12- "default": 12
13- },
14- {
15- "key": "release_ms",
16- "label": "release (ms)",
17- "min": 5,
18- "max": 80,
19- "default": 25
20- }
21- ]
22-}
export-src/recorder/prepare.mdeleted+0−8View file
@@ -1,8 +0,0 @@
1-function state = prepare(params)
2-% Pitch-independent setup shared by every note of a render batch: the
3-% playable pitches of the baroque fingering chart and the chart row that
4-% produces each, C5 to A6.
5-state = struct();
6-state.chart_midi = [72 74 76 77 79 81 83 84 85 86 88 89 91 93];
7-state.chart_note = 1:14;
8-end
export-src/recorder/render_note.mdeleted+0−34View file
@@ -1,34 +0,0 @@
1-function y = render_note(note)
2-% One MIDI note through the recorder model: the pitch is folded by
3-% octaves into the fingering chart's span (C5..A6, MIDI 72..93), then the
4-% nearest playable fingering from the prepared state is taken, so a
5-% melody played below the instrument keeps its contour an octave up
6-% instead of collapsing onto the lowest note. Velocity scales breath
7-% pressure (the model's C5 reference; it tracks pressure up with pitch
8-% itself), and the note's own length sets the blown duration (clamped to
9-% what the model accepts). Fixed: a single tongued note, a fixed
10-% turbulence seed, normalize off so velocity survives in pascals.
11-P = recorder_defaults();
12-P.sample_rate = note.sample_rate;
13-P.note_count = 1;
14-P.seed = 1;
15-P.pre_delay_ms = 0;
16-P.normalize = 0;
17-if note.preview ~= 0
18- P.oversample = 1;
19-end
20-p = note.pitch;
21-if p < 72
22- p = p + 12 * ceil((72 - p) / 12);
23-end
24-if p > 93
25- p = p - 12 * ceil((p - 93) / 12);
26-end
27-[~, ix] = min(abs(note.state.chart_midi - p));
28-P.note1 = note.state.chart_note(ix);
29-P.breath_pa = 220 + (520 - 220) * note.velocity;
30-P.note_dur_s = max(0.15, min(4, note.duration_s));
31-P.attack_ms = note.params.attack_ms;
32-P.release_ms = note.params.release_ms;
33-y = recorder_render(P);
34-end
exports/recorder.jsondeleted+0−81View file
@@ -1,81 +0,0 @@
1-{
2- "format": "numbl-instrument-v1",
3- "id": "recorder",
4- "name": "Recorder",
5- "description": "Soprano recorder, C5 to A6 on the baroque fingering chart; out-of-range pitches fold in by octaves, velocity onto breath pressure, note length onto blown duration.",
6- "source": "simulated_instruments/recorder",
7- "knobs": [
8- {
9- "key": "attack_ms",
10- "label": "attack (ms)",
11- "min": 2,
12- "max": 60,
13- "default": 12
14- },
15- {
16- "key": "release_ms",
17- "label": "release (ms)",
18- "min": 5,
19- "max": 80,
20- "default": 25
21- }
22- ],
23- "files": [
24- {
25- "name": "prepare.m",
26- "content": "function state = prepare(params)\n% Pitch-independent setup shared by every note of a render batch: the\n% playable pitches of the baroque fingering chart and the chart row that\n% produces each, C5 to A6.\nstate = struct();\nstate.chart_midi = [72 74 76 77 79 81 83 84 85 86 88 89 91 93];\nstate.chart_note = 1:14;\nend\n"
27- },
28- {
29- "name": "render_note.m",
30- "content": "function y = render_note(note)\n% One MIDI note through the recorder model: the pitch is folded by\n% octaves into the fingering chart's span (C5..A6, MIDI 72..93), then the\n% nearest playable fingering from the prepared state is taken, so a\n% melody played below the instrument keeps its contour an octave up\n% instead of collapsing onto the lowest note. Velocity scales breath\n% pressure (the model's C5 reference; it tracks pressure up with pitch\n% itself), and the note's own length sets the blown duration (clamped to\n% what the model accepts). Fixed: a single tongued note, a fixed\n% turbulence seed, normalize off so velocity survives in pascals.\nP = recorder_defaults();\nP.sample_rate = note.sample_rate;\nP.note_count = 1;\nP.seed = 1;\nP.pre_delay_ms = 0;\nP.normalize = 0;\nif note.preview ~= 0\n P.oversample = 1;\nend\np = note.pitch;\nif p < 72\n p = p + 12 * ceil((72 - p) / 12);\nend\nif p > 93\n p = p - 12 * ceil((p - 93) / 12);\nend\n[~, ix] = min(abs(note.state.chart_midi - p));\nP.note1 = note.state.chart_note(ix);\nP.breath_pa = 220 + (520 - 220) * note.velocity;\nP.note_dur_s = max(0.15, min(4, note.duration_s));\nP.attack_ms = note.params.attack_ms;\nP.release_ms = note.params.release_ms;\ny = recorder_render(P);\nend\n"
31- },
32- {
33- "name": "recorder_basis.m",
34- "content": "function basis = recorder_basis(P, note)\n% RECORDER_BASIS Modal fit of the passive resonator for one fingering.\n%\n% The series-loop admittance Y(omega) = 1 / (Z_window + Z_bore) from\n% recorder_bore.m relates the acoustic flow U through the mouth to the\n% pressure the jet imposes across it; its peaks are the bore resonances the\n% jet can lock to. Each peak is fitted with one second-order section,\n% Y_n(omega) = i omega B_n / (omega_n^2 - omega^2 + i omega g_n),\n% so that in the time domain U_n'' + g_n U_n' + omega_n^2 U_n = B_n dp/dt.\n% omega_n comes from parabolic interpolation of log|Y| on the grid, g_n from\n% the half-power bandwidth, and B_n = g_n Re(Y(omega_n)) so the fitted peak\n% height matches. The fit discards nothing a recorder player can reach:\n% every local maximum below max_mode_hz is kept, up to max_modes, however\n% weak (a cross-fingered lattice makes the upper resonances weak but they\n% still color the attack).\n%\n% For radiation the basis stores, per mode, the complex flow out of each\n% radiating aperture (the window, each open hole, the foot) per unit loop\n% flow, evaluated at the mode's centre frequency, plus the pressure\n% standing-wave profile along the bore for the mode-shape plot.\n\ngeo = recorder_geometry(P);\nnote = min(max(1, round(note)), geo.nNotes);\nfing = geo.fingerings(note, :);\n\nfgrid = 60:P.grid_hz:P.max_mode_hz;\nout = recorder_bore(P, geo, fing, fgrid);\nmag = abs(out.Y);\n\n% ---- peak picking with parabolic refinement ----\nfpk = [];\nqpk = [];\nypk = [];\nnf = length(fgrid);\nfor i = 2:nf - 1\n if ~(mag(i) > mag(i - 1) && mag(i) >= mag(i + 1))\n continue;\n end\n y1 = log(mag(i - 1)); y2 = log(mag(i)); y3 = log(mag(i + 1));\n den = y1 - 2 * y2 + y3;\n sh = 0;\n if abs(den) > 1e-12\n sh = min(0.5, max(-0.5, 0.5 * (y1 - y3) / den));\n end\n fc = fgrid(i) + sh * P.grid_hz;\n yc = exp(y2 - 0.25 * (y1 - y3) * sh);\n % local bandwidth from the two neighbouring grid points: for one section,\n % |Y(f_n + df)|^2 / |Y(f_n)|^2 = g^2 / ((4 pi df)^2 + g^2), so each\n % neighbour gives g = 4 pi |df| sqrt(r / (1 - r)). Staying local keeps the\n % estimate off the skirts of adjacent resonances, which a half-power scan\n % walks onto.\n gs = [];\n for s = [-1, 1]\n df = fgrid(i + s) - fc;\n r = (mag(i + s) / yc)^2;\n if r < 0.999\n gs = [gs, 4 * pi * abs(df) * sqrt(r / (1 - r))];\n end\n end\n if length(gs) == 0\n g = 2 * pi * P.grid_hz;\n else\n g = sum(gs) / length(gs);\n end\n fpk = [fpk, fc];\n qpk = [qpk, 2 * pi * fc / g];\n ypk = [ypk, yc];\nend\n\nn = min(length(fpk), round(P.max_modes));\nif n == 0\n error('No bore resonances below max_mode_hz -- check the geometry.');\nend\nfpk = fpk(1:n);\nqpk = qpk(1:n);\nypk = ypk(1:n);\n\n% ---- aperture weights and standing-wave profiles at the mode centres ----\nmo = recorder_bore(P, geo, fing, fpk);\n\nbasis.n = n;\nbasis.note = note;\nbasis.f = reshape(fpk, n, 1);\nbasis.omega = 2 * pi * basis.f;\nbasis.Q = reshape(qpk, n, 1);\nbasis.g = basis.omega ./ basis.Q;\nbasis.B = basis.g .* reshape(ypk, n, 1);\nbasis.apX = mo.apX; % nap x 1 axial positions\nbasis.apW = mo.W; % nap x n complex flow weights\nbasis.profX = mo.profX; % 1 x nsta\nbasis.profP = abs(mo.profP); % nsta x n standing-wave magnitudes\nbasis.fTarget = geo.fTarget(note);\nbasis.Sm = geo.Sm;\nbasis.gridF = fgrid;\nbasis.gridY = mag;\nend\n"
35- },
36- {
37- "name": "recorder_bore.m",
38- "content": "function out = recorder_bore(P, geo, fing, fvec)\n% RECORDER_BORE Transmission-line acoustics of the bore at given frequencies.\n%\n% The bore is a chain of short lossy cylindrical segments (the linear taper\n% is discretized at 6 mm pitch) with the eight tone holes as lumped shunts:\n% an open hole is the air mass of its chimney plus two end corrections and a\n% baffled radiation resistance, a closed hole is the compliance of its\n% chimney volume, and a pinched thumb hole is an open hole at a reduced\n% effective radius. Viscothermal wall losses enter through the standard\n% wide-tube propagation constant,\n% Gamma = i k (1 + (1 - i) lossFac), lossFac = (dv / 2a)(1 + (gam-1)/sqrt(Pr)),\n% with dv = sqrt(2 nu / omega) the viscous boundary layer. The foot is an\n% unflanged open end; the window is an air mass (mouth end correction) with\n% a baffled monopole resistance.\n%\n% Everything is vectorized over the frequency vector fvec (row, Hz).\n%\n% Returns, per frequency:\n% out.Y series-loop admittance 1 / (Z_window + Z_bore): the passive\n% resonator the jet drives, whose peaks are the playable notes\n% out.Zbore bore input impedance at the window\n% out.W (nap x nf) complex flow out of each radiating aperture per\n% unit loop flow into the bore (aperture 1 is the window with\n% weight -1, then each open hole, then the foot)\n% out.apX (1 x nap) axial position of each radiating aperture, m\n% out.profX (1 x nsta) axial stations along the bore\n% out.profP (nsta x nf) complex pressure at each station per unit inflow\n% (the standing-wave profile of each resonance when evaluated\n% at its frequency)\n\nc = P.speed_of_sound;\nrho0 = P.air_density;\nnf = length(fvec);\nom = 2 * pi * reshape(fvec, 1, nf);\nk = om / c;\nnu = 1.5e-5; % kinematic viscosity of air\ngam = 1.4;\nsqPr = sqrt(0.71);\ndv = sqrt(2 * nu ./ max(om, 1)); % viscous boundary layer thickness\n\nL = geo.boreLen;\nrH = geo.rHead;\nrF = geo.rFoot;\n\n% ---- segment list: window (x = 0) to foot (x = L), split at the holes ----\nbounds = [0, geo.holePos, L];\nsegX0 = [];\nsegX1 = [];\nfor j = 1:length(bounds) - 1\n span = bounds(j + 1) - bounds(j);\n if span <= 1e-9\n continue;\n end\n m = max(1, ceil(span / 0.006));\n e = bounds(j) + span * (0:m) / m;\n segX0 = [segX0, e(1:m)];\n segX1 = [segX1, e(2:m + 1)];\nend\nnseg = length(segX0);\n\n% ---- hole shunt impedances ----\n% holeZ(j, :) for holes 1..8; holeOpen(j) marks a radiating (open) hole.\nholeZ = zeros(8, nf) + 0i;\nholeOpen = zeros(1, 8);\nfor j = 1:8\n b = geo.holeRad(j);\n t = geo.holeChim(j);\n s = fing(j);\n if s >= 0.25\n pinched = s < 0.75;\n if pinched\n b = b * P.thumb_pinch; % pinched: reduced effective radius\n end\n Sh = pi * b^2;\n teff = t + 1.5 * b; % inner + outer end corrections\n holeZ(j, :) = 1i * om * rho0 * teff / Sh + rho0 * c * (k * b).^2 / (2 * Sh);\n if pinched\n % The thumb crack is a narrow slit, not a round hole: its viscous\n % resistance is what damps the fundamental (whose pressure maximum\n % the vent sits near) while barely touching the second register,\n % which is the whole point of pinching.\n holeZ(j, :) = holeZ(j, :) + P.pinch_loss * rho0 * c / Sh;\n end\n holeOpen(j) = 1;\n else\n V = pi * b^2 * t;\n holeZ(j, :) = -1i * rho0 * c^2 ./ (max(om, 1) * V);\n end\nend\n\n% ---- upward march: input impedance seen from the window ----\naF = rF;\nSF = pi * aF^2;\nZcF = rho0 * c / SF;\nZ = ZcF * ((k * aF).^2 / 4 + 1i * 0.61 * (k * aF)); % unflanged foot\n\n% precompute per-segment exp(Gamma l) and Zc, reused by both passes\nsegE = zeros(nseg, nf) + 0i;\nsegZc = zeros(nseg, 1);\nfor e = 1:nseg\n xm = (segX0(e) + segX1(e)) / 2;\n a = rH + (rF - rH) * xm / L;\n S = pi * a^2;\n segZc(e) = rho0 * c / S;\n lossFac = (dv / (2 * a)) * (1 + (gam - 1) / sqPr);\n G = 1i * k .* (1 + lossFac) + k .* lossFac;\n segE(e, :) = exp(G * (segX1(e) - segX0(e)));\nend\n\n% which hole (if any) sits at the top boundary of each segment\nholeAt = zeros(1, nseg);\nfor j = 1:8\n idx = find(abs(segX0 - geo.holePos(j)) < 1e-9);\n if length(idx) == 1\n holeAt(idx) = j;\n end\nend\n\nfor e = nseg:-1:1\n Zc = segZc(e);\n E = segE(e, :);\n E2 = E .* E;\n th = (E2 - 1) ./ (E2 + 1);\n Z = Zc * (Z + Zc * th) ./ (Zc + Z .* th);\n j = holeAt(e);\n if j > 0\n Zh = holeZ(j, :);\n Z = Z .* Zh ./ (Z + Zh);\n end\nend\nZbore = Z;\n\n% ---- window impedance and the series-loop admittance ----\nZw = 1i * om * rho0 * geo.deltaM / geo.Sm + rho0 * c * k.^2 / (2 * pi);\nY = 1 ./ (Zw + Zbore);\n\n% ---- downward pass: pressure profile and aperture flows per unit inflow ----\nnsta = nseg + 1;\nprofX = zeros(1, nsta);\nprofP = zeros(nsta, nf) + 0i;\np = Zbore; % pressure at the bore entry, U = 1\nU = ones(1, nf) + 0i;\nprofX(1) = 0;\nprofP(1, :) = p;\napX = 0;\nW = -(ones(1, nf) + 0i); % window: flow out = -inflow\nfor e = 1:nseg\n % at the top boundary of this segment, shunt flow leaves through a hole\n j = holeAt(e);\n if j > 0\n q = p ./ holeZ(j, :);\n U = U - q;\n if holeOpen(j)\n apX = [apX; segX0(e)];\n W = [W; q];\n end\n end\n Zc = segZc(e);\n E = segE(e, :);\n ch = (E + 1 ./ E) / 2;\n sh = (E - 1 ./ E) / 2;\n p2 = p .* ch - Zc * U .* sh;\n U = U .* ch - (p / Zc) .* sh;\n p = p2;\n profX(e + 1) = segX1(e);\n profP(e + 1, :) = p;\nend\napX = [apX; L];\nW = [W; U]; % what remains flows out the foot\n\nout.f = reshape(fvec, 1, nf);\nout.Y = Y;\nout.Zbore = Zbore;\nout.W = W;\nout.apX = reshape(apX, length(apX), 1);\nout.profX = profX;\nout.profP = profP;\nend\n"
39- },
40- {
41- "name": "recorder_defaults.m",
42- "content": "function P = recorder_defaults()\n% RECORDER_DEFAULTS Default parameters, mirroring the web app's params.ts.\n% A soprano (descant) recorder in C. The output duration is not a parameter:\n% it is note_count * note_dur_s + (note_count - 1) * gap_ms + tail_s.\n\n% Bore (window at x = 0, open foot at x = bore_length_m)\nP.bore_length_m = 0.3015;\nP.bore_r_head_m = 0.0067;\nP.bore_r_foot_m = 0.0052;\nP.wall_m = 0.003;\nP.hole_scale = 1.0;\nP.thumb_pinch = 0.35;\nP.pinch_loss = 0.5;\n\n% Window, windway, and jet\nP.cutup_m = 0.0045; % flue exit to labium (window length along the bore)\nP.window_width_m = 0.010;\nP.flue_h_m = 0.0010; % flue exit height\nP.flue_w_m = 0.010; % flue exit width\nP.labium_offset_m = 0.0004;\nP.jet_gain = 1.0; % receptivity trim\nP.jet_growth = 0.4; % instability growth per flue height of travel\nP.jet_conv = 0.55; % jet wave speed as a fraction of Uj\nP.jet_stcut = 0.45; % jet instability cutoff, Strouhal-like: f_c = jet_stcut Uj / W\nP.vortex_loss = 1.0; % nonlinear mouth loss coefficient\nP.noise_level = 0.03; % turbulence displacement noise on the jet\nP.direct_gain = 1.0;\n\n% Breath and gesture\nP.breath_pa = 350.0; % blowing pressure for C5; higher notes track up\nP.breath_track = 1.6; % pressure grows as (f_note / 523.25)^breath_track\nP.attack_ms = 12.0;\nP.release_ms = 25.0;\nP.vibrato_hz = 5.0;\nP.vibrato_depth = 0.0;\nP.note_count = 1; % 1..3\nP.note1 = 5; % index into the fingering chart: 5 = G5\nP.note2 = 6; % A5\nP.note3 = 7; % B5\nP.note_dur_s = 1.6;\nP.gap_ms = 60.0;\nP.seed = 1;\n\n% Modal fit\nP.max_mode_hz = 10000.0;\nP.max_modes = 12;\nP.grid_hz = 2.5;\n\n% Radiation / listener (window at the origin, foot along -y, holes toward +z)\nP.listener_x_m = 0.25;\nP.listener_y_m = -0.10;\nP.listener_z_m = 0.35;\nP.speed_of_sound = 343.0;\nP.air_density = 1.204;\n\n% Rendering / output\nP.sample_rate = 48000;\nP.oversample = 2;\nP.tail_s = 0.4;\nP.pre_delay_ms = 3.0;\nP.normalize = 1;\nP.peak_dbfs = -3.0;\nP.full_scale_pa = 0.5;\nend\n"
43- },
44- {
45- "name": "recorder_demo.m",
46- "content": "% Render a recorder note with default parameters and print diagnostics.\n% Set P.note_count = 3 for a three-note figure; P.note1..note3 pick the\n% fingerings (1 = C5 ... 10 = D6, 11..14 = pinched E6, F6, G6, A6).\n% Run with: numbl run recorder_demo.m\n\nP = recorder_defaults();\ntic;\n[y, basis, dinfo] = recorder_render(P);\nelapsed = toc;\n\nfprintf('bore modes : %d resonances, first at %.1f Hz (Q %.0f)\\n', basis.n, basis.f(1), basis.Q(1));\nfprintf('notes : %d\\n', dinfo.noteCount);\nfor i = 1:dinfo.noteCount\n fprintf(' note %d : fingering %d, played %.1f Hz (target %.1f, %+.1f cents)\\n', ...\n i, dinfo.noteIdx(i), dinfo.fPlay(i), 523.25 * 2^((dz_semi(dinfo.noteIdx(i))) / 12), dinfo.cents(i));\nend\nfprintf('peak press.: %.4f Pa (%.1f dB SPL) at %.0f cm\\n', ...\n dinfo.peakPa, dinfo.peakSplDb, dinfo.listenerDistM * 100);\nfprintf('length : %.2f s @ %d Hz\\n', length(y) / P.sample_rate, P.sample_rate);\nfprintf('render time: %.2f s\\n', elapsed);\nif dinfo.listenerInside\n fprintf('WARNING : listener is at or inside the instrument\\n');\nend\n\nfunction s = dz_semi(note)\nsemis = [0 2 4 5 7 9 11 12 13 14 16 17 19 21];\ns = semis(note);\nend\n"
47- },
48- {
49- "name": "recorder_geometry.m",
50- "content": "function geo = recorder_geometry(P)\n% RECORDER_GEOMETRY Bore, tone hole lattice, window, and fingering chart.\n%\n% The bore runs from the window (x = 0, where the jet drives it) to the open\n% foot (x = bore_length_m), with a linear taper from r_head to r_foot; real\n% recorder bores are stepped and locally reworked by the maker, so the taper\n% here is a first-order stand-in. Eight tone holes: index 1 is the thumb\n% hole on the back, 2..8 are finger holes 1..7 from the top down. Hole\n% positions are stored as fractions of the design bore length, so scaling\n% bore_length_m transposes the whole instrument; radii scale with\n% P.hole_scale. The positions and radii below were tuned numerically (see\n% the repository README) so that the first-register fingerings land on an\n% equal-tempered C major scale at A4 = 440 with the default geometry.\n%\n% The fingering chart is baroque (English). Each row is one note, columns\n% are the eight holes: 0 closed, 1 open, 0.5 pinched (the thumb nail crack\n% that vents the second register; modeled as a reduced effective radius).\n% Rows 1..10 are the first register C5..D6; rows 11..14 are pinched\n% second-register fingerings E6..A6, whose intonation inherits every\n% simplification of the model and is honestly worse.\n\nL = P.bore_length_m;\n\n% Tuned hole lattice (fractions of bore length from the window, and radii in\n% m at the default geometry). Hole 1 is the thumb; 2..8 are finger holes.\ngeo.holeFrac = [0.3699 0.4271 0.4640 0.5292 0.6204 0.6519 0.7424 0.8278];\ngeo.holeRad = [0.0027 0.0032 0.0030 0.0029 0.0033 0.0026 0.0032 0.0025] * P.hole_scale;\ngeo.holePos = geo.holeFrac * L;\ngeo.holeChim = P.wall_m * ones(1, 8);\n\n% Fingering chart: thumb, then finger holes 1..7.\n% T 1 2 3 4 5 6 7\ngeo.fingerings = [ ...\n 0 0 0 0 0 0 0 0; % 1 C5\n 0 0 0 0 0 0 0 1; % 2 D5\n 0 0 0 0 0 0 1 1; % 3 E5\n 0 0 0 0 0 1 0 0; % 4 F5 (fork)\n 0 0 0 0 1 1 1 1; % 5 G5\n 0 0 0 1 1 1 1 1; % 6 A5\n 0 0 1 1 1 1 1 1; % 7 B5\n 0 1 0 1 1 1 1 1; % 8 C6\n 1 0 0 1 1 1 1 1; % 9 C#6\n 1 1 0 1 1 1 1 1; % 10 D6\n 0.5 0 0 0 0 0 1 1; % 11 E6 (pinched E5)\n 0.5 0 0 0 0 1 0 1; % 12 F6 (pinched)\n 0.5 0 0 0 1 1 1 1; % 13 G6 (pinched G5)\n 0.5 0 0 1 1 1 1 1; % 14 A6 (pinched A5)\n];\ngeo.nNotes = 14;\n\n% Equal-tempered targets, A4 = 440: semitones of each row above C5.\nsemis = [0 2 4 5 7 9 11 12 13 14 16 17 19 21];\ngeo.fTarget = 440 * 2^(3 / 12) * 2.^(reshape(semis, 1, 14) / 12);\n\n% Bore radius at position x (column or row vector), linear taper.\ngeo.rHead = P.bore_r_head_m;\ngeo.rFoot = P.bore_r_foot_m;\ngeo.boreLen = L;\n\n% Window (mouth) geometry.\ngeo.Sm = P.cutup_m * P.window_width_m; % window area\ngeo.deltaM = 0.7 * sqrt(geo.Sm); % mouth end correction\nend\n"
51- },
52- {
53- "name": "recorder_jet.m",
54- "content": "function [Zr, Zi, Qin, jinfo] = recorder_jet(P, basis, spanS, noteOrd, fsHi)\n% RECORDER_JET Time-domain integration of the jet / bore feedback loop.\n%\n% This is the one stage of the model that cannot be closed-form: a recorder\n% is a self-oscillator, so the loop is integrated sample by sample at fsHi.\n% The loop, following the jet-drive model of Verge and Fabre:\n%\n% 1. Breath pressure p_f(t) (attack/release envelope, vibrato) sets the\n% jet exit velocity Uj = sqrt(2 p_f / rho0) (Bernoulli through the flue).\n% 2. The acoustic flow U through the mouth gives a transverse velocity\n% v = U / S_m at the flue exit, which seeds a displacement wave on the\n% jet. The wave convects at jet_conv Uj (so the transit delay varies\n% with breath, which is what makes attacks speak the way they do),\n% grows by exp(jet_growth W / h), and is low-passed (two poles) at\n% jet_stcut Uj / W, the jet's own instability cutoff: frequencies the\n% jet cannot amplify must not drive the loop, and a one-pole rolloff\n% is shallow enough that an all-holes-closed fingering squeaks to its\n% fifth resonance instead of speaking the fundamental. Turbulence\n% enters here as seeded displacement noise scaling with Uj^1.5.\n% 3. The displaced jet splits at the labium: the flow injected inside is\n% Qin = w_j b Uj (1 + tanh((eta - y0) / b)), b the Bickley half-width\n% grown over the cutup, y0 the labium offset.\n% 4. The injection drives the bore as the jet-drive pressure source\n% dp = (rho0 delta_d / S_m) dQin/dt. Combined with the modal admittance\n% Y_n(s) = B_n s / (s^2 + g_n s + omega_n^2), the flow response of one\n% mode to the injected jet flow is\n% U_n(s) / Qin(s) = c_n s^2 / (s^2 + g_n s + omega_n^2),\n% c_n = B_n rho0 delta_d / S_m,\n% and this is discretized exactly as c_n (Qin - lowpass_n(Qin)) with\n% one impulse-invariant complex pole per mode: no discrete derivative\n% appears anywhere. (Approximating the two s factors by first\n% differences at fsHi is Nyquist-unstable by three orders of magnitude,\n% which is a property of the discretization, not of the physics.) The\n% broadband c_n Qin part is the mouth spitting the injected flow\n% straight back out through the window mass; the complex pole part is\n% the resonant flow the apertures radiate mode by mode.\n% 5. The nonlinear vortex loss at the window, a series resistance\n% R = 0.5 rho0 K_v |v| / S_m, enters as amplitude-dependent extra\n% damping B_n R on each mode; with the tanh it caps the amplitude.\n% The total U closes the loop.\n%\n% Oscillation is not prescribed anywhere: it grows out of the noise when the\n% loop gain crosses one, locks near a bore resonance where the phase closes\n% (jet transit near a quarter period), and overblows to the second resonance\n% when breath pressure carries the transit time out of the first's range.\n%\n% Returns the complex modal flow states Zr + i Zi (n x nT; the analytic\n% signal 2 z_n of each modal flow, which recorder_radiate.m needs for its\n% phase-correct aperture sum), the injected jet flow Qin (1 x nT, the direct\n% broadband source), and diagnostics including the playing frequency\n% estimated from zero crossings of the sustained half of the note.\n\nrho0 = P.air_density;\ndt = 1 / fsHi;\nnT = max(16, floor(spanS * fsHi));\nn = basis.n;\n\nW = P.cutup_m;\nh = P.flue_h_m;\nSm = basis.Sm;\nbj = 0.41 * (h^2 * W)^(1 / 3); % Bickley jet half-width at the labium\ngrowth = exp(min(2.5, P.jet_growth * W / h));\ndScale = rho0 * (4 / pi) * sqrt(2 * h * W) / Sm;\nlossC = 0.5 * rho0 * P.vortex_loss / Sm; % series resistance per unit |v|\ny0 = P.labium_offset_m;\njg = P.jet_gain;\n\n% ---- breath pressure envelope and derived vectors (all precomputed) ----\nt = (0:nT - 1) * dt;\ntauA = max(1e-4, P.attack_ms * 1e-3) / 2.3;\ntauR = max(1e-4, P.release_ms * 1e-3) / 2.3;\nenv = 1 - exp(-t / tauA);\nrel = find(t > P.note_dur_s);\nenv(rel) = env(rel) .* exp(-(t(rel) - P.note_dur_s) / tauR);\nvib = 1 + P.vibrato_depth * sin(2 * pi * P.vibrato_hz * t);\n% Breath tracking: players blow higher notes harder. The jet transit time\n% must stay near a quarter period of the note, so the pressure that centers\n% the lock range grows as f^2; breath_track < 2 undershoots that slightly,\n% which is what measured blowing pressures do.\ntrackFac = (basis.fTarget / 523.25)^P.breath_track;\npf = P.breath_pa * trackFac * env .* vib;\nUj = sqrt(2 * max(pf, 0) / rho0);\nUjE = max(Uj, 1.0); % guarded: receptivity and cutoff need Uj > 0\ntauS = min(0.005, W ./ (P.jet_conv * UjE)) * fsHi;\ntauS = max(2, tauS);\naJet = 1 - exp(-2 * pi * (P.jet_stcut * UjE / W) * dt); % jet instability cutoff\netaAmp = P.noise_level * h * (UjE / 8).^1.5;\n\n% ---- seeded turbulence noise, filtered in the loop ----\nstate = mod(floor(abs(P.seed)) + 7919 * noteOrd, 4294967296);\n[u, state] = recorder_rng(state, nT);\nwhite = 2 * u.' - 1;\naN1 = 1 - exp(-2 * pi * 4000 * dt); % turbulence band: 400 Hz .. 4 kHz\naN2 = 1 - exp(-2 * pi * 400 * dt);\n\n% ---- per-mode discrete resonator coefficients (impulse invariant) ----\n% U_n = c_n (Qin - 2 Re(rho_n z_n)), z_n' = lam_n z_n + Qin, with rho_n the\n% residue of (g s + omega^2) / (s^2 + g s + omega^2) at lam_n.\nomd = basis.omega .* sqrt(max(1e-12, 1 - 1 ./ (4 * basis.Q.^2)));\nlam = -basis.g / 2 + 1i * omd;\nAz = exp(lam * dt);\nar = real(Az);\nai = imag(Az);\ncn = dScale * basis.B;\nrho = (basis.g .* lam + basis.omega.^2) ./ (2i * omd);\n% modal flow per state: U_res,n = wr_n zr_n + wi_n zi_n (the -2 c Re(rho z))\nwr = -2 * cn .* real(rho);\nwi = 2 * cn .* imag(rho);\ncSum = sum(cn);\n\n% ---- the loop ----\nZr = zeros(n, nT);\nZi = zeros(n, nT);\nQin = zeros(1, nT);\nvh = zeros(1, nT);\nzr = zeros(n, 1);\nzi = zeros(n, 1);\nvprev = 0;\nvlp = 0;\nvlp2 = 0;\nnlp1 = 0;\nnlp2 = 0;\nfor k = 1:nT\n % turbulence: white noise bandpassed 400 Hz .. 4 kHz (two one-poles)\n nlp1 = nlp1 + aN1 * (white(k) - nlp1);\n nlp2 = nlp2 + aN2 * (nlp1 - nlp2);\n % transverse acoustic velocity at the flue exit, delayed by the jet\n % transit and low-passed at the jet's instability cutoff\n dk = k - tauS(k);\n vdel = 0;\n if dk >= 1\n i0 = floor(dk);\n wfr = dk - i0;\n vdel = vh(i0) * (1 - wfr) + vh(min(i0 + 1, nT)) * wfr;\n end\n vlp = vlp + aJet(k) * (vdel - vlp);\n vlp2 = vlp2 + aJet(k) * (vlp - vlp2);\n eta = jg * (h / UjE(k)) * growth * vlp2 + etaAmp(k) * (nlp1 - nlp2);\n qin = P.flue_w_m * bj * Uj(k) * (1 + tanh((eta - y0) / bj));\n damp = 1 - (dt / 2) * (lossC * abs(vprev)) * basis.B;\n zr2 = damp .* (ar .* zr - ai .* zi) + dt * qin;\n zi = damp .* (ar .* zi + ai .* zr);\n zr = zr2;\n Zr(:, k) = zr;\n Zi(:, k) = zi;\n U = cSum * qin + wr.' * zr + wi.' * zi;\n vprev = U / Sm;\n vh(k) = vprev;\n Qin(k) = qin;\nend\n\n% ---- diagnostics: playing frequency from the sustained half ----\nk0 = max(1, floor(0.35 * P.note_dur_s * fsHi));\nk1 = min(nT, floor(0.95 * P.note_dur_s * fsHi));\nseg = vh(k0:k1);\nmu = sum(seg) / length(seg);\nseg = seg - mu;\nnzc = 0;\nkFirst = 0;\nkLast = 0;\nfor k = 2:length(seg)\n if seg(k - 1) <= 0 && seg(k) > 0\n nzc = nzc + 1;\n if kFirst == 0\n kFirst = k;\n end\n kLast = k;\n end\nend\nif nzc > 2\n jinfo.fPlay = (nzc - 1) * fsHi / (kLast - kFirst);\nelse\n jinfo.fPlay = 0;\nend\njinfo.fTarget = basis.fTarget;\nif jinfo.fPlay > 0\n jinfo.cents = 1200 * log(jinfo.fPlay / basis.fTarget) / log(2);\nelse\n jinfo.cents = 0;\nend\njinfo.vacRms = sqrt(sum(seg.^2) / length(seg));\njinfo.ujMax = max(Uj);\njinfo.qinMean = sum(Qin) / nT;\nend\n"
55- },
56- {
57- "name": "recorder_notes.m",
58- "content": "function [noteIdx, tOn, spanS, ninfo] = recorder_notes(P)\n% RECORDER_NOTES The gesture: one to three tongued notes.\n%\n% Each note starts note_dur_s + gap_ms after the previous one and is rendered\n% over its own span: the sounding length plus enough ring for the release and\n% the resonator decay (the last note gets the full tail_s). Notes are\n% rendered independently and summed; the bore state does not carry across a\n% note change, which is right for tongued (detached) articulation and is the\n% model's stated limitation for legato.\n%\n% Returns row vectors: noteIdx (fingering chart rows), tOn (onset times, s),\n% spanS (rendered span of each note, s), and ninfo with the total duration.\n\nK = min(3, max(1, round(P.note_count)));\nnotes = [P.note1, P.note2, P.note3];\ngap = max(0, P.gap_ms) * 1e-3;\n\nnoteIdx = zeros(1, K);\ntOn = zeros(1, K);\nspanS = zeros(1, K);\nfor i = 1:K\n noteIdx(i) = min(max(1, round(notes(i))), 14);\n tOn(i) = (i - 1) * (P.note_dur_s + gap);\n if i < K\n ring = min(P.tail_s, 0.30);\n else\n ring = P.tail_s;\n end\n spanS(i) = P.note_dur_s + ring;\nend\n\nninfo.count = K;\nninfo.durationS = tOn(K) + P.note_dur_s + P.tail_s;\nend\n"
59- },
60- {
61- "name": "recorder_output.m",
62- "content": "function [y, peakPa] = recorder_output(P, p, oversample)\n% RECORDER_OUTPUT Anti-aliased decimation and output conditioning.\n% Kaiser-windowed-sinc FIR lowpass at 0.45 * fs_lo, decimate by oversample,\n% pre-roll, tail fade, then either normalize to peak_dbfs or divide by\n% full_scale_pa. Identical in structure to the sibling instruments' output\n% stages.\n\npeakPa = max(abs(p));\nR = oversample;\nT = length(p);\n\nif R > 1\n taps = 12 * R + 1;\n M2 = (taps - 1) / 2;\n fcNorm = 0.45 / R; % cycles per input sample\n m = (0:taps - 1) - M2;\n h = sin(2 * pi * fcNorm * m) ./ (pi * m);\n h(M2 + 1) = 2 * fcNorm; % sinc value at m = 0\n r = m / M2;\n beta = 8;\n h = h .* (rc_i0(beta * sqrt(max(0, 1 - r.^2))) / rc_i0(beta));\n h = h / sum(h); % unity DC gain\n pf = conv(p, h);\n outLen = floor(T / R);\n y = reshape(pf((0:outLen - 1) * R + M2 + 1), 1, outLen); % centered pick\nelse\n y = p;\nend\n\n% Pre-roll, then truncate to the nominal duration\npadN = floor(P.pre_delay_ms * 1e-3 * P.sample_rate);\nK = min(3, max(1, round(P.note_count)));\nduration = K * P.note_dur_s + (K - 1) * max(0, P.gap_ms) * 1e-3 + P.tail_s;\ntotal = floor(duration * P.sample_rate);\nif padN > 0\n y = [zeros(1, padN) y];\nend\ny = y(1:min(length(y), total));\n\n% Squared fade on the tail\nfade = min(2048, floor(length(y) / 8));\nif fade > 1\n y(end - fade + 1:end) = y(end - fade + 1:end) .* linspace(1, 0, fade).^2;\nend\n\nif P.normalize ~= 0\n pk = max(abs(y));\n if pk > 0\n y = y / pk * 10^(P.peak_dbfs / 20);\n end\nelse\n y = y / P.full_scale_pa;\nend\nend\n\nfunction v = rc_i0(x)\n% Modified Bessel function of the first kind, order zero (series expansion).\nv = ones(size(x));\nterm = ones(size(x));\nhx = x / 2;\nfor k = 1:40\n term = term .* (hx / k).^2;\n v = v + term;\nend\nend\n"
63- },
64- {
65- "name": "recorder_radiate.m",
66- "content": "function [p, radinfo] = recorder_radiate(P, basis, Zr, Zi, Qin, fsHi)\n% RECORDER_RADIATE Free-field pressure at the listener for one note.\n%\n% Two contributions:\n%\n% 1. Modal aperture radiation. The resonant part of each modal flow is the\n% narrowband analytic signal a_n(t) = -2 c_n rho_n z_n(t) (see\n% recorder_jet.m), and basis.apW holds each aperture's complex flow per\n% unit loop flow at that mode's centre frequency. Every radiating\n% aperture (the window, each open tone hole, the foot) is a point\n% monopole, so per mode the apertures sum coherently with their relative\n% retardations,\n% G_n = sum_ap (i omega_n rho0 / (4 pi r_ap)) W_ap,n exp(-i k (r_ap - rcen)),\n% and p(t) = sum_n Re(G_n a_n(t)). The common carrier exp(-i k rcen) is\n% stripped: relative phase between the window, the open holes, and the\n% foot is kept (this is where the directivity of a cross-fingered note\n% lives), the overall propagation delay is not. Because each mode is\n% narrowband, applying G_n at the centre frequency is the same\n% approximation the sibling instruments make.\n%\n% 2. Direct broadband term. The injected jet flow minus the broadband part\n% of the mouth response, (1 - sum c_n) Qin, leaves through the window and\n% radiates as a monopole rho0 qdot / (4 pi r): the breath noise, the\n% attack transient, and everything the modal truncation dropped. Scaled\n% by direct_gain; this is the least principled term of the model, and\n% direct_gain = 0 turns it off.\n%\n% The instrument: window at the origin, bore along -y (foot down), listener\n% at (listener_x_m, listener_y_m, listener_z_m).\n\nrho0 = P.air_density;\nc = P.speed_of_sound;\nnT = size(Zr, 2);\nn = basis.n;\n\nlx = P.listener_x_m;\nly = P.listener_y_m;\nlz = P.listener_z_m;\n\nnap = length(basis.apX);\nrap = zeros(nap, 1);\nfor a = 1:nap\n rap(a) = sqrt(lx^2 + (ly + basis.apX(a))^2 + lz^2);\nend\nlistenerInside = min(rap) < 0.02;\nrap = max(rap, 0.01);\nrcen = rap(1); % the window\n\n% modal analytic-signal weights (must mirror recorder_jet.m)\ndScale = rho0 * (4 / pi) * sqrt(2 * P.flue_h_m * P.cutup_m) / basis.Sm;\ncn = dScale * basis.B;\nomd = basis.omega .* sqrt(max(1e-12, 1 - 1 ./ (4 * basis.Q.^2)));\nlam = -basis.g / 2 + 1i * omd;\nrho = (basis.g .* lam + basis.omega.^2) ./ (2i * omd);\n\nH = zeros(1, n) + 0i;\nfor m = 1:n\n om = basis.omega(m);\n k = om / c;\n G = sum((1i * om * rho0 ./ (4 * pi * rap)) .* basis.apW(:, m) .* exp(-1i * k * (rap - rcen)));\n H(m) = G * (-2 * cn(m) * rho(m));\nend\n\np = real(H) * Zr - imag(H) * Zi; % 1 x nT\n\n% direct broadband monopole from the window\ndq = zeros(1, nT);\nif nT > 2\n dq(2:nT - 1) = (Qin(3:nT) - Qin(1:nT - 2)) * (fsHi / 2);\nend\np = p + P.direct_gain * (1 - sum(cn)) * rho0 / (4 * pi * rcen) * dq;\n\nradinfo.gainAbs = abs(H);\nradinfo.listenerInside = listenerInside;\nradinfo.listenerDistM = rcen;\nend\n"
67- },
68- {
69- "name": "recorder_render.m",
70- "content": "function [y, basis, dinfo] = recorder_render(P)\n% RECORDER_RENDER Full render: notes -> basis + jet -> radiate -> output.\n% Convenience composition of the stage functions; the web app calls the\n% stages separately so it can cache intermediates (the bore basis per note,\n% and the jet integration, which is the expensive stage of this model).\n% Returns the basis of the first note (the demo prints its resonances).\n\n[noteIdx, tOn, spanS, ninfo] = recorder_notes(P);\nfsHi = P.sample_rate * P.oversample;\nT = floor(ninfo.durationS * fsHi);\np = zeros(1, T);\n\nK = ninfo.count;\nfPlay = zeros(1, K);\ncents = zeros(1, K);\nvacRms = zeros(1, K);\nlistenerInside = 0;\nlistenerDistM = 0;\nbasis = [];\nfor i = 1:K\n bi = recorder_basis(P, noteIdx(i));\n if i == 1\n basis = bi;\n end\n [Zr, Zi, Qin, jinfo] = recorder_jet(P, bi, spanS(i), i, fsHi);\n [pn, radinfo] = recorder_radiate(P, bi, Zr, Zi, Qin, fsHi);\n k0 = min(T, round(tOn(i) * fsHi) + 1);\n len = min(length(pn), T - k0 + 1);\n p(k0:k0 + len - 1) = p(k0:k0 + len - 1) + pn(1:len);\n fPlay(i) = jinfo.fPlay;\n cents(i) = jinfo.cents;\n vacRms(i) = jinfo.vacRms;\n listenerInside = listenerInside || radinfo.listenerInside;\n listenerDistM = radinfo.listenerDistM;\nend\n\n[y, peakPa] = recorder_output(P, p, P.oversample);\n\ndinfo.noteCount = K;\ndinfo.noteIdx = noteIdx;\ndinfo.fPlay = fPlay;\ndinfo.cents = cents;\ndinfo.vacRms = vacRms;\ndinfo.durationS = ninfo.durationS;\ndinfo.peakPa = peakPa;\ndinfo.peakSplDb = 20 * log10(peakPa / 20e-6 + 1e-30);\ndinfo.listenerInside = listenerInside;\ndinfo.listenerDistM = listenerDistM;\nend\n"
71- },
72- {
73- "name": "recorder_rng.m",
74- "content": "function [r, state] = recorder_rng(state, n)\n% RECORDER_RNG Portable seeded uniform generator (32-bit LCG).\n% Returns n uniforms in (0, 1) as a column vector, plus the advanced state.\n% Deliberately not MATLAB's rand: the same seed must produce the identical\n% breath noise in the numbl CLI, Node, and the browser, and every double\n% here stays below 2^53 so the arithmetic is exact everywhere.\nr = zeros(n, 1);\nfor i = 1:n\n state = mod(state * 1664525 + 1013904223, 4294967296);\n r(i) = (state + 0.5) / 4294967296;\nend\nend\n"
75- },
76- {
77- "name": "recorder_validate.m",
78- "content": "% Validate the recorder model. There is no external reference implementation,\n% so the checks are (a) closed-form identities the passive acoustics must\n% satisfy, (b) internal identities of the modal fit, (c) emergent behavior\n% the self-oscillating jet must reproduce (register selection, overblowing),\n% (d) exact reproducibility of the seeded turbulence, and (e) pinned\n% end-to-end diagnostics from the default configuration, recorded when the\n% model was built and treated as a regression reference since.\n% Run with: numbl run recorder_validate.m\n\nfails = 0;\nP = recorder_defaults();\n\n% ---- 1. uniform open-open cylinder against the closed form ----\n% With the tone holes shrunk to nothing and the taper removed, the bore\n% admittance peaks must sit at n c / 2 (L + 0.61 a): the open-open pipe with\n% the unflanged end correction at the foot (the window end is where Zbore is\n% measured, so it carries no correction of its own). Viscothermal losses\n% shift these by well under a percent.\nQ = P;\nQ.bore_r_head_m = 0.006;\nQ.bore_r_foot_m = 0.006;\ngeo = recorder_geometry(Q);\ngeo.holeRad = 1e-9 * ones(1, 8);\nf = 100:1:2500;\nout = recorder_bore(Q, geo, zeros(1, 8), f);\nmag = abs(1 ./ out.Zbore);\npk = [];\nfor i = 2:length(f) - 1\n if mag(i) > mag(i - 1) && mag(i) >= mag(i + 1)\n pk = [pk, f(i)];\n end\nend\nc = Q.speed_of_sound;\nLeff = geo.boreLen + 0.61 * 0.006;\nfClosed = (1:3) * c / (2 * Leff);\n% viscothermal dispersion: the phase velocity is c / (1 + lossFac), with\n% lossFac = (dv / 2a)(1 + (gam - 1) / sqrt(Pr)) evaluated at each resonance\ndv = sqrt(2 * 1.5e-5 ./ (2 * pi * fClosed));\nfClosed = fClosed ./ (1 + (dv / 0.012) * (1 + 0.4 / sqrt(0.71)));\nerrC = max(abs(pk(1:3) - fClosed) ./ fClosed);\nok = errC < 3e-3;\nfails = fails + (~ok);\nfprintf('open-open cylinder: %.1f %.1f %.1f Hz vs closed form %.1f %.1f %.1f (err %.1e) %s\\n', ...\n pk(1), pk(2), pk(3), fClosed(1), fClosed(2), fClosed(3), errC, rv_verdict(ok));\n\n% ---- 2. the modal fit reconstructs the admittance it was fitted to ----\n% At each fitted centre frequency the modal sum must reproduce the computed\n% series-loop admittance. The check covers the first four resonances, which\n% carry the oscillation; above them the per-peak fit overcounts by the\n% skirts of neighbouring modes (up to ~20% on the weakest peaks), which is\n% inherent to independent-peak fitting and acoustically negligible there.\nbasis = recorder_basis(P, 5);\ngeo = recorder_geometry(P);\nbo = recorder_bore(P, geo, geo.fingerings(5, :), reshape(basis.f, 1, basis.n));\neFit = 0;\nfor m = 1:min(4, basis.n)\n om = basis.omega(m);\n Yfit = 0;\n for j = 1:basis.n\n Yfit = Yfit + 1i * om * basis.B(j) / (basis.omega(j)^2 - om^2 + 1i * om * basis.g(j));\n end\n eFit = max(eFit, abs(abs(Yfit) - abs(bo.Y(m))) / abs(bo.Y(m)));\nend\nok = eFit < 0.06;\nfails = fails + (~ok);\nfprintf('modal fit: worst |Y| error over the first four peaks %.1e %s\\n', eFit, rv_verdict(ok));\n\n% ---- 3. register selection is emergent ----\n% Three fingerings that must each speak their own register at the default\n% (tracked) breath: C5 all closed (the easiest fingering to squeak, so this\n% is the sharpest test of the jet's instability cutoff), G5, and pinched E6,\n% whose fundamental must be suppressed by the vent and not played.\nQ = P;\nQ.note_dur_s = 0.6;\nQ.tail_s = 0.2;\nfsHi = P.sample_rate * P.oversample;\ncentsAbs = zeros(1, 3);\nnotes3 = [1 5 11];\nfor i = 1:3\n b = recorder_basis(Q, notes3(i));\n [~, ~, ~, j] = recorder_jet(Q, b, 0.7, 1, fsHi);\n centsAbs(i) = abs(j.cents);\nend\nok = max(centsAbs) < 60;\nfails = fails + (~ok);\nfprintf('registers: C5 %+.0f c, G5 %+.0f c, E6 %+.0f c of equal temperament %s\\n', ...\n centsAbs(1), centsAbs(2), centsAbs(3), rv_verdict(ok));\n\n% ---- 4. overblowing is emergent ----\n% Quadrupling the breath pressure on the G5 fingering must carry the jet\n% transit time out of the fundamental's lock range and onto the second\n% resonance: an octave (plus the resonator's inharmonicity) up. Nothing in\n% the model prescribes this.\nQ = P;\nQ.note_dur_s = 0.6;\nQ.tail_s = 0.2;\nQ.breath_pa = 4 * P.breath_pa;\nb = recorder_basis(Q, 5);\n[~, ~, ~, j] = recorder_jet(Q, b, 0.7, 1, fsHi);\nratio = j.fPlay / b.f(1);\nok = ratio > 1.9 && ratio < 2.1;\nfails = fails + (~ok);\nfprintf('overblow at 4x breath: %.1f Hz = %.3f x f1 %s\\n', j.fPlay, ratio, rv_verdict(ok));\n\n% ---- 5. the seeded turbulence is exactly reproducible ----\nQ = P;\nQ.note_dur_s = 0.4;\nQ.tail_s = 0.15;\n[y1, ~, ~] = recorder_render(Q);\n[y2, ~, ~] = recorder_render(Q);\nQ.seed = 2;\n[y3, ~, ~] = recorder_render(Q);\nsame = max(abs(y1 - y2)) == 0;\ndiff = max(abs(y1 - y3)) > 0;\nok = same && diff;\nfails = fails + (~ok);\nfprintf('seed: identical rerun %d, different seed differs %d %s\\n', same, diff, rv_verdict(ok));\n\n% ---- 6. far-field falloff: -6 dB per doubling of distance ----\n% RMS over the render, both listeners on one ray from the window so every\n% aperture monopole keeps its interference geometry.\nQ = P;\nQ.normalize = 0;\nQ.note_dur_s = 0.5;\nQ.tail_s = 0.15;\nQ.listener_x_m = 0.5; Q.listener_y_m = 0.4; Q.listener_z_m = 1.0;\n[y1, ~, ~] = recorder_render(Q);\nQ.listener_x_m = 1.0; Q.listener_y_m = 0.8; Q.listener_z_m = 2.0;\n[y2, ~, ~] = recorder_render(Q);\ndrop = 10 * log10(sum(y2.^2) / sum(y1.^2));\nok = abs(drop + 6.02) < 0.35;\nfails = fails + (~ok);\nfprintf('falloff 1.2 m -> 2.4 m: %.2f dB rms (target -6.02) %s\\n', drop, rv_verdict(ok));\n\n% ---- 7. pinned end-to-end diagnostics at the defaults ----\n[y, basis, dinfo] = recorder_render(P);\nok = abs(dinfo.fPlay(1) - 781.88) < 1.5 ...\n && abs(dinfo.peakPa - 1.7982) < 0.05 ...\n && basis.n == 12 ...\n && abs(basis.f(1) - 783.94) < 0.1;\nfails = fails + (~ok);\nfprintf('pinned render: played %.2f Hz (target %.2f), peak %.4f Pa (target %.4f), %d modes %s\\n', ...\n dinfo.fPlay(1), 781.88, dinfo.peakPa, 1.7982, basis.n, rv_verdict(ok));\n\n% ---- 8. the note stops when the breath stops ----\nfs = P.sample_rate;\nn = length(y);\nsus = y(round(0.5 * fs):round(1.0 * fs));\ntail = y(n - round(0.2 * fs):n);\ndropE = 10 * log10(sum(tail.^2) / length(tail) / (sum(sus.^2) / length(sus)));\nok = dropE < -40;\nfails = fails + (~ok);\nfprintf('release: last 200 ms %.1f dB re sustain %s\\n', dropE, rv_verdict(ok));\n\nif fails > 0\n fprintf('%d CHECK(S) FAILED\\n', fails);\nelse\n fprintf('all checks passed\\n');\nend\n\nfunction s = rv_verdict(ok)\nif ok\n s = 'ok';\nelse\n s = 'MISMATCH';\nend\nend\n"
79- }
80- ]
81-}
instrument/.gitignoreadded+1−0View file
@@ -0,0 +1 @@
1+node_modules/
instrument/core.tsadded+562−0View file
@@ -0,0 +1,562 @@
1+// The soprano recorder as a track-render instrument: pure rendering logic
2+// with no DOM or Worker dependency, shared by the browser worker
3+// (worker.ts) and the node tests (test.ts). Two request shapes arrive:
4+//
5+// - the track path (midiboot): every note of a MIDI track. Notes are
6+// grouped by cache key (fingering, bucketed velocity, and bucketed
7+// duration; the recorder genuinely reads note length as blown
8+// duration), each distinct sound renders once through recorder_render,
9+// and copies are summed at the onsets. Pitch folds by octaves into the
10+// baroque fingering chart's span (C5..A6), exactly as the retired
11+// per-note export driver did.
12+//
13+// - the explorer path (the recorder web app, wantDiagnostics set): the
14+// full parameter surface and live-edited .m sources drive one staged
15+// render (basis + jet per note -> radiate/output, intermediates cached
16+// per tier) with the diagnostics payload the app's plots consume.
17+
18+import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
19+import type { MFile, TrackRenderRequest } from './types.ts';
20+
21+import { RECORDER_KNOBS, type RecorderVariant } from './knobs.ts';
22+import { NoteCache } from './noteCache.ts';
23+
24+export { RECORDER_KNOBS, type RecorderVariant };
25+
26+/** The environment the host provides: sources, progress, cancellation, and
27+ * a macrotask yield so a browser worker can receive a cancel message. */
28+export interface RenderEnv {
29+ mfiles: MFile[];
30+ post: (frac: number, stage: string) => void;
31+ /** Throws to abandon the render; called between stages and notes. */
32+ checkCancel: () => void;
33+ yieldToHost: () => Promise<void>;
34+}
35+
36+// --- diagnostics payload for the explorer path (the web app's plots) ---
37+
38+export interface RecorderExploreDiag {
39+ quality: 'preview' | 'full';
40+ noteIdx: Float32Array;
41+ noteOn: Float32Array;
42+ fPlay: Float32Array;
43+ fTarget: Float32Array;
44+ cents: Float32Array;
45+ qPlot: Float32Array;
46+ qPlotRate: number;
47+ modeF: Float32Array;
48+ modeQ: Float32Array;
49+ gridF: Float32Array;
50+ gridY: Float32Array;
51+ profX: Float32Array;
52+ profP: Float32Array;
53+ diag: {
54+ noteCount: number;
55+ modesKept: number;
56+ durationS: number;
57+ peakPa: number;
58+ peakSplDb: number;
59+ listenerDistM: number;
60+ warnings: string[];
61+ basisMs: number;
62+ jetMs: number;
63+ radiateMs: number;
64+ basisCached: boolean;
65+ jetCached: boolean;
66+ oversample: number;
67+ };
68+}
69+
70+// --- shared helpers ---
71+
72+export function hashMFiles(files: MFile[]): string {
73+ let h = 0x811c9dc5;
74+ for (const f of files) {
75+ const s = f.name + ' ' + f.source;
76+ for (let i = 0; i < s.length; i++) {
77+ h ^= s.charCodeAt(i);
78+ h = Math.imul(h, 0x01000193);
79+ }
80+ }
81+ return (h >>> 0).toString(36);
82+}
83+
84+function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
85+ const v = cache.get(key);
86+ if (v !== undefined) {
87+ cache.delete(key);
88+ cache.set(key, v);
89+ }
90+ return v;
91+}
92+
93+function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
94+ cache.delete(key);
95+ cache.set(key, value);
96+ while (cache.size > max) {
97+ const oldest = cache.keys().next().value as string;
98+ cache.delete(oldest);
99+ }
100+}
101+
102+type Params = Record<string, number | boolean>;
103+
104+function pLiteral(params: Params): string {
105+ const lines: string[] = [];
106+ for (const [k, v] of Object.entries(params)) {
107+ if (typeof v === 'number') {
108+ if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
109+ lines.push(`P.${k} = ${v};`);
110+ } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
111+ }
112+ return lines.join('\n');
113+}
114+
115+function keyFor(params: Params, keys: readonly string[]): string {
116+ return keys.map((k) => String(params[k] ?? 'd')).join('|');
117+}
118+
119+function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
120+ return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
121+}
122+
123+function asTensor(v: RuntimeValue): RuntimeTensor {
124+ const t = v as RuntimeTensor;
125+ if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
126+ return t;
127+}
128+
129+function asScalar(v: RuntimeValue): number {
130+ if (typeof v === 'number') return v;
131+ const t = v as RuntimeTensor;
132+ if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
133+ if (typeof v === 'boolean') return v ? 1 : 0;
134+ throw new Error('expected scalar result from numbl stage');
135+}
136+
137+function toF32(v: RuntimeValue): Float32Array {
138+ const t = asTensor(v);
139+ const out = new Float32Array(t.data.length);
140+ for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
141+ return out;
142+}
143+
144+function knobValue(settings: Record<string, number>, key: string): number {
145+ const spec = RECORDER_KNOBS.find((k) => k.key === key)!;
146+ const raw = settings[key];
147+ return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
148+}
149+
150+/** Velocity quantized to a few levels so identical notes share a cached
151+ * render; 8 levels is inaudible next to the model's own dynamics. */
152+export function bucketVelocity(velocity01: number, levels = 8): number {
153+ return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
154+}
155+
156+/** Duration quantized to 0.25 s steps; blown duration is real input here. */
157+export function bucketDuration(durationSec: number): number {
158+ return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25);
159+}
160+
161+// The playable pitches of the baroque fingering chart, C5..A6, and the
162+// fold of an arbitrary MIDI pitch into that span by octaves.
163+const CHART_MIDI = [72, 74, 76, 77, 79, 81, 83, 84, 85, 86, 88, 89, 91, 93];
164+
165+export function chartIndexForPitch(pitch: number): number {
166+ let p = Math.round(pitch);
167+ if (p < 72) p += 12 * Math.ceil((72 - p) / 12);
168+ if (p > 93) p -= 12 * Math.ceil((p - 93) / 12);
169+ let best = 0;
170+ for (let i = 1; i < CHART_MIDI.length; i++) {
171+ if (Math.abs(CHART_MIDI[i] - p) < Math.abs(CHART_MIDI[best] - p)) best = i;
172+ }
173+ return best + 1; // 1-based chart row
174+}
175+
176+const BASIS_KEYS = [
177+ 'bore_length_m', 'bore_r_head_m', 'bore_r_foot_m', 'wall_m',
178+ 'hole_scale', 'thumb_pinch', 'pinch_loss',
179+ 'cutup_m', 'window_width_m',
180+ 'max_mode_hz', 'max_modes', 'grid_hz',
181+ 'speed_of_sound', 'air_density',
182+];
183+
184+const JET_KEYS = [
185+ 'flue_h_m', 'flue_w_m', 'labium_offset_m',
186+ 'jet_gain', 'jet_growth', 'jet_conv', 'jet_stcut', 'vortex_loss',
187+ 'noise_level',
188+ 'breath_pa', 'breath_track', 'attack_ms', 'release_ms',
189+ 'vibrato_hz', 'vibrato_depth',
190+ 'note_dur_s', 'tail_s', 'seed',
191+ 'sample_rate',
192+];
193+
194+// Basis fields that cross from the basis stage into the jet and radiate
195+// stages as numbl RuntimeValues.
196+const BASIS_FIELDS = ['f', 'omega', 'Q', 'g', 'B', 'apX', 'apW', 'Sm', 'fTarget'];
197+
198+interface BasisEntry {
199+ vars: Record<string, RuntimeValue>;
200+ modeF: Float32Array;
201+ modeQ: Float32Array;
202+ gridF: Float32Array;
203+ gridY: Float32Array;
204+ profX: Float32Array;
205+ profP: Float32Array;
206+}
207+
208+interface JetEntry {
209+ Zr: RuntimeValue;
210+ Zi: RuntimeValue;
211+ Qin: RuntimeValue;
212+ fPlay: number;
213+ fTarget: number;
214+ cents: number;
215+ qPlot: Float32Array;
216+ qPlotRate: number;
217+ fsHi: number;
218+}
219+
220+// Explorer-tier caches, keyed under the code hash so editing the .m sources
221+// invalidates everything computed from the previous version.
222+const basisCache = new Map<string, BasisEntry>();
223+const jetCache = new Map<string, JetEntry>();
224+const BASIS_CACHE_MAX = 6;
225+const JET_CACHE_MAX = 4;
226+
227+// Finished per-note audio: an in-memory LRU in front of an IndexedDB
228+// store, so renders survive page reloads (budgets and eviction live in
229+// noteCache.ts).
230+const noteCache = new NoteCache('recorder-instrument-notes');
231+
232+// --- the explorer path (full params, staged, with diagnostics) ---
233+
234+async function renderExplore(
235+ req: TrackRenderRequest,
236+ env: RenderEnv,
237+): Promise<{ audio: Float32Array; diag: RecorderExploreDiag }> {
238+ const params = req.params ?? {};
239+ const mfiles = env.mfiles;
240+ let quality = req.quality;
241+
242+ const num = (k: string, d: number) => Number(params[k] ?? d);
243+
244+ const codeKey = hashMFiles(mfiles);
245+ const basisParamsKey = 'x|' + codeKey + '|' + keyFor(params, BASIS_KEYS);
246+ const jetParamsKey = keyFor(params, JET_KEYS);
247+
248+ // The gesture, mirrored from recorder_notes.m.
249+ const K = Math.min(3, Math.max(1, Math.round(num('note_count', 1))));
250+ const noteDurS = num('note_dur_s', 1.6);
251+ const tailS = num('tail_s', 0.4);
252+ const notes = [num('note1', 5), num('note2', 6), num('note3', 7)]
253+ .slice(0, K)
254+ .map((n) => Math.min(14, Math.max(1, Math.round(n))));
255+ const gap = Math.max(0, num('gap_ms', 60)) * 1e-3;
256+ const tOn = notes.map((_, i) => i * (noteDurS + gap));
257+ const spans = notes.map((_, i) => noteDurS + (i < K - 1 ? Math.min(tailS, 0.3) : tailS));
258+ const durationS = tOn[K - 1] + noteDurS + tailS;
259+
260+ const fullOversample = num('oversample', 2);
261+ const sampleRate = num('sample_rate', req.sampleRate);
262+
263+ const jetKeyAt = (i: number, os: number) =>
264+ basisParamsKey + `|n=${notes[i]}` + '||' + jetParamsKey + `|span=${spans[i]}|ord=${i + 1}|os=${os}`;
265+
266+ // A preview right after a full render reuses the full-rate jet runs.
267+ if (quality === 'preview' && notes.every((_, i) => jetCache.has(jetKeyAt(i, fullOversample)))) {
268+ quality = 'full';
269+ }
270+ const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
271+ const fsHi = sampleRate * oversample;
272+
273+ const base = `P = recorder_defaults();\n${pLiteral(params)}`;
274+
275+ let basisMs = 0;
276+ let jetMs = 0;
277+ let basisCached = true;
278+ let jetCached = true;
279+ const basisEntries: BasisEntry[] = [];
280+ const jetEntries: JetEntry[] = [];
281+
282+ for (let i = 0; i < K; i++) {
283+ // ---- basis stage: transmission-line solve + modal fit ----
284+ const bKey = basisParamsKey + `|n=${notes[i]}`;
285+ let bEntry = lruGet(basisCache, bKey);
286+ if (!bEntry) {
287+ basisCached = false;
288+ env.post(((i + 0.5) / K) * 0.2, 'basis');
289+ await env.yieldToHost();
290+ env.checkCancel();
291+ const t0 = performance.now();
292+ const r = runStage(mfiles, `
293+${base}
294+basis = recorder_basis(P, ${notes[i]});
295+${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
296+b_profP = basis.profP;
297+b_profX = basis.profX;
298+b_gridF = basis.gridF;
299+b_gridY = basis.gridY;
300+`);
301+ basisMs += performance.now() - t0;
302+ const vars: Record<string, RuntimeValue> = {};
303+ for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
304+ bEntry = {
305+ vars,
306+ modeF: toF32(r.variableValues.b_f),
307+ modeQ: toF32(r.variableValues.b_Q),
308+ gridF: toF32(r.variableValues.b_gridF),
309+ gridY: toF32(r.variableValues.b_gridY),
310+ profX: toF32(r.variableValues.b_profX),
311+ profP: toF32(r.variableValues.b_profP),
312+ };
313+ lruSet(basisCache, bKey, bEntry, BASIS_CACHE_MAX);
314+ }
315+ basisEntries.push(bEntry);
316+
317+ // ---- jet stage: the time-domain integration ----
318+ const jKey = jetKeyAt(i, oversample);
319+ let jEntry = lruGet(jetCache, jKey);
320+ if (!jEntry) {
321+ jetCached = false;
322+ env.post(0.2 + ((i + 0.5) / K) * 0.6, 'jet');
323+ await env.yieldToHost();
324+ env.checkCancel();
325+ const t0 = performance.now();
326+ const ds = Math.max(1, Math.round(fsHi / 2000));
327+ const r = runStage(mfiles, `
328+${base}
329+${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
330+basis.n = length(basis.f);
331+[Zr, Zi, Qin, jinfo] = recorder_jet(P, basis, ${spans[i]}, ${i + 1}, ${fsHi});
332+fPlay = jinfo.fPlay;
333+fTarget = jinfo.fTarget;
334+cents = jinfo.cents;
335+qPlot = Qin(1:${ds}:length(Qin));
336+`, bEntry.vars);
337+ jetMs += performance.now() - t0;
338+ jEntry = {
339+ Zr: r.variableValues.Zr,
340+ Zi: r.variableValues.Zi,
341+ Qin: r.variableValues.Qin,
342+ fPlay: asScalar(r.variableValues.fPlay),
343+ fTarget: asScalar(r.variableValues.fTarget),
344+ cents: asScalar(r.variableValues.cents),
345+ qPlot: toF32(r.variableValues.qPlot),
346+ qPlotRate: fsHi / ds,
347+ fsHi,
348+ };
349+ lruSet(jetCache, jKey, jEntry, JET_CACHE_MAX);
350+ }
351+ jetEntries.push(jEntry);
352+ }
353+
354+ // ---- radiate + assemble + output ----
355+ env.post(0.9, 'radiate');
356+ await env.yieldToHost();
357+ env.checkCancel();
358+ const t0 = performance.now();
359+ const inputs: Record<string, RuntimeValue> = {};
360+ const noteBlocks: string[] = [];
361+ for (let i = 0; i < K; i++) {
362+ for (const f of BASIS_FIELDS) inputs[`b${i + 1}_${f}`] = basisEntries[i].vars[`b_${f}`];
363+ inputs[`n${i + 1}_Zr`] = jetEntries[i].Zr;
364+ inputs[`n${i + 1}_Zi`] = jetEntries[i].Zi;
365+ inputs[`n${i + 1}_Qin`] = jetEntries[i].Qin;
366+ noteBlocks.push(`
367+${BASIS_FIELDS.map((f) => `basis.${f} = b${i + 1}_${f};`).join('\n')}
368+basis.n = length(basis.f);
369+[pn, radinfo] = recorder_radiate(P, basis, n${i + 1}_Zr, n${i + 1}_Zi, n${i + 1}_Qin, ${fsHi});
370+k0 = min(T, round(${tOn[i]} * ${fsHi}) + 1);
371+len = min(length(pn), T - k0 + 1);
372+p(k0:k0 + len - 1) = p(k0:k0 + len - 1) + pn(1:len);
373+inside = max(inside, radinfo.listenerInside);
374+distM = radinfo.listenerDistM;
375+`);
376+ }
377+ const rOut = runStage(mfiles, `
378+${base}
379+T = floor(${durationS} * ${fsHi});
380+p = zeros(1, T);
381+inside = 0;
382+distM = 0;
383+${noteBlocks.join('\n')}
384+[y, peakPa] = recorder_output(P, p, ${oversample});
385+`, inputs);
386+ const radiateMs = performance.now() - t0;
387+
388+ const y32 = toF32(rOut.variableValues.y);
389+ const peakPa = asScalar(rOut.variableValues.peakPa);
390+ const listenerInside = asScalar(rOut.variableValues.inside) !== 0;
391+ const listenerDistM = asScalar(rOut.variableValues.distM);
392+
393+ // assemble the jet-flow envelope over the whole timeline
394+ const qRate = jetEntries[0].qPlotRate;
395+ const qPlot = new Float32Array(Math.ceil(durationS * qRate));
396+ for (let i = 0; i < K; i++) {
397+ const off = Math.round(tOn[i] * qRate);
398+ const q = jetEntries[i].qPlot;
399+ for (let k = 0; k < q.length && off + k < qPlot.length; k++) qPlot[off + k] += q[k];
400+ }
401+
402+ const warns: string[] = [];
403+ if (listenerInside) {
404+ warns.push('the listener is within 2 cm of the instrument; the monopole field is evaluated at a clamped minimum distance');
405+ }
406+ for (let i = 0; i < K; i++) {
407+ const j = jetEntries[i];
408+ if (j.fPlay <= 0) {
409+ warns.push(`note ${i + 1} did not speak (no oscillation locked); more breath or jet receptivity may help`);
410+ } else if (Math.abs(j.cents) > 60) {
411+ warns.push(`note ${i + 1} locked to ${j.fPlay.toFixed(0)} Hz, not its fingering's register (${j.fTarget.toFixed(0)} Hz)`);
412+ }
413+ }
414+ if (!params.normalize) {
415+ let peak = 0;
416+ for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
417+ if (peak > 1.0) {
418+ warns.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${num('full_scale_pa', 0.5).toFixed(2)}`);
419+ }
420+ }
421+
422+ const diag: RecorderExploreDiag = {
423+ quality,
424+ noteIdx: new Float32Array(notes),
425+ noteOn: new Float32Array(tOn),
426+ fPlay: new Float32Array(jetEntries.map((j) => j.fPlay)),
427+ fTarget: new Float32Array(jetEntries.map((j) => j.fTarget)),
428+ cents: new Float32Array(jetEntries.map((j) => j.cents)),
429+ qPlot,
430+ qPlotRate: qRate,
431+ modeF: basisEntries[0].modeF.slice(),
432+ modeQ: basisEntries[0].modeQ.slice(),
433+ gridF: basisEntries[0].gridF.slice(),
434+ gridY: basisEntries[0].gridY.slice(),
435+ profX: basisEntries[0].profX.slice(),
436+ profP: basisEntries[0].profP.slice(),
437+ diag: {
438+ noteCount: K,
439+ modesKept: basisEntries[0].modeF.length,
440+ durationS,
441+ peakPa,
442+ peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
443+ listenerDistM,
444+ warnings: warns,
445+ basisMs,
446+ jetMs,
447+ radiateMs,
448+ basisCached,
449+ jetCached,
450+ oversample,
451+ },
452+ };
453+ return { audio: y32, diag };
454+}
455+
456+// --- the track path (midiboot): per-note rendering with caching ---
457+
458+function notePLines(note: { chart: number; velocity: number; durationSec: number }, req: TrackRenderRequest): string {
459+ // The note-to-parameter mapping: the folded pitch's nearest playable
460+ // fingering, velocity onto breath pressure (the model's C5 reference; it
461+ // tracks pressure up with pitch itself), and the note's own length onto
462+ // the blown duration (clamped to what the model accepts). A single
463+ // tongued note with a fixed turbulence seed so renders are cacheable,
464+ // normalize off so velocity survives in pascals.
465+ const settings = req.settings ?? {};
466+ const lines = [
467+ 'P = recorder_defaults();',
468+ `P.sample_rate = ${req.sampleRate};`,
469+ 'P.note_count = 1;',
470+ 'P.seed = 1;',
471+ 'P.pre_delay_ms = 0;',
472+ 'P.normalize = 0;',
473+ ];
474+ if (req.quality === 'preview') lines.push('P.oversample = 1;');
475+ lines.push(
476+ `P.note1 = ${note.chart};`,
477+ `P.breath_pa = ${220 + (520 - 220) * note.velocity};`,
478+ `P.note_dur_s = ${Math.max(0.15, Math.min(4, note.durationSec))};`,
479+ `P.attack_ms = ${knobValue(settings, 'attack_ms')};`,
480+ `P.release_ms = ${knobValue(settings, 'release_ms')};`,
481+ );
482+ return lines.join('\n');
483+}
484+
485+async function renderTrackNotes(
486+ req: TrackRenderRequest,
487+ env: RenderEnv,
488+): Promise<{ audio: Float32Array }> {
489+ if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
490+ const codeKey = hashMFiles(env.mfiles);
491+ const settings = req.settings ?? {};
492+ const knobsKey = RECORDER_KNOBS.map((k) => `${k.key}=${knobValue(settings, k.key)}`).join(',');
493+
494+ // Group notes by everything that affects their sound: fingering,
495+ // velocity level, and blown duration (quantized to 0.25 s).
496+ const groups = new Map<string, { chart: number; velocity: number; durationSec: number; onsets: number[] }>();
497+ for (const n of req.notes) {
498+ const chart = chartIndexForPitch(n.pitch);
499+ const vel = bucketVelocity(n.velocity);
500+ const dur = bucketDuration(n.durationSec);
501+ const key = `${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${chart}|${vel}|${dur}`;
502+ let g = groups.get(key);
503+ if (!g) groups.set(key, (g = { chart, velocity: vel, durationSec: dur, onsets: [] }));
504+ g.onsets.push(n.timeSec);
505+ }
506+
507+ const placed: { buf: Float32Array; onsets: number[] }[] = [];
508+ // Resolve cache hits first (memory, then IndexedDB), so the progress
509+ // denominator counts only the sounds that actually need rendering.
510+ const resolved = new Map<string, Float32Array>();
511+ for (const key of groups.keys()) {
512+ const hit = await noteCache.get(key);
513+ if (hit) resolved.set(key, hit);
514+ }
515+ const misses = groups.size - resolved.size;
516+ let done = 0;
517+ for (const [key, g] of groups) {
518+ let buf = resolved.get(key);
519+ if (!buf) {
520+ env.post(done / Math.max(1, misses), `note ${done + 1}/${misses}: jet`);
521+ await env.yieldToHost();
522+ env.checkCancel();
523+ const r = runStage(env.mfiles, `
524+${notePLines(g, req)}
525+[y, basis, dinfo] = recorder_render(P);
526+`);
527+ buf = toF32(r.variableValues.y);
528+ noteCache.put(key, buf);
529+ done++;
530+ }
531+ placed.push({ buf, onsets: g.onsets });
532+ }
533+
534+ // lengthSamples is a minimum: size the output to the longest tail so a
535+ // ring that outlives the last note is returned in full, and place every
536+ // copy.
537+ let length = req.lengthSamples;
538+ for (const p of placed) {
539+ for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
540+ }
541+ const out = new Float32Array(length);
542+ for (const p of placed) {
543+ for (const t of p.onsets) {
544+ const off = Math.round(t * req.sampleRate);
545+ const end = Math.min(out.length, off + p.buf.length);
546+ for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
547+ }
548+ }
549+ return { audio: out };
550+}
551+
552+/** The single entry point: explorer path when diagnostics are requested,
553+ * the per-note track path otherwise. */
554+export async function renderRequest(
555+ variant: RecorderVariant,
556+ req: TrackRenderRequest,
557+ env: RenderEnv,
558+): Promise<{ audio: Float32Array; diag?: RecorderExploreDiag }> {
559+ void variant;
560+ if (req.wantDiagnostics) return renderExplore(req, env);
561+ return renderTrackNotes(req, env);
562+}
instrument/index.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+// The soprano recorder behind the generic track-render instrument interface, for
2+// direct import by midiboot and by the recorder web app. The MATLAB model
3+// in ../matlab is executed by numbl in a lazily spawned worker that this
4+// module owns; the worker is kept alive across renders so its caches (the
5+// explorer stage tiers and finished note renders) persist. Aborting a
6+// request is cooperative, checked between render stages; if the worker
7+// does not respond within a grace period it is torn down (and the caches
8+// with it) and respawned.
9+import type { Instrument, TrackRenderRequest, TrackRenderResult } from './types.ts';
10+import { RECORDER_KNOBS, type RecorderVariant } from './knobs.ts';
11+import type { WorkerCancelMsg, WorkerRenderMsg, WorkerResponse } from './worker.ts';
12+
13+const CANCEL_GRACE_MS = 5000;
14+
15+interface Pending {
16+ resolve: (r: TrackRenderResult) => void;
17+ reject: (e: Error) => void;
18+ onProgress?: (p: { frac: number; stage: string }) => void;
19+}
20+
21+let worker: Worker | null = null;
22+let nextId = 1;
23+const pending = new Map<number, Pending>();
24+
25+function ensureWorker(): Worker {
26+ if (worker) return worker;
27+ worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
28+ worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
29+ const msg = ev.data;
30+ const p = pending.get(msg.id);
31+ if (!p) return;
32+ if (msg.kind === 'progress') {
33+ p.onProgress?.({ frac: msg.frac, stage: msg.stage });
34+ return;
35+ }
36+ pending.delete(msg.id);
37+ if (msg.kind === 'result') p.resolve({ audio: msg.audio, diag: msg.diag });
38+ else if (msg.kind === 'cancelled') p.reject(new DOMException('render cancelled', 'AbortError'));
39+ else p.reject(Object.assign(new Error(msg.message), { file: msg.file, line: msg.line }));
40+ };
41+ worker.onerror = (ev) => {
42+ const err = new Error(`recorder instrument worker failed: ${ev.message}`);
43+ for (const p of pending.values()) p.reject(err);
44+ pending.clear();
45+ };
46+ return worker;
47+}
48+
49+function teardownWorker(reason: Error): void {
50+ if (worker) worker.terminate();
51+ worker = null;
52+ for (const p of pending.values()) p.reject(reason);
53+ pending.clear();
54+}
55+
56+function renderOnWorker(variant: RecorderVariant, req: TrackRenderRequest): Promise<TrackRenderResult> {
57+ if (req.signal?.aborted) return Promise.reject(new DOMException('render cancelled', 'AbortError'));
58+ const w = ensureWorker();
59+ const id = nextId++;
60+ const msg: WorkerRenderMsg = {
61+ kind: 'render',
62+ id,
63+ variant,
64+ notes: req.notes.map((n) => ({ timeSec: n.timeSec, durationSec: n.durationSec, pitch: n.pitch, velocity: n.velocity })),
65+ sampleRate: req.sampleRate,
66+ lengthSamples: req.lengthSamples,
67+ quality: req.quality,
68+ settings: req.settings ?? {},
69+ params: req.params,
70+ mfiles: req.mfiles,
71+ wantDiagnostics: req.wantDiagnostics,
72+ };
73+ return new Promise<TrackRenderResult>((resolve, reject) => {
74+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
75+ const onAbort = () => {
76+ const cancel: WorkerCancelMsg = { kind: 'cancel', id };
77+ worker?.postMessage(cancel);
78+ // Escalation: a stage that never yields (or a hung worker) is torn
79+ // down after the grace period, losing the caches.
80+ graceTimer = setTimeout(() => {
81+ if (pending.has(id)) teardownWorker(new DOMException('render cancelled', 'AbortError'));
82+ }, CANCEL_GRACE_MS);
83+ };
84+ req.signal?.addEventListener('abort', onAbort, { once: true });
85+ pending.set(id, {
86+ resolve: (r) => {
87+ req.signal?.removeEventListener('abort', onAbort);
88+ if (graceTimer !== null) clearTimeout(graceTimer);
89+ resolve(r);
90+ },
91+ reject: (e) => {
92+ req.signal?.removeEventListener('abort', onAbort);
93+ if (graceTimer !== null) clearTimeout(graceTimer);
94+ reject(e);
95+ },
96+ onProgress: req.onProgress,
97+ });
98+ w.postMessage(msg);
99+ });
100+}
101+
102+class RecorderInstrument implements Instrument {
103+ readonly id = 'recorder';
104+ readonly name = 'Recorder';
105+ readonly knobs = RECORDER_KNOBS;
106+ renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult> {
107+ return renderOnWorker('recorder', req);
108+ }
109+}
110+
111+export const recorderInstrument: Instrument = new RecorderInstrument();
112+export const recorderInstruments: Instrument[] = [recorderInstrument];
instrument/knobs.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+// Curated knob declarations, in their own module so the app-facing
2+// index.ts can import them without pulling numbl into the main bundle.
3+import type { KnobSpec } from './types.ts';
4+
5+export const RECORDER_KNOBS: KnobSpec[] = [
6+ { key: 'attack_ms', label: 'attack (ms)', min: 2, max: 60, default: 12 },
7+ { key: 'release_ms', label: 'release (ms)', min: 5, max: 80, default: 25 },
8+];
9+
10+export type RecorderVariant = 'recorder';
instrument/mfiles.tsadded+14−0View file
@@ -0,0 +1,14 @@
1+// The shipped MATLAB sources, loaded from ../matlab at build time. Browser
2+// only (import.meta.glob is a Vite feature); node tests read the same files
3+// through fs instead.
4+import type { MFile } from './types.ts';
5+
6+const modules = import.meta.glob('../matlab/*.m', {
7+ eager: true,
8+ query: '?raw',
9+ import: 'default',
10+}) as Record<string, string>;
11+
12+export const DEFAULT_MFILES: MFile[] = Object.entries(modules)
13+ .map(([path, source]) => ({ name: path.split('/').pop()!, source }))
14+ .sort((a, b) => a.name.localeCompare(b.name));
instrument/noteCache.tsadded+180−0View file
@@ -0,0 +1,180 @@
1+// Persistent cache for finished note renders: an in-memory LRU in front of
2+// an IndexedDB store, so a rendered note survives page reloads, worker
3+// teardowns, and browser restarts. Keys already carry everything that
4+// identifies a sound (the .m code hash, quality tier, sample rate, knob
5+// values, pitch, bucketed velocity/duration), so a stale entry from older
6+// code is simply never asked for again and ages out.
7+//
8+// Eviction is by budget and by age. The memory tier is bounded by total
9+// samples and evicts least-recently-used, as before. The IndexedDB tier
10+// has its own larger sample budget, trimmed least-recently-used after
11+// each write, and entries unused for 30 days are dropped when the store
12+// is opened. Sample data and bookkeeping live in separate object stores
13+// ('data' and 'meta'), so the eviction scan never loads audio.
14+//
15+// Everything degrades to memory-only: no indexedDB global (the node
16+// tests), a blocked or failing database, or a private-mode quota all just
17+// disable persistence.
18+
19+interface MetaRec {
20+ key: string;
21+ sampleCount: number;
22+ lastUsedAt: number;
23+}
24+
25+interface DataRec {
26+ key: string;
27+ samples: Float32Array;
28+}
29+
30+const MEM_MAX_SAMPLES = 48e6; // ~192 MB of Float32 renders in memory
31+const DB_MAX_SAMPLES = 96e6; // ~384 MB on disk
32+const MAX_AGE_MS = 30 * 24 * 3600e3;
33+
34+function reqDone<T>(req: IDBRequest<T>): Promise<T> {
35+ return new Promise((resolve, reject) => {
36+ req.onsuccess = () => resolve(req.result);
37+ req.onerror = () => reject(req.error);
38+ });
39+}
40+
41+export class NoteCache {
42+ private mem = new Map<string, Float32Array>();
43+ private memSamples = 0;
44+ private dbPromise: Promise<IDBDatabase | null> | null = null;
45+ /** Bookkeeping mirror of the 'meta' store, loaded once at open. */
46+ private meta = new Map<string, MetaRec>();
47+ private dbSamples = 0;
48+
49+ constructor(private readonly dbName: string) {}
50+
51+ private open(): Promise<IDBDatabase | null> {
52+ if (this.dbPromise) return this.dbPromise;
53+ this.dbPromise = (async () => {
54+ if (typeof indexedDB === 'undefined') return null;
55+ try {
56+ const req = indexedDB.open(this.dbName, 1);
57+ req.onupgradeneeded = () => {
58+ const db = req.result;
59+ if (!db.objectStoreNames.contains('data')) db.createObjectStore('data', { keyPath: 'key' });
60+ if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'key' });
61+ };
62+ const db = await new Promise<IDBDatabase>((resolve, reject) => {
63+ req.onsuccess = () => resolve(req.result);
64+ req.onerror = () => reject(req.error);
65+ req.onblocked = () => reject(new Error('blocked'));
66+ });
67+ db.onversionchange = () => db.close();
68+
69+ // Load the bookkeeping and drop entries unused for too long.
70+ const metas = (await reqDone(
71+ db.transaction('meta', 'readonly').objectStore('meta').getAll(),
72+ )) as MetaRec[];
73+ const now = Date.now();
74+ const stale: string[] = [];
75+ for (const m of metas) {
76+ if (now - m.lastUsedAt > MAX_AGE_MS) stale.push(m.key);
77+ else {
78+ this.meta.set(m.key, m);
79+ this.dbSamples += m.sampleCount;
80+ }
81+ }
82+ if (stale.length > 0) {
83+ const tx = db.transaction(['data', 'meta'], 'readwrite');
84+ for (const key of stale) {
85+ tx.objectStore('data').delete(key);
86+ tx.objectStore('meta').delete(key);
87+ }
88+ }
89+ return db;
90+ } catch {
91+ return null; // persistence unavailable; memory tier still works
92+ }
93+ })();
94+ return this.dbPromise;
95+ }
96+
97+ private memPut(key: string, samples: Float32Array): void {
98+ const old = this.mem.get(key);
99+ if (old) this.memSamples -= old.length;
100+ this.mem.delete(key);
101+ this.mem.set(key, samples);
102+ this.memSamples += samples.length;
103+ while (this.memSamples > MEM_MAX_SAMPLES && this.mem.size > 1) {
104+ const oldest = this.mem.keys().next().value as string;
105+ this.memSamples -= this.mem.get(oldest)!.length;
106+ this.mem.delete(oldest);
107+ }
108+ }
109+
110+ /** Record a use; fire-and-forget so hits never wait on a write. */
111+ private touch(key: string): void {
112+ const m = this.meta.get(key);
113+ if (!m) return;
114+ m.lastUsedAt = Date.now();
115+ void this.open().then((db) => {
116+ if (!db) return;
117+ try {
118+ db.transaction('meta', 'readwrite').objectStore('meta').put(m);
119+ } catch { /* persistence unavailable */ }
120+ });
121+ }
122+
123+ async get(key: string): Promise<Float32Array | undefined> {
124+ const hit = this.mem.get(key);
125+ if (hit) {
126+ this.mem.delete(key);
127+ this.mem.set(key, hit); // LRU refresh
128+ this.touch(key);
129+ return hit;
130+ }
131+ const db = await this.open();
132+ if (!db) return undefined;
133+ try {
134+ const rec = (await reqDone(
135+ db.transaction('data', 'readonly').objectStore('data').get(key),
136+ )) as DataRec | undefined;
137+ if (!rec) return undefined;
138+ const samples = rec.samples instanceof Float32Array ? rec.samples : new Float32Array(rec.samples as ArrayLike<number>);
139+ this.memPut(key, samples);
140+ this.touch(key);
141+ return samples;
142+ } catch {
143+ return undefined;
144+ }
145+ }
146+
147+ /** Store a fresh render: memory now, disk in the background. */
148+ put(key: string, samples: Float32Array): void {
149+ this.memPut(key, samples);
150+ void this.persist(key, samples);
151+ }
152+
153+ private async persist(key: string, samples: Float32Array): Promise<void> {
154+ const db = await this.open();
155+ if (!db) return;
156+ try {
157+ const prev = this.meta.get(key);
158+ if (prev) this.dbSamples -= prev.sampleCount;
159+ const m: MetaRec = { key, sampleCount: samples.length, lastUsedAt: Date.now() };
160+ const tx = db.transaction(['data', 'meta'], 'readwrite');
161+ tx.objectStore('data').put({ key, samples } satisfies DataRec);
162+ tx.objectStore('meta').put(m);
163+ this.meta.set(key, m);
164+ this.dbSamples += m.sampleCount;
165+
166+ // Trim least-recently-used until the disk tier is under budget.
167+ if (this.dbSamples > DB_MAX_SAMPLES) {
168+ const byAge = [...this.meta.values()].sort((a, b) => a.lastUsedAt - b.lastUsedAt);
169+ for (const victim of byAge) {
170+ if (this.dbSamples <= DB_MAX_SAMPLES) break;
171+ if (victim.key === key) continue; // never evict what was just written
172+ tx.objectStore('data').delete(victim.key);
173+ tx.objectStore('meta').delete(victim.key);
174+ this.meta.delete(victim.key);
175+ this.dbSamples -= victim.sampleCount;
176+ }
177+ }
178+ } catch { /* persistence unavailable; the memory tier holds it */ }
179+ }
180+}
instrument/package-lock.jsonadded+2301−0View file
This diff is 2,306 lines long and is not shown.
instrument/package.jsonadded+16−0View file
@@ -0,0 +1,16 @@
1+{
2+ "name": "recorder-instrument",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "The soprano recorder model behind the track-render instrument interface",
7+ "scripts": {
8+ "test": "tsx test.ts"
9+ },
10+ "dependencies": {
11+ "numbl": "^0.4.19"
12+ },
13+ "devDependencies": {
14+ "tsx": "^4.19.0"
15+ }
16+}
instrument/test.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+// Node-side test of the recorder track-render instrument: executes the
2+// actual MATLAB model through numbl (no browser, no worker) and checks the
3+// audio. Run with: npx tsx test.ts
4+import { readdirSync, readFileSync } from 'node:fs';
5+import { join, dirname } from 'node:path';
6+import { fileURLToPath } from 'node:url';
7+import { chartIndexForPitch, renderRequest, type RenderEnv } from './core.ts';
8+import type { NoteSpec, TrackRenderRequest } from './types.ts';
9+
10+const matlabDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'matlab');
11+const mfiles = readdirSync(matlabDir)
12+ .filter((f) => f.endsWith('.m'))
13+ .sort()
14+ .map((f) => ({ name: f, source: readFileSync(join(matlabDir, f), 'utf8') }));
15+
16+const env: RenderEnv = {
17+ mfiles,
18+ post: () => {},
19+ checkCancel: () => {},
20+ yieldToHost: async () => {},
21+};
22+
23+let failures = 0;
24+function check(label: string, ok: boolean, detail = ''): void {
25+ console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
26+ if (!ok) failures++;
27+}
28+
29+function stats(y: Float32Array) {
30+ let peak = 0;
31+ let finite = true;
32+ for (let i = 0; i < y.length; i++) {
33+ if (!Number.isFinite(y[i])) finite = false;
34+ peak = Math.max(peak, Math.abs(y[i]));
35+ }
36+ return { peak, finite };
37+}
38+
39+function req(notes: NoteSpec[], overrides: Partial<TrackRenderRequest> = {}): TrackRenderRequest {
40+ const end = notes.reduce((m, n) => Math.max(m, n.timeSec + n.durationSec), 0);
41+ return {
42+ notes,
43+ sampleRate: 22050,
44+ lengthSamples: Math.ceil((end + 1.0) * 22050),
45+ quality: 'preview',
46+ settings: {},
47+ ...overrides,
48+ };
49+}
50+
51+const note = (timeSec: number, pitch: number, velocity: number, durationSec = 0.5): NoteSpec =>
52+ ({ timeSec, durationSec, pitch, velocity });
53+
54+// The pitch fold: in-range pitches map to their own row, out-of-range ones
55+// keep their contour an octave in.
56+check('C5 is chart row 1', chartIndexForPitch(72) === 1);
57+check('A6 is chart row 14', chartIndexForPitch(93) === 14);
58+check('C4 folds up to row 1', chartIndexForPitch(60) === 1);
59+check('C7 folds down', chartIndexForPitch(96) === chartIndexForPitch(84));
60+
61+const t0 = performance.now();
62+
63+// One note: finite, audible.
64+const a = await renderRequest('recorder', req([note(0, 79, 0.7)]), env);
65+{
66+ const s = stats(a.audio);
67+ check('single note is finite', s.finite);
68+ check('single note is audible', s.peak > 1e-4, `peak ${s.peak.toExponential(2)}`);
69+}
70+
71+// Determinism and the note cache.
72+const b = await renderRequest('recorder', req([note(0, 79, 0.7)]), env);
73+{
74+ let same = a.audio.length === b.audio.length;
75+ if (same) for (let i = 0; i < a.audio.length; i++) if (a.audio[i] !== b.audio[i]) { same = false; break; }
76+ check('render is deterministic', same);
77+}
78+{
79+ const t = performance.now();
80+ await renderRequest('recorder', req([note(0, 79, 0.7)]), env);
81+ const ms = performance.now() - t;
82+ check('repeat render hits the note cache', ms < 250, `${ms.toFixed(0)} ms`);
83+}
84+
85+// Velocity (breath) and duration change the sound.
86+const soft = await renderRequest('recorder', req([note(0, 79, 0.2)]), env);
87+{
88+ let diff = false;
89+ const n = Math.min(a.audio.length, soft.audio.length);
90+ for (let i = 0; i < n; i++) if (Math.abs(a.audio[i] - soft.audio[i]) > 1e-9) { diff = true; break; }
91+ check('velocity changes the sound', diff);
92+}
93+const long = await renderRequest('recorder', req([note(0, 79, 0.7, 1.5)]), env);
94+{
95+ const shortTail = stats(a.audio.slice(Math.floor(1.2 * 22050))).peak;
96+ const longTail = stats(long.audio.slice(Math.floor(1.2 * 22050))).peak;
97+ check('duration changes the sound', longTail > shortTail, `long ${longTail.toExponential(2)} > short ${shortTail.toExponential(2)}`);
98+}
99+
100+// A phrase: distinct pitches at their onsets, repeats from the cache.
101+const tr = await renderRequest('recorder', req([note(0, 79, 0.7), note(0.8, 84, 0.7), note(1.6, 79, 0.7)]), env);
102+{
103+ const s = stats(tr.audio);
104+ check('track render is finite', s.finite);
105+ const at = (sec: number) => stats(tr.audio.slice(Math.floor(sec * 22050), Math.floor((sec + 0.4) * 22050))).peak;
106+ check('energy at first onset', at(0.05) > 1e-4);
107+ check('energy at second onset', at(0.85) > 1e-4);
108+ check('energy at third onset', at(1.65) > 1e-4);
109+}
110+
111+console.log(`total time: ${((performance.now() - t0) / 1000).toFixed(1)} s`);
112+if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }
instrument/types.tsadded+91−0View file
@@ -0,0 +1,91 @@
1+// The track-render instrument contract shared between midiboot and the
2+// instrument web apps. Each simulated-instrument repo carries its own
3+// structurally identical copy of these types (there is no shared package;
4+// TypeScript's structural typing keeps the copies interchangeable), and
5+// midiboot's src/instruments/types.ts is the reference.
6+//
7+// An instrument receives an entire MIDI track per call: every note with
8+// times already in seconds. It owns its rendering strategy end to end,
9+// including any internal caching (per-note, per-stage, or none), progress
10+// reporting, and cancellation. The optional fields at the bottom exist for
11+// the instrument's own web app, which drives the full parameter surface
12+// and live-edited MATLAB sources; midiboot never sets them.
13+
14+export interface NoteSpec {
15+ timeSec: number;
16+ durationSec: number;
17+ pitch: number; // MIDI note number
18+ velocity: number; // 0..1
19+}
20+
21+/** A user-editable instrument parameter, declared by the instrument. */
22+export interface KnobSpec {
23+ key: string;
24+ label: string;
25+ min: number;
26+ max: number;
27+ default: number;
28+ step?: number;
29+ scale?: 'log';
30+}
31+
32+/** One MATLAB source file, as numbl consumes it. */
33+export interface MFile {
34+ name: string;
35+ source: string;
36+}
37+
38+export interface RenderProgress {
39+ /** 0..1 through the whole request. */
40+ frac: number;
41+ /** Short human-readable stage label, e.g. "note 3/8: solve". */
42+ stage: string;
43+}
44+
45+export interface TrackRenderRequest {
46+ /** Every note of the track, sorted by timeSec. */
47+ notes: NoteSpec[];
48+ sampleRate: number;
49+ /** Song length incl. tail, as a minimum: the returned buffer may be
50+ * longer, so a ring that outlives the last note (a cymbal crash, a
51+ * long bass tail) is kept in full; the host mixes buffers of unequal
52+ * lengths. */
53+ lengthSamples: number;
54+ /** Fast preview for playback, full quality for export. */
55+ quality: 'preview' | 'full';
56+ /** Values for the instrument's knobs; missing keys mean the default. */
57+ settings?: Record<string, number>;
58+ /** Cooperative cancellation; checked between stages and notes. */
59+ signal?: AbortSignal;
60+ onProgress?: (p: RenderProgress) => void;
61+
62+ // --- fields used only by the instrument's own web app ---
63+
64+ /** Full model parameter overrides, applied after the defaults (and after
65+ * any note mapping). midiboot never sets this. */
66+ params?: Record<string, number | boolean>;
67+ /** Live-edited MATLAB sources replacing the shipped ones. */
68+ mfiles?: MFile[];
69+ /** Also compute the instrument-specific diagnostics payload. */
70+ wantDiagnostics?: boolean;
71+}
72+
73+export interface TrackRenderResult {
74+ audio: Float32Array;
75+ /** Instrument-specific diagnostics; present only when requested. */
76+ diag?: unknown;
77+}
78+
79+export interface LiveVoice {
80+ noteOff(): void; // begin release
81+ cut(): void; // stop immediately
82+}
83+
84+export interface Instrument {
85+ id: string;
86+ name: string;
87+ /** Curated editable parameters; tracks store their values in settings. */
88+ knobs?: KnobSpec[];
89+ renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult>;
90+ noteOn?(ctx: BaseAudioContext, dest: AudioNode, pitch: number, velocity: number, whenSec: number): LiveVoice;
91+}
instrument/worker.tsadded+120−0View file
@@ -0,0 +1,120 @@
1+// The render worker: numbl runs here so a seconds-long jet integration never
2+// blocks the UI. Requests are processed one at a time; the core yields to
3+// the macrotask queue between stages, which is when queued cancel messages
4+// are delivered, so cancellation is cooperative and the caches survive it.
5+import { renderRequest, type RecorderVariant } from './core.ts';
6+import { DEFAULT_MFILES } from './mfiles.ts';
7+import type { MFile, NoteSpec } from './types.ts';
8+
9+export interface WorkerRenderMsg {
10+ kind: 'render';
11+ id: number;
12+ variant: RecorderVariant;
13+ notes: NoteSpec[];
14+ sampleRate: number;
15+ lengthSamples: number;
16+ quality: 'preview' | 'full';
17+ settings?: Record<string, number>;
18+ params?: Record<string, number | boolean>;
19+ mfiles?: MFile[];
20+ wantDiagnostics?: boolean;
21+}
22+
23+export interface WorkerCancelMsg {
24+ kind: 'cancel';
25+ id: number;
26+}
27+
28+export type WorkerResponse =
29+ | { kind: 'progress'; id: number; frac: number; stage: string }
30+ | { kind: 'result'; id: number; audio: Float32Array; diag?: unknown }
31+ | { kind: 'cancelled'; id: number }
32+ | { kind: 'error'; id: number; message: string; file?: string; line?: number };
33+
34+class Cancelled extends Error {}
35+
36+const cancelled = new Set<number>();
37+const queue: WorkerRenderMsg[] = [];
38+let running = false;
39+
40+const post = (m: WorkerResponse, transfer?: Transferable[]) =>
41+ (self as unknown as Worker).postMessage(m, transfer ?? []);
42+
43+const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
44+
45+async function run(msg: WorkerRenderMsg): Promise<void> {
46+ // numbl reports a file it could not parse through console.warn and then
47+ // skips it, so the failure surfaces at the call site as "Undefined
48+ // function". Capture the warning to report the actual syntax error.
49+ const warnings: string[] = [];
50+ const origWarn = console.warn;
51+ console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); };
52+ try {
53+ const result = await renderRequest(msg.variant, {
54+ notes: msg.notes,
55+ sampleRate: msg.sampleRate,
56+ lengthSamples: msg.lengthSamples,
57+ quality: msg.quality,
58+ settings: msg.settings,
59+ params: msg.params,
60+ wantDiagnostics: msg.wantDiagnostics,
61+ }, {
62+ mfiles: msg.mfiles ?? DEFAULT_MFILES,
63+ post: (frac, stage) => post({ kind: 'progress', id: msg.id, frac, stage }),
64+ checkCancel: () => { if (cancelled.has(msg.id)) throw new Cancelled(); },
65+ yieldToHost: () => new Promise((res) => setTimeout(res, 0)),
66+ });
67+ const transfer: Transferable[] = [result.audio.buffer];
68+ if (result.diag) {
69+ for (const v of Object.values(result.diag as unknown as Record<string, unknown>)) {
70+ if (v instanceof Float32Array && v.buffer !== result.audio.buffer) transfer.push(v.buffer);
71+ }
72+ }
73+ post({ kind: 'result', id: msg.id, audio: result.audio, diag: result.diag }, transfer);
74+ } catch (err) {
75+ if (err instanceof Cancelled) {
76+ post({ kind: 'cancelled', id: msg.id });
77+ } else {
78+ const e = err as { message?: string; file?: string; line?: number };
79+ let m: WorkerResponse = {
80+ kind: 'error',
81+ id: msg.id,
82+ message: e?.message ? String(e.message) : String(err),
83+ file: typeof e?.file === 'string' ? e.file : undefined,
84+ line: typeof e?.line === 'number' ? e.line : undefined,
85+ };
86+ for (const w of warnings) {
87+ const hit = SKIP_WARNING.exec(w);
88+ if (hit) {
89+ m = { kind: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
90+ break;
91+ }
92+ }
93+ post(m);
94+ }
95+ } finally {
96+ console.warn = origWarn;
97+ cancelled.delete(msg.id);
98+ }
99+}
100+
101+async function pump(): Promise<void> {
102+ if (running) return;
103+ running = true;
104+ while (queue.length > 0) {
105+ const msg = queue.shift()!;
106+ if (cancelled.has(msg.id)) { post({ kind: 'cancelled', id: msg.id }); cancelled.delete(msg.id); continue; }
107+ await run(msg);
108+ }
109+ running = false;
110+}
111+
112+self.onmessage = (ev: MessageEvent<WorkerRenderMsg | WorkerCancelMsg>) => {
113+ const msg = ev.data;
114+ if (msg.kind === 'render') {
115+ queue.push(msg);
116+ void pump();
117+ } else if (msg.kind === 'cancel') {
118+ cancelled.add(msg.id);
119+ }
120+};
scripts/build-exports.mjsdeleted+0−79View file
@@ -1,79 +0,0 @@
1-#!/usr/bin/env node
2-// Build exported instrument bundles (format numbl-instrument-v1) for one
3-// instrument repository. Run from the repo root:
4-//
5-// node scripts/build-exports.mjs
6-//
7-// For each export-src/<name>/export.json, the bundle's files are that
8-// directory's .m sources (the drivers, render_note.m required) plus every
9-// matlab/*.m of the repo, and the result is written to exports/<id>.json.
10-// The canonical copy of this script lives in
11-// simulated_instruments/instrument-format; instrument repos carry a copy so
12-// they build standalone.
13-
14-import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
15-import { join } from 'node:path';
16-
17-const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
18-const SRC = 'export-src';
19-const OUT = 'exports';
20-
21-function fail(msg) {
22- console.error(`build-exports: ${msg}`);
23- process.exit(1);
24-}
25-
26-function checkKnobs(id, knobs) {
27- if (!Array.isArray(knobs)) fail(`${id}: 'knobs' must be an array`);
28- for (const k of knobs) {
29- if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(`${id}: knobs need key and label`);
30- if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
31- fail(`${id}: knob '${k.key}' needs min < max and a default`);
32- }
33- if (k.default < k.min || k.default > k.max) fail(`${id}: knob '${k.key}' default outside its range`);
34- if (k.step !== undefined && !(k.step > 0)) fail(`${id}: knob '${k.key}' step must be > 0`);
35- if (k.scale !== undefined && k.scale !== 'log') fail(`${id}: knob '${k.key}' has unknown scale`);
36- if (k.scale === 'log' && k.min <= 0) fail(`${id}: knob '${k.key}' log scale needs min > 0`);
37- }
38-}
39-
40-function mfilesIn(dir) {
41- if (!existsSync(dir)) return [];
42- return readdirSync(dir)
43- .filter((f) => f.endsWith('.m'))
44- .sort()
45- .map((name) => ({ name, content: readFileSync(join(dir, name), 'utf8') }));
46-}
47-
48-if (!existsSync(SRC)) fail(`no ${SRC}/ directory here; run from the instrument repo root`);
49-const shared = mfilesIn('matlab');
50-const dirs = readdirSync(SRC, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
51-if (dirs.length === 0) fail(`${SRC}/ has no instrument directories`);
52-mkdirSync(OUT, { recursive: true });
53-
54-for (const dir of dirs) {
55- const metaPath = join(SRC, dir, 'export.json');
56- if (!existsSync(metaPath)) fail(`${metaPath} is missing`);
57- const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
58- const id = meta.id;
59- if (typeof id !== 'string' || !ID_RE.test(id)) fail(`${metaPath}: 'id' must match ${ID_RE}`);
60- if (typeof meta.name !== 'string' || meta.name === '') fail(`${id}: missing 'name'`);
61- checkKnobs(id, meta.knobs ?? []);
62- const drivers = mfilesIn(join(SRC, dir));
63- if (!drivers.some((f) => f.name === 'render_note.m')) fail(`${id}: no render_note.m in ${SRC}/${dir}/`);
64- const names = new Set(drivers.map((f) => f.name));
65- const clash = shared.find((f) => names.has(f.name));
66- if (clash) fail(`${id}: driver file '${clash.name}' collides with matlab/${clash.name}`);
67- const bundle = {
68- format: 'numbl-instrument-v1',
69- id,
70- name: meta.name,
71- ...(meta.description ? { description: meta.description } : {}),
72- ...(meta.source ? { source: meta.source } : {}),
73- knobs: meta.knobs ?? [],
74- files: [...drivers, ...shared],
75- };
76- const outPath = join(OUT, `${id}.json`);
77- writeFileSync(outPath, JSON.stringify(bundle, null, 2) + '\n');
78- console.log(`wrote ${outPath} (${bundle.files.length} files, ${bundle.knobs.length} knobs)`);
79-}
webapp/README.mdmodified+1−1View file
@@ -32,7 +32,7 @@ Edits live in memory only. They are deliberately not persisted, so a reload rest
3232 - `../matlab/` — the algorithm: `recorder_geometry.m`, `recorder_bore.m`, `recorder_basis.m`, `recorder_notes.m`, `recorder_jet.m`, `recorder_radiate.m`, `recorder_output.m`, `recorder_rng.m`, plus `recorder_render.m` / `recorder_demo.m` / `recorder_validate.m` for CLI use
3333 - `src/params.ts` — parameter schema, defaults, tier assignment, persistence
3434 - `src/mfiles.ts` — the `.m` sources, bundled at build time (`src/mfilesCore.ts` holds the hash used for cache keying)
35-- `src/worker/renderWorker.ts` — off-thread numbl execution with tiered caching
35+- `../instrument/` — off-thread numbl execution with tiered caching, behind the shared track-render instrument interface (also used by midiboot)
3636 - `src/lib/instrument.ts` — the hole lattice and fingering chart, mirrored from `recorder_geometry.m` for the 3D scene
3737 - `src/components/` — 3D scene (three.js), parameter panel, code panel and editor, plots (jet flow, waveform, spectrum, spectrogram, bore admittance, standing waves)
3838 - `src/selftest.ts` — Node harness for the MATLAB validation script
webapp/src/components/DiagnosticsPanel.tsxmodified+2−2View file
@@ -1,7 +1,7 @@
11 import { NOTE_NAMES } from '../params';
2-import type { RenderResponse } from '../worker/renderWorker';
2+import type { RenderResult } from '../hooks/useRenderer';
33
4-export default function DiagnosticsPanel({ result }: { result: RenderResponse }) {
4+export default function DiagnosticsPanel({ result }: { result: RenderResult }) {
55 const d = result.diag;
66 const modes = Array.from(result.modeF);
77 const shown = modes.slice(0, 8);
webapp/src/hooks/useRenderer.tsmodified+72−65View file
@@ -1,12 +1,24 @@
1-// Hook owning the render worker. Renders are explicit: nothing starts until
2-// Render preview or Render full is pressed, so changing a parameter or a .m
3-// source never kicks off work on its own. A request in flight can be
4-// cancelled, which tears the worker down and builds a fresh one.
1+// Hook owning the render requests. Renders are explicit: nothing starts
2+// until Render preview or Render full is pressed, so changing a parameter
3+// or a .m source never kicks off work on its own. Rendering goes through
4+// the repo's instrument/ module (the same track-render interface midiboot
5+// uses), with the full parameter surface, the edited .m sources, and the
6+// diagnostics payload passed through the request's app-facing fields.
7+// Cancelling is cooperative: the worker checks between render stages, and
8+// the instrument module tears the worker down only if it never responds.
59
610 import { useCallback, useEffect, useRef, useState } from 'react';
711 import type { RecorderParams } from '../params';
812 import type { MFile } from '../mfiles';
9-import type { ErrorMessage, ProgressMessage, RenderResponse } from '../worker/renderWorker';
13+import { recorderInstrument } from '../../../instrument/index.ts';
14+import type { RecorderExploreDiag } from '../../../instrument/core.ts';
15+
16+/** The old worker response shape, reassembled from the instrument result
17+ * so the plot components are unchanged. */
18+export type RenderResult = RecorderExploreDiag & {
19+ y: Float32Array;
20+ sampleRate: number;
21+};
1022
1123 export interface RenderError {
1224 message: string;
@@ -15,7 +27,7 @@ export interface RenderError {
1527 }
1628
1729 export interface RenderState {
18- result: RenderResponse | null;
30+ result: RenderResult | null;
1931 error: RenderError | null;
2032 busy: boolean;
2133 stage: string;
@@ -24,9 +36,7 @@ export interface RenderState {
2436 }
2537
2638 export function useRenderer(params: RecorderParams, mfiles: MFile[]) {
27- const workerRef = useRef<Worker | null>(null);
28- const nextId = useRef(1);
29- const inFlight = useRef<number | null>(null);
39+ const abortRef = useRef<AbortController | null>(null);
3040 const startedAt = useRef(0);
3141 const [state, setState] = useState<RenderState>({
3242 result: null,
@@ -37,49 +47,7 @@ export function useRenderer(params: RecorderParams, mfiles: MFile[]) {
3747 elapsedS: 0,
3848 });
3949
40- const attachHandlers = useCallback((worker: Worker) => {
41- worker.onmessage = (ev: MessageEvent<RenderResponse | ProgressMessage | ErrorMessage>) => {
42- const msg = ev.data;
43- if (msg.type === 'progress') {
44- setState((s) => ({ ...s, stage: msg.stage, progress: msg.frac }));
45- return;
46- }
47- inFlight.current = null;
48- if (msg.type === 'error') {
49- // A failed render leaves nothing playable: the previous result is
50- // dropped, so Play and Download cannot hand back audio that no
51- // longer corresponds to the code or parameters on screen.
52- setState((s) => ({
53- ...s,
54- result: null,
55- error: { message: msg.message, file: msg.file, line: msg.line },
56- busy: false,
57- stage: '',
58- progress: 0,
59- }));
60- return;
61- }
62- setState((s) => ({ ...s, result: msg, error: null, busy: false, stage: '', progress: 0 }));
63- };
64- }, []);
65-
66- const makeWorker = useCallback(() => {
67- const worker = new Worker(new URL('../worker/renderWorker.ts', import.meta.url), {
68- type: 'module',
69- });
70- attachHandlers(worker);
71- workerRef.current = worker;
72- return worker;
73- }, [attachHandlers]);
74-
75- useEffect(() => {
76- const worker = makeWorker();
77- return () => {
78- worker.terminate();
79- workerRef.current = null;
80- inFlight.current = null;
81- };
82- }, [makeWorker]);
50+ useEffect(() => () => abortRef.current?.abort(), []);
8351
8452 // elapsed-time ticker while a render is in flight
8553 useEffect(() => {
@@ -93,27 +61,66 @@ export function useRenderer(params: RecorderParams, mfiles: MFile[]) {
9361
9462 const render = useCallback(
9563 (quality: 'preview' | 'full') => {
96- const worker = workerRef.current;
97- if (!worker || inFlight.current !== null) return;
98- const id = nextId.current++;
99- inFlight.current = id;
64+ if (abortRef.current) return; // a request is already in flight
65+ const ctrl = new AbortController();
66+ abortRef.current = ctrl;
10067 startedAt.current = performance.now();
10168 setState((s) => ({ ...s, busy: true, stage: 'starting', progress: 0, elapsedS: 0 }));
102- worker.postMessage({ type: 'render', id, params, mfiles, quality });
69+ const sampleRate = Number(params.sample_rate);
70+ void recorderInstrument.renderTrack({
71+ notes: [],
72+ sampleRate,
73+ lengthSamples: 1,
74+ quality,
75+ params: params as unknown as Record<string, number | boolean>,
76+ mfiles,
77+ wantDiagnostics: true,
78+ signal: ctrl.signal,
79+ onProgress: (p) => setState((s) => ({ ...s, stage: p.stage, progress: p.frac })),
80+ }).then((r) => {
81+ if (abortRef.current === ctrl) abortRef.current = null;
82+ if (ctrl.signal.aborted) return;
83+ const diag = r.diag as RecorderExploreDiag;
84+ setState((s) => ({
85+ ...s,
86+ result: { ...diag, y: r.audio, sampleRate },
87+ error: null,
88+ busy: false,
89+ stage: '',
90+ progress: 0,
91+ }));
92+ }).catch((err: unknown) => {
93+ if (abortRef.current === ctrl) abortRef.current = null;
94+ if (err instanceof DOMException && err.name === 'AbortError') {
95+ setState((s) => ({ ...s, busy: false, stage: '', progress: 0 }));
96+ return;
97+ }
98+ // A failed render leaves nothing playable: the previous result is
99+ // dropped, so Play and Download cannot hand back audio that no
100+ // longer corresponds to the code or parameters on screen.
101+ const e = err as { message?: string; file?: string; line?: number };
102+ setState((s) => ({
103+ ...s,
104+ result: null,
105+ error: {
106+ message: e?.message ? String(e.message) : String(err),
107+ file: typeof e?.file === 'string' ? e.file : undefined,
108+ line: typeof e?.line === 'number' ? e.line : undefined,
109+ },
110+ busy: false,
111+ stage: '',
112+ progress: 0,
113+ }));
114+ });
103115 },
104116 [params, mfiles],
105117 );
106118
107- // Cancelling terminates the worker outright: the .m interpreter runs a tight
108- // loop that never yields, so there is nothing to poll a flag from. The
109- // replacement worker starts with empty stage caches.
110119 const cancel = useCallback(() => {
111- const worker = workerRef.current;
112- if (worker) worker.terminate();
113- inFlight.current = null;
114- makeWorker();
120+ abortRef.current?.abort();
121+ abortRef.current = null;
115122 setState((s) => ({ ...s, busy: false, stage: '', progress: 0, elapsedS: 0 }));
116- }, [makeWorker]);
123+ }, []);
117124
118125 const renderPreview = useCallback(() => render('preview'), [render]);
119126 const renderFull = useCallback(() => render('full'), [render]);
webapp/src/worker/renderWorker.tsdeleted+0−436View file
@@ -1,436 +0,0 @@
1-// Web Worker that renders the recorder 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-// basis tier -> recorder_basis per note (transmission-line solve +
6-// modal fit; cached on bore/hole/window params and the
7-// note's fingering)
8-// jet tier -> recorder_jet per note (the time-domain integration;
9-// the expensive stage, cached on the breath, the jet,
10-// and the synthesis rate)
11-// radiate/output -> recorder_radiate per note + assembly + recorder_output
12-// (cheap, always re-run: moving the listener costs a
13-// matrix multiply, not a re-integration)
14-// Tensors cross the boundary as numbl RuntimeValues (column-major Float64).
15-
16-import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
17-import { hashMFiles, type MFile } from '../mfilesCore';
18-import { BASIS_KEYS, JET_KEYS, type RecorderParams } from '../params';
19-
20-export interface RenderRequest {
21- type: 'render';
22- id: number;
23- params: RecorderParams;
24- quality: 'preview' | 'full';
25- mfiles: MFile[];
26-}
27-
28-export interface RenderResponse {
29- type: 'result';
30- id: number;
31- quality: 'preview' | 'full';
32- y: Float32Array; // final signal, [-1, 1]
33- sampleRate: number;
34- noteIdx: Float32Array; // fingering chart row per note
35- noteOn: Float32Array; // onset time of each note, s
36- fPlay: Float32Array; // played frequency per note, Hz
37- fTarget: Float32Array; // equal-tempered target per note, Hz
38- cents: Float32Array; // played vs target, cents
39- qPlot: Float32Array; // jet inflow envelope, decimated, whole timeline
40- qPlotRate: number; // samples per second of qPlot
41- modeF: Float32Array; // first note: fitted resonances, Hz
42- modeQ: Float32Array; // first note: fitted Q per mode
43- gridF: Float32Array; // first note: admittance grid, Hz
44- gridY: Float32Array; // first note: |Y| over the grid
45- profX: Float32Array; // first note: bore stations, m
46- profP: Float32Array; // first note: |p(x)| per mode, nsta x n col-major
47- diag: {
48- noteCount: number;
49- modesKept: number;
50- durationS: number;
51- peakPa: number;
52- peakSplDb: number;
53- listenerDistM: number;
54- warnings: string[];
55- basisMs: number; // 0 when every note's basis was cached
56- jetMs: number; // 0 when every note's jet run was cached
57- radiateMs: number;
58- basisCached: boolean;
59- jetCached: boolean;
60- oversample: number;
61- };
62-}
63-
64-export interface ProgressMessage {
65- type: 'progress';
66- id: number;
67- stage: 'basis' | 'jet' | 'radiate';
68- frac: number;
69-}
70-
71-// Raised by a fault in the .m code (the user can edit it), so it is reported
72-// rather than left to stall the request.
73-export interface ErrorMessage {
74- type: 'error';
75- id: number;
76- message: string;
77- file?: string;
78- line?: number;
79-}
80-
81-// Basis fields that cross from the basis stage into the jet and radiate
82-// stages as numbl RuntimeValues.
83-const BASIS_FIELDS = ['f', 'omega', 'Q', 'g', 'B', 'apX', 'apW', 'Sm', 'fTarget'];
84-
85-interface BasisEntry {
86- vars: Record<string, RuntimeValue>;
87- modeF: Float32Array;
88- modeQ: Float32Array;
89- gridF: Float32Array;
90- gridY: Float32Array;
91- profX: Float32Array;
92- profP: Float32Array;
93-}
94-
95-interface JetEntry {
96- Zr: RuntimeValue;
97- Zi: RuntimeValue;
98- Qin: RuntimeValue;
99- fPlay: number;
100- fTarget: number;
101- cents: number;
102- qPlot: Float32Array;
103- qPlotRate: number;
104- fsHi: number;
105-}
106-
107-const basisCache = new Map<string, BasisEntry>();
108-const jetCache = new Map<string, JetEntry>();
109-const BASIS_CACHE_MAX = 6;
110-const JET_CACHE_MAX = 4;
111-
112-function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
113- const v = cache.get(key);
114- if (v !== undefined) {
115- cache.delete(key);
116- cache.set(key, v);
117- }
118- return v;
119-}
120-
121-function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
122- cache.delete(key);
123- cache.set(key, value);
124- while (cache.size > max) {
125- const oldest = cache.keys().next().value as string;
126- cache.delete(oldest);
127- }
128-}
129-
130-function keyFor(params: RecorderParams, keys: readonly (keyof RecorderParams)[]): string {
131- return keys.map((k) => String(params[k])).join('|');
132-}
133-
134-// MATLAB source lines assigning every parameter onto a struct P.
135-function pLiteral(params: RecorderParams): string {
136- const lines: string[] = [];
137- for (const [k, v] of Object.entries(params)) {
138- if (typeof v === 'number') lines.push(`P.${k} = ${v};`);
139- else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
140- }
141- return lines.join('\n');
142-}
143-
144-function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
145- return executeCode(
146- source,
147- { initialVariableValues: inputs, displayResults: false },
148- mfiles,
149- 'stage.m',
150- );
151-}
152-
153-function asTensor(v: RuntimeValue): RuntimeTensor {
154- const t = v as RuntimeTensor;
155- if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
156- return t;
157-}
158-
159-function asScalar(v: RuntimeValue): number {
160- if (typeof v === 'number') return v;
161- const t = v as RuntimeTensor;
162- if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
163- if (typeof v === 'boolean') return v ? 1 : 0;
164- throw new Error('expected scalar result from numbl stage');
165-}
166-
167-function toF32(v: RuntimeValue): Float32Array {
168- const t = asTensor(v);
169- const out = new Float32Array(t.data.length);
170- for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
171- return out;
172-}
173-
174-const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
175-
176-self.onmessage = (ev: MessageEvent<RenderRequest>) => {
177- const msg = ev.data;
178- if (msg.type !== 'render') return;
179-
180- // numbl reports a file it could not parse through console.warn and then
181- // skips it, so the failure surfaces at the call site as "Undefined
182- // function". Capture the warning to report the actual syntax error instead.
183- const warnings: string[] = [];
184- const origWarn = console.warn;
185- console.warn = (...args: unknown[]) => {
186- warnings.push(args.map(String).join(' '));
187- };
188-
189- try {
190- render(msg);
191- } catch (err) {
192- const e = err as { message?: string; file?: string; line?: number };
193- let m: ErrorMessage = {
194- type: 'error',
195- id: msg.id,
196- message: e?.message ? String(e.message) : String(err),
197- file: typeof e?.file === 'string' ? e.file : undefined,
198- line: typeof e?.line === 'number' ? e.line : undefined,
199- };
200- for (const w of warnings) {
201- const hit = SKIP_WARNING.exec(w);
202- if (hit) {
203- m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
204- break;
205- }
206- }
207- (self as unknown as Worker).postMessage(m);
208- } finally {
209- console.warn = origWarn;
210- }
211-};
212-
213-function render(msg: RenderRequest): void {
214- const { id, params, mfiles } = msg;
215- let quality = msg.quality;
216-
217- const codeKey = hashMFiles(mfiles);
218- const basisParamsKey = codeKey + '|' + keyFor(params, BASIS_KEYS);
219- const jetParamsKey = keyFor(params, JET_KEYS);
220-
221- // The gesture, mirrored from recorder_notes.m.
222- const K = Math.min(3, Math.max(1, Math.round(params.note_count)));
223- const notes = [params.note1, params.note2, params.note3]
224- .slice(0, K)
225- .map((n) => Math.min(14, Math.max(1, Math.round(n))));
226- const gap = Math.max(0, params.gap_ms) * 1e-3;
227- const tOn = notes.map((_, i) => i * (params.note_dur_s + gap));
228- const spans = notes.map((_, i) =>
229- params.note_dur_s + (i < K - 1 ? Math.min(params.tail_s, 0.3) : params.tail_s),
230- );
231- const durationS = tOn[K - 1] + params.note_dur_s + params.tail_s;
232-
233- const jetKeyAt = (i: number, os: number) =>
234- basisParamsKey + `|n=${notes[i]}` + '||' + jetParamsKey + `|span=${spans[i]}|ord=${i + 1}|os=${os}`;
235-
236- // A preview right after a full render reuses the full-rate jet runs.
237- if (quality === 'preview' && notes.every((_, i) => jetCache.has(jetKeyAt(i, params.oversample)))) {
238- quality = 'full';
239- }
240- const oversample = quality === 'preview' ? Math.min(1, params.oversample) : params.oversample;
241- const fsHi = params.sample_rate * oversample;
242-
243- const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
244-
245- let basisMs = 0;
246- let jetMs = 0;
247- let basisCached = true;
248- let jetCached = true;
249- const basisEntries: BasisEntry[] = [];
250- const jetEntries: JetEntry[] = [];
251-
252- for (let i = 0; i < K; i++) {
253- // ---- basis stage: transmission-line solve + modal fit ----
254- const bKey = basisParamsKey + `|n=${notes[i]}`;
255- let bEntry = lruGet(basisCache, bKey);
256- if (!bEntry) {
257- basisCached = false;
258- post({ type: 'progress', id, stage: 'basis', frac: (i + 0.5) / K * 0.2 });
259- const t0 = performance.now();
260- const r = runStage(mfiles, `
261-${pLiteral(params)}
262-basis = recorder_basis(P, ${notes[i]});
263-${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
264-b_profP = basis.profP;
265-b_profX = basis.profX;
266-b_gridF = basis.gridF;
267-b_gridY = basis.gridY;
268-`);
269- basisMs += performance.now() - t0;
270- const vars: Record<string, RuntimeValue> = {};
271- for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
272- bEntry = {
273- vars,
274- modeF: toF32(r.variableValues.b_f),
275- modeQ: toF32(r.variableValues.b_Q),
276- gridF: toF32(r.variableValues.b_gridF),
277- gridY: toF32(r.variableValues.b_gridY),
278- profX: toF32(r.variableValues.b_profX),
279- profP: toF32(r.variableValues.b_profP),
280- };
281- lruSet(basisCache, bKey, bEntry, BASIS_CACHE_MAX);
282- }
283- basisEntries.push(bEntry);
284-
285- // ---- jet stage: the time-domain integration ----
286- const jKey = jetKeyAt(i, oversample);
287- let jEntry = lruGet(jetCache, jKey);
288- if (!jEntry) {
289- jetCached = false;
290- post({ type: 'progress', id, stage: 'jet', frac: 0.2 + ((i + 0.5) / K) * 0.6 });
291- const t0 = performance.now();
292- const ds = Math.max(1, Math.round(fsHi / 2000));
293- const r = runStage(mfiles, `
294-${pLiteral(params)}
295-${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
296-basis.n = length(basis.f);
297-[Zr, Zi, Qin, jinfo] = recorder_jet(P, basis, ${spans[i]}, ${i + 1}, ${fsHi});
298-fPlay = jinfo.fPlay;
299-fTarget = jinfo.fTarget;
300-cents = jinfo.cents;
301-qPlot = Qin(1:${ds}:length(Qin));
302-`, bEntry.vars);
303- jetMs += performance.now() - t0;
304- jEntry = {
305- Zr: r.variableValues.Zr,
306- Zi: r.variableValues.Zi,
307- Qin: r.variableValues.Qin,
308- fPlay: asScalar(r.variableValues.fPlay),
309- fTarget: asScalar(r.variableValues.fTarget),
310- cents: asScalar(r.variableValues.cents),
311- qPlot: toF32(r.variableValues.qPlot),
312- qPlotRate: fsHi / ds,
313- fsHi,
314- };
315- lruSet(jetCache, jKey, jEntry, JET_CACHE_MAX);
316- }
317- jetEntries.push(jEntry);
318- }
319-
320- // ---- radiate + assemble + output ----
321- post({ type: 'progress', id, stage: 'radiate', frac: 0.9 });
322- const t0 = performance.now();
323- const inputs: Record<string, RuntimeValue> = {};
324- const noteBlocks: string[] = [];
325- for (let i = 0; i < K; i++) {
326- for (const f of BASIS_FIELDS) inputs[`b${i + 1}_${f}`] = basisEntries[i].vars[`b_${f}`];
327- inputs[`n${i + 1}_Zr`] = jetEntries[i].Zr;
328- inputs[`n${i + 1}_Zi`] = jetEntries[i].Zi;
329- inputs[`n${i + 1}_Qin`] = jetEntries[i].Qin;
330- noteBlocks.push(`
331-${BASIS_FIELDS.map((f) => `basis.${f} = b${i + 1}_${f};`).join('\n')}
332-basis.n = length(basis.f);
333-[pn, radinfo] = recorder_radiate(P, basis, n${i + 1}_Zr, n${i + 1}_Zi, n${i + 1}_Qin, ${fsHi});
334-k0 = min(T, round(${tOn[i]} * ${fsHi}) + 1);
335-len = min(length(pn), T - k0 + 1);
336-p(k0:k0 + len - 1) = p(k0:k0 + len - 1) + pn(1:len);
337-inside = max(inside, radinfo.listenerInside);
338-distM = radinfo.listenerDistM;
339-`);
340- }
341- const rOut = runStage(mfiles, `
342-${pLiteral(params)}
343-T = floor(${durationS} * ${fsHi});
344-p = zeros(1, T);
345-inside = 0;
346-distM = 0;
347-${noteBlocks.join('\n')}
348-[y, peakPa] = recorder_output(P, p, ${oversample});
349-`, inputs);
350- const radiateMs = performance.now() - t0;
351-
352- const y32 = toF32(rOut.variableValues.y);
353- const peakPa = asScalar(rOut.variableValues.peakPa);
354- const listenerInside = asScalar(rOut.variableValues.inside) !== 0;
355- const listenerDistM = asScalar(rOut.variableValues.distM);
356-
357- // assemble the jet-flow envelope over the whole timeline
358- const qRate = jetEntries[0].qPlotRate;
359- const qPlot = new Float32Array(Math.ceil(durationS * qRate));
360- for (let i = 0; i < K; i++) {
361- const off = Math.round(tOn[i] * qRate);
362- const q = jetEntries[i].qPlot;
363- for (let k = 0; k < q.length && off + k < qPlot.length; k++) qPlot[off + k] += q[k];
364- }
365-
366- const warns: string[] = [];
367- if (listenerInside) {
368- warns.push('the listener is within 2 cm of the instrument; the monopole field is evaluated at a clamped minimum distance');
369- }
370- for (let i = 0; i < K; i++) {
371- const j = jetEntries[i];
372- if (j.fPlay <= 0) {
373- warns.push(`note ${i + 1} did not speak (no oscillation locked); more breath or jet receptivity may help`);
374- } else if (Math.abs(j.cents) > 60) {
375- warns.push(`note ${i + 1} locked to ${j.fPlay.toFixed(0)} Hz, not its fingering's register (${j.fTarget.toFixed(0)} Hz)`);
376- }
377- }
378- if (!params.normalize) {
379- let peak = 0;
380- for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
381- if (peak > 1.0) {
382- warns.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`);
383- }
384- }
385-
386- const resp: RenderResponse = {
387- type: 'result',
388- id,
389- quality,
390- y: y32,
391- sampleRate: params.sample_rate,
392- noteIdx: new Float32Array(notes),
393- noteOn: new Float32Array(tOn),
394- fPlay: new Float32Array(jetEntries.map((j) => j.fPlay)),
395- fTarget: new Float32Array(jetEntries.map((j) => j.fTarget)),
396- cents: new Float32Array(jetEntries.map((j) => j.cents)),
397- qPlot,
398- qPlotRate: qRate,
399- modeF: basisEntries[0].modeF.slice(),
400- modeQ: basisEntries[0].modeQ.slice(),
401- gridF: basisEntries[0].gridF.slice(),
402- gridY: basisEntries[0].gridY.slice(),
403- profX: basisEntries[0].profX.slice(),
404- profP: basisEntries[0].profP.slice(),
405- diag: {
406- noteCount: K,
407- modesKept: basisEntries[0].modeF.length,
408- durationS,
409- peakPa,
410- peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
411- listenerDistM,
412- warnings: warns,
413- basisMs,
414- jetMs,
415- radiateMs,
416- basisCached,
417- jetCached,
418- oversample,
419- },
420- };
421- (self as unknown as Worker).postMessage(resp, [
422- resp.y.buffer,
423- resp.noteIdx.buffer,
424- resp.noteOn.buffer,
425- resp.fPlay.buffer,
426- resp.fTarget.buffer,
427- resp.cents.buffer,
428- resp.qPlot.buffer,
429- resp.modeF.buffer,
430- resp.modeQ.buffer,
431- resp.gridF.buffer,
432- resp.gridY.buffer,
433- resp.profX.buffer,
434- resp.profP.buffer,
435- ]);
436-}
webapp/tsconfig.jsonmodified+1−0View file
@@ -6,6 +6,7 @@
66 "module": "ESNext",
77 "skipLibCheck": true,
88 "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
910 "resolveJsonModule": true,
1011 "isolatedModules": true,
1112 "noEmit": true,
↑↓ move↵openescclose