// Web Worker that renders the pizzicato bass by executing the MATLAB // implementation in ../matlab through numbl. The stages run as separate // executeCode calls so intermediates can be cached and cheap parameter // changes skip the expensive ones: // solve tier -> bass_notes + bass_basis (one coupled eigensolve per // fingering; the expensive stage, cached on the strings, // the body, and the gesture's pitches) // radiate tier -> bass_radiate (pluck projection + closed-form synthesis + // radiation; cached on the pluck, the tempo, the listener, // and the synthesis rate) // output tier -> bass_output (always cheap, always re-run) // Tensors cross the boundary as numbl RuntimeValues (column-major Float64). import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl'; import { hashMFiles, type MFile } from '../mfiles'; import { SOLVE_KEYS, RADIATE_KEYS, midiHz, type BassParams } from '../params'; export interface RenderRequest { type: 'render'; id: number; params: BassParams; quality: 'preview' | 'full'; mfiles: MFile[]; } export interface RenderResponse { type: 'result'; id: number; quality: 'preview' | 'full'; y: Float32Array; // final signal, [-1, 1] sampleRate: number; noteT: Float32Array; // note onsets, s noteMidi: Float32Array; // pitches noteStr: Float32Array; // string index, 1 = G2 .. 4 = E1 noteStop: Float32Array; // semitones above the open string noteForce: Float32Array; // pluck forces, N mode1F: Float32Array; // coupled modes of the first fingering, Hz mode1Zeta: Float32Array; // their damping ratios mode1BodyFrac: Float32Array; // their body-energy fraction (0 string .. 1 body) bodyF: Float32Array; // the body mode table, Hz bodyZeta: Float32Array; bodyA: Float32Array; // mass-normalized bridge amplitudes, 1/sqrt(kg) bodyMono: Float32Array; // 1 = monopole (A0), 0 = dipole 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; // 0 when the cached eigensolves were reused radiateMs: number; // 0 when the cached radiation was reused solveCached: boolean; radiateCached: boolean; oversample: number; }; } export interface ProgressMessage { type: 'progress'; id: number; stage: 'solve' | 'radiate' | 'output'; frac: number; } // Raised by a fault in the .m code (the user can edit it), so it is reported // rather than left to stall the request. export interface ErrorMessage { type: 'error'; id: number; message: string; file?: string; line?: number; } // Basis fields that cross from the solve stage into the radiate stage as // numbl RuntimeValues, plus their Float32 copies for the plots. 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; } // Small LRU caches. The solve cache does not depend on the oversample rate, // so a preview render right after a full one reuses the same eigensolves. const solveCache = new Map(); const radiateCache = new Map(); const SOLVE_CACHE_MAX = 2; const RADIATE_CACHE_MAX = 3; 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); } } function keyFor(params: BassParams, keys: readonly (keyof BassParams)[]): string { return keys.map((k) => String(params[k])).join('|'); } // MATLAB source lines assigning every parameter onto a struct P. function pLiteral(params: BassParams): string { const lines: string[] = []; for (const [k, v] of Object.entries(params)) { if (typeof v === 'number') lines.push(`P.${k} = ${v};`); else if (typeof v === 'boolean') lines.push(`P.${k} = ${v ? 1 : 0};`); } return lines.join('\n'); } 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; } // 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 SKIP_WARNING = /skipping (\S+\.m) \(syntax error at line (\d+)\)/; self.onmessage = (ev: MessageEvent) => { const msg = ev.data; if (msg.type !== 'render') return; // numbl reports a file it could not parse through console.warn and then // skips it, so the failure surfaces at the call site as "Undefined // function". Capture the warning to report the actual syntax error instead. const warnings: string[] = []; const origWarn = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }; try { render(msg); } catch (err) { // Most likely a fault in the (user-editable) .m code. Report it so the // request completes and the message reaches the code panel. const e = err as { message?: string; file?: string; line?: number }; let m: ErrorMessage = { type: 'error', id: msg.id, message: e?.message ? String(e.message) : String(err), file: typeof e?.file === 'string' ? e.file : undefined, line: typeof e?.line === 'number' ? e.line : undefined, }; for (const w of warnings) { const hit = SKIP_WARNING.exec(w); if (hit) { m = { type: 'error', id: msg.id, message: 'syntax error', file: hit[1], line: Number(hit[2]) }; break; } } (self as unknown as Worker).postMessage(m); } finally { console.warn = origWarn; } }; function render(msg: RenderRequest): void { const { id, params, mfiles } = msg; let quality = msg.quality; // The .m sources are part of the cache identity: editing the code must // invalidate everything computed from the previous version. const codeKey = hashMFiles(mfiles); const solveKey = codeKey + '|' + keyFor(params, SOLVE_KEYS); const fullOversample = params.oversample; 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, params.oversample) : params.oversample; const fsHi = params.sample_rate * oversample; const post = (m: ProgressMessage) => (self as unknown as Worker).postMessage(m); // ---- solve stage: the gesture and the coupled eigensolves ---- let solveMs = 0; let solveCached = true; let solveEntry = lruGet(solveCache, solveKey); if (!solveEntry) { solveCached = false; post({ type: 'progress', id, stage: 'solve', frac: 0.15 }); const t0 = performance.now(); const r = runStage(mfiles, ` ${pLiteral(params)} [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; post({ type: 'progress', id, stage: 'radiate', frac: 0.55 }); const t0 = performance.now(); const r = runStage(mfiles, ` ${pLiteral(params)} ${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 ---- post({ type: 'progress', id, stage: 'output', frac: 0.9 }); const rOut = runStage(mfiles, ` ${pLiteral(params)} [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 ${params.full_scale_pa.toFixed(2)}`, ); } } // wolf warning: the fundamental pulled well away from equal temperament const target = midiHz(solveEntry.noteMidi[0]) * params.tuning_trim; 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 resp: RenderResponse = { type: 'result', id, quality, y: y32, sampleRate: params.sample_rate, 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, }, }; (self as unknown as Worker).postMessage(resp, [ resp.y.buffer, resp.noteT.buffer, resp.noteMidi.buffer, resp.noteStr.buffer, resp.noteStop.buffer, resp.noteForce.buffer, resp.mode1F.buffer, resp.mode1Zeta.buffer, resp.mode1BodyFrac.buffer, resp.bodyF.buffer, resp.bodyZeta.buffer, resp.bodyA.buffer, resp.bodyMono.buffer, ]); }