/ simulated_instruments / bass
Sign in
simulated_instruments / bass
bass / instrument / core.ts
571 lines · 20.0 KBBlameHistoryRaw
1// The standup bass as a track-render instrument: pure rendering logic with
2// no DOM or Worker dependency, shared by the browser worker (worker.ts) and
3// the node tests (test.ts). Two request shapes arrive here:
4//
5// - the track path (midiboot): every note of a MIDI track. Notes are
6// grouped by cache key (pitch and bucketed velocity; the bass ignores
7// note duration, ring-out is the tail_s knob), each distinct sound is
8// rendered once through the staged pipeline, and copies are summed at
9// the onsets. The per-fingering eigensolve is cached separately, so two
10// velocities of the same pitch share one solve.
11//
12// - the explorer path (the bass web app, wantDiagnostics set): the full
13// parameter surface and live-edited .m sources drive one staged render
14// with the diagnostics payload the app's plots consume, exactly the
15// arrangement of the app's former render worker.
16//
17// Stage intermediates cross executeCode calls as numbl RuntimeValues
18// (column-major Float64).
20import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
21import type { KnobSpec, MFile, NoteSpec, TrackRenderRequest } from './types.ts';
23import { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant } from './knobs.ts';
24import { NoteCache } from './noteCache.ts';
26export { BASS_KNOBS, BASS_MUTED_KNOBS, type BassVariant };
28/** The environment the host provides: sources, progress, cancellation, and
29 * a macrotask yield so a browser worker can receive a cancel message. */
30export interface RenderEnv {
31 mfiles: MFile[];
32 post: (frac: number, stage: string) => void;
33 /** Throws to abandon the render; called between stages and notes. */
34 checkCancel: () => void;
35 yieldToHost: () => Promise<void>;
38// --- diagnostics payload for the explorer path (the web app's plots) ---
40export interface BassExploreDiag {
41 quality: 'preview' | 'full';
42 noteT: Float32Array;
43 noteMidi: Float32Array;
44 noteStr: Float32Array;
45 noteStop: Float32Array;
46 noteForce: Float32Array;
47 mode1F: Float32Array;
48 mode1Zeta: Float32Array;
49 mode1BodyFrac: Float32Array;
50 bodyF: Float32Array;
51 bodyZeta: Float32Array;
52 bodyA: Float32Array;
53 bodyMono: Float32Array;
54 diag: {
55 noteCount: number;
56 fingerings: number;
57 modesTotal: number;
58 modesNote1: number;
59 fundHz: number;
60 fundT60: number;
61 meanForce: number;
62 durationS: number;
63 peakPa: number;
64 peakSplDb: number;
65 listenerDistM: number;
66 warnings: string[];
67 solveMs: number;
68 radiateMs: number;
69 solveCached: boolean;
70 radiateCached: boolean;
71 oversample: number;
72 };
75// --- shared helpers ---
77export function hashMFiles(files: MFile[]): string {
78 let h = 0x811c9dc5;
79 for (const f of files) {
80 const s = f.name + ' ' + f.source;
81 for (let i = 0; i < s.length; i++) {
82 h ^= s.charCodeAt(i);
83 h = Math.imul(h, 0x01000193);
84 }
85 }
86 return (h >>> 0).toString(36);
89function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
90 const v = cache.get(key);
91 if (v !== undefined) {
92 cache.delete(key);
93 cache.set(key, v);
94 }
95 return v;
98function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
99 cache.delete(key);
100 cache.set(key, value);
101 while (cache.size > max) {
102 const oldest = cache.keys().next().value as string;
103 cache.delete(oldest);
104 }
107type Params = Record<string, number | boolean>;
109function pLiteral(params: Params): string {
110 const lines: string[] = [];
111 for (const [k, v] of Object.entries(params)) {
112 if (typeof v === 'number') {
113 if (!Number.isFinite(v)) throw new Error(`parameter ${k} is not finite`);
114 lines.push(`P.${k} = ${v};`);
115 } else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
116 }
117 return lines.join('\n');
120function keyFor(params: Params, keys: readonly string[]): string {
121 return keys.map((k) => String(params[k] ?? 'd')).join('|');
124function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
125 return executeCode(source, { initialVariableValues: inputs, displayResults: false }, mfiles, 'stage.m');
128function asTensor(v: RuntimeValue): RuntimeTensor {
129 const t = v as RuntimeTensor;
130 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
131 return t;
134function asScalar(v: RuntimeValue): number {
135 if (typeof v === 'number') return v;
136 const t = v as RuntimeTensor;
137 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
138 if (typeof v === 'boolean') return v ? 1 : 0;
139 throw new Error('expected scalar result from numbl stage');
142function toF32(v: RuntimeValue): Float32Array {
143 const t = asTensor(v);
144 const out = new Float32Array(t.data.length);
145 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
146 return out;
149function knobValue(knobs: KnobSpec[], settings: Record<string, number>, key: string): number {
150 const spec = knobs.find((k) => k.key === key)!;
151 const raw = settings[key];
152 return raw === undefined ? spec.default : Math.max(spec.min, Math.min(spec.max, raw));
155/** Velocity quantized to a few levels so identical notes share a cached
156 * render; 8 levels is inaudible next to the model's own dynamics. */
157export function bucketVelocity(velocity01: number, levels = 8): number {
158 return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
161// The basis fields handed from the solve stage into the radiate stage, in
162// the order the reassembly script expects.
163const BASIS_FIELDS = [
164 'fIdx', 'off', 'nModes', 'pmOff', 'ns', 'Lv', 'mu', 'T', 'f0', 'str', 'stop',
165 'f', 'zeta', 'Vm', 'Dd', 'Fcoef', 'ybAmp', 'bodyFrac', 'PMflat',
166 'bodyF', 'bodyZeta', 'bodyA', 'bodyS', 'bodyMono',
167];
169const SOLVE_KEYS = [
170 'scale_length_m', 'tension_g_n', 'tension_d_n', 'tension_a_n', 'tension_e_n',
171 'inharmonicity', 'tuning_trim',
172 'loss_factor', 'loss_slope', 'finger_loss', 'residual_y',
173 'a0_hz', 'body_trim', 'body_q_scale', 'bridge_mobility',
174 'rad_area_scale', 'dipole_length_m', 'radiation_loss',
175 'root_midi', 'note_count', 'seed',
176 'max_mode_hz', 'speed_of_sound', 'air_density',
177];
179const RADIATE_KEYS = [
180 'pluck_force_n', 'pluck_pos', 'finger_release_ms', 'choke_ms',
181 'tempo_bpm', 'force_spread', 'timing_jitter_ms',
182 'direct_gain', 'listener_x_m', 'listener_y_m', 'listener_z_m',
183 'sample_rate', 'tail_s',
184];
186interface SolveEntry {
187 vars: Record<string, RuntimeValue>;
188 noteT: Float32Array;
189 noteMidi: Float32Array;
190 noteStr: Float32Array;
191 noteStop: Float32Array;
192 noteForce: Float32Array;
193 mode1F: Float32Array;
194 mode1Zeta: Float32Array;
195 mode1BodyFrac: Float32Array;
196 bodyF: Float32Array;
197 bodyZeta: Float32Array;
198 bodyA: Float32Array;
199 bodyMono: Float32Array;
200 fingerings: number;
201 modesTotal: number;
202 meanForce: number;
203 durationS: number;
206interface RadiateEntry {
207 p: RuntimeValue;
208 fundHz: number;
209 fundT60: number;
210 listenerDistM: number;
211 listenerInside: boolean;
212 fsHi: number;
213 oversample: number;
216// Caches, all keyed under the code hash so editing the .m sources
217// invalidates everything computed from the previous version. The explorer
218// caches are small (the app compares a handful of settings); the note-solve
219// cache holds one coupled eigensolve per fingered pitch, which is what lets
220// a track reuse the expensive stage across velocities and repeats.
221const solveCache = new Map<string, SolveEntry>();
222const radiateCache = new Map<string, RadiateEntry>();
223const SOLVE_CACHE_MAX = 2;
224const RADIATE_CACHE_MAX = 3;
225interface NoteSolveEntry { vars: Record<string, RuntimeValue> }
226const noteSolveCache = new Map<string, NoteSolveEntry>();
227const NOTE_SOLVE_CACHE_MAX = 16;
229// Finished per-note audio: an in-memory LRU in front of an IndexedDB
230// store, so renders survive page reloads (budgets and eviction live in
231// noteCache.ts).
232const noteCache = new NoteCache('bass-instrument-notes');
234function midiHz(m: number): number {
235 return 440 * Math.pow(2, (m - 69) / 12);
238// --- the explorer path (full params, staged, with diagnostics) ---
240async function renderExplore(
241 req: TrackRenderRequest,
242 env: RenderEnv,
243): Promise<{ audio: Float32Array; diag: BassExploreDiag }> {
244 const params = req.params ?? {};
245 const mfiles = env.mfiles;
246 let quality = req.quality;
248 const codeKey = hashMFiles(mfiles);
249 const solveKey = 'x|' + codeKey + '|' + keyFor(params, SOLVE_KEYS);
250 const fullOversample = Number(params.oversample ?? 2);
251 const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
252 if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) {
253 quality = 'full'; // full-rate radiation already cached
254 }
255 const oversample = quality === 'preview' ? Math.min(1, fullOversample) : fullOversample;
256 const sampleRate = Number(params.sample_rate ?? req.sampleRate);
257 const fsHi = sampleRate * oversample;
259 const base = `P = bass_defaults();\n${pLiteral(params)}`;
261 // ---- solve stage: the gesture and the coupled eigensolves ----
262 let solveMs = 0;
263 let solveCached = true;
264 let solveEntry = lruGet(solveCache, solveKey);
265 if (!solveEntry) {
266 solveCached = false;
267 env.post(0.15, 'solve');
268 await env.yieldToHost();
269 env.checkCancel();
270 const t0 = performance.now();
271 const r = runStage(mfiles, `
272${base}
273[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
274basis = bass_basis(P, str, stop);
275${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
276n1 = basis.nModes(1);
277mode1F = basis.f(1:n1);
278mode1Zeta = basis.zeta(1:n1);
279mode1BodyFrac = basis.bodyFrac(1:n1);
280fingerings = basis.nf;
281modesTotal = basis.n;
282meanForce = ninfo.meanForce;
283durationS = ninfo.durationS;
284`);
285 solveMs = performance.now() - t0;
286 const vars: Record<string, RuntimeValue> = {};
287 for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
288 solveEntry = {
289 vars,
290 noteT: toF32(r.variableValues.tOn),
291 noteMidi: toF32(r.variableValues.midi),
292 noteStr: toF32(r.variableValues.str),
293 noteStop: toF32(r.variableValues.stop),
294 noteForce: toF32(r.variableValues.force),
295 mode1F: toF32(r.variableValues.mode1F),
296 mode1Zeta: toF32(r.variableValues.mode1Zeta),
297 mode1BodyFrac: toF32(r.variableValues.mode1BodyFrac),
298 bodyF: toF32(r.variableValues.b_bodyF),
299 bodyZeta: toF32(r.variableValues.b_bodyZeta),
300 bodyA: toF32(r.variableValues.b_bodyA),
301 bodyMono: toF32(r.variableValues.b_bodyMono),
302 fingerings: asScalar(r.variableValues.fingerings),
303 modesTotal: asScalar(r.variableValues.modesTotal),
304 meanForce: asScalar(r.variableValues.meanForce),
305 durationS: asScalar(r.variableValues.durationS),
306 };
307 lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
308 }
310 // ---- radiate stage: pluck projection + closed-form synthesis ----
311 const radiateKey = radiateParamsKey + `|os=${oversample}`;
312 let radiateMs = 0;
313 let radiateCached = true;
314 let radEntry = lruGet(radiateCache, radiateKey);
315 if (!radEntry) {
316 radiateCached = false;
317 env.post(0.55, 'radiate');
318 await env.yieldToHost();
319 env.checkCancel();
320 const t0 = performance.now();
321 const r = runStage(mfiles, `
322${base}
323${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
324basis.nf = length(basis.off);
325basis.n = length(basis.f);
326[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
327[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, ${fsHi});
328fundHz = radinfo.fundHz;
329fundT60 = radinfo.fundT60;
330listenerDistM = radinfo.listenerDistM;
331listenerInside = radinfo.listenerInside;
332`, solveEntry.vars);
333 radiateMs = performance.now() - t0;
334 radEntry = {
335 p: r.variableValues.p,
336 fundHz: asScalar(r.variableValues.fundHz),
337 fundT60: asScalar(r.variableValues.fundT60),
338 listenerDistM: asScalar(r.variableValues.listenerDistM),
339 listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
340 fsHi,
341 oversample,
342 };
343 lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
344 }
346 // ---- output stage ----
347 env.post(0.9, 'output');
348 await env.yieldToHost();
349 env.checkCancel();
350 const rOut = runStage(mfiles, `
351${base}
352[y, peakPa] = bass_output(P, p, ${radEntry.oversample});
353`, { p: radEntry.p });
355 const y32 = toF32(rOut.variableValues.y);
356 const peakPa = asScalar(rOut.variableValues.peakPa);
358 const warnings: string[] = [];
359 if (radEntry.listenerInside) {
360 warnings.push('the listener is within 35 cm of the bridge, inside or against the body; the field there is not free');
361 }
362 if (!params.normalize) {
363 let peak = 0;
364 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
365 if (peak > 1.0) {
366 warnings.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${Number(params.full_scale_pa ?? 0.2).toFixed(2)}`);
367 }
368 }
369 // wolf warning: the fundamental pulled well away from equal temperament
370 const target = midiHz(solveEntry.noteMidi[0]) * Number(params.tuning_trim ?? 1);
371 const cents = 1200 * Math.log2(radEntry.fundHz / target);
372 if (Math.abs(cents) > 35) {
373 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)`);
374 }
376 const diag: BassExploreDiag = {
377 quality,
378 noteT: solveEntry.noteT.slice(),
379 noteMidi: solveEntry.noteMidi.slice(),
380 noteStr: solveEntry.noteStr.slice(),
381 noteStop: solveEntry.noteStop.slice(),
382 noteForce: solveEntry.noteForce.slice(),
383 mode1F: solveEntry.mode1F.slice(),
384 mode1Zeta: solveEntry.mode1Zeta.slice(),
385 mode1BodyFrac: solveEntry.mode1BodyFrac.slice(),
386 bodyF: solveEntry.bodyF.slice(),
387 bodyZeta: solveEntry.bodyZeta.slice(),
388 bodyA: solveEntry.bodyA.slice(),
389 bodyMono: solveEntry.bodyMono.slice(),
390 diag: {
391 noteCount: solveEntry.noteT.length,
392 fingerings: solveEntry.fingerings,
393 modesTotal: solveEntry.modesTotal,
394 modesNote1: solveEntry.mode1F.length,
395 fundHz: radEntry.fundHz,
396 fundT60: radEntry.fundT60,
397 meanForce: solveEntry.meanForce,
398 durationS: solveEntry.durationS,
399 peakPa,
400 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
401 listenerDistM: radEntry.listenerDistM,
402 warnings,
403 solveMs,
404 radiateMs,
405 solveCached,
406 radiateCached,
407 oversample: radEntry.oversample,
408 },
409 };
410 return { audio: y32, diag };
413// --- the track path (midiboot): per-note rendering with caching ---
415function notePLines(variant: BassVariant, note: { root: number; velocity: number }, req: TrackRenderRequest): string {
416 // The note-to-parameter mapping: pitch onto the fingered root, velocity
417 // onto pluck force, knobs onto the curated parameters. A single-pluck
418 // gesture with a fixed seed so renders are cacheable, normalize off so
419 // velocity survives in pascals; the muted variant bakes in a slow
420 // fingertip release and a short ring-out.
421 const knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS;
422 const settings = req.settings ?? {};
423 const lines = [
424 'P = bass_defaults();',
425 `P.sample_rate = ${req.sampleRate};`,
426 'P.note_count = 1;',
427 'P.seed = 1;',
428 'P.pre_delay_ms = 0;',
429 'P.normalize = 0;',
430 ];
431 if (req.quality === 'preview') lines.push('P.oversample = 1;');
432 lines.push(
433 `P.root_midi = ${note.root};`,
434 `P.pluck_force_n = ${3 + (30 - 3) * note.velocity};`,
435 `P.pluck_pos = ${knobValue(knobs, settings, 'pluck_pos')};`,
436 );
437 if (variant === 'bass') {
438 lines.push(
439 `P.finger_release_ms = ${knobValue(knobs, settings, 'finger_release_ms')};`,
440 `P.tail_s = ${knobValue(knobs, settings, 'tail_s')};`,
441 );
442 } else {
443 lines.push('P.finger_release_ms = 6;', 'P.tail_s = 0.8;');
444 }
445 return lines.join('\n');
448async function renderNoteAudio(
449 variant: BassVariant,
450 note: { root: number; velocity: number },
451 req: TrackRenderRequest,
452 env: RenderEnv,
453 codeKey: string,
454 progress: (frac: number, stage: string) => void,
455): Promise<Float32Array> {
456 const pLines = notePLines(variant, note, req);
458 // solve: one coupled eigensolve per fingered pitch, shared across
459 // velocities, knob values, and both variants (the knobs are all
460 // radiate-tier parameters).
461 const solveKey = `n|${codeKey}|${note.root}`;
462 let entry = lruGet(noteSolveCache, solveKey);
463 if (!entry) {
464 progress(0.1, 'solve');
465 await env.yieldToHost();
466 env.checkCancel();
467 const r = runStage(env.mfiles, `
468${pLines}
469[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
470basis = bass_basis(P, str, stop);
471${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
472`);
473 const vars: Record<string, RuntimeValue> = {};
474 for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
475 entry = { vars };
476 lruSet(noteSolveCache, solveKey, entry, NOTE_SOLVE_CACHE_MAX);
477 }
479 progress(0.5, 'radiate');
480 await env.yieldToHost();
481 env.checkCancel();
482 const r = runStage(env.mfiles, `
483${pLines}
484${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
485basis.nf = length(basis.off);
486basis.n = length(basis.f);
487[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
488fsHi = P.sample_rate * P.oversample;
489[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, fsHi);
490[y, peakPa] = bass_output(P, p, P.oversample);
491`, entry.vars);
492 return toF32(r.variableValues.y);
495async function renderTrackNotes(
496 variant: BassVariant,
497 req: TrackRenderRequest,
498 env: RenderEnv,
499): Promise<{ audio: Float32Array }> {
500 if (req.notes.length === 0) return { audio: new Float32Array(req.lengthSamples) };
501 const codeKey = hashMFiles(env.mfiles);
502 const knobs = variant === 'bass' ? BASS_KNOBS : BASS_MUTED_KNOBS;
503 const settings = req.settings ?? {};
504 const knobsKey = knobs.map((k) => `${k.key}=${knobValue(knobs, settings, k.key)}`).join(',');
506 // Group notes by everything that affects their sound. The bass ignores
507 // note duration (ring-out is the tail_s knob), so duration is not part
508 // of the key and a walking line of even eighths costs one render per
509 // distinct pitch and velocity level.
510 const groups = new Map<string, { root: number; velocity: number; onsets: number[] }>();
511 for (const n of req.notes) {
512 const root = Math.max(28, Math.min(55, Math.round(n.pitch)));
513 const vel = bucketVelocity(n.velocity);
514 const key = `${variant}|${codeKey}|${req.quality}|${req.sampleRate}|${knobsKey}|${root}|${vel}`;
515 let g = groups.get(key);
516 if (!g) groups.set(key, (g = { root, velocity: vel, onsets: [] }));
517 g.onsets.push(n.timeSec);
518 }
520 const placed: { buf: Float32Array; onsets: number[] }[] = [];
521 // Resolve cache hits first (memory, then IndexedDB), so the progress
522 // denominator counts only the sounds that actually need rendering.
523 const resolved = new Map<string, Float32Array>();
524 for (const key of groups.keys()) {
525 const hit = await noteCache.get(key);
526 if (hit) resolved.set(key, hit);
527 }
528 const misses = groups.size - resolved.size;
529 let done = 0;
530 for (const [key, g] of groups) {
531 let buf = resolved.get(key);
532 if (!buf) {
533 const i = done;
534 buf = await renderNoteAudio(variant, g, req, env, codeKey, (frac, stage) =>
535 env.post((i + frac) / Math.max(1, misses), `note ${i + 1}/${misses}: ${stage}`));
536 noteCache.put(key, buf);
537 done++;
538 }
539 placed.push({ buf, onsets: g.onsets });
540 }
542 // lengthSamples is a minimum: size the output to the longest tail so a
543 // ring that outlives the last note is returned in full, and place every
544 // copy.
545 let length = req.lengthSamples;
546 for (const p of placed) {
547 for (const t of p.onsets) length = Math.max(length, Math.round(t * req.sampleRate) + p.buf.length);
548 }
549 const out = new Float32Array(length);
550 for (const p of placed) {
551 for (const t of p.onsets) {
552 const off = Math.round(t * req.sampleRate);
553 const end = Math.min(out.length, off + p.buf.length);
554 for (let j = Math.max(0, off); j < end; j++) out[j] += p.buf[j - off];
555 }
556 }
557 return { audio: out };
560/** The single entry point: explorer path when diagnostics are requested,
561 * the per-note track path otherwise. */
562export async function renderRequest(
563 variant: BassVariant,
564 req: TrackRenderRequest,
565 env: RenderEnv,
566): Promise<{ audio: Float32Array; diag?: BassExploreDiag }> {
567 if (req.wantDiagnostics) return renderExplore(req, env);
568 return renderTrackNotes(variant, req, env);
571export type { NoteSpec };
↑↓ move↵openescclose