// Web Worker that renders the recorder 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: // basis tier -> recorder_basis per note (transmission-line solve + // modal fit; cached on bore/hole/window params and the // note's fingering) // jet tier -> recorder_jet per note (the time-domain integration; // the expensive stage, cached on the breath, the jet, // and the synthesis rate) // radiate/output -> recorder_radiate per note + assembly + recorder_output // (cheap, always re-run: moving the listener costs a // matrix multiply, not a re-integration) // Tensors cross the boundary as numbl RuntimeValues (column-major Float64). import { executeCode, type RuntimeValue, type RuntimeTensor } from 'numbl'; import { hashMFiles, type MFile } from '../mfilesCore'; import { BASIS_KEYS, JET_KEYS, type RecorderParams } from '../params'; export interface RenderRequest { type: 'render'; id: number; params: RecorderParams; quality: 'preview' | 'full'; mfiles: MFile[]; } export interface RenderResponse { type: 'result'; id: number; quality: 'preview' | 'full'; y: Float32Array; // final signal, [-1, 1] sampleRate: number; noteIdx: Float32Array; // fingering chart row per note noteOn: Float32Array; // onset time of each note, s fPlay: Float32Array; // played frequency per note, Hz fTarget: Float32Array; // equal-tempered target per note, Hz cents: Float32Array; // played vs target, cents qPlot: Float32Array; // jet inflow envelope, decimated, whole timeline qPlotRate: number; // samples per second of qPlot modeF: Float32Array; // first note: fitted resonances, Hz modeQ: Float32Array; // first note: fitted Q per mode gridF: Float32Array; // first note: admittance grid, Hz gridY: Float32Array; // first note: |Y| over the grid profX: Float32Array; // first note: bore stations, m profP: Float32Array; // first note: |p(x)| per mode, nsta x n col-major diag: { noteCount: number; modesKept: number; durationS: number; peakPa: number; peakSplDb: number; listenerDistM: number; warnings: string[]; basisMs: number; // 0 when every note's basis was cached jetMs: number; // 0 when every note's jet run was cached radiateMs: number; basisCached: boolean; jetCached: boolean; oversample: number; }; } export interface ProgressMessage { type: 'progress'; id: number; stage: 'basis' | 'jet' | 'radiate'; 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 basis stage into the jet and radiate // stages as numbl RuntimeValues. const BASIS_FIELDS = ['f', 'omega', 'Q', 'g', 'B', 'apX', 'apW', 'Sm', 'fTarget']; interface BasisEntry { vars: Record; modeF: Float32Array; modeQ: Float32Array; gridF: Float32Array; gridY: Float32Array; profX: Float32Array; profP: Float32Array; } interface JetEntry { Zr: RuntimeValue; Zi: RuntimeValue; Qin: RuntimeValue; fPlay: number; fTarget: number; cents: number; qPlot: Float32Array; qPlotRate: number; fsHi: number; } const basisCache = new Map(); const jetCache = new Map(); const BASIS_CACHE_MAX = 6; const JET_CACHE_MAX = 4; 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: RecorderParams, keys: readonly (keyof RecorderParams)[]): string { return keys.map((k) => String(params[k])).join('|'); } // MATLAB source lines assigning every parameter onto a struct P. function pLiteral(params: RecorderParams): 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; } 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) { 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; const codeKey = hashMFiles(mfiles); const basisParamsKey = codeKey + '|' + keyFor(params, BASIS_KEYS); const jetParamsKey = keyFor(params, JET_KEYS); // The gesture, mirrored from recorder_notes.m. const K = Math.min(3, Math.max(1, Math.round(params.note_count))); const notes = [params.note1, params.note2, params.note3] .slice(0, K) .map((n) => Math.min(14, Math.max(1, Math.round(n)))); const gap = Math.max(0, params.gap_ms) * 1e-3; const tOn = notes.map((_, i) => i * (params.note_dur_s + gap)); const spans = notes.map((_, i) => params.note_dur_s + (i < K - 1 ? Math.min(params.tail_s, 0.3) : params.tail_s), ); const durationS = tOn[K - 1] + params.note_dur_s + params.tail_s; const jetKeyAt = (i: number, os: number) => basisParamsKey + `|n=${notes[i]}` + '||' + jetParamsKey + `|span=${spans[i]}|ord=${i + 1}|os=${os}`; // A preview right after a full render reuses the full-rate jet runs. if (quality === 'preview' && notes.every((_, i) => jetCache.has(jetKeyAt(i, params.oversample)))) { quality = 'full'; } 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); let basisMs = 0; let jetMs = 0; let basisCached = true; let jetCached = true; const basisEntries: BasisEntry[] = []; const jetEntries: JetEntry[] = []; for (let i = 0; i < K; i++) { // ---- basis stage: transmission-line solve + modal fit ---- const bKey = basisParamsKey + `|n=${notes[i]}`; let bEntry = lruGet(basisCache, bKey); if (!bEntry) { basisCached = false; post({ type: 'progress', id, stage: 'basis', frac: (i + 0.5) / K * 0.2 }); const t0 = performance.now(); const r = runStage(mfiles, ` ${pLiteral(params)} basis = recorder_basis(P, ${notes[i]}); ${BASIS_FIELDS.map((f) => `b_${f} = basis.${f};`).join('\n')} b_profP = basis.profP; b_profX = basis.profX; b_gridF = basis.gridF; b_gridY = basis.gridY; `); basisMs += performance.now() - t0; const vars: Record = {}; for (const f of BASIS_FIELDS) vars[`b_${f}`] = r.variableValues[`b_${f}`]; bEntry = { vars, modeF: toF32(r.variableValues.b_f), modeQ: toF32(r.variableValues.b_Q), gridF: toF32(r.variableValues.b_gridF), gridY: toF32(r.variableValues.b_gridY), profX: toF32(r.variableValues.b_profX), profP: toF32(r.variableValues.b_profP), }; lruSet(basisCache, bKey, bEntry, BASIS_CACHE_MAX); } basisEntries.push(bEntry); // ---- jet stage: the time-domain integration ---- const jKey = jetKeyAt(i, oversample); let jEntry = lruGet(jetCache, jKey); if (!jEntry) { jetCached = false; post({ type: 'progress', id, stage: 'jet', frac: 0.2 + ((i + 0.5) / K) * 0.6 }); const t0 = performance.now(); const ds = Math.max(1, Math.round(fsHi / 2000)); const r = runStage(mfiles, ` ${pLiteral(params)} ${BASIS_FIELDS.map((f) => `basis.${f} = b_${f};`).join('\n')} basis.n = length(basis.f); [Zr, Zi, Qin, jinfo] = recorder_jet(P, basis, ${spans[i]}, ${i + 1}, ${fsHi}); fPlay = jinfo.fPlay; fTarget = jinfo.fTarget; cents = jinfo.cents; qPlot = Qin(1:${ds}:length(Qin)); `, bEntry.vars); jetMs += performance.now() - t0; jEntry = { Zr: r.variableValues.Zr, Zi: r.variableValues.Zi, Qin: r.variableValues.Qin, fPlay: asScalar(r.variableValues.fPlay), fTarget: asScalar(r.variableValues.fTarget), cents: asScalar(r.variableValues.cents), qPlot: toF32(r.variableValues.qPlot), qPlotRate: fsHi / ds, fsHi, }; lruSet(jetCache, jKey, jEntry, JET_CACHE_MAX); } jetEntries.push(jEntry); } // ---- radiate + assemble + output ---- post({ type: 'progress', id, stage: 'radiate', frac: 0.9 }); const t0 = performance.now(); const inputs: Record = {}; const noteBlocks: string[] = []; for (let i = 0; i < K; i++) { for (const f of BASIS_FIELDS) inputs[`b${i + 1}_${f}`] = basisEntries[i].vars[`b_${f}`]; inputs[`n${i + 1}_Zr`] = jetEntries[i].Zr; inputs[`n${i + 1}_Zi`] = jetEntries[i].Zi; inputs[`n${i + 1}_Qin`] = jetEntries[i].Qin; noteBlocks.push(` ${BASIS_FIELDS.map((f) => `basis.${f} = b${i + 1}_${f};`).join('\n')} basis.n = length(basis.f); [pn, radinfo] = recorder_radiate(P, basis, n${i + 1}_Zr, n${i + 1}_Zi, n${i + 1}_Qin, ${fsHi}); k0 = min(T, round(${tOn[i]} * ${fsHi}) + 1); len = min(length(pn), T - k0 + 1); p(k0:k0 + len - 1) = p(k0:k0 + len - 1) + pn(1:len); inside = max(inside, radinfo.listenerInside); distM = radinfo.listenerDistM; `); } const rOut = runStage(mfiles, ` ${pLiteral(params)} T = floor(${durationS} * ${fsHi}); p = zeros(1, T); inside = 0; distM = 0; ${noteBlocks.join('\n')} [y, peakPa] = recorder_output(P, p, ${oversample}); `, inputs); const radiateMs = performance.now() - t0; const y32 = toF32(rOut.variableValues.y); const peakPa = asScalar(rOut.variableValues.peakPa); const listenerInside = asScalar(rOut.variableValues.inside) !== 0; const listenerDistM = asScalar(rOut.variableValues.distM); // assemble the jet-flow envelope over the whole timeline const qRate = jetEntries[0].qPlotRate; const qPlot = new Float32Array(Math.ceil(durationS * qRate)); for (let i = 0; i < K; i++) { const off = Math.round(tOn[i] * qRate); const q = jetEntries[i].qPlot; for (let k = 0; k < q.length && off + k < qPlot.length; k++) qPlot[off + k] += q[k]; } const warns: string[] = []; if (listenerInside) { warns.push('the listener is within 2 cm of the instrument; the monopole field is evaluated at a clamped minimum distance'); } for (let i = 0; i < K; i++) { const j = jetEntries[i]; if (j.fPlay <= 0) { warns.push(`note ${i + 1} did not speak (no oscillation locked); more breath or jet receptivity may help`); } else if (Math.abs(j.cents) > 60) { warns.push(`note ${i + 1} locked to ${j.fPlay.toFixed(0)} Hz, not its fingering's register (${j.fTarget.toFixed(0)} Hz)`); } } 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) { warns.push(`clipping: peak is ${peakPa.toFixed(2)} Pa but full_scale_pa is ${params.full_scale_pa.toFixed(2)}`); } } const resp: RenderResponse = { type: 'result', id, quality, y: y32, sampleRate: params.sample_rate, noteIdx: new Float32Array(notes), noteOn: new Float32Array(tOn), fPlay: new Float32Array(jetEntries.map((j) => j.fPlay)), fTarget: new Float32Array(jetEntries.map((j) => j.fTarget)), cents: new Float32Array(jetEntries.map((j) => j.cents)), qPlot, qPlotRate: qRate, modeF: basisEntries[0].modeF.slice(), modeQ: basisEntries[0].modeQ.slice(), gridF: basisEntries[0].gridF.slice(), gridY: basisEntries[0].gridY.slice(), profX: basisEntries[0].profX.slice(), profP: basisEntries[0].profP.slice(), diag: { noteCount: K, modesKept: basisEntries[0].modeF.length, durationS, peakPa, peakSplDb: 20 * Math.log10(peakPa / 20e-6 + 1e-30), listenerDistM, warnings: warns, basisMs, jetMs, radiateMs, basisCached, jetCached, oversample, }, }; (self as unknown as Worker).postMessage(resp, [ resp.y.buffer, resp.noteIdx.buffer, resp.noteOn.buffer, resp.fPlay.buffer, resp.fTarget.buffer, resp.cents.buffer, resp.qPlot.buffer, resp.modeF.buffer, resp.modeQ.buffer, resp.gridF.buffer, resp.gridY.buffer, resp.profX.buffer, resp.profP.buffer, ]); }