entangled-worlds-orbiters-shared 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Composing a rack description into one FAUST program.
3
+ *
4
+ * This is the play runtime's source: the same per-module `.dsp` files the edit
5
+ * runtime compiles standalone, stitched together with `component()` so both
6
+ * runtimes are built from one authored source. Parity is structural, and
7
+ * measured — a two-module chain renders bit-identically either way.
8
+ *
9
+ * ## The slot group is not optional
10
+ *
11
+ * Two instances of the same module in one program **silently collapse into a
12
+ * single control**:
13
+ *
14
+ * process = component("psGain.dsp") : component("psGain.dsp");
15
+ * -> one address: /psVoice/gain
16
+ *
17
+ * Both multiplies still happen, so the audio is right, but the two slots share
18
+ * one parameter and move together — and Faust does not warn. Wrapping each slot
19
+ * in its own group fixes it and costs nothing:
20
+ *
21
+ * process = hgroup("s0", component(...)) : hgroup("s1", component(...));
22
+ * -> /psVoice/s0/gain, /psVoice/s1/gain
23
+ *
24
+ * That group name is also the **per-slot identity** a single compiled program
25
+ * was said to lack. It has one — as long as the composer never forgets, which
26
+ * is why it is emitted here rather than left to each `.dsp`, and why
27
+ * `slotGroupId` is derived from the slot index and cannot be omitted.
28
+ *
29
+ * ## A note chain
30
+ *
31
+ * A head rack that plays notes composes as lanes (`lib/ps_notes.lib`):
32
+ *
33
+ * process = nt.entry(8) // the lanes the allocator writes
34
+ * : hgroup("s0", nt.bypass(8, 1, library("pitch.dsp")[SLOT=0;].notes(8)))
35
+ * : hgroup("tap0", nt.tap(8)) // the notes leaving s0
36
+ * : hgroup("s1", library("synth.dsp")[SLOT=1;].play(8)) // a note voice per lane
37
+ * : hgroup("s2", component("filter.dsp"));
38
+ *
39
+ * One entry at the head, sized by the instrument's polyphony; each generator or MIDI effect takes the
40
+ * lanes so far and multiplies them by its `lanesPerLane`, inside its note-domain bypass; the
41
+ * instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
42
+ * a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
43
+ * since `notes(n)` is told only n.
44
+ */
45
+ /** The most note lanes one head plays: what reaches the instrument after every note module has multiplied them. */
46
+ export const MAX_NOTE_LANES = 16;
47
+ /** Faust identifiers: the group name goes into generated source, so keep it tame. */
48
+ const SAFE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
49
+ /**
50
+ * The group name for a slot. Index-based, so it is stable across renames and
51
+ * unique by construction — the two properties the collision above needs.
52
+ */
53
+ export function slotGroupId(index) {
54
+ if (!Number.isInteger(index) || index < 0) {
55
+ throw new Error(`[voiceProgram] slot index must be a non-negative integer, got ${index}`);
56
+ }
57
+ return `s${index}`;
58
+ }
59
+ /** The address a slot's control has once composed: `/<voice>/<group>/<control>`. */
60
+ export function composedParamAddress(voiceName, index, controlName) {
61
+ return `/${voiceName}/${slotGroupId(index)}/${controlName}`;
62
+ }
63
+ /**
64
+ * Build the `.dsp` source for one voice. `voiceName` is the program name and the address root;
65
+ * `slots` the ordered rack chain.
66
+ */
67
+ export function buildVoiceProgram({ voiceName, slots } = {}) {
68
+ if (!SAFE_SEGMENT.test(String(voiceName ?? ''))) {
69
+ throw new Error(`[voiceProgram] unsafe voice name: ${voiceName}`);
70
+ }
71
+ if (!Array.isArray(slots) || slots.length === 0) {
72
+ throw new Error('[voiceProgram] a voice needs at least one slot');
73
+ }
74
+ slots.forEach((slot, index) => {
75
+ const file = String(slot?.dspFileName ?? '');
76
+ if (!/^[A-Za-z0-9_.-]+\.dsp$/.test(file)) {
77
+ throw new Error(`[voiceProgram] slot ${index}: unsafe dsp file name "${file}"`);
78
+ }
79
+ });
80
+ const head = slots.findIndex((slot) => slot.notes?.kind === 'instrument');
81
+ const noteParts = head < 0 ? [] : noteChain(slots, head);
82
+ if (head < 0 && slots.some((slot) => slot.notes)) {
83
+ throw new Error('[voiceProgram] a generator or MIDI effect needs a notes instrument after it');
84
+ }
85
+ // The group is emitted here, for every slot, always. See the note above.
86
+ const rest = slots.slice(head + 1).map((slot, i) => `hgroup("${slotGroupId(head + 1 + i)}", component("${slot.dspFileName}"))`);
87
+ const chain = [...noteParts, ...rest].join('\n : ');
88
+ return `declare name "${voiceName}";\n`
89
+ + '// GENERATED per voice by src/audio/modules/engines/faust/voiceProgram.ts — not authored.\n'
90
+ + (noteParts.length ? 'nt = library("ps_notes.lib");\n' : '')
91
+ + `process = ${chain};\n`;
92
+ }
93
+ /** The group of the tap after a note module (its slot index): the notes leaving it. */
94
+ export function tapGroupId(index) {
95
+ return `tap${index}`;
96
+ }
97
+ /** The entry and the note modules before `head` (the instrument), and the lanes that reach it. */
98
+ function noteModules(slots, head) {
99
+ const instrument = slots[head].notes;
100
+ let lanes = wholeAtLeastOne(instrument.polyphony, `slot ${head}: polyphony`);
101
+ // A tap after every note module: the notes leaving it, for the meters and screens. The entry has
102
+ // none: the main thread knows what it sends in (the keys, MIDI in, the clip's notes reported back).
103
+ const parts = [`nt.entry(${lanes})`];
104
+ slots.slice(0, head).forEach((slot, index) => {
105
+ const notes = slot.notes;
106
+ if (!notes || notes.kind === 'instrument') {
107
+ throw new Error(`[voiceProgram] slot ${index}: only generators and MIDI effects sit before a notes instrument`);
108
+ }
109
+ const k = wholeAtLeastOne(notes.lanesPerLane, `slot ${index}: lanesPerLane`);
110
+ // A module that adds lanes is told how many it puts out per lane in (an echo's repeats + 1).
111
+ const env = k === 1 ? `SLOT = ${index};` : `SLOT = ${index}; LANES_OUT = ${k};`;
112
+ const fx = `library("${slot.dspFileName}")[${env}].notes(${lanes})`;
113
+ parts.push(`hgroup("${slotGroupId(index)}", nt.bypass(${lanes}, ${k}, ${fx}))`);
114
+ lanes *= k;
115
+ parts.push(`hgroup("${tapGroupId(index)}", nt.tap(${lanes}))`);
116
+ });
117
+ if (lanes > MAX_NOTE_LANES) {
118
+ throw new Error(`[voiceProgram] ${lanes} note lanes reach the instrument; it plays at most ${MAX_NOTE_LANES}`);
119
+ }
120
+ return { parts, lanes };
121
+ }
122
+ /** The instrument at `head`, playing `lanes` lanes: the last part of a note chain. */
123
+ function instrumentPart(slots, head, lanes) {
124
+ // The instrument's place salts its random draws too (an LFO's Random), as every note module's does;
125
+ // its program constants (unison's `U`) shape it, as its polyphony does.
126
+ const constants = Object.entries(slots[head].notes.constants ?? {})
127
+ .map(([name, value]) => {
128
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name))
129
+ throw new Error(`[voiceProgram] slot ${head}: unsafe constant ${name}`);
130
+ return ` ${name} = ${wholeAtLeastOne(value, `slot ${head}: ${name}`)};`;
131
+ }).join('');
132
+ return `hgroup("${slotGroupId(head)}", library("${slots[head].dspFileName}")[SLOT = ${head};${constants}].play(${lanes}))`;
133
+ }
134
+ /** The entry, the note modules and the instrument, up to `head` (the instrument): lanes all the way. */
135
+ function noteChain(slots, head) {
136
+ const { parts, lanes } = noteModules(slots, head);
137
+ return [...parts, instrumentPart(slots, head, lanes)];
138
+ }
139
+ function wholeAtLeastOne(value, what) {
140
+ if (!Number.isInteger(value) || value < 1)
141
+ throw new Error(`[voiceProgram] ${what} is a whole number of 1 or more, got ${value}`);
142
+ return value;
143
+ }
144
+ /**
145
+ * A stable key for a composed voice.
146
+ *
147
+ * The compiled program is a pure function of the ordered slot list, so this is
148
+ * what makes "compile once, then every listener fetches a finished artifact"
149
+ * possible. It deliberately covers *only* what changes the program: the modules
150
+ * and their order. Parameter **values** are written into the running unit and
151
+ * must not invalidate the cache — a knob turn is not a recompile
152
+ * (`reactivity: 'live'` versus `'structural'`).
153
+ *
154
+ */
155
+ export function voiceCacheKey(slots) {
156
+ // A note module's lane count is structural too: it shapes the program.
157
+ const shape = (slots ?? []).map((slot) => {
158
+ const notes = slot?.notes;
159
+ const constants = notes?.kind === 'instrument' ? constantsKey(notes.constants) : '';
160
+ const lanes = !notes ? '' : notes.kind === 'instrument' ? `#p${notes.polyphony}${constants}` : `#${notes.kind}${notes.lanesPerLane}`;
161
+ return String(slot?.dspFileName ?? '') + lanes;
162
+ }).join('>');
163
+ return `v${fnv(shape)}`;
164
+ }
165
+ /**
166
+ * An instrument's program constants as part of a key: by name, each name closed off from its value
167
+ * (`U=12` and `U1=2` are different programs), so the same constants give the same key in any order.
168
+ */
169
+ function constantsKey(constants) {
170
+ return Object.entries(constants ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, value]) => `#${name}=${value}`).join('');
171
+ }
172
+ /**
173
+ * The hash of a released program's key: two passes of FNV-1a from different starts, 64 bits. Files
174
+ * are stored under it for good and shared between Orbiters, so two shapes must not meet in one.
175
+ */
176
+ function fileKey(text) {
177
+ return fnv(text) + fnv(text, 0x9e3779b9);
178
+ }
179
+ /** FNV-1a: short, dependency-free, and only needs to distinguish rack shapes. */
180
+ function fnv(text, start = 0x811c9dc5) {
181
+ let hash = start;
182
+ for (let i = 0; i < text.length; i += 1) {
183
+ hash ^= text.charCodeAt(i);
184
+ hash = Math.imul(hash, 0x01000193) >>> 0;
185
+ }
186
+ return hash.toString(16).padStart(8, '0');
187
+ }
188
+ /**
189
+ * The name every program of a released head declares. A control's address starts with it, so with one
190
+ * name the two programs of a head read as one (`/psHead/s0/rate`, `/psHead/s1/cutoff`), and an
191
+ * instrument's program is the same file whichever Orbiter plays it.
192
+ */
193
+ export const HEAD_PROGRAM_NAME = 'psHead';
194
+ /**
195
+ * A head composed for release: always two programs when a note module sits in front of the
196
+ * instrument, so neither grows past the size a phone still optimises, and the instrument's (the
197
+ * large one) depends only on the instrument, its lanes, its constants and its place: every Orbiter
198
+ * with that instrument there shares one file.
199
+ *
200
+ * `slots`: the note modules in rack order, then the instrument (the audio effects after it stay
201
+ * units of their own). The addresses are the ones `buildVoiceProgram` gives the same chain.
202
+ */
203
+ export function buildHeadPrograms(slots) {
204
+ const head = slots.length - 1;
205
+ if (slots[head]?.notes?.kind !== 'instrument')
206
+ throw new Error('[voiceProgram] a head ends with its notes instrument');
207
+ const instrument = slots[head];
208
+ const notes = instrument.notes;
209
+ const constants = constantsKey(notes.constants);
210
+ const header = `declare name "${HEAD_PROGRAM_NAME}";\n// GENERATED per head by voiceProgram.ts (not authored).\n`;
211
+ if (head === 0) {
212
+ return {
213
+ notes: null,
214
+ instrument: {
215
+ key: `i${fileKey(`alone>${instrument.dspFileName}#p${notes.polyphony}${constants}`)}`,
216
+ source: buildVoiceProgram({ voiceName: HEAD_PROGRAM_NAME, slots }),
217
+ },
218
+ lanes: notes.polyphony,
219
+ };
220
+ }
221
+ slots.forEach((slot, index) => {
222
+ const file = String(slot?.dspFileName ?? '');
223
+ if (!/^[A-Za-z0-9_.-]+\.dsp$/.test(file))
224
+ throw new Error(`[voiceProgram] slot ${index}: unsafe dsp file name "${file}"`);
225
+ });
226
+ const { parts, lanes } = noteModules(slots, head);
227
+ // FAUST roots a program's controls in a group named after it only when it has several groups at the
228
+ // top: named here, both programs' addresses start as the one program's do, whatever each holds.
229
+ const root = (chain) => `vgroup("${HEAD_PROGRAM_NAME}", ${chain})`;
230
+ const shape = slots.slice(0, head).map((slot) => `${slot.dspFileName}#${slot.notes.kind}${slot.notes.lanesPerLane}`).join('>');
231
+ return {
232
+ notes: {
233
+ key: `n${fileKey(`${shape}>#p${notes.polyphony}`)}`,
234
+ source: `${header}nt = library("ps_notes.lib");\nprocess = ${root(parts.join('\n : '))};\n`,
235
+ },
236
+ instrument: {
237
+ key: `i${fileKey(`behind>${instrument.dspFileName}#l${lanes}#s${head}${constants}`)}`,
238
+ source: `${header}process = ${root(instrumentPart(slots, head, lanes))};\n`,
239
+ },
240
+ lanes,
241
+ };
242
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * How large a compiled program's largest function is. Safari never optimises a wasm function over
3
+ * 100 000 bytes: a program past that line stays on its baseline tier for good, three to five times
4
+ * the cost, and FAUST writes a whole program as one `compute`.
5
+ */
6
+ /** Under Safari's 100 000 with a margin: a program that reaches this is heavy on an iPhone. */
7
+ export declare const HEAVY_FUNCTION_BYTES = 90000;
8
+ /** The size of a wasm module's largest function body: for a compiled DSP, its `compute`. */
9
+ export declare function largestFunction(wasm: Uint8Array): number;
10
+ /**
11
+ * The shrink step's passes: only the ones that tidy locals. FAUST's wasm keeps a local for every
12
+ * value and never reuses one; these make the same program a quarter smaller, bit for bit the same
13
+ * output. Binaryen's general optimisation is no smaller and made the optimised code slower.
14
+ */
15
+ export declare const SHRINK_PASSES: readonly ["simplify-locals", "coalesce-locals", "vacuum", "reorder-locals"];
16
+ /** The part of Binaryen the shrink step uses: handed in, so this folder imports nothing. */
17
+ export interface ShrinkTool {
18
+ readBinary(wasm: Uint8Array): {
19
+ runPasses(passes: string[]): void;
20
+ emitBinary(): Uint8Array;
21
+ dispose(): void;
22
+ };
23
+ }
24
+ /** A compiled DSP made smaller, computing exactly what it did. */
25
+ export declare function shrinkWasm(binaryen: ShrinkTool, wasm: Uint8Array): Uint8Array;
26
+ /** What the step is, for the stamp of a program it shrank: another Binaryen or other passes is another program. */
27
+ export declare const shrinkVersion: (binaryenMajor: string | number) => string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * How large a compiled program's largest function is. Safari never optimises a wasm function over
3
+ * 100 000 bytes: a program past that line stays on its baseline tier for good, three to five times
4
+ * the cost, and FAUST writes a whole program as one `compute`.
5
+ */
6
+ /** Under Safari's 100 000 with a margin: a program that reaches this is heavy on an iPhone. */
7
+ export const HEAVY_FUNCTION_BYTES = 90_000;
8
+ /** The size of a wasm module's largest function body: for a compiled DSP, its `compute`. */
9
+ export function largestFunction(wasm) {
10
+ let at = 8;
11
+ const leb = () => {
12
+ let value = 0;
13
+ for (let shift = 0;; shift += 7) {
14
+ const byte = wasm[at];
15
+ at += 1;
16
+ value |= (byte & 0x7f) << shift;
17
+ if (!(byte & 0x80))
18
+ return value >>> 0;
19
+ }
20
+ };
21
+ while (at < wasm.length) {
22
+ const id = wasm[at];
23
+ at += 1;
24
+ const size = leb();
25
+ if (id === 10) {
26
+ let largest = 0;
27
+ for (let count = leb(); count > 0; count -= 1) {
28
+ const body = leb();
29
+ largest = Math.max(largest, body);
30
+ at += body;
31
+ }
32
+ return largest;
33
+ }
34
+ at += size;
35
+ }
36
+ return 0;
37
+ }
38
+ /**
39
+ * The shrink step's passes: only the ones that tidy locals. FAUST's wasm keeps a local for every
40
+ * value and never reuses one; these make the same program a quarter smaller, bit for bit the same
41
+ * output. Binaryen's general optimisation is no smaller and made the optimised code slower.
42
+ */
43
+ export const SHRINK_PASSES = ['simplify-locals', 'coalesce-locals', 'vacuum', 'reorder-locals'];
44
+ /** A compiled DSP made smaller, computing exactly what it did. */
45
+ export function shrinkWasm(binaryen, wasm) {
46
+ const module = binaryen.readBinary(wasm);
47
+ try {
48
+ module.runPasses([...SHRINK_PASSES]);
49
+ return module.emitBinary();
50
+ }
51
+ finally {
52
+ module.dispose();
53
+ }
54
+ }
55
+ /** What the step is, for the stamp of a program it shrank: another Binaryen or other passes is another program. */
56
+ export const shrinkVersion = (binaryenMajor) => `binaryen-${binaryenMajor}:${SHRINK_PASSES.join(',')}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/capabilities/index.d.ts",
43
43
  "import": "./dist/capabilities/index.js"
44
44
  },
45
+ "./faust": {
46
+ "types": "./dist/faust/index.d.ts",
47
+ "import": "./dist/faust/index.js"
48
+ },
45
49
  "./notes": {
46
50
  "types": "./dist/notes/index.d.ts",
47
51
  "import": "./dist/notes/index.js"
@@ -84,6 +88,14 @@
84
88
  "three": "^0.166.1",
85
89
  "three-stdlib": "^2.34.0"
86
90
  },
91
+ "peerDependenciesMeta": {
92
+ "three": {
93
+ "optional": true
94
+ },
95
+ "three-stdlib": {
96
+ "optional": true
97
+ }
98
+ },
87
99
  "devDependencies": {
88
100
  "@eslint/js": "^10.0.1",
89
101
  "@types/node": "^20.19.0",
@@ -114,10 +126,11 @@
114
126
  "test:clock": "node --test --import tsx src/clock/*.test.ts",
115
127
  "test:capabilities": "node --test --import tsx src/capabilities/*.test.js",
116
128
  "test:notes": "node --test --import tsx src/notes/*.test.ts",
129
+ "test:faust": "node --test --import tsx src/faust/*.test.ts",
117
130
  "test:studio": "node --test --import tsx src/studio/*.test.js",
118
131
  "test:uploads": "node --test --import tsx src/uploads/*.test.js",
119
132
  "test:render": "node --test src/render/*.test.js",
120
- "test": "npm run test:clock && npm run test:capabilities && npm run test:notes && npm run test:studio && npm run test:uploads && npm run test:render && npm run test:moons && npm run test:star",
133
+ "test": "npm run test:clock && npm run test:capabilities && npm run test:notes && npm run test:faust && npm run test:studio && npm run test:uploads && npm run test:render && npm run test:moons && npm run test:star",
121
134
  "test:moons": "node --test src/moons/*.test.js",
122
135
  "test:star": "node --test --import tsx src/star/*.test.ts",
123
136
  "bench:star:build": "esbuild bench/star/main.ts --bundle --format=esm --target=es2022 --outfile=bench/star/build/main.js --log-level=warning",
@@ -0,0 +1,171 @@
1
+ /**
2
+ * A head made ready for play mode: its programs composed, compiled, shrunk, sized and stamped.
3
+ *
4
+ * Play mode downloads finished programs: no compiler in the page, no first-build wait on a phone.
5
+ * This is the step that makes them, once, when an Orbiter is released. It runs where a compiler and
6
+ * the shrink step exist (a server, a build, a test), never in a listener's browser, so both arrive
7
+ * as tools rather than imports: this file stays pure.
8
+ *
9
+ * For one head (`voiceProgram.ts`, `buildHeadPrograms`):
10
+ *
11
+ * 1. **Compose** its programs: the note modules as one, the instrument as another; the instrument
12
+ * alone when nothing sits in front of it.
13
+ * 2. **Compile** each with the app's own options.
14
+ * 3. **Shrink** each (the same step the module build uses: the same program, a quarter smaller).
15
+ * 4. **Size** each: past the heavy line its largest function is one Safari may never optimise. The
16
+ * export still goes through; the author is told.
17
+ * 5. **Stamp** the export with what it was built from and the runtime it expects.
18
+ *
19
+ * A released Orbiter keeps its sound: an export stays valid when a module's source changes later
20
+ * (the stamp says what it was built from). Play mode refuses it only when the runtime it expects is
21
+ * no longer the app's, and then plays as it would have without one.
22
+ */
23
+ import { buildHeadPrograms, HEAD_PROGRAM_NAME, type VoiceSlot } from './voiceProgram.js';
24
+ import { HEAVY_FUNCTION_BYTES, largestFunction } from './wasmSize.js';
25
+
26
+ /** One item of Faust's UI tree: a group (with `items`) or a control. */
27
+ export interface FaustUiItem {
28
+ type: string;
29
+ label: string;
30
+ address?: string;
31
+ init?: number;
32
+ min?: number;
33
+ max?: number;
34
+ step?: number;
35
+ items?: FaustUiItem[];
36
+ [field: string]: unknown;
37
+ }
38
+
39
+ /** Faust's metadata for a compiled DSP (its JSON): the UI tree, inputs, outputs, declares. */
40
+ export interface FaustDspMeta {
41
+ name: string;
42
+ inputs: number;
43
+ outputs: number;
44
+ compile_options: string;
45
+ ui: FaustUiItem[];
46
+ meta?: Array<Record<string, string>>;
47
+ [field: string]: unknown;
48
+ }
49
+
50
+ /** A compiled program as it is stored and downloaded: its wasm and the description of its controls. */
51
+ export interface CompiledProgram {
52
+ /** Faust's sha key; the `registerProcessor` name. */
53
+ processorName: string;
54
+ dspName: string;
55
+ dspMeta: FaustDspMeta;
56
+ /** Fully-qualified control addresses, in Faust's order. */
57
+ paramAddresses: string[];
58
+ wasmBase64: string;
59
+ /** 4 for a single-precision build, 8 for -double. */
60
+ sampleSize: 4 | 8;
61
+ }
62
+
63
+ /** Every control address in the DSP's UI tree, in Faust's own order. */
64
+ export function collectParamAddresses(ui: FaustUiItem[] | null | undefined): string[] {
65
+ const out: string[] = [];
66
+ const walk = (items: FaustUiItem[]) => items.forEach((item) => {
67
+ if (item.items) walk(item.items);
68
+ else if (item.address) out.push(item.address);
69
+ });
70
+ walk(ui ?? []);
71
+ return out;
72
+ }
73
+
74
+ /** What an export was built from, and what it needs to play. */
75
+ export interface HeadExportStamp {
76
+ /** The runtime contract the programs were built for: the app's, as its recipes file names it. */
77
+ runtime: number;
78
+ /** The FAUST compiler's version, and its options. */
79
+ faust: string;
80
+ options: string;
81
+ /** The shrink step's version. */
82
+ shrink: string;
83
+ /** The fingerprint of the recipes file: every `.dsp` source and library the compiler could read. */
84
+ sources: string;
85
+ }
86
+
87
+ /** The compiler and the shrink step, handed in by whoever runs the export. */
88
+ export interface HeadExportTools {
89
+ /** Compile `source` under `name` with the sources and libraries mounted: FAUST's factory. */
90
+ compile(name: string, source: string): Promise<{ code: Uint8Array; json: string; shaKey: string }>;
91
+ /** The same program, smaller. */
92
+ shrink(wasm: Uint8Array): Uint8Array;
93
+ /**
94
+ * A program already filed under this key, if there is one: it is taken as it is, never compiled
95
+ * again (an instrument's program is the same file for every Orbiter that plays it).
96
+ */
97
+ find?(key: string): Promise<CompiledProgram | null>;
98
+ stamp: HeadExportStamp;
99
+ }
100
+
101
+ /** One finished program of a head. */
102
+ export interface ExportedProgram {
103
+ /** What it is filed under: its shape. */
104
+ key: string;
105
+ artifact: CompiledProgram;
106
+ /** Its largest function, in bytes, and whether that is past the heavy line. */
107
+ largestFunction: number;
108
+ heavy: boolean;
109
+ }
110
+
111
+ export interface HeadExport {
112
+ /** Null when nothing sits in front of the instrument. */
113
+ notes: ExportedProgram | null;
114
+ instrument: ExportedProgram;
115
+ stamp: HeadExportStamp;
116
+ }
117
+
118
+ function base64(bytes: Uint8Array): string {
119
+ let binary = '';
120
+ // In pieces: one call with every byte as an argument overflows the stack on a large program.
121
+ for (let at = 0; at < bytes.length; at += 0x8000) binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
122
+ return btoa(binary);
123
+ }
124
+
125
+ function bytes(text: string): Uint8Array {
126
+ const binary = atob(text);
127
+ const out = new Uint8Array(binary.length);
128
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
129
+ return out;
130
+ }
131
+
132
+ async function exportProgram(key: string, source: string, tools: HeadExportTools): Promise<ExportedProgram> {
133
+ const filed = await tools.find?.(key);
134
+ if (filed) {
135
+ const size = largestFunction(bytes(filed.wasmBase64));
136
+ return { key, artifact: filed, largestFunction: size, heavy: size > HEAVY_FUNCTION_BYTES };
137
+ }
138
+ const factory = await tools.compile(HEAD_PROGRAM_NAME, source);
139
+ const wasm = tools.shrink(factory.code);
140
+ const dspMeta: FaustDspMeta = JSON.parse(factory.json);
141
+ const size = largestFunction(wasm);
142
+ return {
143
+ key,
144
+ artifact: {
145
+ processorName: factory.shaKey,
146
+ dspName: HEAD_PROGRAM_NAME,
147
+ dspMeta,
148
+ paramAddresses: collectParamAddresses(dspMeta.ui),
149
+ wasmBase64: base64(wasm),
150
+ sampleSize: dspMeta.compile_options.includes('-double') ? 8 : 4,
151
+ },
152
+ largestFunction: size,
153
+ heavy: size > HEAVY_FUNCTION_BYTES,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * `slots`: the head's note modules in rack order, then its instrument, each with what it does with
159
+ * notes. Throws when a program does not compile: the caller saves the release without programs, and
160
+ * play mode plays as it does without them.
161
+ */
162
+ export async function exportHead(slots: readonly VoiceSlot[], tools: HeadExportTools): Promise<HeadExport> {
163
+ const programs = buildHeadPrograms(slots);
164
+ // One after the other: one libfaust compiles one program at a time.
165
+ const notes = programs.notes ? await exportProgram(programs.notes.key, programs.notes.source, tools) : null;
166
+ const instrument = await exportProgram(programs.instrument.key, programs.instrument.source, tools);
167
+ if (notes && notes.artifact.dspMeta.outputs !== instrument.artifact.dspMeta.inputs) {
168
+ throw new Error(`[headExport] the note modules put out ${notes.artifact.dspMeta.outputs} signals; the instrument takes ${instrument.artifact.dspMeta.inputs}`);
169
+ }
170
+ return { notes, instrument, stamp: tools.stamp };
171
+ }
@@ -0,0 +1,7 @@
1
+ // Composing a rack's note head into FAUST programs, and making them ready for play mode: shared by
2
+ // the app that plays them and the server step that compiles them at release, so the source, its key
3
+ // and its file name are the same on both sides. Pure: the compiler and the shrink tool are handed in.
4
+ export * from './voiceProgram.js';
5
+ export * from './wasmSize.js';
6
+ export * from './headExport.js';
7
+ export * from './recipes.js';