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.
- package/README.md +6 -0
- package/dist/faust/headExport.d.ts +109 -0
- package/dist/faust/headExport.js +89 -0
- package/dist/faust/index.d.ts +4 -0
- package/dist/faust/index.js +7 -0
- package/dist/faust/recipes.d.ts +69 -0
- package/dist/faust/recipes.js +102 -0
- package/dist/faust/voiceProgram.d.ts +131 -0
- package/dist/faust/voiceProgram.js +242 -0
- package/dist/faust/wasmSize.d.ts +27 -0
- package/dist/faust/wasmSize.js +56 -0
- package/package.json +15 -2
- package/src/faust/headExport.ts +171 -0
- package/src/faust/index.ts +7 -0
- package/src/faust/recipes.ts +136 -0
- package/src/faust/voiceProgram.ts +292 -0
- package/src/faust/wasmSize.ts +64 -0
package/README.md
CHANGED
|
@@ -93,6 +93,12 @@ import * as EntangledWorlds from 'entangled-worlds-orbiters-shared';
|
|
|
93
93
|
- `createStatusChannel` - Status-line seam ("Saving…", "Saved")
|
|
94
94
|
- `isTrustedHostOrigin`, `isEmbedded`, `postToHost` - Host-frame transport
|
|
95
95
|
|
|
96
|
+
### Note head programs (`entangled-worlds-orbiters-shared/faust`)
|
|
97
|
+
- `buildVoiceProgram`, `buildHeadPrograms` - Compose a rack's note head into FAUST source, keyed by shape
|
|
98
|
+
- `exportHead` - Compose, compile, shrink, size and stamp a head's programs; the compiler and the shrink tool are handed in
|
|
99
|
+
- `recipesFingerprint`, `readRecipes`, `readHeadShape`, `programsNamespace` - The recipes file a build publishes, and the checks a release step makes on it
|
|
100
|
+
- `largestFunction`, `shrinkWasm` - The size check and the shrink step
|
|
101
|
+
|
|
96
102
|
### Uploads (`entangled-worlds-orbiters-shared/uploads`)
|
|
97
103
|
- `uploadViaSignedUrl` - The API's signed-URL upload: `/spaces/upload-init` → PUT → `/spaces/upload-complete`, over the app's own authenticated JSON caller
|
|
98
104
|
- `putToSignedUrl` - The storage PUT alone, with progress and abort
|
|
@@ -0,0 +1,109 @@
|
|
|
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 { type VoiceSlot } from './voiceProgram.js';
|
|
24
|
+
/** One item of Faust's UI tree: a group (with `items`) or a control. */
|
|
25
|
+
export interface FaustUiItem {
|
|
26
|
+
type: string;
|
|
27
|
+
label: string;
|
|
28
|
+
address?: string;
|
|
29
|
+
init?: number;
|
|
30
|
+
min?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
step?: number;
|
|
33
|
+
items?: FaustUiItem[];
|
|
34
|
+
[field: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
/** Faust's metadata for a compiled DSP (its JSON): the UI tree, inputs, outputs, declares. */
|
|
37
|
+
export interface FaustDspMeta {
|
|
38
|
+
name: string;
|
|
39
|
+
inputs: number;
|
|
40
|
+
outputs: number;
|
|
41
|
+
compile_options: string;
|
|
42
|
+
ui: FaustUiItem[];
|
|
43
|
+
meta?: Array<Record<string, string>>;
|
|
44
|
+
[field: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
/** A compiled program as it is stored and downloaded: its wasm and the description of its controls. */
|
|
47
|
+
export interface CompiledProgram {
|
|
48
|
+
/** Faust's sha key; the `registerProcessor` name. */
|
|
49
|
+
processorName: string;
|
|
50
|
+
dspName: string;
|
|
51
|
+
dspMeta: FaustDspMeta;
|
|
52
|
+
/** Fully-qualified control addresses, in Faust's order. */
|
|
53
|
+
paramAddresses: string[];
|
|
54
|
+
wasmBase64: string;
|
|
55
|
+
/** 4 for a single-precision build, 8 for -double. */
|
|
56
|
+
sampleSize: 4 | 8;
|
|
57
|
+
}
|
|
58
|
+
/** Every control address in the DSP's UI tree, in Faust's own order. */
|
|
59
|
+
export declare function collectParamAddresses(ui: FaustUiItem[] | null | undefined): string[];
|
|
60
|
+
/** What an export was built from, and what it needs to play. */
|
|
61
|
+
export interface HeadExportStamp {
|
|
62
|
+
/** The runtime contract the programs were built for: the app's, as its recipes file names it. */
|
|
63
|
+
runtime: number;
|
|
64
|
+
/** The FAUST compiler's version, and its options. */
|
|
65
|
+
faust: string;
|
|
66
|
+
options: string;
|
|
67
|
+
/** The shrink step's version. */
|
|
68
|
+
shrink: string;
|
|
69
|
+
/** The fingerprint of the recipes file: every `.dsp` source and library the compiler could read. */
|
|
70
|
+
sources: string;
|
|
71
|
+
}
|
|
72
|
+
/** The compiler and the shrink step, handed in by whoever runs the export. */
|
|
73
|
+
export interface HeadExportTools {
|
|
74
|
+
/** Compile `source` under `name` with the sources and libraries mounted: FAUST's factory. */
|
|
75
|
+
compile(name: string, source: string): Promise<{
|
|
76
|
+
code: Uint8Array;
|
|
77
|
+
json: string;
|
|
78
|
+
shaKey: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** The same program, smaller. */
|
|
81
|
+
shrink(wasm: Uint8Array): Uint8Array;
|
|
82
|
+
/**
|
|
83
|
+
* A program already filed under this key, if there is one: it is taken as it is, never compiled
|
|
84
|
+
* again (an instrument's program is the same file for every Orbiter that plays it).
|
|
85
|
+
*/
|
|
86
|
+
find?(key: string): Promise<CompiledProgram | null>;
|
|
87
|
+
stamp: HeadExportStamp;
|
|
88
|
+
}
|
|
89
|
+
/** One finished program of a head. */
|
|
90
|
+
export interface ExportedProgram {
|
|
91
|
+
/** What it is filed under: its shape. */
|
|
92
|
+
key: string;
|
|
93
|
+
artifact: CompiledProgram;
|
|
94
|
+
/** Its largest function, in bytes, and whether that is past the heavy line. */
|
|
95
|
+
largestFunction: number;
|
|
96
|
+
heavy: boolean;
|
|
97
|
+
}
|
|
98
|
+
export interface HeadExport {
|
|
99
|
+
/** Null when nothing sits in front of the instrument. */
|
|
100
|
+
notes: ExportedProgram | null;
|
|
101
|
+
instrument: ExportedProgram;
|
|
102
|
+
stamp: HeadExportStamp;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `slots`: the head's note modules in rack order, then its instrument, each with what it does with
|
|
106
|
+
* notes. Throws when a program does not compile: the caller saves the release without programs, and
|
|
107
|
+
* play mode plays as it does without them.
|
|
108
|
+
*/
|
|
109
|
+
export declare function exportHead(slots: readonly VoiceSlot[], tools: HeadExportTools): Promise<HeadExport>;
|
|
@@ -0,0 +1,89 @@
|
|
|
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 } from './voiceProgram.js';
|
|
24
|
+
import { HEAVY_FUNCTION_BYTES, largestFunction } from './wasmSize.js';
|
|
25
|
+
/** Every control address in the DSP's UI tree, in Faust's own order. */
|
|
26
|
+
export function collectParamAddresses(ui) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const walk = (items) => items.forEach((item) => {
|
|
29
|
+
if (item.items)
|
|
30
|
+
walk(item.items);
|
|
31
|
+
else if (item.address)
|
|
32
|
+
out.push(item.address);
|
|
33
|
+
});
|
|
34
|
+
walk(ui ?? []);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function base64(bytes) {
|
|
38
|
+
let binary = '';
|
|
39
|
+
// In pieces: one call with every byte as an argument overflows the stack on a large program.
|
|
40
|
+
for (let at = 0; at < bytes.length; at += 0x8000)
|
|
41
|
+
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
|
42
|
+
return btoa(binary);
|
|
43
|
+
}
|
|
44
|
+
function bytes(text) {
|
|
45
|
+
const binary = atob(text);
|
|
46
|
+
const out = new Uint8Array(binary.length);
|
|
47
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
48
|
+
out[i] = binary.charCodeAt(i);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
async function exportProgram(key, source, tools) {
|
|
52
|
+
const filed = await tools.find?.(key);
|
|
53
|
+
if (filed) {
|
|
54
|
+
const size = largestFunction(bytes(filed.wasmBase64));
|
|
55
|
+
return { key, artifact: filed, largestFunction: size, heavy: size > HEAVY_FUNCTION_BYTES };
|
|
56
|
+
}
|
|
57
|
+
const factory = await tools.compile(HEAD_PROGRAM_NAME, source);
|
|
58
|
+
const wasm = tools.shrink(factory.code);
|
|
59
|
+
const dspMeta = JSON.parse(factory.json);
|
|
60
|
+
const size = largestFunction(wasm);
|
|
61
|
+
return {
|
|
62
|
+
key,
|
|
63
|
+
artifact: {
|
|
64
|
+
processorName: factory.shaKey,
|
|
65
|
+
dspName: HEAD_PROGRAM_NAME,
|
|
66
|
+
dspMeta,
|
|
67
|
+
paramAddresses: collectParamAddresses(dspMeta.ui),
|
|
68
|
+
wasmBase64: base64(wasm),
|
|
69
|
+
sampleSize: dspMeta.compile_options.includes('-double') ? 8 : 4,
|
|
70
|
+
},
|
|
71
|
+
largestFunction: size,
|
|
72
|
+
heavy: size > HEAVY_FUNCTION_BYTES,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `slots`: the head's note modules in rack order, then its instrument, each with what it does with
|
|
77
|
+
* notes. Throws when a program does not compile: the caller saves the release without programs, and
|
|
78
|
+
* play mode plays as it does without them.
|
|
79
|
+
*/
|
|
80
|
+
export async function exportHead(slots, tools) {
|
|
81
|
+
const programs = buildHeadPrograms(slots);
|
|
82
|
+
// One after the other: one libfaust compiles one program at a time.
|
|
83
|
+
const notes = programs.notes ? await exportProgram(programs.notes.key, programs.notes.source, tools) : null;
|
|
84
|
+
const instrument = await exportProgram(programs.instrument.key, programs.instrument.source, tools);
|
|
85
|
+
if (notes && notes.artifact.dspMeta.outputs !== instrument.artifact.dspMeta.inputs) {
|
|
86
|
+
throw new Error(`[headExport] the note modules put out ${notes.artifact.dspMeta.outputs} signals; the instrument takes ${instrument.artifact.dspMeta.inputs}`);
|
|
87
|
+
}
|
|
88
|
+
return { notes, instrument, stamp: tools.stamp };
|
|
89
|
+
}
|
|
@@ -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';
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { type VoiceSlot } from './voiceProgram.js';
|
|
13
|
+
export interface Recipes {
|
|
14
|
+
/** `.dsp` file name → FAUST source. */
|
|
15
|
+
sources: Readonly<Record<string, string>>;
|
|
16
|
+
/** Library file name → FAUST source, mounted beside the standard ones. */
|
|
17
|
+
libraries: Readonly<Record<string, string>>;
|
|
18
|
+
/** The compiler: the version of the package that carries libfaust. */
|
|
19
|
+
faust: string;
|
|
20
|
+
/** The compiler's options. */
|
|
21
|
+
options: string;
|
|
22
|
+
/** The runtime contract a program compiled from these plays under. */
|
|
23
|
+
runtime: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const isRecipesFingerprint: (value: unknown) => value is string;
|
|
26
|
+
/** The file a fingerprint names, as the app publishes it. */
|
|
27
|
+
export declare const recipesFileName: (fingerprint: string) => string;
|
|
28
|
+
/** A hash of everything a compile reads and is told (SHA-256, its first 16 hex digits). */
|
|
29
|
+
export declare function recipesFingerprint(recipes: Recipes): Promise<string>;
|
|
30
|
+
/** A downloaded recipes file, if it reads as one; anything else is null. */
|
|
31
|
+
export declare function readRecipes(value: unknown): Recipes | null;
|
|
32
|
+
/**
|
|
33
|
+
* How this package composes a program from a shape. Raise it when the same shape would compose to
|
|
34
|
+
* different text: a test keeps a fingerprint of the composed text beside it and fails until then.
|
|
35
|
+
*/
|
|
36
|
+
export declare const COMPOSER_VERSION = 1;
|
|
37
|
+
/**
|
|
38
|
+
* Where a build's compiled programs are filed: the recipes, the shrink step and the composer
|
|
39
|
+
* together. A file under it is never replaced, so a release keeps the programs it was given.
|
|
40
|
+
*/
|
|
41
|
+
export declare function programsNamespace(fingerprint: string, shrink: string): Promise<string>;
|
|
42
|
+
/** The most World dimensions an Orbiter has: a head each. */
|
|
43
|
+
export declare const MAX_HEADS = 3;
|
|
44
|
+
/** The most note modules in front of an instrument: a generator and the MIDI effects. */
|
|
45
|
+
export declare const MAX_HEAD_NOTE_MODULES = 12;
|
|
46
|
+
/**
|
|
47
|
+
* A head's shape as a client sent it, checked before anything is composed from it: the note modules
|
|
48
|
+
* in rack order, then the instrument. Every file must be one of the recipes', every count a whole
|
|
49
|
+
* number inside the rack's limits. Returns the shape with nothing else on it; throws with the reason.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readHeadShape(value: unknown, sources: Readonly<Record<string, string>>): VoiceSlot[];
|
|
52
|
+
/** One head's programs on a release: their keys, and whether the author was told it is heavy. */
|
|
53
|
+
export interface ReleasedHead {
|
|
54
|
+
/** Null when nothing sits in front of the instrument. */
|
|
55
|
+
notes: string | null;
|
|
56
|
+
instrument: string;
|
|
57
|
+
heavy?: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** What a release carries of its programs: written by the server at release, never by a client. */
|
|
60
|
+
export interface ReleasedPrograms {
|
|
61
|
+
/** The runtime contract the programs were built for. */
|
|
62
|
+
runtime: number;
|
|
63
|
+
/** Where they are filed (`programsNamespace`): what they were built from and with. */
|
|
64
|
+
sources: string;
|
|
65
|
+
/** By World dimension id. */
|
|
66
|
+
heads: Record<string, ReleasedHead>;
|
|
67
|
+
}
|
|
68
|
+
/** A release's programs, if what it carries reads as them; anything else is no programs. */
|
|
69
|
+
export declare function readReleasedPrograms(value: unknown): ReleasedPrograms | null;
|
|
@@ -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;
|