{ "format": "numbl-instrument-v1", "id": "recorder", "name": "Recorder", "description": "Soprano recorder, C5 to A6 on the baroque fingering chart; velocity onto breath pressure, note length onto blown duration.", "source": "simulated_instruments/recorder", "knobs": [ { "key": "attack_ms", "label": "attack (ms)", "min": 2, "max": 60, "default": 12 }, { "key": "release_ms", "label": "release (ms)", "min": 5, "max": 80, "default": 25 } ], "files": [ { "name": "prepare.m", "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" }, { "name": "render_note.m", "content": "function y = render_note(note)\n% One MIDI note through the recorder model: the nearest playable fingering\n% from the chart in the prepared state, velocity onto breath pressure, and\n% the note's own length onto the blown duration (clamped to what the model\n% accepts). Fixed: a single tongued note, a fixed turbulence seed,\n% 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\n[~, ix] = min(abs(note.state.chart_midi - note.pitch));\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" }, { "name": "recorder_basis.m", "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" }, { "name": "recorder_bore.m", "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" }, { "name": "recorder_defaults.m", "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" }, { "name": "recorder_demo.m", "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" }, { "name": "recorder_geometry.m", "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" }, { "name": "recorder_jet.m", "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" }, { "name": "recorder_notes.m", "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" }, { "name": "recorder_output.m", "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" }, { "name": "recorder_radiate.m", "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" }, { "name": "recorder_render.m", "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" }, { "name": "recorder_rng.m", "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" }, { "name": "recorder_validate.m", "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" } ] }