/** * The fast render path: any generated scene rendered from precomputation * instead of timestepping, at the same numbers the full solver produces. * * The pieces are src/studio/modal.ts (every object in closed form) and * src/studio/room.ts (every pattern-to-mic impulse response from one * reciprocal air run); this module strings them together the way * StudioSession strings the full pipeline: evaluate the scene, record the * room, synthesize the mic trace. Each object contributes its coupling * routes as pattern groups — one kernel per mode for the rad route, plus, * for a string, the single bridge-patch kernel its modes' arriving slopes * drive. What the recording costs is one air-only run of `irSeconds`, once * per *geometry* — the body, every object's size and placement, the grid, * and the microphone. Every model parameter (pitches, decays, brightness, * excitations, gains) stays free afterwards: re-synthesizing from the same * room is CPU work measured in seconds. That is a fourth cost tier under * the app's existing three — params free, scene cheap, structure * recompiles, and now: geometry or mic edits re-record the room. * * The trade, stated plainly: the fast path produces the microphone's trace * and nothing else — no pressure field to draw — and it is exact only up to * the membrane mode set kept (eps, fmax; strings and bars keep every mode) * and the kernel length (irSeconds); a room response still ringing at the * kernel's end is truncated. `kernelDecay` reports how far down the kernels * are by then, so the truncation is a number rather than a hope. */ import { makeAirGrid, stableDt, type AirGrid, type ObjectGrid } from '../grids.ts'; import { Scene } from '../scene/scene.ts'; import { C_AIR, CFL, DOMAIN_X } from '../units.ts'; import { generate } from './codegen.ts'; import { accPoles, nextPoles, objectModeSet, radSineTables, synthesize, type Contribution, type ModeSetOptions, } from './modal.ts'; import { recordRoom, type Room, type RoomPatternGroup } from './room.ts'; import { suffixed, type ObjectType, type Params, type SceneSpec } from './types.ts'; /** Why a scene cannot take the fast path, or null if it can. */ export function fastIneligible(spec: SceneSpec, modelSource?: string): string | null { if (modelSource !== undefined) { return 'the scene carries a hand-edited model .m; the fast path assumes the generated physics'; } if (spec.objects.length === 0) { return 'a scene needs at least one object'; } return null; } export interface FastRoomOptions { device: GPUDevice; spec: SceneSpec; /** Fully merged parameter values (defaults filled), as specParams gives. */ params: Params; sceneParams: Params; /** A hand-edited scene .m, if any; evaluated exactly as the session would. */ sceneSource?: string; nx: number; Lx?: number; mic: { x: number; y: number; z: number }; /** Kernel (room response) length, seconds. */ irSeconds: number; /** Optional cap on the kernel length, steps (the renderer clips it to the * trace it is about to make). */ maxKernelSteps?: number; /** Membrane recording set (line objects always record every mode). */ modeOpts?: ModeSetOptions; onProgress?: (done: number, total: number) => void; /** Polled during the recording; returning true abandons it. */ cancelled?: () => boolean; } /** One object's slice of the recorded room. */ export interface ObjectRoom { id: string; type: ObjectType; grid: ObjectGrid; /** The modes with recorded rad kernels, in row order from rowStart * (n = 0 for a line object's modes). */ modes: { m: number; n: number }[]; rowStart: number; /** The bridge patch's kernel row, strings only. */ bridgeRow: number | null; } export interface FastRoom { room: Room; dt: number; air: AirGrid; objects: ObjectRoom[]; /** Peak |kernel| in the recording's last tenth over the overall peak — how * decayed the room response is where the kernel truncates it. */ kernelDecay: number; } export interface FastRenderOptions extends FastRoomOptions { /** Trace length, steps. */ steps: number; } export interface FastResult extends FastRoom { trace: Float32Array; /** Patterns synthesized / wanted / wanted-but-unrecorded, across objects. */ used: number; wanted: number; missing: number; } /** Evaluate the scene and record the room: everything that depends only on * geometry and the microphone, once. */ export async function prepareRoom(opts: FastRoomOptions): Promise { const { device, spec, params, sceneParams } = opts; const bad = fastIneligible(spec); if (bad) throw new Error(bad); const air = makeAirGrid(opts.nx, opts.Lx ?? DOMAIN_X); let dt = stableDt(air.h, C_AIR, CFL); let generated = generate(spec, air, dt); const buildScene = (): Scene => Scene.create({ air, source: opts.sceneSource ?? generated.sceneSource, outputs: generated.sceneOutputs, consts: generated.sceneConsts, paramNames: generated.sceneParamNames, params: sceneParams, }); const scene = buildScene(); // Reciprocity (see room.ts) rests on the air update's symmetry, which a // spatially varying sound speed breaks; every generated scene is uniform. if (scene.cmax - scene.cmin > 1e-6 * scene.cmax) { throw new Error( 'the scene has a spatially varying sound speed; the fast path needs the uniform air every generated scene has', ); } const dtFinal = stableDt(air.h, Math.max(scene.cmax, C_AIR), CFL); if (dtFinal < dt) { dt = dtFinal; generated = generate(spec, air, dt); } const micIndex = Math.max(0, Math.min(air.nx - 1, Math.round((opts.mic.x + air.Lx / 2) / air.h - 0.5))) + air.nx * (Math.max(0, Math.min(air.ny - 1, Math.round((opts.mic.y + air.Ly / 2) / air.h - 0.5))) + air.ny * Math.max(0, Math.min(air.nz - 1, Math.round((opts.mic.z + air.Lz / 2) / air.h - 0.5)))); // One rad group per object (a membrane records the mode set the options // keep; a line object records every interior mode, so no later excitation // can want one the room lacks), plus a string's bridge-patch group. const groups: RoomPatternGroup[] = []; const objects: ObjectRoom[] = []; let row = 0; for (const o of spec.objects) { const grid = generated.grids[o.id]; const { sx, sy, M1, M2 } = radSineTables(grid, air); const modes = o.type === 'membrane' ? objectModeSet(o, grid, dt, params, opts.modeOpts).map((md) => ({ m: md.m, n: md.n })) : Array.from({ length: grid.n1 - 2 }, (_, i) => ({ m: i + 1, n: 0 })); groups.push({ label: `rad-${o.id}`, prof: scene.fields[`prof_${o.id}`], sx, sxRows: M1, sy, syRows: M2, pairs: modes.map((md) => ({ xi: md.m - 1, yi: Math.max(0, md.n - 1) })), }); const or: ObjectRoom = { id: o.id, type: o.type, grid, modes, rowStart: row, bridgeRow: null, }; row += modes.length; if (o.type === 'string') { groups.push({ label: `brg-${o.id}`, prof: scene.fields[`bprof_${o.id}`], sx: new Float32Array(air.nx).fill(1), sxRows: 1, sy: new Float32Array(air.ny).fill(1), syRows: 1, pairs: [{ xi: 0, yi: 0 }], }); or.bridgeRow = row; row += 1; } objects.push(or); } const irSteps = Math.max( 1, Math.min(opts.maxKernelSteps ?? Infinity, Math.round(opts.irSeconds / dt)), ); const room = await recordRoom({ device, air, fields: { c: scene.fields.c, sig: scene.fields.sig, wall: scene.fields.wall }, dt, micIndex, groups, steps: irSteps, onProgress: opts.onProgress, cancelled: opts.cancelled, }); let peak = 0; let tailPeak = 0; const tailFrom = Math.floor(0.9 * irSteps); for (let q = 0; q < room.rows; q++) { for (let t = 0; t < irSteps; t++) { const v = Math.abs(room.kernels[q * irSteps + t]); if (v > peak) peak = v; if (t >= tailFrom && v > tailPeak) tailPeak = v; } } return { room, dt, air, objects, kernelDecay: peak > 0 ? tailPeak / peak : 0 }; } /** * Everything one synthesis needs from a room under the current parameters — * the fourth tier's payoff: pitches, decays, brightness, excitations and * gains all live here, none of them touch the air, so building this and * running `synthesize` over it is pure CPU work. * * A membrane's room only holds kernels for the modes it was recorded with; a * new strike can reach outside that set, and those modes are dropped and * counted in `missing` (a recording made with a small eps keeps that rare — * line objects record every mode and can never miss). */ export function roomContributions( fr: FastRoom, spec: SceneSpec, params: Params, modeOpts?: ModeSetOptions, ): { contribs: Contribution[]; used: number; wanted: number; missing: number } { const contribs: Contribution[] = []; let used = 0; let wanted = 0; let missing = 0; const K = fr.room.steps; const kernelRow = (r: number): Float32Array => fr.room.kernels.subarray(r * K, (r + 1) * K); for (const o of spec.objects) { const or = fr.objects.find((x) => x.id === o.id); if (!or || or.type !== o.type) { throw new Error(`the room was recorded for a different scene (object '${o.id}')`); } const modes = objectModeSet(o, or.grid, fr.dt, params, modeOpts); const rowOf = new Map(); or.modes.forEach((md, q) => rowOf.set((md.m << 16) | md.n, or.rowStart + q)); const gair = params[suffixed('gair', o.id)] ?? 0; wanted += modes.length; const present = modes.filter((md) => { const has = rowOf.has((md.m << 16) | md.n); if (!has) missing++; return has; }); used += present.length; for (const md of present) { contribs.push({ poles: accPoles(md, fr.dt, gair), kernel: kernelRow(rowOf.get((md.m << 16) | md.n)!), }); } if (o.type === 'string' && or.bridgeRow !== null) { // The bridge: the string's tension pulls the patch with the slope its // modes arrive at the pinned end with. The generated step's term is // −(gbridge·cs²/L)·bprof·fb, fb = (un[n−1] − un[n−2])/h, // and un[n−1] = 0, so mode m contributes +gbridge·cs²/(L·h) times // sin(mπ(n−2)/(n−1)) times its alpha[k+1] series. const g = or.grid; const cs = 2 * g.L1 * (params[suffixed('f0', o.id)] ?? 0); const gbridge = params[suffixed('gbridge', o.id)] ?? 0; const gain = (gbridge * cs * cs) / (g.L1 * g.h1); const poles = present.flatMap((md) => nextPoles(md, gain * Math.sin((md.m * Math.PI * (g.n1 - 2)) / (g.n1 - 1))), ); contribs.push({ poles, kernel: kernelRow(or.bridgeRow) }); } } return { contribs, used, wanted, missing }; } /** The whole fast path in one call: record the room, synthesize the trace. */ export async function fastRender(opts: FastRenderOptions): Promise { const fr = await prepareRoom({ ...opts, maxKernelSteps: opts.steps }); const { contribs, used, wanted, missing } = roomContributions( fr, opts.spec, opts.params, opts.modeOpts, ); const trace = synthesize(contribs, fr.room.steps, opts.steps); return { ...fr, trace, used, wanted, missing }; }