entangled-worlds-orbiters-shared 2.12.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,102 @@
1
+ /**
2
+ * The recipes file: everything one build of the app compiles its programs from, in one file named by
3
+ * a fingerprint of its content.
4
+ *
5
+ * The app publishes it beside its compiler; the release step fetches it by the fingerprint the author's
6
+ * page sends, checks the content against the name, and compiles from exactly that. The sources stay
7
+ * where they are authored: nothing else has to hold a copy of them in step.
8
+ *
9
+ * The fingerprint covers all of it (the sources, the libraries, the compiler's version and options,
10
+ * the runtime contract), so two builds that would compile a different program never share a name.
11
+ */
12
+ import { MAX_NOTE_LANES } from './voiceProgram.js';
13
+ const FINGERPRINT = /^[0-9a-f]{16}$/;
14
+ export const isRecipesFingerprint = (value) => typeof value === 'string' && FINGERPRINT.test(value);
15
+ /** The file a fingerprint names, as the app publishes it. */
16
+ export const recipesFileName = (fingerprint) => `sources-${fingerprint}.json`;
17
+ async function shortHash(parts) {
18
+ const text = new TextEncoder().encode(JSON.stringify(parts));
19
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', text));
20
+ return Array.from(digest.subarray(0, 8), (byte) => byte.toString(16).padStart(2, '0')).join('');
21
+ }
22
+ /** A hash of everything a compile reads and is told (SHA-256, its first 16 hex digits). */
23
+ export function recipesFingerprint(recipes) {
24
+ const sorted = (files) => Object.keys(files).sort().map((file) => [file, files[file]]);
25
+ return shortHash([sorted(recipes.sources), sorted(recipes.libraries), recipes.faust, recipes.options, recipes.runtime]);
26
+ }
27
+ const isTextMap = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
28
+ && Object.values(value).every((text) => typeof text === 'string');
29
+ /** A downloaded recipes file, if it reads as one; anything else is null. */
30
+ export function readRecipes(value) {
31
+ const recipes = value;
32
+ if (!recipes || typeof recipes !== 'object')
33
+ return null;
34
+ if (!isTextMap(recipes.sources) || !isTextMap(recipes.libraries) || !Object.keys(recipes.sources).length)
35
+ return null;
36
+ if (typeof recipes.faust !== 'string' || typeof recipes.options !== 'string' || !Number.isInteger(recipes.runtime))
37
+ return null;
38
+ return { sources: recipes.sources, libraries: recipes.libraries, faust: recipes.faust, options: recipes.options, runtime: recipes.runtime };
39
+ }
40
+ /**
41
+ * How this package composes a program from a shape. Raise it when the same shape would compose to
42
+ * different text: a test keeps a fingerprint of the composed text beside it and fails until then.
43
+ */
44
+ export const COMPOSER_VERSION = 1;
45
+ /**
46
+ * Where a build's compiled programs are filed: the recipes, the shrink step and the composer
47
+ * together. A file under it is never replaced, so a release keeps the programs it was given.
48
+ */
49
+ export function programsNamespace(fingerprint, shrink) {
50
+ return shortHash([fingerprint, shrink, COMPOSER_VERSION]);
51
+ }
52
+ /** The most World dimensions an Orbiter has: a head each. */
53
+ export const MAX_HEADS = 3;
54
+ /** The most note modules in front of an instrument: a generator and the MIDI effects. */
55
+ export const MAX_HEAD_NOTE_MODULES = 12;
56
+ /** The largest program constant an instrument takes. */
57
+ const MAX_CONSTANT = 64;
58
+ const whole = (value, most) => Number.isInteger(value) && value >= 1 && value <= most;
59
+ /**
60
+ * A head's shape as a client sent it, checked before anything is composed from it: the note modules
61
+ * in rack order, then the instrument. Every file must be one of the recipes', every count a whole
62
+ * number inside the rack's limits. Returns the shape with nothing else on it; throws with the reason.
63
+ */
64
+ export function readHeadShape(value, sources) {
65
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_HEAD_NOTE_MODULES + 1)
66
+ throw new Error('a head is 1 to 13 modules');
67
+ return value.map((slot, index) => {
68
+ const file = slot?.dspFileName;
69
+ const notes = slot?.notes;
70
+ if (typeof file !== 'string' || !Object.hasOwn(sources, file))
71
+ throw new Error(`module ${index}: no such source`);
72
+ const last = index === value.length - 1;
73
+ if (last) {
74
+ if (notes?.kind !== 'instrument' || !whole(notes.polyphony, MAX_NOTE_LANES))
75
+ throw new Error(`module ${index}: a head ends with an instrument of 1 to ${MAX_NOTE_LANES} voices`);
76
+ const constants = notes.constants ?? {};
77
+ if (typeof constants !== 'object' || Array.isArray(constants))
78
+ throw new Error(`module ${index}: constants`);
79
+ const entries = Object.entries(constants);
80
+ if (entries.length > 8 || entries.some(([name, n]) => !/^[A-Z][A-Z0-9_]{0,23}$/.test(name) || !whole(n, MAX_CONSTANT)))
81
+ throw new Error(`module ${index}: constants`);
82
+ return { dspFileName: file, notes: { kind: 'instrument', polyphony: notes.polyphony, ...(entries.length ? { constants: Object.fromEntries(entries) } : {}) } };
83
+ }
84
+ if ((notes?.kind !== 'generator' && notes?.kind !== 'midi') || !whole(notes.lanesPerLane, MAX_NOTE_LANES))
85
+ throw new Error(`module ${index}: a note module with 1 to ${MAX_NOTE_LANES} lanes per lane`);
86
+ return { dspFileName: file, notes: { kind: notes.kind, lanesPerLane: notes.lanesPerLane } };
87
+ });
88
+ }
89
+ const isHead = (head) => {
90
+ const h = head;
91
+ return Boolean(h && typeof h === 'object' && typeof h.instrument === 'string' && (h.notes === null || typeof h.notes === 'string'));
92
+ };
93
+ /** A release's programs, if what it carries reads as them; anything else is no programs. */
94
+ export function readReleasedPrograms(value) {
95
+ const programs = value;
96
+ if (!programs || typeof programs !== 'object' || !Number.isInteger(programs.runtime) || typeof programs.sources !== 'string')
97
+ return null;
98
+ if (!/^[a-z0-9]+$/i.test(programs.sources) || !programs.heads || typeof programs.heads !== 'object')
99
+ return null;
100
+ const heads = Object.fromEntries(Object.entries(programs.heads).filter(([, head]) => isHead(head)));
101
+ return Object.keys(heads).length ? { runtime: programs.runtime, sources: programs.sources, heads } : null;
102
+ }
@@ -0,0 +1,131 @@
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 declare const MAX_NOTE_LANES = 16;
47
+ /**
48
+ * What a slot does with notes: a notes instrument's polyphony (and its program constants, when it has
49
+ * any), or a generator's or MIDI effect's lanes out per lane in.
50
+ */
51
+ export type SlotNotes = {
52
+ kind: 'instrument';
53
+ polyphony: number;
54
+ constants?: Readonly<Record<string, number>>;
55
+ copies?: number;
56
+ } | {
57
+ kind: 'generator' | 'midi';
58
+ lanesPerLane: number;
59
+ };
60
+ /** One slot of a composed voice: the module's `.dsp`, by file name. */
61
+ export interface VoiceSlot {
62
+ dspFileName: string;
63
+ /**
64
+ * A note module, with what it does with notes at its stored values (the registry's `resolveNotes`):
65
+ * a generator or MIDI effect's lanes out per lane in, or a notes instrument's polyphony. Both are
66
+ * structural: they shape the program.
67
+ */
68
+ notes?: SlotNotes;
69
+ }
70
+ /**
71
+ * The group name for a slot. Index-based, so it is stable across renames and
72
+ * unique by construction — the two properties the collision above needs.
73
+ */
74
+ export declare function slotGroupId(index: number): string;
75
+ /** The address a slot's control has once composed: `/<voice>/<group>/<control>`. */
76
+ export declare function composedParamAddress(voiceName: string, index: number, controlName: string): string;
77
+ /**
78
+ * Build the `.dsp` source for one voice. `voiceName` is the program name and the address root;
79
+ * `slots` the ordered rack chain.
80
+ */
81
+ export declare function buildVoiceProgram({ voiceName, slots }?: {
82
+ voiceName?: string;
83
+ slots?: readonly VoiceSlot[];
84
+ }): string;
85
+ /** The group of the tap after a note module (its slot index): the notes leaving it. */
86
+ export declare function tapGroupId(index: number): string;
87
+ /**
88
+ * A stable key for a composed voice.
89
+ *
90
+ * The compiled program is a pure function of the ordered slot list, so this is
91
+ * what makes "compile once, then every listener fetches a finished artifact"
92
+ * possible. It deliberately covers *only* what changes the program: the modules
93
+ * and their order. Parameter **values** are written into the running unit and
94
+ * must not invalidate the cache — a knob turn is not a recompile
95
+ * (`reactivity: 'live'` versus `'structural'`).
96
+ *
97
+ */
98
+ export declare function voiceCacheKey(slots: readonly VoiceSlot[] | null | undefined): string;
99
+ /**
100
+ * The name every program of a released head declares. A control's address starts with it, so with one
101
+ * name the two programs of a head read as one (`/psHead/s0/rate`, `/psHead/s1/cutoff`), and an
102
+ * instrument's program is the same file whichever Orbiter plays it.
103
+ */
104
+ export declare const HEAD_PROGRAM_NAME = "psHead";
105
+ /** One program of a released head: what it is filed under, and its source. */
106
+ export interface HeadProgram {
107
+ key: string;
108
+ source: string;
109
+ }
110
+ /**
111
+ * A released head, as the programs play mode downloads: the note modules as one (the entry, each
112
+ * module in its bypass, a tap after each; its outputs are the lanes), and the instrument as another
113
+ * (its inputs are those lanes), played one after the other in one audio processor.
114
+ */
115
+ export interface HeadPrograms {
116
+ /** Null when nothing sits in front of the instrument: its program then carries the entry itself. */
117
+ notes: HeadProgram | null;
118
+ instrument: HeadProgram;
119
+ /** The lanes between the two (three signals a lane). */
120
+ lanes: number;
121
+ }
122
+ /**
123
+ * A head composed for release: always two programs when a note module sits in front of the
124
+ * instrument, so neither grows past the size a phone still optimises, and the instrument's (the
125
+ * large one) depends only on the instrument, its lanes, its constants and its place: every Orbiter
126
+ * with that instrument there shares one file.
127
+ *
128
+ * `slots`: the note modules in rack order, then the instrument (the audio effects after it stay
129
+ * units of their own). The addresses are the ones `buildVoiceProgram` gives the same chain.
130
+ */
131
+ export declare function buildHeadPrograms(slots: readonly VoiceSlot[]): HeadPrograms;
@@ -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.12.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",