// The standup bass as a track-render instrument: pure rendering logic with // no DOM or Worker dependency, shared by the browser worker (worker.ts) and // the node tests (test.ts). Two request shapes arrive here: // // - the track path (midiboot): every note of a MIDI track. Notes are // grouped by cache key (pitch and bucketed velocity; the bass ignores // note duration, ring-out is the tail_s knob), each distinct sound is // rendered once through the staged pipeline, and copies are summed at // the onsets. The per-fingering eigensolve is cached separately, so two // velocities of the same pitch share one solve. // // - the explorer path (the bass web app, wantDiagnostics set): the full // parameter surface and live-edited .m sources drive one staged render // with the diagnostics payload the app's plots consume, exactly the // arrangement of the app's former render worker. // // Stage intermediates cross executeCode calls as numbl RuntimeValues // (column-major Float64). import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl'; import type { KnobSpec, MFile, NoteSpec, TrackRenderRequest } from './types.ts'; import { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant } from './knobs.ts'; import { NoteCache } from './noteCache.ts'; export { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant }; /** The environment the host provides: sources, progress, cancellation, and * a macrotask yield so a browser worker can receive a cancel message. */ export interface RenderEnv { mfiles: MFile[]; post: (frac: number, stage: string) => void; /** Throws to abandon the render; called between stages and notes. */ checkCancel: () => void; yieldToHost: () => Promise; } // --- diagnostics payload for the explorer path (the web app's plots) --- export interface BassExploreDiag { quality: 'preview' | 'full'; noteT: Float32Array; noteMidi: Float32Array; noteStr: Float32Array; noteStop: Float32Array; noteForce: Float32Array; mode1F: Float32Array; mode1Zeta: Float32Array; mode1BodyFrac: Float32Array; bodyF: Float32Array; bodyZeta: Float32Array; bodyA: Float32Array; bodyMono: Float32Array; diag: { noteCount: number; fingerings: number; modesTotal: number; modesNote1: number; fundHz: number; fundT60: number; meanForce: number; durationS: number; peakPa: number; peakSplDb: number; listenerDistM: number; warnings: string[]; solveMs: number; radiateMs: number; solveCached: boolean; radiateCached: boolean; oversample: number; }; } // --- shared helpers --- export function hashMFiles(files: MFile[]): string { let h = 0x811c9dc5; for (const f of files) { const s = f.name + ' ' + f.source; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } } return (h >>> 0).toString(36); } function lruGet(cache: Map, key: string): T | undefined { const v = cache.get(key); if (v !== undefined) { cache.delete(key); cache.set(key, v); } return v; } function lruSet(cache: Map, key: string, value: T, max: number): void { cache.delete(key); cache.set(key, value); while (cache.size > max) { const oldest = cache.keys().next().value as string; cache.delete(oldest); } } type Params = Record; function pLiteral(params: Params): string { const lines: string[] = []; for (const [k, v] of Object.entries(params)) { if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`); lines.push(`P.${k} = ${v};`); } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`); } return lines.join('\n'); } function keyFor(params: Params, keys: readonly string[]): string { return keys.map((k) => String(params[k] ?? 'd')).join('|'); } function runStage(mfiles: MFile[], source: string, inputs?: Record) { return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m'); } function asTensor(v: RuntimeValue): RuntimeTensor { const t = v as RuntimeTensor; if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage'); return t; } function asScalar(v: RuntimeValue): number { if (typeof v === 'number') return v; const t = v as RuntimeTensor; if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0]; if (typeof v === 'boolean') return v ? 1 : 0; throw new Error('expected scalar result from numbl stage'); } function toF32(v: RuntimeValue): Float32Array { const t = asTensor(v); const out = new Float32Array(t.data.length); for (let i = 0; i < t.data.length; i++) out[i] = t.data[i]; return out; } function knobValue(knobs: KnobSpec[], settings: Record, key: string): number { const spec = knobs.find((k) => k.key === key)!; const raw = settings[key]; return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw)); } /** Velocity quantized to a few levels so identical notes share a cached * render; 8 levels is inaudible next to the model's own dynamics. */ export function bucketVelocity(velocity01: number, levels = 8): number { return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels; } // The basis fields handed from the solve stage into the radiate stage, in // the order the reassembly script expects. const BASIS_FIELDS = [ 'fIdx', 'off', 'nModes', 'pmOff', 'ns', 'Lv', 'mu', 'T', 'f0', 'str', 'stop', 'f', 'zeta', 'Vm', 'Dd', 'Fcoef', 'ybAmp', 'bodyFrac', 'PMflat', 'bodyF', 'bodyZeta', 'bodyA', 'bodyS', 'bodyMono', ]; const SOLVE_KEYS = [ 'scale_length_m', 'tension_g_n', 'tension_d_n', 'tension_a_n', 'tension_e_n', 'inharmonicity', 'tuning_trim', 'loss_factor', 'loss_slope', 'finger_loss', 'residual_y', 'a0_hz', 'body_trim', 'body_q_scale', 'bridge_mobility', 'rad_area_scale', 'dipole_length_m', 'radiation_loss', 'root_midi', 'note_count', 'seed', 'max_mode_hz', 'speed_of_sound', 'air_density', ]; const RADIATE_KEYS = [ 'pluck_force_n', 'pluck_pos', 'finger_release_ms', 'choke_ms', 'tempo_bpm', 'force_spread', 'timing_jitter_ms', 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m', 'sample_rate', 'tail_s', ]; interface SolveEntry { vars: Record; noteT: Float32Array; noteMidi: Float32Array; noteStr: Float32Array; noteStop: Float32Array; noteForce: Float32Array; mode1F: Float32Array; mode1Zeta: Float32Array; mode1BodyFrac: Float32Array; bodyF: Float32Array; bodyZeta: Float32Array; bodyA: Float32Array; bodyMono: Float32Array; fingerings: number; modesTotal: number; meanForce: number; durationS: number; } interface RadiateEntry { p: RuntimeValue; fundHz: number; fundT60: number; listenerDistM: number; listenerInside: boolean; fsHi: number; oversample: number; } // Caches, all keyed under the code hash so editing the .m sources // invalidates everything computed from the previous version. The explorer // caches are small (the app compares a handful of settings); the note-solve // cache holds one coupled eigensolve per fingered pitch, which is what lets // a track reuse the expensive stage across velocities and repeats. const solveCache = new Map(); const radiateCache = new Map(); const SOLVE_CACHE_MAX = 2; const RADIATE_CACHE_MAX = 3; interface NoteSolveEntry { vars: Record } const noteSolveCache = new Map(); const NOTE_SOLVE_CACHE_MAX = 16; // Finished per-note audio: an in-memory LRU in front of an IndexedDB // store, so renders survive page reloads (budgets and eviction live in // noteCache.ts). const noteCache = new NoteCache('bass-instrument-notes'); function midiHz(m: number): number { return 440 * Math.pow(2, (m - 69) / 12); } // --- the explorer path (full params, staged, with diagnostics) --- async function renderExplore( req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag: BassExploreDiag }> { const params = req.params ?? {}; const mfiles = env.mfiles; let quality = req.quality; const codeKey = hashMFiles(mfiles); const solveKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS); const fullOversample = Number(params.oversample ?? 2); const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS); if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) { quality = 'full'; // full-rate radiation already cached } const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample; const sampleRate = Number(params.sample_rate ?? req.sampleRate); const fsHi = sampleRate * oversample; const base = `P = bass_defaults();\n${pLiteral(params)}`; // ---- solve stage: the gesture and the coupled eigensolves ---- let solveMs = 0; let solveCached = true; let solveEntry = lruGet(solveCache, solveKey); if (!solveEntry) { solveCached = false; env.post(0.15, 'solve'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const r = runStage(mfiles, ` ${base} [tOn, midi, str, stop, force, ninfo] = bass_notes(P); basis = bass_basis(P, str, stop); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} n1 = basis.nModes(1); mode1F = basis.f(1:n1); mode1Zeta = basis.zeta(1:n1); mode1BodyFrac = basis.bodyFrac(1:n1); fingerings = basis.nf; modesTotal = basis.n; meanForce = ninfo.meanForce; durationS = ninfo.durationS; `); solveMs = performance.now() - t0; const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; solveEntry = { vars, noteT: toF32(r.variableValues.tOn), noteMidi: toF32(r.variableValues.midi), noteStr: toF32(r.variableValues.str), noteStop: toF32(r.variableValues.stop), noteForce: toF32(r.variableValues.force), mode1F: toF32(r.variableValues.mode1F), mode1Zeta: toF32(r.variableValues.mode1Zeta), mode1BodyFrac: toF32(r.variableValues.mode1BodyFrac), bodyF: toF32(r.variableValues.b_bodyF), bodyZeta: toF32(r.variableValues.b_bodyZeta), bodyA: toF32(r.variableValues.b_bodyA), bodyMono: toF32(r.variableValues.b_bodyMono), fingerings: asScalar(r.variableValues.fingerings), modesTotal: asScalar(r.variableValues.modesTotal), meanForce: asScalar(r.variableValues.meanForce), durationS: asScalar(r.variableValues.durationS), }; lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX); } // ---- radiate stage: pluck projection + closed-form synthesis ---- const radiateKey = radiateParamsKey + `|os=${oversample}`; let radiateMs = 0; let radiateCached = true; let radEntry = lruGet(radiateCache, radiateKey); if (!radEntry) { radiateCached = false; env.post(0.55, 'radiate'); await env.yieldToHost(); env.checkCancel(); const t0 = performance.now(); const r = runStage(mfiles, ` ${base} ${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.nf = length(basis.off); basis.n = length(basis.f); [tOn, midi, str, stop, force, ninfo] = bass_notes(P); [p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, ${fsHi}); fundHz = radinfo.fundHz; fundT60 = radinfo.fundT60; listenerDistM = radinfo.listenerDistM; listenerInside = radinfo.listenerInside; `, solveEntry.vars); radiateMs = performance.now() - t0; radEntry = { p: r.variableValues.p, fundHz: asScalar(r.variableValues.fundHz), fundT60: asScalar(r.variableValues.fundT60), listenerDistM: asScalar(r.variableValues.listenerDistM), listenerInside: asScalar(r.variableValues.listenerInside) !== 0, fsHi, oversample, }; lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX); } // ---- output stage ---- env.post(0.9, 'output'); await env.yieldToHost(); env.checkCancel(); const rOut = runStage(mfiles, ` ${base} [y, peakPa] = bass_output(P, p, ${radEntry.oversample}); `, { p: radEntry.p }); const y32 = toF32(rOut.variableValues.y); const peakPa = asScalar(rOut.variableValues.peakPa); const warnings: string[] = []; if (radEntry.listenerInside) { warnings.push('the listener is within 35 cm of the bridge, inside or against the body; the field there is not free'); } if (!params.normalize) { let peak = 0; for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i])); if (peak > 1.0) { warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`); } } // wolf warning: the fundamental pulled well away from equal temperament const target = midiHz(solveEntry.noteMidi[0]) * Number(params.tuning_trim ?? 1); const cents = 1200 * Math.log2(radEntry.fundHz / target); if (Math.abs(cents) > 35) { warnings.push(`the first note sounds ${Math.abs(cents).toFixed(0)} cents ${cents > 0 ? 'sharp' : 'flat'}: its fundamental is being pulled by a body resonance (wolf territory)`); } const diag: BassExploreDiag = { quality, noteT: solveEntry.noteT.slice(), noteMidi: solveEntry.noteMidi.slice(), noteStr: solveEntry.noteStr.slice(), noteStop: solveEntry.noteStop.slice(), noteForce: solveEntry.noteForce.slice(), mode1F: solveEntry.mode1F.slice(), mode1Zeta: solveEntry.mode1Zeta.slice(), mode1BodyFrac: solveEntry.mode1BodyFrac.slice(), bodyF: solveEntry.bodyF.slice(), bodyZeta: solveEntry.bodyZeta.slice(), bodyA: solveEntry.bodyA.slice(), bodyMono: solveEntry.bodyMono.slice(), diag: { noteCount: solveEntry.noteT.length, fingerings: solveEntry.fingerings, modesTotal: solveEntry.modesTotal, modesNote1: solveEntry.mode1F.length, fundHz: radEntry.fundHz, fundT60: radEntry.fundT60, meanForce: solveEntry.meanForce, durationS: solveEntry.durationS, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), listenerDistM: radEntry.listenerDistM, warnings, solveMs, radiateMs, solveCached, radiateCached, oversample: radEntry.oversample, }, }; return { audio: y32, diag }; } // --- the track path (midiboot): per-note rendering with caching --- function notePLines(variant: BassVariant, note: { root: number; velocity: number }, req: TrackRenderRequest): string { // The note-to-parameter mapping: pitch onto the fingered root, velocity // onto pluck force, knobs onto the curated parameters. A single-pluck // gesture with a fixed seed so renders are cacheable, normalize off so // velocity survives in pascals; the muted variant bakes in a slow // fingertip release and a short ring-out. const knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS; const settings = req.settings ?? {}; const lines = [ 'P = bass_defaults();', `P.sample_rate = ${req.sampleRate};`, 'P.note_count = 1;', 'P.seed = 1;', 'P.pre_delay_ms = 0;', 'P.normalize = 0;', ]; if (req.quality === 'preview') lines.push('P.oversample = 1;'); lines.push( `P.root_midi = ${note.root};`, `P.pluck_force_n = ${3 + (30 - 3) * note.velocity};`, `P.pluck_pos = ${knobValue(knobs, settings, 'pluck_pos')};`, ); if (variant === 'bass') { lines.push( `P.finger_release_ms = ${knobValue(knobs, settings, 'finger_release_ms')};`, `P.tail_s = ${knobValue(knobs, settings, 'tail_s')};`, ); } else { lines.push('P.finger_release_ms = 6;', 'P.tail_s = 0.8;'); } return lines.join('\n'); } async function renderNoteAudio( variant: BassVariant, note: { root: number; velocity: number }, req: TrackRenderRequest, env: RenderEnv, codeKey: string, progress: (frac: number, stage: string) => void, ): Promise { const pLines = notePLines(variant, note, req); // solve: one coupled eigensolve per fingered pitch, shared across // velocities, knob values, and both variants (the knobs are all // radiate-tier parameters). const solveKey = `n|${codeKey}|${note.root}`; let entry = lruGet(noteSolveCache, solveKey); if (!entry) { progress(0.1, 'solve'); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${pLines} [tOn, midi, str, stop, force, ninfo] = bass_notes(P); basis = bass_basis(P, str, stop); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} `); const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; entry = { vars }; lruSet(noteSolveCache, solveKey, entry, NOTE_SOLVE_CACHE_MAX); } progress(0.5, 'radiate'); await env.yieldToHost(); env.checkCancel(); const r = runStage(env.mfiles, ` ${pLines} ${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.nf = length(basis.off); basis.n = length(basis.f); [tOn, midi, str, stop, force, ninfo] = bass_notes(P); fsHi = P.sample_rate * P.oversample; [p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi); [y, peakPa] = bass_output(P, p, P.oversample); `, entry.vars); return toF32(r.variableValues.y); } async function renderTrackNotes( variant: BassVariant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array }> { if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) }; const codeKey = hashMFiles(env.mfiles); const knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS; const settings = req.settings ?? {}; const knobsKey = knobs.map((k) => `${k.key}=${knobValue(knobs, settings, k.key)}`).join(','); // Group notes by everything that affects their sound. The bass ignores // note duration (ring-out is the tail_s knob), so duration is not part // of the key and a walking line of even eighths costs one render per // distinct pitch and velocity level. const groups = new Map(); for (const n of req.notes) { const root = Math.max(28, Math.min(55, Math.round(n.pitch))); const vel = bucketVelocity(n.velocity); const key = `${variant}|${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${root}|${vel}`; let g = groups.get(key); if (!g) groups.set(key, (g = { root, velocity: vel, onsets: [] })); g.onsets.push(n.timeSec); } const placed: { buf: Float32Array; onsets: number[] }[] = []; // Resolve cache hits first (memory, then IndexedDB), so the progress // denominator counts only the sounds that actually need rendering. const resolved = new Map(); for (const key of groups.keys()) { const hit = await noteCache.get(key); if (hit) resolved.set(key, hit); } const misses = groups.size - resolved.size; let done = 0; for (const [key, g] of groups) { let buf = resolved.get(key); if (!buf) { const i = done; buf = await renderNoteAudio(variant, g, req, env, codeKey, (frac, stage) => env.post((i + frac) / Math.max(1, misses), `note ${i + 1}/${misses}: ${stage}`)); noteCache.put(key, buf); done++; } placed.push({ buf, onsets: g.onsets }); } // lengthSamples is a minimum: size the output to the longest tail so a // ring that outlives the last note is returned in full, and place every // copy. let length = req.lengthSamples; for (const p of placed) { for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length); } const out = new Float32Array(length); for (const p of placed) { for (const t of p.onsets) { const off = Math.round(t * req.sampleRate); const end = Math.min(out.length, off + p.buf.length); for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off]; } } return { audio: out }; } /** The single entry point: explorer path when diagnostics are requested, * the per-note track path otherwise. */ export async function renderRequest( variant: BassVariant, req: TrackRenderRequest, env: RenderEnv, ): Promise<{ audio: Float32Array; diag?: BassExploreDiag }> { if (req.wantDiagnostics) return renderExplore(req, env); return renderTrackNotes(variant, req, env); } export type { NoteSpec };