/ simulated_instruments / bass
Sign in
simulated_instruments / bass
bass / webapp / src / worker / renderWorker.ts
420 lines · 14.1 KBBlameHistoryRaw
1// Web Worker that renders the pizzicato bass 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// solve tier -> bass_notes + bass_basis (one coupled eigensolve per
6// fingering; the expensive stage, cached on the strings,
7// the body, and the gesture's pitches)
8// radiate tier -> bass_radiate (pluck projection + closed-form synthesis +
9// radiation; cached on the pluck, the tempo, the listener,
10// and the synthesis rate)
11// output tier -> bass_output (always cheap, always re-run)
12// Tensors cross the boundary as numbl RuntimeValues (column-major Float64).
14import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl';
15import { hashMFiles, type MFile } from '../mfiles';
16import { SOLVE_KEYS, RADIATE_KEYS, midiHz, type BassParams } from '../params';
18export interface RenderRequest {
19 type: 'render';
20 id: number;
21 params: BassParams;
22 quality: 'preview' | 'full';
23 mfiles: MFile[];
26export interface RenderResponse {
27 type: 'result';
28 id: number;
29 quality: 'preview' | 'full';
30 y: Float32Array; // final signal, [-1, 1]
31 sampleRate: number;
32 noteT: Float32Array; // note onsets, s
33 noteMidi: Float32Array; // pitches
34 noteStr: Float32Array; // string index, 1 = G2 .. 4 = E1
35 noteStop: Float32Array; // semitones above the open string
36 noteForce: Float32Array; // pluck forces, N
37 mode1F: Float32Array; // coupled modes of the first fingering, Hz
38 mode1Zeta: Float32Array; // their damping ratios
39 mode1BodyFrac: Float32Array; // their body-energy fraction (0 string .. 1 body)
40 bodyF: Float32Array; // the body mode table, Hz
41 bodyZeta: Float32Array;
42 bodyA: Float32Array; // mass-normalized bridge amplitudes, 1/sqrt(kg)
43 bodyMono: Float32Array; // 1 = monopole (A0), 0 = dipole
44 diag: {
45 noteCount: number;
46 fingerings: number;
47 modesTotal: number;
48 modesNote1: number;
49 fundHz: number;
50 fundT60: number;
51 meanForce: number;
52 durationS: number;
53 peakPa: number;
54 peakSplDb: number;
55 listenerDistM: number;
56 warnings: string[];
57 solveMs: number; // 0 when the cached eigensolves were reused
58 radiateMs: number; // 0 when the cached radiation was reused
59 solveCached: boolean;
60 radiateCached: boolean;
61 oversample: number;
62 };
65export interface ProgressMessage {
66 type: 'progress';
67 id: number;
68 stage: 'solve' | 'radiate' | 'output';
69 frac: number;
72// Raised by a fault in the .m code (the user can edit it), so it is reported
73// rather than left to stall the request.
74export interface ErrorMessage {
75 type: 'error';
76 id: number;
77 message: string;
78 file?: string;
79 line?: number;
82// Basis fields that cross from the solve stage into the radiate stage as
83// numbl RuntimeValues, plus their Float32 copies for the plots.
84interface SolveEntry {
85 vars: Record<string, RuntimeValue>;
86 noteT: Float32Array;
87 noteMidi: Float32Array;
88 noteStr: Float32Array;
89 noteStop: Float32Array;
90 noteForce: Float32Array;
91 mode1F: Float32Array;
92 mode1Zeta: Float32Array;
93 mode1BodyFrac: Float32Array;
94 bodyF: Float32Array;
95 bodyZeta: Float32Array;
96 bodyA: Float32Array;
97 bodyMono: Float32Array;
98 fingerings: number;
99 modesTotal: number;
100 meanForce: number;
101 durationS: number;
104interface RadiateEntry {
105 p: RuntimeValue;
106 fundHz: number;
107 fundT60: number;
108 listenerDistM: number;
109 listenerInside: boolean;
110 fsHi: number;
111 oversample: number;
114// Small LRU caches. The solve cache does not depend on the oversample rate,
115// so a preview render right after a full one reuses the same eigensolves.
116const solveCache = new Map<string, SolveEntry>();
117const radiateCache = new Map<string, RadiateEntry>();
118const SOLVE_CACHE_MAX = 2;
119const RADIATE_CACHE_MAX = 3;
121function lruGet<T>(cache: Map<string, T>, key: string): T | undefined {
122 const v = cache.get(key);
123 if (v !== undefined) {
124 cache.delete(key);
125 cache.set(key, v);
126 }
127 return v;
130function lruSet<T>(cache: Map<string, T>, key: string, value: T, max: number): void {
131 cache.delete(key);
132 cache.set(key, value);
133 while (cache.size > max) {
134 const oldest = cache.keys().next().value as string;
135 cache.delete(oldest);
136 }
139function keyFor(params: BassParams, keys: readonly (keyof BassParams)[]): string {
140 return keys.map((k) => String(params[k])).join('|');
143// MATLAB source lines assigning every parameter onto a struct P.
144function pLiteral(params: BassParams): string {
145 const lines: string[] = [];
146 for (const [k, v] of Object.entries(params)) {
147 if (typeof v === 'number') lines.push(`P.${k} = ${v};`);
148 else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`);
149 }
150 return lines.join('\n');
153function runStage(mfiles: MFile[], source: string, inputs?: Record<string, RuntimeValue>) {
154 return executeCode(
155 source,
156 { initialVariableValues: inputs, displayResults: false },
157 mfiles,
158 'stage.m',
159 );
162function asTensor(v: RuntimeValue): RuntimeTensor {
163 const t = v as RuntimeTensor;
164 if (!t || t.kind !== 'tensor') throw new Error('expected tensor result from numbl stage');
165 return t;
168function asScalar(v: RuntimeValue): number {
169 if (typeof v === 'number') return v;
170 const t = v as RuntimeTensor;
171 if (t && t.kind === 'tensor' && t.data.length === 1) return t.data[0];
172 if (typeof v === 'boolean') return v ? 1 : 0;
173 throw new Error('expected scalar result from numbl stage');
176function toF32(v: RuntimeValue): Float32Array {
177 const t = asTensor(v);
178 const out = new Float32Array(t.data.length);
179 for (let i = 0; i < t.data.length; i++) out[i] = t.data[i];
180 return out;
183// The basis fields handed from the solve stage into the radiate stage, in the
184// order the reassembly script expects.
185const BASIS_FIELDS = [
186 'fIdx', 'off', 'nModes', 'pmOff', 'ns', 'Lv', 'mu', 'T', 'f0', 'str', 'stop',
187 'f', 'zeta', 'Vm', 'Dd', 'Fcoef', 'ybAmp', 'bodyFrac', 'PMflat',
188 'bodyF', 'bodyZeta', 'bodyA', 'bodyS', 'bodyMono',
189];
191const SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/;
193self.onmessage = (ev: MessageEvent<RenderRequest>) => {
194 const msg = ev.data;
195 if (msg.type !== 'render') return;
197 // numbl reports a file it could not parse through console.warn and then
198 // skips it, so the failure surfaces at the call site as "Undefined
199 // function". Capture the warning to report the actual syntax error instead.
200 const warnings: string[] = [];
201 const origWarn = console.warn;
202 console.warn = (...args: unknown[]) => {
203 warnings.push(args.map(String).join(' '));
204 };
206 try {
207 render(msg);
208 } catch (err) {
209 // Most likely a fault in the (user-editable) .m code. Report it so the
210 // request completes and the message reaches the code panel.
211 const e = err as { message?: string; file?: string; line?: number };
212 let m: ErrorMessage = {
213 type: 'error',
214 id: msg.id,
215 message: e?.message ? String(e.message) : String(err),
216 file: typeof e?.file === 'string' ? e.file : undefined,
217 line: typeof e?.line === 'number' ? e.line : undefined,
218 };
219 for (const w of warnings) {
220 const hit = SKIP_WARNING.exec(w);
221 if (hit) {
222 m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) };
223 break;
224 }
225 }
226 (self as unknown as Worker).postMessage(m);
227 } finally {
228 console.warn = origWarn;
229 }
230};
232function render(msg: RenderRequest): void {
233 const { id, params, mfiles } = msg;
234 let quality = msg.quality;
236 // The .m sources are part of the cache identity: editing the code must
237 // invalidate everything computed from the previous version.
238 const codeKey = hashMFiles(mfiles);
239 const solveKey = codeKey + '|' + keyFor(params, SOLVE_KEYS);
241 const fullOversample = params.oversample;
242 const radiateParamsKey = solveKey + '||' + keyFor(params, RADIATE_KEYS);
243 if (quality === 'preview' && radiateCache.has(radiateParamsKey + `|os=${fullOversample}`)) {
244 quality = 'full'; // full-rate radiation already cached
245 }
246 const oversample = quality === 'preview' ? Math.min(1, params.oversample) : params.oversample;
247 const fsHi = params.sample_rate * oversample;
249 const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m);
251 // ---- solve stage: the gesture and the coupled eigensolves ----
252 let solveMs = 0;
253 let solveCached = true;
254 let solveEntry = lruGet(solveCache, solveKey);
255 if (!solveEntry) {
256 solveCached = false;
257 post({ type: 'progress', id, stage: 'solve', frac: 0.15 });
258 const t0 = performance.now();
259 const r = runStage(mfiles, `
260${pLiteral(params)}
261[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
262basis = bass_basis(P, str, stop);
263${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')}
264n1 = basis.nModes(1);
265mode1F = basis.f(1:n1);
266mode1Zeta = basis.zeta(1:n1);
267mode1BodyFrac = basis.bodyFrac(1:n1);
268fingerings = basis.nf;
269modesTotal = basis.n;
270meanForce = ninfo.meanForce;
271durationS = ninfo.durationS;
272`);
273 solveMs = performance.now() - t0;
275 const vars: Record<string, RuntimeValue> = {};
276 for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`];
277 solveEntry = {
278 vars,
279 noteT: toF32(r.variableValues.tOn),
280 noteMidi: toF32(r.variableValues.midi),
281 noteStr: toF32(r.variableValues.str),
282 noteStop: toF32(r.variableValues.stop),
283 noteForce: toF32(r.variableValues.force),
284 mode1F: toF32(r.variableValues.mode1F),
285 mode1Zeta: toF32(r.variableValues.mode1Zeta),
286 mode1BodyFrac: toF32(r.variableValues.mode1BodyFrac),
287 bodyF: toF32(r.variableValues.b_bodyF),
288 bodyZeta: toF32(r.variableValues.b_bodyZeta),
289 bodyA: toF32(r.variableValues.b_bodyA),
290 bodyMono: toF32(r.variableValues.b_bodyMono),
291 fingerings: asScalar(r.variableValues.fingerings),
292 modesTotal: asScalar(r.variableValues.modesTotal),
293 meanForce: asScalar(r.variableValues.meanForce),
294 durationS: asScalar(r.variableValues.durationS),
295 };
296 lruSet(solveCache, solveKey, solveEntry, SOLVE_CACHE_MAX);
297 }
299 // ---- radiate stage: pluck projection + closed-form synthesis ----
300 const radiateKey = radiateParamsKey + `|os=${oversample}`;
301 let radiateMs = 0;
302 let radiateCached = true;
303 let radEntry = lruGet(radiateCache, radiateKey);
304 if (!radEntry) {
305 radiateCached = false;
306 post({ type: 'progress', id, stage: 'radiate', frac: 0.55 });
307 const t0 = performance.now();
308 const r = runStage(mfiles, `
309${pLiteral(params)}
310${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')}
311basis.nf = length(basis.off);
312basis.n = length(basis.f);
313[tOn, midi, str, stop, force, ninfo] = bass_notes(P);
314[p, radinfo] = bass_radiate(P, basis, tOn, str, stop, force, ${fsHi});
315fundHz = radinfo.fundHz;
316fundT60 = radinfo.fundT60;
317listenerDistM = radinfo.listenerDistM;
318listenerInside = radinfo.listenerInside;
319`, solveEntry.vars);
320 radiateMs = performance.now() - t0;
321 radEntry = {
322 p: r.variableValues.p,
323 fundHz: asScalar(r.variableValues.fundHz),
324 fundT60: asScalar(r.variableValues.fundT60),
325 listenerDistM: asScalar(r.variableValues.listenerDistM),
326 listenerInside: asScalar(r.variableValues.listenerInside) !== 0,
327 fsHi,
328 oversample,
329 };
330 lruSet(radiateCache, radiateKey, radEntry, RADIATE_CACHE_MAX);
331 }
333 // ---- output stage ----
334 post({ type: 'progress', id, stage: 'output', frac: 0.9 });
335 const rOut = runStage(mfiles, `
336${pLiteral(params)}
337[y, peakPa] = bass_output(P, p, ${radEntry.oversample});
338`, { p: radEntry.p });
340 const y32 = toF32(rOut.variableValues.y);
341 const peakPa = asScalar(rOut.variableValues.peakPa);
343 const warnings: string[] = [];
344 if (radEntry.listenerInside) {
345 warnings.push(
346 'the listener is within 35 cm of the bridge, inside or against the body; the field there is not free',
347 );
348 }
349 if (!params.normalize) {
350 let peak = 0;
351 for (let i = 0; i < y32.length; i++) peak = Math.max(peak, Math.abs(y32[i]));
352 if (peak > 1.0) {
353 warnings.push(
354 `clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`,
355 );
356 }
357 }
358 // wolf warning: the fundamental pulled well away from equal temperament
359 const target = midiHz(solveEntry.noteMidi[0]) * params.tuning_trim;
360 const cents = 1200 * Math.log2(radEntry.fundHz / target);
361 if (Math.abs(cents) > 35) {
362 warnings.push(
363 `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)`,
364 );
365 }
367 const resp: RenderResponse = {
368 type: 'result',
369 id,
370 quality,
371 y: y32,
372 sampleRate: params.sample_rate,
373 noteT: solveEntry.noteT.slice(),
374 noteMidi: solveEntry.noteMidi.slice(),
375 noteStr: solveEntry.noteStr.slice(),
376 noteStop: solveEntry.noteStop.slice(),
377 noteForce: solveEntry.noteForce.slice(),
378 mode1F: solveEntry.mode1F.slice(),
379 mode1Zeta: solveEntry.mode1Zeta.slice(),
380 mode1BodyFrac: solveEntry.mode1BodyFrac.slice(),
381 bodyF: solveEntry.bodyF.slice(),
382 bodyZeta: solveEntry.bodyZeta.slice(),
383 bodyA: solveEntry.bodyA.slice(),
384 bodyMono: solveEntry.bodyMono.slice(),
385 diag: {
386 noteCount: solveEntry.noteT.length,
387 fingerings: solveEntry.fingerings,
388 modesTotal: solveEntry.modesTotal,
389 modesNote1: solveEntry.mode1F.length,
390 fundHz: radEntry.fundHz,
391 fundT60: radEntry.fundT60,
392 meanForce: solveEntry.meanForce,
393 durationS: solveEntry.durationS,
394 peakPa,
395 peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30),
396 listenerDistM: radEntry.listenerDistM,
397 warnings,
398 solveMs,
399 radiateMs,
400 solveCached,
401 radiateCached,
402 oversample: radEntry.oversample,
403 },
404 };
405 (self as unknown as Worker).postMessage(resp, [
406 resp.y.buffer,
407 resp.noteT.buffer,
408 resp.noteMidi.buffer,
409 resp.noteStr.buffer,
410 resp.noteStop.buffer,
411 resp.noteForce.buffer,
412 resp.mode1F.buffer,
413 resp.mode1Zeta.buffer,
414 resp.mode1BodyFrac.buffer,
415 resp.bodyF.buffer,
416 resp.bodyZeta.buffer,
417 resp.bodyA.buffer,
418 resp.bodyMono.buffer,
419 ]);
↑↓ move↵openescclose