2 * The scene file: everything the browser knows about a scene, as one JSON
3 * document, so a scene built here can be rendered somewhere else.
4 *
5 * The file carries the scene description (objects and body), every parameter
6 * value, the microphone, the air grid size, and, only when they have been
7 * edited by hand, the two .m sources. Generated sources are deliberately
8 * *not* stored: the generator is deterministic, so omitting them keeps the
9 * file small, diffable, and honest about what the user actually authored.
10 * The same file loads back into the browser (Load scene) and into the
11 * headless renderer (scripts/render.ts), and both go through the same
12 * StudioSession, so a scene renders identically wherever it runs.
13 *
14 * Parsing validates shape and types with named errors rather than letting a
15 * malformed file surface as a compile failure three layers down.
16 */
17import { OBJECT_TEMPLATES } from './objects.ts';
18import { BODY_TEMPLATES } from './bodies.ts';
19import type { BodyType, ObjectType, Params, SceneSpec } from './types.ts';
21export interface SceneFile {
22 /** Format marker and version. */
23 soundbox: 1;
24 spec: SceneSpec;
25 /** Model parameter values, by suffixed key (f0_s1, ...). */
26 params: Params;
27 /** Scene parameter values: placement and body, by key. */
28 sceneParams: Params;
29 mic: { x: number; y: number; z: number };
30 /** Air grid points along x. */
31 nx: number;
32 /** Hand-edited .m sources, present only where the user edited them. */
33 sources?: { model?: string; scene?: string };
34}
36export function makeSceneFile(init: {
37 spec: SceneSpec;
38 params: Params;
39 sceneParams: Params;
40 mic: { x: number; y: number; z: number };
41 nx: number;
42 modelSource?: string | null;
43 sceneSource?: string | null;
44}): SceneFile {
45 const file: SceneFile = {
46 soundbox: 1,
47 spec: init.spec,
48 params: init.params,
49 sceneParams: init.sceneParams,
50 mic: { ...init.mic },
51 nx: init.nx,
52 };
53 const sources: { model?: string; scene?: string } = {};
54 if (init.modelSource) sources.model = init.modelSource;
55 if (init.sceneSource) sources.scene = init.sceneSource;
56 if (sources.model || sources.scene) file.sources = sources;
57 return file;
58}
60export const serializeSceneFile = (file: SceneFile): string =>
61 `${JSON.stringify(file, null, 2)}\n`;
63class SceneFileError extends Error {
64 constructor(message: string) {
65 super(`not a valid soundbox scene file: ${message}`);
66 this.name = 'SceneFileError';
67 }
68}
70const isRecord = (v: unknown): v is Record<string, unknown> =>
71 typeof v === 'object' && v !== null && !Array.isArray(v);
73function numberMap(v: unknown, what: string): Params {
74 if (v === undefined) return {};
75 if (!isRecord(v)) throw new SceneFileError(`${what} must be an object`);
76 const out: Params = {};
77 for (const [k, val] of Object.entries(v)) {
78 if (typeof val !== 'number' || !Number.isFinite(val)) {
79 throw new SceneFileError(`${what}.${k} must be a finite number`);
80 }
81 out[k] = val;
82 }
83 return out;
84}
86export function parseSceneFile(text: string): SceneFile {
87 let raw: unknown;
88 try {
89 raw = JSON.parse(text);
90 } catch (e) {
91 throw new SceneFileError(`not JSON (${e instanceof Error ? e.message : e})`);
92 }
93 if (!isRecord(raw)) throw new SceneFileError('the top level must be an object');
94 if (raw.soundbox !== 1) {
95 throw new SceneFileError(`missing or unsupported format marker (want "soundbox": 1)`);
96 }
98 const specRaw = raw.spec;
99 if (!isRecord(specRaw)) throw new SceneFileError('missing "spec"');
100 const objectsRaw = specRaw.objects;
101 if (!Array.isArray(objectsRaw) || objectsRaw.length === 0) {
102 throw new SceneFileError('"spec.objects" must be a non-empty array');
103 }
104 const seen = new Set<string>();
105 const objects = objectsRaw.map((o, i) => {
106 if (!isRecord(o)) throw new SceneFileError(`object ${i} must be an object`);
107 const { id, type } = o;
108 if (typeof id !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(id)) {
109 throw new SceneFileError(`object ${i} needs an alphanumeric "id"`);
110 }
111 if (seen.has(id)) throw new SceneFileError(`duplicate object id '${id}'`);
112 seen.add(id);
113 if (typeof type !== 'string' || !(type in OBJECT_TEMPLATES)) {
114 throw new SceneFileError(
115 `object '${id}' has unknown type '${String(type)}' ` +
116 `(one of: ${Object.keys(OBJECT_TEMPLATES).join(', ')})`,
117 );
118 }
119 const size = o.size;
120 if (!isRecord(size) || typeof size.L1 !== 'number' || !(size.L1 > 0)) {
121 throw new SceneFileError(`object '${id}' needs a positive "size.L1"`);
122 }
123 if (type === 'membrane' && size.L2 !== undefined && !(typeof size.L2 === 'number' && size.L2 > 0)) {
124 throw new SceneFileError(`object '${id}': "size.L2" must be a positive number`);
125 }
126 return {
127 id,
128 type: type as ObjectType,
129 size: { L1: size.L1, ...(typeof size.L2 === 'number' ? { L2: size.L2 } : {}) },
130 params: numberMap(o.params, `object '${id}' params`),
131 place: numberMap(o.place, `object '${id}' place`),
132 };
133 });
135 const bodyRaw = specRaw.body;
136 if (!isRecord(bodyRaw)) throw new SceneFileError('missing "spec.body"');
137 if (typeof bodyRaw.type !== 'string' || !(bodyRaw.type in BODY_TEMPLATES)) {
138 throw new SceneFileError(
139 `unknown body type '${String(bodyRaw.type)}' ` +
140 `(one of: ${Object.keys(BODY_TEMPLATES).join(', ')})`,
141 );
142 }
143 const spec: SceneSpec = {
144 objects,
145 body: {
146 type: bodyRaw.type as BodyType,
147 params: numberMap(bodyRaw.params, 'body params'),
148 },
149 };
151 const micRaw = raw.mic;
152 if (
153 !isRecord(micRaw) ||
154 typeof micRaw.x !== 'number' || typeof micRaw.y !== 'number' || typeof micRaw.z !== 'number'
155 ) {
156 throw new SceneFileError('"mic" must be {x, y, z} in metres');
157 }
159 const nx = raw.nx;
160 if (typeof nx !== 'number' || !Number.isInteger(nx) || nx < 16 || nx % 2 !== 0) {
161 throw new SceneFileError('"nx" must be an even integer of at least 16');
162 }
164 let sources: SceneFile['sources'];
165 if (raw.sources !== undefined) {
166 if (!isRecord(raw.sources)) throw new SceneFileError('"sources" must be an object');
167 sources = {};
168 for (const key of ['model', 'scene'] as const) {
169 const v = raw.sources[key];
170 if (v === undefined) continue;
171 if (typeof v !== 'string') throw new SceneFileError(`"sources.${key}" must be a string`);
172 sources[key] = v;
173 }
174 }
176 return {
177 soundbox: 1,
178 spec,
179 params: numberMap(raw.params, 'params'),
180 sceneParams: numberMap(raw.sceneParams, 'sceneParams'),
181 mic: { x: micRaw.x, y: micRaw.y, z: micRaw.z },
182 nx,
183 ...(sources && (sources.model || sources.scene) ? { sources } : {}),
184 };
185}