/ proof-of-concept / soundbox
Sign in
proof-of-concept / soundbox
soundbox / src / studio / fast.ts
290 lines · 11.1 KBCodeBlameHistory
2 * The fast render path: any generated scene rendered from precomputation
3 * instead of timestepping, at the same numbers the full solver produces.
4 *
5 * The pieces are src/studio/modal.ts (every object in closed form) and
6 * src/studio/room.ts (every pattern-to-mic impulse response from one
7 * reciprocal air run); this module strings them together the way
8 * StudioSession strings the full pipeline: evaluate the scene, record the
9 * room, synthesize the mic trace. Each object contributes its coupling
10 * routes as pattern groups — one kernel per mode for the rad route, plus,
11 * for a string, the single bridge-patch kernel its modes' arriving slopes
12 * drive. What the recording costs is one air-only run of `irSeconds`, once
13 * per *geometry* — the body, every object's size and placement, the grid,
14 * and the microphone. Every model parameter (pitches, decays, brightness,
15 * excitations, gains) stays free afterwards: re-synthesizing from the same
16 * room is CPU work measured in seconds. That is a fourth cost tier under
17 * the app's existing three — params free, scene cheap, structure
18 * recompiles, and now: geometry or mic edits re-record the room.
19 *
20 * The trade, stated plainly: the fast path produces the microphone's trace
21 * and nothing else — no pressure field to draw — and it is exact only up to
22 * the membrane mode set kept (eps, fmax; strings and bars keep every mode)
23 * and the kernel length (irSeconds); a room response still ringing at the
24 * kernel's end is truncated. `kernelDecay` reports how far down the kernels
25 * are by then, so the truncation is a number rather than a hope.
26 */
27import { makeAirGrid, stableDt, type AirGrid, type ObjectGrid } from '../grids.ts';
28import { Scene } from '../scene/scene.ts';
29import { C_AIR, CFL, DOMAIN_X } from '../units.ts';
30import { generate } from './codegen.ts';
31import {
32 accPoles, nextPoles, objectModeSet, radSineTables, synthesize,
33 type Contribution, type ModeSetOptions,
34} from './modal.ts';
35import { recordRoom, type Room, type RoomPatternGroup } from './room.ts';
36import { suffixed, type ObjectType, type Params, type SceneSpec } from './types.ts';
38/** Why a scene cannot take the fast path, or null if it can. */
39export function fastIneligible(spec: SceneSpec, modelSource?: string): string | null {
40 if (modelSource !== undefined) {
41 return 'the scene carries a hand-edited model .m; the fast path assumes the generated physics';
42 }
43 if (spec.objects.length === 0) {
44 return 'a scene needs at least one object';
45 }
46 return null;
49export interface FastRoomOptions {
50 device: GPUDevice;
51 spec: SceneSpec;
52 /** Fully merged parameter values (defaults filled), as specParams gives. */
53 params: Params;
54 sceneParams: Params;
55 /** A hand-edited scene .m, if any; evaluated exactly as the session would. */
56 sceneSource?: string;
57 nx: number;
58 Lx?: number;
59 mic: { x: number; y: number; z: number };
60 /** Kernel (room response) length, seconds. */
61 irSeconds: number;
62 /** Optional cap on the kernel length, steps (the renderer clips it to the
63 * trace it is about to make). */
64 maxKernelSteps?: number;
65 /** Membrane recording set (line objects always record every mode). */
66 modeOpts?: ModeSetOptions;
67 onProgress?: (done: number, total: number) => void;
68 /** Polled during the recording; returning true abandons it. */
69 cancelled?: () => boolean;
72/** One object's slice of the recorded room. */
73export interface ObjectRoom {
74 id: string;
75 type: ObjectType;
76 grid: ObjectGrid;
77 /** The modes with recorded rad kernels, in row order from rowStart
78 * (n = 0 for a line object's modes). */
79 modes: { m: number; n: number }[];
80 rowStart: number;
81 /** The bridge patch's kernel row, strings only. */
82 bridgeRow: number | null;
85export interface FastRoom {
86 room: Room;
87 dt: number;
88 air: AirGrid;
89 objects: ObjectRoom[];
90 /** Peak |kernel| in the recording's last tenth over the overall peak — how
91 * decayed the room response is where the kernel truncates it. */
92 kernelDecay: number;
95export interface FastRenderOptions extends FastRoomOptions {
96 /** Trace length, steps. */
97 steps: number;
100export interface FastResult extends FastRoom {
101 trace: Float32Array;
102 /** Patterns synthesized / wanted / wanted-but-unrecorded, across objects. */
103 used: number;
104 wanted: number;
105 missing: number;
108/** Evaluate the scene and record the room: everything that depends only on
109 * geometry and the microphone, once. */
110export async function prepareRoom(opts: FastRoomOptions): Promise<FastRoom> {
111 const { device, spec, params, sceneParams } = opts;
112 const bad = fastIneligible(spec);
113 if (bad) throw new Error(bad);
115 const air = makeAirGrid(opts.nx, opts.Lx ?? DOMAIN_X);
116 let dt = stableDt(air.h, C_AIR, CFL);
117 let generated = generate(spec, air, dt);
118 const buildScene = (): Scene =>
119 Scene.create({
120 air,
121 source: opts.sceneSource ?? generated.sceneSource,
122 outputs: generated.sceneOutputs,
123 consts: generated.sceneConsts,
124 paramNames: generated.sceneParamNames,
125 params: sceneParams,
126 });
127 const scene = buildScene();
128 // Reciprocity (see room.ts) rests on the air update's symmetry, which a
129 // spatially varying sound speed breaks; every generated scene is uniform.
130 if (scene.cmax - scene.cmin > 1e-6 * scene.cmax) {
131 throw new Error(
132 'the scene has a spatially varying sound speed; the fast path needs the uniform air every generated scene has',
133 );
134 }
135 const dtFinal = stableDt(air.h, Math.max(scene.cmax, C_AIR), CFL);
136 if (dtFinal < dt) {
137 dt = dtFinal;
138 generated = generate(spec, air, dt);
139 }
141 const micIndex =
142 Math.max(0, Math.min(air.nx - 1, Math.round((opts.mic.x + air.Lx / 2) / air.h - 0.5))) +
143 air.nx *
144 (Math.max(0, Math.min(air.ny - 1, Math.round((opts.mic.y + air.Ly / 2) / air.h - 0.5))) +
145 air.ny *
146 Math.max(0, Math.min(air.nz - 1, Math.round((opts.mic.z + air.Lz / 2) / air.h - 0.5))));
148 // One rad group per object (a membrane records the mode set the options
149 // keep; a line object records every interior mode, so no later excitation
150 // can want one the room lacks), plus a string's bridge-patch group.
151 const groups: RoomPatternGroup[] = [];
152 const objects: ObjectRoom[] = [];
153 let row = 0;
154 for (const o of spec.objects) {
155 const grid = generated.grids[o.id];
156 const { sx, sy, M1, M2 } = radSineTables(grid, air);
157 const modes =
158 o.type === 'membrane'
159 ? objectModeSet(o, grid, dt, params, opts.modeOpts).map((md) => ({ m: md.m, n: md.n }))
160 : Array.from({ length: grid.n1 - 2 }, (_, i) => ({ m: i + 1, n: 0 }));
161 groups.push({
162 label: `rad-${o.id}`,
163 prof: scene.fields[`prof_${o.id}`],
164 sx, sxRows: M1, sy, syRows: M2,
165 pairs: modes.map((md) => ({ xi: md.m - 1, yi: Math.max(0, md.n - 1) })),
166 });
167 const or: ObjectRoom = {
168 id: o.id, type: o.type, grid, modes, rowStart: row, bridgeRow: null,
169 };
170 row += modes.length;
171 if (o.type === 'string') {
172 groups.push({
173 label: `brg-${o.id}`,
174 prof: scene.fields[`bprof_${o.id}`],
175 sx: new Float32Array(air.nx).fill(1), sxRows: 1,
176 sy: new Float32Array(air.ny).fill(1), syRows: 1,
177 pairs: [{ xi: 0, yi: 0 }],
178 });
179 or.bridgeRow = row;
180 row += 1;
181 }
182 objects.push(or);
183 }
185 const irSteps = Math.max(
186 1,
187 Math.min(opts.maxKernelSteps ?? Infinity, Math.round(opts.irSeconds / dt)),
188 );
189 const room = await recordRoom({
190 device,
191 air,
192 fields: { c: scene.fields.c, sig: scene.fields.sig, wall: scene.fields.wall },
193 dt,
194 micIndex,
195 groups,
196 steps: irSteps,
197 onProgress: opts.onProgress,
198 cancelled: opts.cancelled,
199 });
201 let peak = 0;
202 let tailPeak = 0;
203 const tailFrom = Math.floor(0.9 * irSteps);
204 for (let q = 0; q < room.rows; q++) {
205 for (let t = 0; t < irSteps; t++) {
206 const v = Math.abs(room.kernels[q * irSteps + t]);
207 if (v > peak) peak = v;
208 if (t >= tailFrom && v > tailPeak) tailPeak = v;
209 }
210 }
212 return { room, dt, air, objects, kernelDecay: peak > 0 ? tailPeak / peak : 0 };
215/**
216 * Everything one synthesis needs from a room under the current parameters —
217 * the fourth tier's payoff: pitches, decays, brightness, excitations and
218 * gains all live here, none of them touch the air, so building this and
219 * running `synthesize` over it is pure CPU work.
220 *
221 * A membrane's room only holds kernels for the modes it was recorded with; a
222 * new strike can reach outside that set, and those modes are dropped and
223 * counted in `missing` (a recording made with a small eps keeps that rare —
224 * line objects record every mode and can never miss).
225 */
226export function roomContributions(
227 fr: FastRoom,
228 spec: SceneSpec,
229 params: Params,
230 modeOpts?: ModeSetOptions,
231): { contribs: Contribution[]; used: number; wanted: number; missing: number } {
232 const contribs: Contribution[] = [];
233 let used = 0;
234 let wanted = 0;
235 let missing = 0;
236 const K = fr.room.steps;
237 const kernelRow = (r: number): Float32Array => fr.room.kernels.subarray(r * K, (r + 1) * K);
239 for (const o of spec.objects) {
240 const or = fr.objects.find((x) => x.id === o.id);
241 if (!or || or.type !== o.type) {
242 throw new Error(`the room was recorded for a different scene (object '${o.id}')`);
243 }
244 const modes = objectModeSet(o, or.grid, fr.dt, params, modeOpts);
245 const rowOf = new Map<number, number>();
246 or.modes.forEach((md, q) => rowOf.set((md.m << 16) | md.n, or.rowStart + q));
247 const gair = params[suffixed('gair', o.id)] ?? 0;
248 wanted += modes.length;
250 const present = modes.filter((md) => {
251 const has = rowOf.has((md.m << 16) | md.n);
252 if (!has) missing++;
253 return has;
254 });
255 used += present.length;
256 for (const md of present) {
257 contribs.push({
258 poles: accPoles(md, fr.dt, gair),
259 kernel: kernelRow(rowOf.get((md.m << 16) | md.n)!),
260 });
261 }
263 if (o.type === 'string' && or.bridgeRow !== null) {
264 // The bridge: the string's tension pulls the patch with the slope its
265 // modes arrive at the pinned end with. The generated step's term is
266 // −(gbridge·cs²/L)·bprof·fb, fb = (un[n−1] − un[n−2])/h,
267 // and un[n−1] = 0, so mode m contributes +gbridge·cs²/(L·h) times
268 // sin(mπ(n−2)/(n−1)) times its alpha[k+1] series.
269 const g = or.grid;
270 const cs = 2 * g.L1 * (params[suffixed('f0', o.id)] ?? 0);
271 const gbridge = params[suffixed('gbridge', o.id)] ?? 0;
272 const gain = (gbridge * cs * cs) / (g.L1 * g.h1);
273 const poles = present.flatMap((md) =>
274 nextPoles(md, gain * Math.sin((md.m * Math.PI * (g.n1 - 2)) / (g.n1 - 1))),
275 );
276 contribs.push({ poles, kernel: kernelRow(or.bridgeRow) });
277 }
278 }
279 return { contribs, used, wanted, missing };
282/** The whole fast path in one call: record the room, synthesize the trace. */
283export async function fastRender(opts: FastRenderOptions): Promise<FastResult> {
284 const fr = await prepareRoom({ ...opts, maxKernelSteps: opts.steps });
285 const { contribs, used, wanted, missing } = roomContributions(
286 fr, opts.spec, opts.params, opts.modeOpts,
287 );
288 const trace = synthesize(contribs, fr.room.steps, opts.steps);
289 return { ...fr, trace, used, wanted, missing };
↑↓ move↵openescclose