entangled-worlds-orbiters-shared 2.14.0 → 2.16.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/dist/audio/aiff.d.ts +43 -0
- package/dist/audio/aiff.js +305 -0
- package/dist/audio/index.d.ts +32 -0
- package/dist/audio/index.js +89 -0
- package/dist/faust/recipes.d.ts +1 -1
- package/dist/faust/recipes.js +1 -1
- package/dist/faust/voiceProgram.d.ts +7 -1
- package/dist/faust/voiceProgram.js +13 -2
- package/package.json +8 -2
- package/src/audio/aiff.ts +337 -0
- package/src/audio/index.ts +102 -0
- package/src/faust/recipes.ts +1 -1
- package/src/faust/voiceProgram.ts +12 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type AiffErrorCode = 'not-aiff' | 'truncated' | 'compressed' | 'unsupported';
|
|
2
|
+
export declare class AiffError extends Error {
|
|
3
|
+
readonly code: AiffErrorCode;
|
|
4
|
+
/** The AIFF-C compression id (`'ima4'`, `'ulaw'`, …) when `code` is `'compressed'`. */
|
|
5
|
+
readonly compression?: string;
|
|
6
|
+
constructor(code: AiffErrorCode, message: string, compression?: string);
|
|
7
|
+
}
|
|
8
|
+
export interface AiffInfo {
|
|
9
|
+
/** `'AIFF'`, or `'AIFC'` for AIFF-C. */
|
|
10
|
+
form: 'AIFF' | 'AIFC';
|
|
11
|
+
/** The AIFF-C compression id; `'NONE'` for plain AIFF. */
|
|
12
|
+
compression: string;
|
|
13
|
+
channels: number;
|
|
14
|
+
sampleRate: number;
|
|
15
|
+
/** Bits per sample as the file declares them (1–32 for integers, 32 or 64 for floats). */
|
|
16
|
+
bitsPerSample: number;
|
|
17
|
+
/** Sample frames that are really in the file (fewer than declared when it was cut short; all
|
|
18
|
+
* of the sound data when the header says 0, as a streaming writer leaves it). */
|
|
19
|
+
frames: number;
|
|
20
|
+
/** True when the file promised more frames than it holds. */
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** True when the bytes are an AIFF or AIFF-C file. Judged on the bytes, never on a file name. */
|
|
24
|
+
export declare function isAiff(input: ArrayBuffer | ArrayBufferView): boolean;
|
|
25
|
+
/** The header only: format, length, and whether the file can be read at all (throws `AiffError`). */
|
|
26
|
+
export declare function readAiffInfo(input: ArrayBuffer | ArrayBufferView): AiffInfo;
|
|
27
|
+
export interface AiffAudio extends AiffInfo {
|
|
28
|
+
/** One array per channel, samples in -1…1. */
|
|
29
|
+
channelData: Float32Array[];
|
|
30
|
+
}
|
|
31
|
+
/** Decode an AIFF file to float samples per channel. Throws `AiffError`. */
|
|
32
|
+
export declare function readAiff(input: ArrayBuffer | ArrayBufferView): AiffAudio;
|
|
33
|
+
/**
|
|
34
|
+
* Rewrite an AIFF file as a WAV with the same samples at the same bit depth (integers stay
|
|
35
|
+
* integers, 32-bit floats stay floats; 64-bit floats become 32-bit, which every browser decodes).
|
|
36
|
+
* An `ID3 ` chunk (the tags and cover) goes along as the WAV's `id3 ` chunk. Throws `AiffError`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function aiffToWav(input: ArrayBuffer | ArrayBufferView): Uint8Array;
|
|
39
|
+
/**
|
|
40
|
+
* A minimal valid AIFF (16-bit mono, 8000 Hz, a few frames of silence): enough for asking a
|
|
41
|
+
* browser whether its own decoder reads AIFF.
|
|
42
|
+
*/
|
|
43
|
+
export declare function makeProbeAiff(frames?: number): Uint8Array;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Reading AIFF ourselves, because Chrome and Firefox can't (`decodeAudioData` and `<audio>` both
|
|
2
|
+
// refuse it; Safari plays it). AIFF is the same uncompressed PCM as WAV, big-endian, in an IFF
|
|
3
|
+
// container, so "decoding" is reading the header and putting the bytes in the right order: one
|
|
4
|
+
// linear pass, no codec, no dependency.
|
|
5
|
+
//
|
|
6
|
+
// Two outputs from one parse:
|
|
7
|
+
// - `readAiff` → float samples per channel, for building an `AudioBuffer` in memory;
|
|
8
|
+
// - `aiffToWav` → a WAV file with the same samples at the same bit depth, for a copy the browser
|
|
9
|
+
// plays natively from then on.
|
|
10
|
+
//
|
|
11
|
+
// Compressed AIFF-C (`ima4`, `ulaw`, `alaw`, `MAC3`, `MAC6`, …) is refused with `AiffError`
|
|
12
|
+
// code `'compressed'`; there is no PCM in it to reorder.
|
|
13
|
+
export class AiffError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
/** The AIFF-C compression id (`'ima4'`, `'ulaw'`, …) when `code` is `'compressed'`. */
|
|
16
|
+
compression;
|
|
17
|
+
constructor(code, message, compression) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'AiffError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.compression = compression;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** AIFF-C ids that hold plain PCM, and how to read them. */
|
|
25
|
+
const PCM_COMPRESSIONS = {
|
|
26
|
+
NONE: { encoding: 'int-be' },
|
|
27
|
+
twos: { encoding: 'int-be' },
|
|
28
|
+
sowt: { encoding: 'int-le' },
|
|
29
|
+
in24: { encoding: 'int-be', bits: 24 },
|
|
30
|
+
in32: { encoding: 'int-be', bits: 32 },
|
|
31
|
+
'42ni': { encoding: 'int-le', bits: 24 },
|
|
32
|
+
'23ni': { encoding: 'int-le', bits: 32 },
|
|
33
|
+
fl32: { encoding: 'float-be', bits: 32 },
|
|
34
|
+
FL32: { encoding: 'float-be', bits: 32 },
|
|
35
|
+
fl64: { encoding: 'float-be', bits: 64 },
|
|
36
|
+
FL64: { encoding: 'float-be', bits: 64 },
|
|
37
|
+
};
|
|
38
|
+
function fourCC(bytes, at) {
|
|
39
|
+
return String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]);
|
|
40
|
+
}
|
|
41
|
+
function asBytes(input) {
|
|
42
|
+
if (input instanceof Uint8Array)
|
|
43
|
+
return input;
|
|
44
|
+
if (ArrayBuffer.isView(input))
|
|
45
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
46
|
+
return new Uint8Array(input);
|
|
47
|
+
}
|
|
48
|
+
/** True when the bytes are an AIFF or AIFF-C file. Judged on the bytes, never on a file name. */
|
|
49
|
+
export function isAiff(input) {
|
|
50
|
+
const bytes = asBytes(input);
|
|
51
|
+
if (bytes.length < 12)
|
|
52
|
+
return false;
|
|
53
|
+
const form = fourCC(bytes, 8);
|
|
54
|
+
return fourCC(bytes, 0) === 'FORM' && (form === 'AIFF' || form === 'AIFC');
|
|
55
|
+
}
|
|
56
|
+
/** The 80-bit IEEE 754 extended float AIFF stores its sample rate in. */
|
|
57
|
+
function readExtended(view, at) {
|
|
58
|
+
const signExp = view.getUint16(at);
|
|
59
|
+
const hi = view.getUint32(at + 2);
|
|
60
|
+
const lo = view.getUint32(at + 6);
|
|
61
|
+
const exp = (signExp & 0x7fff) - 16383;
|
|
62
|
+
if ((signExp & 0x7fff) === 0 && hi === 0 && lo === 0)
|
|
63
|
+
return 0;
|
|
64
|
+
const value = hi * 2 ** (exp - 31) + lo * 2 ** (exp - 63);
|
|
65
|
+
return signExp & 0x8000 ? -value : value;
|
|
66
|
+
}
|
|
67
|
+
function parse(bytes) {
|
|
68
|
+
if (!isAiff(bytes))
|
|
69
|
+
throw new AiffError('not-aiff', 'Not an AIFF file');
|
|
70
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
71
|
+
const form = fourCC(bytes, 8);
|
|
72
|
+
// The FORM size can be wrong: larger than the file (a file cut short) or 0 (a streaming writer
|
|
73
|
+
// that never went back to fill it in). The bytes we have are the real limit.
|
|
74
|
+
const formSize = view.getUint32(4);
|
|
75
|
+
const end = formSize >= 4 ? Math.min(bytes.length, 8 + formSize) : bytes.length;
|
|
76
|
+
let comm = null;
|
|
77
|
+
let ssnd = null;
|
|
78
|
+
let id3 = null;
|
|
79
|
+
// Chunks may come in any order; walk them all.
|
|
80
|
+
let at = 12;
|
|
81
|
+
while (at + 8 <= end) {
|
|
82
|
+
const id = fourCC(bytes, at);
|
|
83
|
+
const size = view.getUint32(at + 4);
|
|
84
|
+
const body = at + 8;
|
|
85
|
+
const bodyEnd = Math.min(body + size, end);
|
|
86
|
+
if (id === 'COMM') {
|
|
87
|
+
if (bodyEnd - body < 18)
|
|
88
|
+
throw new AiffError('truncated', 'The AIFF header is cut short');
|
|
89
|
+
let compression = 'NONE';
|
|
90
|
+
if (form === 'AIFC') {
|
|
91
|
+
if (bodyEnd - body < 22)
|
|
92
|
+
throw new AiffError('truncated', 'The AIFF header is cut short');
|
|
93
|
+
compression = fourCC(bytes, body + 18);
|
|
94
|
+
}
|
|
95
|
+
comm = {
|
|
96
|
+
channels: view.getUint16(body),
|
|
97
|
+
frames: view.getUint32(body + 2),
|
|
98
|
+
bits: view.getUint16(body + 6),
|
|
99
|
+
sampleRate: readExtended(view, body + 8),
|
|
100
|
+
compression,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
else if (id === 'SSND') {
|
|
104
|
+
if (bodyEnd - body < 8)
|
|
105
|
+
throw new AiffError('truncated', 'The AIFF sound data is cut short');
|
|
106
|
+
const offset = view.getUint32(body);
|
|
107
|
+
ssnd = { start: Math.min(body + 8 + offset, bodyEnd), end: bodyEnd };
|
|
108
|
+
}
|
|
109
|
+
else if (id === 'ID3 ' && bodyEnd > body) {
|
|
110
|
+
id3 = { start: body, end: bodyEnd };
|
|
111
|
+
}
|
|
112
|
+
// Chunk bodies are padded to an even length.
|
|
113
|
+
at = body + size + (size & 1);
|
|
114
|
+
}
|
|
115
|
+
if (!comm)
|
|
116
|
+
throw new AiffError('truncated', 'The AIFF file has no format chunk');
|
|
117
|
+
const pcm = PCM_COMPRESSIONS[comm.compression];
|
|
118
|
+
if (!pcm) {
|
|
119
|
+
throw new AiffError('compressed', `Compressed AIFF-C (${comm.compression.trim() || 'unknown'}) is not supported`, comm.compression);
|
|
120
|
+
}
|
|
121
|
+
const bits = pcm.bits ?? comm.bits;
|
|
122
|
+
if (comm.channels < 1)
|
|
123
|
+
throw new AiffError('unsupported', 'The AIFF file has no channels');
|
|
124
|
+
if (!(comm.sampleRate > 0) || !Number.isFinite(comm.sampleRate)) {
|
|
125
|
+
throw new AiffError('unsupported', 'The AIFF file has no usable sample rate');
|
|
126
|
+
}
|
|
127
|
+
if (pcm.encoding === 'float-be' ? bits !== 32 && bits !== 64 : bits < 1 || bits > 32) {
|
|
128
|
+
throw new AiffError('unsupported', `${bits}-bit AIFF samples are not supported`);
|
|
129
|
+
}
|
|
130
|
+
const bytesPerSample = Math.ceil(bits / 8);
|
|
131
|
+
const frameBytes = bytesPerSample * comm.channels;
|
|
132
|
+
const available = ssnd ? Math.floor((ssnd.end - ssnd.start) / frameBytes) : 0;
|
|
133
|
+
if (comm.frames > 0 && !ssnd)
|
|
134
|
+
throw new AiffError('truncated', 'The AIFF file has no sound data');
|
|
135
|
+
const frames = comm.frames > 0 ? Math.min(comm.frames, available) : available;
|
|
136
|
+
return {
|
|
137
|
+
form,
|
|
138
|
+
compression: comm.compression,
|
|
139
|
+
channels: comm.channels,
|
|
140
|
+
sampleRate: comm.sampleRate,
|
|
141
|
+
bitsPerSample: bits,
|
|
142
|
+
frames,
|
|
143
|
+
truncated: frames < comm.frames,
|
|
144
|
+
encoding: pcm.encoding,
|
|
145
|
+
bytesPerSample,
|
|
146
|
+
dataStart: ssnd ? ssnd.start : 0,
|
|
147
|
+
id3,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function infoOf(p) {
|
|
151
|
+
const { form, compression, channels, sampleRate, bitsPerSample, frames, truncated } = p;
|
|
152
|
+
return { form, compression, channels, sampleRate, bitsPerSample, frames, truncated };
|
|
153
|
+
}
|
|
154
|
+
/** The header only: format, length, and whether the file can be read at all (throws `AiffError`). */
|
|
155
|
+
export function readAiffInfo(input) {
|
|
156
|
+
return infoOf(parse(asBytes(input)));
|
|
157
|
+
}
|
|
158
|
+
/** Decode an AIFF file to float samples per channel. Throws `AiffError`. */
|
|
159
|
+
export function readAiff(input) {
|
|
160
|
+
const bytes = asBytes(input);
|
|
161
|
+
const p = parse(bytes);
|
|
162
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
163
|
+
const { channels, frames, bytesPerSample: n, encoding } = p;
|
|
164
|
+
const channelData = Array.from({ length: channels }, () => new Float32Array(frames));
|
|
165
|
+
const scale = 1 / 2 ** (n * 8 - 1);
|
|
166
|
+
let at = p.dataStart;
|
|
167
|
+
if (encoding === 'float-be') {
|
|
168
|
+
const read = n === 4 ? (i) => view.getFloat32(i) : (i) => view.getFloat64(i);
|
|
169
|
+
for (let f = 0; f < frames; f++) {
|
|
170
|
+
for (let c = 0; c < channels; c++, at += n)
|
|
171
|
+
channelData[c][f] = read(at);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (n === 2) {
|
|
175
|
+
const le = encoding === 'int-le';
|
|
176
|
+
for (let f = 0; f < frames; f++) {
|
|
177
|
+
for (let c = 0; c < channels; c++, at += 2)
|
|
178
|
+
channelData[c][f] = view.getInt16(at, le) * scale;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else if (n === 4) {
|
|
182
|
+
const le = encoding === 'int-le';
|
|
183
|
+
for (let f = 0; f < frames; f++) {
|
|
184
|
+
for (let c = 0; c < channels; c++, at += 4)
|
|
185
|
+
channelData[c][f] = view.getInt32(at, le) * scale;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// 8- and 24-bit (and anything else that fills 1 or 3 bytes): assemble, then sign-extend.
|
|
190
|
+
const le = encoding === 'int-le';
|
|
191
|
+
const shift = 32 - n * 8;
|
|
192
|
+
for (let f = 0; f < frames; f++) {
|
|
193
|
+
for (let c = 0; c < channels; c++, at += n) {
|
|
194
|
+
let v = 0;
|
|
195
|
+
for (let b = 0; b < n; b++)
|
|
196
|
+
v = (v << 8) | bytes[le ? at + n - 1 - b : at + b];
|
|
197
|
+
channelData[c][f] = ((v << shift) >> shift) * scale;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ...infoOf(p), channelData };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Rewrite an AIFF file as a WAV with the same samples at the same bit depth (integers stay
|
|
205
|
+
* integers, 32-bit floats stay floats; 64-bit floats become 32-bit, which every browser decodes).
|
|
206
|
+
* An `ID3 ` chunk (the tags and cover) goes along as the WAV's `id3 ` chunk. Throws `AiffError`.
|
|
207
|
+
*/
|
|
208
|
+
export function aiffToWav(input) {
|
|
209
|
+
const bytes = asBytes(input);
|
|
210
|
+
const p = parse(bytes);
|
|
211
|
+
const { channels, frames, encoding } = p;
|
|
212
|
+
const inN = p.bytesPerSample;
|
|
213
|
+
const isFloat = encoding === 'float-be';
|
|
214
|
+
const outN = isFloat ? 4 : inN;
|
|
215
|
+
const dataBytes = frames * channels * outN;
|
|
216
|
+
const headerBytes = 44;
|
|
217
|
+
const dataEnd = headerBytes + dataBytes + (dataBytes & 1);
|
|
218
|
+
const id3Bytes = p.id3 ? p.id3.end - p.id3.start : 0;
|
|
219
|
+
const out = new Uint8Array(dataEnd + (id3Bytes ? 8 + id3Bytes + (id3Bytes & 1) : 0));
|
|
220
|
+
const w = new DataView(out.buffer);
|
|
221
|
+
const ascii = (at, s) => {
|
|
222
|
+
for (let i = 0; i < 4; i++)
|
|
223
|
+
out[at + i] = s.charCodeAt(i);
|
|
224
|
+
};
|
|
225
|
+
ascii(0, 'RIFF');
|
|
226
|
+
w.setUint32(4, out.length - 8, true);
|
|
227
|
+
ascii(8, 'WAVE');
|
|
228
|
+
ascii(12, 'fmt ');
|
|
229
|
+
w.setUint32(16, 16, true);
|
|
230
|
+
w.setUint16(20, isFloat ? 3 : 1, true);
|
|
231
|
+
w.setUint16(22, channels, true);
|
|
232
|
+
// WAV holds an integer rate; AIFF's is a float that is almost always whole already.
|
|
233
|
+
const rate = Math.round(p.sampleRate);
|
|
234
|
+
w.setUint32(24, rate, true);
|
|
235
|
+
w.setUint32(28, rate * channels * outN, true);
|
|
236
|
+
w.setUint16(32, channels * outN, true);
|
|
237
|
+
w.setUint16(34, outN * 8, true);
|
|
238
|
+
ascii(36, 'data');
|
|
239
|
+
w.setUint32(40, dataBytes, true);
|
|
240
|
+
const src = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
241
|
+
const samples = frames * channels;
|
|
242
|
+
let i = p.dataStart;
|
|
243
|
+
let o = headerBytes;
|
|
244
|
+
if (isFloat) {
|
|
245
|
+
for (let s = 0; s < samples; s++, i += inN, o += 4) {
|
|
246
|
+
w.setFloat32(o, inN === 4 ? src.getFloat32(i) : src.getFloat64(i), true);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else if (inN === 1) {
|
|
250
|
+
// AIFF 8-bit is signed, WAV 8-bit is unsigned.
|
|
251
|
+
for (let s = 0; s < samples; s++)
|
|
252
|
+
out[o++] = bytes[i++] ^ 0x80;
|
|
253
|
+
}
|
|
254
|
+
else if (encoding === 'int-le') {
|
|
255
|
+
out.set(bytes.subarray(i, i + samples * inN), o);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
for (let s = 0; s < samples; s++, i += inN, o += inN) {
|
|
259
|
+
for (let b = 0; b < inN; b++)
|
|
260
|
+
out[o + b] = bytes[i + inN - 1 - b];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (p.id3) {
|
|
264
|
+
ascii(dataEnd, 'id3 ');
|
|
265
|
+
w.setUint32(dataEnd + 4, id3Bytes, true);
|
|
266
|
+
out.set(bytes.subarray(p.id3.start, p.id3.end), dataEnd + 8);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
function writeExtended(view, at, value) {
|
|
271
|
+
// Positive, whole sample rates only; enough for the probe below.
|
|
272
|
+
const exp = Math.floor(Math.log2(value));
|
|
273
|
+
view.setUint16(at, exp + 16383);
|
|
274
|
+
const mantissa = value / 2 ** exp;
|
|
275
|
+
view.setUint32(at + 2, Math.floor(mantissa * 2 ** 31) >>> 0);
|
|
276
|
+
view.setUint32(at + 6, 0);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* A minimal valid AIFF (16-bit mono, 8000 Hz, a few frames of silence): enough for asking a
|
|
280
|
+
* browser whether its own decoder reads AIFF.
|
|
281
|
+
*/
|
|
282
|
+
export function makeProbeAiff(frames = 8) {
|
|
283
|
+
const sampleRate = 8000;
|
|
284
|
+
const dataBytes = frames * 2;
|
|
285
|
+
const out = new Uint8Array(12 + 26 + 16 + dataBytes);
|
|
286
|
+
const w = new DataView(out.buffer);
|
|
287
|
+
const ascii = (at, s) => {
|
|
288
|
+
for (let i = 0; i < 4; i++)
|
|
289
|
+
out[at + i] = s.charCodeAt(i);
|
|
290
|
+
};
|
|
291
|
+
ascii(0, 'FORM');
|
|
292
|
+
w.setUint32(4, out.length - 8);
|
|
293
|
+
ascii(8, 'AIFF');
|
|
294
|
+
ascii(12, 'COMM');
|
|
295
|
+
w.setUint32(16, 18);
|
|
296
|
+
w.setUint16(20, 1);
|
|
297
|
+
w.setUint32(22, frames);
|
|
298
|
+
w.setUint16(26, 16);
|
|
299
|
+
writeExtended(w, 28, sampleRate);
|
|
300
|
+
ascii(38, 'SSND');
|
|
301
|
+
w.setUint32(42, 8 + dataBytes);
|
|
302
|
+
w.setUint32(46, 0);
|
|
303
|
+
w.setUint32(50, 0);
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export { AiffError, aiffToWav, isAiff, readAiff, readAiffInfo } from './aiff.js';
|
|
2
|
+
export type { AiffAudio, AiffErrorCode, AiffInfo } from './aiff.js';
|
|
3
|
+
/** The slice of `BaseAudioContext` decoding needs, so tests and workers can pass a stand-in. */
|
|
4
|
+
export interface DecodeContext {
|
|
5
|
+
readonly sampleRate: number;
|
|
6
|
+
decodeAudioData(data: ArrayBuffer): Promise<AudioBuffer>;
|
|
7
|
+
createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer;
|
|
8
|
+
}
|
|
9
|
+
/** Build an `AudioBuffer` from AIFF bytes with our own decoder. Throws `AiffError`. */
|
|
10
|
+
export declare function decodeAiff(ctx: DecodeContext, bytes: ArrayBuffer | ArrayBufferView): Promise<AudioBuffer>;
|
|
11
|
+
/**
|
|
12
|
+
* Decode audio bytes to an `AudioBuffer`: the browser's decoder first, then, if that fails and the
|
|
13
|
+
* bytes are AIFF, ours. Detected by the bytes, not by `name` (which only labels the error).
|
|
14
|
+
* A compressed AIFF-C rejects with `AiffError` code `'compressed'`; anything else the browser
|
|
15
|
+
* can't read rejects with the browser's own error.
|
|
16
|
+
*
|
|
17
|
+
* Like `decodeAudioData`, this may detach an `ArrayBuffer` it is given: anything that is not AIFF
|
|
18
|
+
* goes to the browser as it is, so a large download is never held twice. AIFF goes straight to
|
|
19
|
+
* ours where the browser is known not to read it (`canDecodeAiffNatively`, asked once); elsewhere
|
|
20
|
+
* it is copied for the browser's try, because ours may still need it.
|
|
21
|
+
*
|
|
22
|
+
* Ours runs on the calling thread, as one linear pass: fine for a file the size of a take, felt as
|
|
23
|
+
* a pause for a long high-rate one.
|
|
24
|
+
*/
|
|
25
|
+
export declare function decodeAudio(ctx: DecodeContext, bytes: ArrayBuffer | ArrayBufferView, name?: string): Promise<AudioBuffer>;
|
|
26
|
+
/** Forget the answer of `canDecodeAiffNatively`; for tests. */
|
|
27
|
+
export declare function resetAiffProbeForTests(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Whether this browser's own decoder reads AIFF (Safari does; Chrome and Firefox don't). Asked
|
|
30
|
+
* once with a tiny generated file and remembered for the page's life.
|
|
31
|
+
*/
|
|
32
|
+
export declare function canDecodeAiffNatively(ctx: DecodeContext): Promise<boolean>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Decoding audio bytes in any browser: the browser's own decoder first, and ours for the formats
|
|
2
|
+
// it can't read (AIFF in Chrome and Firefox). Framework-free; needs only a Web Audio context.
|
|
3
|
+
import { AiffError, isAiff, makeProbeAiff, readAiff } from './aiff.js';
|
|
4
|
+
export { AiffError, aiffToWav, isAiff, readAiff, readAiffInfo } from './aiff.js';
|
|
5
|
+
/** The bytes as an `ArrayBuffer` for `decodeAudioData`, copied only when `copy` or a view needs it. */
|
|
6
|
+
function toArrayBuffer(bytes, copy) {
|
|
7
|
+
if (bytes instanceof ArrayBuffer)
|
|
8
|
+
return copy ? bytes.slice(0) : bytes;
|
|
9
|
+
const view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
10
|
+
if (!copy && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength && bytes.buffer instanceof ArrayBuffer) {
|
|
11
|
+
return bytes.buffer;
|
|
12
|
+
}
|
|
13
|
+
return view.slice().buffer;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Bring a buffer to the context's rate, as `decodeAudioData` would have. Without an
|
|
17
|
+
* `OfflineAudioContext` (a worker, a test) the buffer keeps its own rate, which a source node
|
|
18
|
+
* still plays correctly.
|
|
19
|
+
*/
|
|
20
|
+
async function resampleTo(buffer, sampleRate) {
|
|
21
|
+
const Offline = globalThis.OfflineAudioContext;
|
|
22
|
+
if (!Offline || buffer.sampleRate === sampleRate)
|
|
23
|
+
return buffer;
|
|
24
|
+
const length = Math.max(1, Math.round((buffer.length * sampleRate) / buffer.sampleRate));
|
|
25
|
+
const offline = new Offline(buffer.numberOfChannels, length, sampleRate);
|
|
26
|
+
const source = offline.createBufferSource();
|
|
27
|
+
source.buffer = buffer;
|
|
28
|
+
source.connect(offline.destination);
|
|
29
|
+
source.start();
|
|
30
|
+
return offline.startRendering();
|
|
31
|
+
}
|
|
32
|
+
/** Build an `AudioBuffer` from AIFF bytes with our own decoder. Throws `AiffError`. */
|
|
33
|
+
export async function decodeAiff(ctx, bytes) {
|
|
34
|
+
const audio = readAiff(bytes);
|
|
35
|
+
if (audio.frames === 0)
|
|
36
|
+
throw new AiffError('truncated', 'The AIFF file holds no audio');
|
|
37
|
+
const buffer = ctx.createBuffer(audio.channels, audio.frames, audio.sampleRate);
|
|
38
|
+
audio.channelData.forEach((data, c) => buffer.copyToChannel(data, c));
|
|
39
|
+
return resampleTo(buffer, ctx.sampleRate);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Decode audio bytes to an `AudioBuffer`: the browser's decoder first, then, if that fails and the
|
|
43
|
+
* bytes are AIFF, ours. Detected by the bytes, not by `name` (which only labels the error).
|
|
44
|
+
* A compressed AIFF-C rejects with `AiffError` code `'compressed'`; anything else the browser
|
|
45
|
+
* can't read rejects with the browser's own error.
|
|
46
|
+
*
|
|
47
|
+
* Like `decodeAudioData`, this may detach an `ArrayBuffer` it is given: anything that is not AIFF
|
|
48
|
+
* goes to the browser as it is, so a large download is never held twice. AIFF goes straight to
|
|
49
|
+
* ours where the browser is known not to read it (`canDecodeAiffNatively`, asked once); elsewhere
|
|
50
|
+
* it is copied for the browser's try, because ours may still need it.
|
|
51
|
+
*
|
|
52
|
+
* Ours runs on the calling thread, as one linear pass: fine for a file the size of a take, felt as
|
|
53
|
+
* a pause for a long high-rate one.
|
|
54
|
+
*/
|
|
55
|
+
export async function decodeAudio(ctx, bytes, name) {
|
|
56
|
+
if (!isAiff(bytes))
|
|
57
|
+
return ctx.decodeAudioData(toArrayBuffer(bytes, false));
|
|
58
|
+
try {
|
|
59
|
+
if (!(await canDecodeAiffNatively(ctx)))
|
|
60
|
+
throw new Error('not native');
|
|
61
|
+
return await ctx.decodeAudioData(toArrayBuffer(bytes, true));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
try {
|
|
65
|
+
return await decodeAiff(ctx, bytes);
|
|
66
|
+
}
|
|
67
|
+
catch (aiffErr) {
|
|
68
|
+
if (aiffErr instanceof AiffError && name)
|
|
69
|
+
aiffErr.message = `${name}: ${aiffErr.message}`;
|
|
70
|
+
throw aiffErr;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let nativeAiff = null;
|
|
75
|
+
/** Forget the answer of `canDecodeAiffNatively`; for tests. */
|
|
76
|
+
export function resetAiffProbeForTests() {
|
|
77
|
+
nativeAiff = null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether this browser's own decoder reads AIFF (Safari does; Chrome and Firefox don't). Asked
|
|
81
|
+
* once with a tiny generated file and remembered for the page's life.
|
|
82
|
+
*/
|
|
83
|
+
export function canDecodeAiffNatively(ctx) {
|
|
84
|
+
nativeAiff ??= ctx
|
|
85
|
+
.decodeAudioData(makeProbeAiff().buffer)
|
|
86
|
+
.then((buffer) => buffer.length > 0)
|
|
87
|
+
.catch(() => false);
|
|
88
|
+
return nativeAiff;
|
|
89
|
+
}
|
package/dist/faust/recipes.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare function readRecipes(value: unknown): Recipes | null;
|
|
|
33
33
|
* How this package composes a program from a shape. Raise it when the same shape would compose to
|
|
34
34
|
* different text: a test keeps a fingerprint of the composed text beside it and fails until then.
|
|
35
35
|
*/
|
|
36
|
-
export declare const COMPOSER_VERSION =
|
|
36
|
+
export declare const COMPOSER_VERSION = 2;
|
|
37
37
|
/**
|
|
38
38
|
* Where a build's compiled programs are filed: the recipes, the shrink step and the composer
|
|
39
39
|
* together. A file under it is never replaced, so a release keeps the programs it was given.
|
package/dist/faust/recipes.js
CHANGED
|
@@ -41,7 +41,7 @@ export function readRecipes(value) {
|
|
|
41
41
|
* How this package composes a program from a shape. Raise it when the same shape would compose to
|
|
42
42
|
* different text: a test keeps a fingerprint of the composed text beside it and fails until then.
|
|
43
43
|
*/
|
|
44
|
-
export const COMPOSER_VERSION =
|
|
44
|
+
export const COMPOSER_VERSION = 2;
|
|
45
45
|
/**
|
|
46
46
|
* Where a build's compiled programs are filed: the recipes, the shrink step and the composer
|
|
47
47
|
* together. A file under it is never replaced, so a release keeps the programs it was given.
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
*
|
|
31
31
|
* A head rack that plays notes composes as lanes (`lib/ps_notes.lib`):
|
|
32
32
|
*
|
|
33
|
-
* process = nt.
|
|
33
|
+
* process = nt.chainEntry(8) // the lanes the allocator writes
|
|
34
34
|
* : hgroup("s0", nt.bypass(8, 1, library("pitch.dsp")[SLOT=0;].notes(8)))
|
|
35
35
|
* : hgroup("tap0", nt.tap(8)) // the notes leaving s0
|
|
36
|
+
* : nt.toPlay(8) // the beats stay behind
|
|
36
37
|
* : hgroup("s1", library("synth.dsp")[SLOT=1;].play(8)) // a note voice per lane
|
|
37
38
|
* : hgroup("s2", component("filter.dsp"));
|
|
38
39
|
*
|
|
@@ -41,6 +42,11 @@
|
|
|
41
42
|
* instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
|
|
42
43
|
* a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
|
|
43
44
|
* since `notes(n)` is told only n.
|
|
45
|
+
*
|
|
46
|
+
* Between the note modules a lane carries a fourth signal, the beat its note started on (what Random
|
|
47
|
+
* and Velocity draw from): `nt.chainEntry` writes it, `nt.toPlay` drops it, so an instrument takes
|
|
48
|
+
* three signals a lane whether or not anything sits in front of it. An instrument alone keeps the
|
|
49
|
+
* plain `nt.entry`.
|
|
44
50
|
*/
|
|
45
51
|
/** The most note lanes one head plays: what reaches the instrument after every note module has multiplied them. */
|
|
46
52
|
export declare const MAX_NOTE_LANES = 16;
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
*
|
|
31
31
|
* A head rack that plays notes composes as lanes (`lib/ps_notes.lib`):
|
|
32
32
|
*
|
|
33
|
-
* process = nt.
|
|
33
|
+
* process = nt.chainEntry(8) // the lanes the allocator writes
|
|
34
34
|
* : hgroup("s0", nt.bypass(8, 1, library("pitch.dsp")[SLOT=0;].notes(8)))
|
|
35
35
|
* : hgroup("tap0", nt.tap(8)) // the notes leaving s0
|
|
36
|
+
* : nt.toPlay(8) // the beats stay behind
|
|
36
37
|
* : hgroup("s1", library("synth.dsp")[SLOT=1;].play(8)) // a note voice per lane
|
|
37
38
|
* : hgroup("s2", component("filter.dsp"));
|
|
38
39
|
*
|
|
@@ -41,6 +42,11 @@
|
|
|
41
42
|
* instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
|
|
42
43
|
* a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
|
|
43
44
|
* since `notes(n)` is told only n.
|
|
45
|
+
*
|
|
46
|
+
* Between the note modules a lane carries a fourth signal, the beat its note started on (what Random
|
|
47
|
+
* and Velocity draw from): `nt.chainEntry` writes it, `nt.toPlay` drops it, so an instrument takes
|
|
48
|
+
* three signals a lane whether or not anything sits in front of it. An instrument alone keeps the
|
|
49
|
+
* plain `nt.entry`.
|
|
44
50
|
*/
|
|
45
51
|
/** The most note lanes one head plays: what reaches the instrument after every note module has multiplied them. */
|
|
46
52
|
export const MAX_NOTE_LANES = 16;
|
|
@@ -100,7 +106,10 @@ function noteModules(slots, head) {
|
|
|
100
106
|
let lanes = wholeAtLeastOne(instrument.polyphony, `slot ${head}: polyphony`);
|
|
101
107
|
// A tap after every note module: the notes leaving it, for the meters and screens. The entry has
|
|
102
108
|
// none: the main thread knows what it sends in (the keys, MIDI in, the clip's notes reported back).
|
|
103
|
-
|
|
109
|
+
// Note modules in front: lanes carry their beats up to the instrument. An instrument alone takes the
|
|
110
|
+
// plain entry, its committed program unchanged.
|
|
111
|
+
const chain = head > 0;
|
|
112
|
+
const parts = [chain ? `nt.chainEntry(${lanes})` : `nt.entry(${lanes})`];
|
|
104
113
|
slots.slice(0, head).forEach((slot, index) => {
|
|
105
114
|
const notes = slot.notes;
|
|
106
115
|
if (!notes || notes.kind === 'instrument') {
|
|
@@ -117,6 +126,8 @@ function noteModules(slots, head) {
|
|
|
117
126
|
if (lanes > MAX_NOTE_LANES) {
|
|
118
127
|
throw new Error(`[voiceProgram] ${lanes} note lanes reach the instrument; it plays at most ${MAX_NOTE_LANES}`);
|
|
119
128
|
}
|
|
129
|
+
if (chain)
|
|
130
|
+
parts.push(`nt.toPlay(${lanes})`);
|
|
120
131
|
return { parts, lanes };
|
|
121
132
|
}
|
|
122
133
|
/** The instrument at `head`, playing `lanes` lanes: the last part of a note chain. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.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",
|
|
@@ -61,12 +61,17 @@
|
|
|
61
61
|
"./star": {
|
|
62
62
|
"types": "./dist/star/index.d.ts",
|
|
63
63
|
"import": "./dist/star/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./audio": {
|
|
66
|
+
"types": "./dist/audio/index.d.ts",
|
|
67
|
+
"import": "./dist/audio/index.js"
|
|
64
68
|
}
|
|
65
69
|
},
|
|
66
70
|
"files": [
|
|
67
71
|
"src/**/*",
|
|
68
72
|
"!src/**/*.test.ts",
|
|
69
73
|
"!src/**/*.test.js",
|
|
74
|
+
"!src/**/fixtures/**",
|
|
70
75
|
"dist/**/*",
|
|
71
76
|
"public/**/*",
|
|
72
77
|
"README.md",
|
|
@@ -129,8 +134,9 @@
|
|
|
129
134
|
"test:faust": "node --test --import tsx src/faust/*.test.ts",
|
|
130
135
|
"test:studio": "node --test --import tsx src/studio/*.test.js",
|
|
131
136
|
"test:uploads": "node --test --import tsx src/uploads/*.test.js",
|
|
137
|
+
"test:audio": "node --test --import tsx src/audio/*.test.ts",
|
|
132
138
|
"test:render": "node --test src/render/*.test.js",
|
|
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",
|
|
139
|
+
"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:audio && npm run test:render && npm run test:moons && npm run test:star",
|
|
134
140
|
"test:moons": "node --test src/moons/*.test.js",
|
|
135
141
|
"test:star": "node --test --import tsx src/star/*.test.ts",
|
|
136
142
|
"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,337 @@
|
|
|
1
|
+
// Reading AIFF ourselves, because Chrome and Firefox can't (`decodeAudioData` and `<audio>` both
|
|
2
|
+
// refuse it; Safari plays it). AIFF is the same uncompressed PCM as WAV, big-endian, in an IFF
|
|
3
|
+
// container, so "decoding" is reading the header and putting the bytes in the right order: one
|
|
4
|
+
// linear pass, no codec, no dependency.
|
|
5
|
+
//
|
|
6
|
+
// Two outputs from one parse:
|
|
7
|
+
// - `readAiff` → float samples per channel, for building an `AudioBuffer` in memory;
|
|
8
|
+
// - `aiffToWav` → a WAV file with the same samples at the same bit depth, for a copy the browser
|
|
9
|
+
// plays natively from then on.
|
|
10
|
+
//
|
|
11
|
+
// Compressed AIFF-C (`ima4`, `ulaw`, `alaw`, `MAC3`, `MAC6`, …) is refused with `AiffError`
|
|
12
|
+
// code `'compressed'`; there is no PCM in it to reorder.
|
|
13
|
+
|
|
14
|
+
export type AiffErrorCode = 'not-aiff' | 'truncated' | 'compressed' | 'unsupported'
|
|
15
|
+
|
|
16
|
+
export class AiffError extends Error {
|
|
17
|
+
readonly code: AiffErrorCode
|
|
18
|
+
/** The AIFF-C compression id (`'ima4'`, `'ulaw'`, …) when `code` is `'compressed'`. */
|
|
19
|
+
readonly compression?: string
|
|
20
|
+
|
|
21
|
+
constructor(code: AiffErrorCode, message: string, compression?: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'AiffError'
|
|
24
|
+
this.code = code
|
|
25
|
+
this.compression = compression
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How the sample bytes are laid out. Every AIFF-C id we accept maps to one of these. */
|
|
30
|
+
type SampleEncoding = 'int-be' | 'int-le' | 'float-be'
|
|
31
|
+
|
|
32
|
+
export interface AiffInfo {
|
|
33
|
+
/** `'AIFF'`, or `'AIFC'` for AIFF-C. */
|
|
34
|
+
form: 'AIFF' | 'AIFC'
|
|
35
|
+
/** The AIFF-C compression id; `'NONE'` for plain AIFF. */
|
|
36
|
+
compression: string
|
|
37
|
+
channels: number
|
|
38
|
+
sampleRate: number
|
|
39
|
+
/** Bits per sample as the file declares them (1–32 for integers, 32 or 64 for floats). */
|
|
40
|
+
bitsPerSample: number
|
|
41
|
+
/** Sample frames that are really in the file (fewer than declared when it was cut short; all
|
|
42
|
+
* of the sound data when the header says 0, as a streaming writer leaves it). */
|
|
43
|
+
frames: number
|
|
44
|
+
/** True when the file promised more frames than it holds. */
|
|
45
|
+
truncated: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ParsedAiff extends AiffInfo {
|
|
49
|
+
encoding: SampleEncoding
|
|
50
|
+
/** Bytes per sample in the file (integers are left-justified in whole bytes). */
|
|
51
|
+
bytesPerSample: number
|
|
52
|
+
/** Byte offset of the first sample frame. */
|
|
53
|
+
dataStart: number
|
|
54
|
+
/** Where an `ID3 ` chunk's body is (the file's tags and cover), when it has one. */
|
|
55
|
+
id3: { start: number; end: number } | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** AIFF-C ids that hold plain PCM, and how to read them. */
|
|
59
|
+
const PCM_COMPRESSIONS: Record<string, { encoding: SampleEncoding; bits?: number }> = {
|
|
60
|
+
NONE: { encoding: 'int-be' },
|
|
61
|
+
twos: { encoding: 'int-be' },
|
|
62
|
+
sowt: { encoding: 'int-le' },
|
|
63
|
+
in24: { encoding: 'int-be', bits: 24 },
|
|
64
|
+
in32: { encoding: 'int-be', bits: 32 },
|
|
65
|
+
'42ni': { encoding: 'int-le', bits: 24 },
|
|
66
|
+
'23ni': { encoding: 'int-le', bits: 32 },
|
|
67
|
+
fl32: { encoding: 'float-be', bits: 32 },
|
|
68
|
+
FL32: { encoding: 'float-be', bits: 32 },
|
|
69
|
+
fl64: { encoding: 'float-be', bits: 64 },
|
|
70
|
+
FL64: { encoding: 'float-be', bits: 64 },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fourCC(bytes: Uint8Array, at: number): string {
|
|
74
|
+
return String.fromCharCode(bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function asBytes(input: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
78
|
+
if (input instanceof Uint8Array) return input
|
|
79
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
|
80
|
+
return new Uint8Array(input)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when the bytes are an AIFF or AIFF-C file. Judged on the bytes, never on a file name. */
|
|
84
|
+
export function isAiff(input: ArrayBuffer | ArrayBufferView): boolean {
|
|
85
|
+
const bytes = asBytes(input)
|
|
86
|
+
if (bytes.length < 12) return false
|
|
87
|
+
const form = fourCC(bytes, 8)
|
|
88
|
+
return fourCC(bytes, 0) === 'FORM' && (form === 'AIFF' || form === 'AIFC')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The 80-bit IEEE 754 extended float AIFF stores its sample rate in. */
|
|
92
|
+
function readExtended(view: DataView, at: number): number {
|
|
93
|
+
const signExp = view.getUint16(at)
|
|
94
|
+
const hi = view.getUint32(at + 2)
|
|
95
|
+
const lo = view.getUint32(at + 6)
|
|
96
|
+
const exp = (signExp & 0x7fff) - 16383
|
|
97
|
+
if ((signExp & 0x7fff) === 0 && hi === 0 && lo === 0) return 0
|
|
98
|
+
const value = hi * 2 ** (exp - 31) + lo * 2 ** (exp - 63)
|
|
99
|
+
return signExp & 0x8000 ? -value : value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parse(bytes: Uint8Array): ParsedAiff {
|
|
103
|
+
if (!isAiff(bytes)) throw new AiffError('not-aiff', 'Not an AIFF file')
|
|
104
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
105
|
+
const form = fourCC(bytes, 8) as 'AIFF' | 'AIFC'
|
|
106
|
+
// The FORM size can be wrong: larger than the file (a file cut short) or 0 (a streaming writer
|
|
107
|
+
// that never went back to fill it in). The bytes we have are the real limit.
|
|
108
|
+
const formSize = view.getUint32(4)
|
|
109
|
+
const end = formSize >= 4 ? Math.min(bytes.length, 8 + formSize) : bytes.length
|
|
110
|
+
|
|
111
|
+
let comm: { channels: number; frames: number; bits: number; sampleRate: number; compression: string } | null = null
|
|
112
|
+
let ssnd: { start: number; end: number } | null = null
|
|
113
|
+
let id3: { start: number; end: number } | null = null
|
|
114
|
+
|
|
115
|
+
// Chunks may come in any order; walk them all.
|
|
116
|
+
let at = 12
|
|
117
|
+
while (at + 8 <= end) {
|
|
118
|
+
const id = fourCC(bytes, at)
|
|
119
|
+
const size = view.getUint32(at + 4)
|
|
120
|
+
const body = at + 8
|
|
121
|
+
const bodyEnd = Math.min(body + size, end)
|
|
122
|
+
if (id === 'COMM') {
|
|
123
|
+
if (bodyEnd - body < 18) throw new AiffError('truncated', 'The AIFF header is cut short')
|
|
124
|
+
let compression = 'NONE'
|
|
125
|
+
if (form === 'AIFC') {
|
|
126
|
+
if (bodyEnd - body < 22) throw new AiffError('truncated', 'The AIFF header is cut short')
|
|
127
|
+
compression = fourCC(bytes, body + 18)
|
|
128
|
+
}
|
|
129
|
+
comm = {
|
|
130
|
+
channels: view.getUint16(body),
|
|
131
|
+
frames: view.getUint32(body + 2),
|
|
132
|
+
bits: view.getUint16(body + 6),
|
|
133
|
+
sampleRate: readExtended(view, body + 8),
|
|
134
|
+
compression,
|
|
135
|
+
}
|
|
136
|
+
} else if (id === 'SSND') {
|
|
137
|
+
if (bodyEnd - body < 8) throw new AiffError('truncated', 'The AIFF sound data is cut short')
|
|
138
|
+
const offset = view.getUint32(body)
|
|
139
|
+
ssnd = { start: Math.min(body + 8 + offset, bodyEnd), end: bodyEnd }
|
|
140
|
+
} else if (id === 'ID3 ' && bodyEnd > body) {
|
|
141
|
+
id3 = { start: body, end: bodyEnd }
|
|
142
|
+
}
|
|
143
|
+
// Chunk bodies are padded to an even length.
|
|
144
|
+
at = body + size + (size & 1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!comm) throw new AiffError('truncated', 'The AIFF file has no format chunk')
|
|
148
|
+
const pcm = PCM_COMPRESSIONS[comm.compression]
|
|
149
|
+
if (!pcm) {
|
|
150
|
+
throw new AiffError(
|
|
151
|
+
'compressed',
|
|
152
|
+
`Compressed AIFF-C (${comm.compression.trim() || 'unknown'}) is not supported`,
|
|
153
|
+
comm.compression,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
const bits = pcm.bits ?? comm.bits
|
|
157
|
+
if (comm.channels < 1) throw new AiffError('unsupported', 'The AIFF file has no channels')
|
|
158
|
+
if (!(comm.sampleRate > 0) || !Number.isFinite(comm.sampleRate)) {
|
|
159
|
+
throw new AiffError('unsupported', 'The AIFF file has no usable sample rate')
|
|
160
|
+
}
|
|
161
|
+
if (pcm.encoding === 'float-be' ? bits !== 32 && bits !== 64 : bits < 1 || bits > 32) {
|
|
162
|
+
throw new AiffError('unsupported', `${bits}-bit AIFF samples are not supported`)
|
|
163
|
+
}
|
|
164
|
+
const bytesPerSample = Math.ceil(bits / 8)
|
|
165
|
+
const frameBytes = bytesPerSample * comm.channels
|
|
166
|
+
const available = ssnd ? Math.floor((ssnd.end - ssnd.start) / frameBytes) : 0
|
|
167
|
+
if (comm.frames > 0 && !ssnd) throw new AiffError('truncated', 'The AIFF file has no sound data')
|
|
168
|
+
const frames = comm.frames > 0 ? Math.min(comm.frames, available) : available
|
|
169
|
+
return {
|
|
170
|
+
form,
|
|
171
|
+
compression: comm.compression,
|
|
172
|
+
channels: comm.channels,
|
|
173
|
+
sampleRate: comm.sampleRate,
|
|
174
|
+
bitsPerSample: bits,
|
|
175
|
+
frames,
|
|
176
|
+
truncated: frames < comm.frames,
|
|
177
|
+
encoding: pcm.encoding,
|
|
178
|
+
bytesPerSample,
|
|
179
|
+
dataStart: ssnd ? ssnd.start : 0,
|
|
180
|
+
id3,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function infoOf(p: ParsedAiff): AiffInfo {
|
|
185
|
+
const { form, compression, channels, sampleRate, bitsPerSample, frames, truncated } = p
|
|
186
|
+
return { form, compression, channels, sampleRate, bitsPerSample, frames, truncated }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The header only: format, length, and whether the file can be read at all (throws `AiffError`). */
|
|
190
|
+
export function readAiffInfo(input: ArrayBuffer | ArrayBufferView): AiffInfo {
|
|
191
|
+
return infoOf(parse(asBytes(input)))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface AiffAudio extends AiffInfo {
|
|
195
|
+
/** One array per channel, samples in -1…1. */
|
|
196
|
+
channelData: Float32Array[]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Decode an AIFF file to float samples per channel. Throws `AiffError`. */
|
|
200
|
+
export function readAiff(input: ArrayBuffer | ArrayBufferView): AiffAudio {
|
|
201
|
+
const bytes = asBytes(input)
|
|
202
|
+
const p = parse(bytes)
|
|
203
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
204
|
+
const { channels, frames, bytesPerSample: n, encoding } = p
|
|
205
|
+
const channelData = Array.from({ length: channels }, () => new Float32Array(frames))
|
|
206
|
+
const scale = 1 / 2 ** (n * 8 - 1)
|
|
207
|
+
let at = p.dataStart
|
|
208
|
+
|
|
209
|
+
if (encoding === 'float-be') {
|
|
210
|
+
const read = n === 4 ? (i: number) => view.getFloat32(i) : (i: number) => view.getFloat64(i)
|
|
211
|
+
for (let f = 0; f < frames; f++) {
|
|
212
|
+
for (let c = 0; c < channels; c++, at += n) channelData[c][f] = read(at)
|
|
213
|
+
}
|
|
214
|
+
} else if (n === 2) {
|
|
215
|
+
const le = encoding === 'int-le'
|
|
216
|
+
for (let f = 0; f < frames; f++) {
|
|
217
|
+
for (let c = 0; c < channels; c++, at += 2) channelData[c][f] = view.getInt16(at, le) * scale
|
|
218
|
+
}
|
|
219
|
+
} else if (n === 4) {
|
|
220
|
+
const le = encoding === 'int-le'
|
|
221
|
+
for (let f = 0; f < frames; f++) {
|
|
222
|
+
for (let c = 0; c < channels; c++, at += 4) channelData[c][f] = view.getInt32(at, le) * scale
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// 8- and 24-bit (and anything else that fills 1 or 3 bytes): assemble, then sign-extend.
|
|
226
|
+
const le = encoding === 'int-le'
|
|
227
|
+
const shift = 32 - n * 8
|
|
228
|
+
for (let f = 0; f < frames; f++) {
|
|
229
|
+
for (let c = 0; c < channels; c++, at += n) {
|
|
230
|
+
let v = 0
|
|
231
|
+
for (let b = 0; b < n; b++) v = (v << 8) | bytes[le ? at + n - 1 - b : at + b]
|
|
232
|
+
channelData[c][f] = ((v << shift) >> shift) * scale
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { ...infoOf(p), channelData }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Rewrite an AIFF file as a WAV with the same samples at the same bit depth (integers stay
|
|
241
|
+
* integers, 32-bit floats stay floats; 64-bit floats become 32-bit, which every browser decodes).
|
|
242
|
+
* An `ID3 ` chunk (the tags and cover) goes along as the WAV's `id3 ` chunk. Throws `AiffError`.
|
|
243
|
+
*/
|
|
244
|
+
export function aiffToWav(input: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
245
|
+
const bytes = asBytes(input)
|
|
246
|
+
const p = parse(bytes)
|
|
247
|
+
const { channels, frames, encoding } = p
|
|
248
|
+
const inN = p.bytesPerSample
|
|
249
|
+
const isFloat = encoding === 'float-be'
|
|
250
|
+
const outN = isFloat ? 4 : inN
|
|
251
|
+
const dataBytes = frames * channels * outN
|
|
252
|
+
const headerBytes = 44
|
|
253
|
+
const dataEnd = headerBytes + dataBytes + (dataBytes & 1)
|
|
254
|
+
const id3Bytes = p.id3 ? p.id3.end - p.id3.start : 0
|
|
255
|
+
const out = new Uint8Array(dataEnd + (id3Bytes ? 8 + id3Bytes + (id3Bytes & 1) : 0))
|
|
256
|
+
const w = new DataView(out.buffer)
|
|
257
|
+
const ascii = (at: number, s: string) => {
|
|
258
|
+
for (let i = 0; i < 4; i++) out[at + i] = s.charCodeAt(i)
|
|
259
|
+
}
|
|
260
|
+
ascii(0, 'RIFF')
|
|
261
|
+
w.setUint32(4, out.length - 8, true)
|
|
262
|
+
ascii(8, 'WAVE')
|
|
263
|
+
ascii(12, 'fmt ')
|
|
264
|
+
w.setUint32(16, 16, true)
|
|
265
|
+
w.setUint16(20, isFloat ? 3 : 1, true)
|
|
266
|
+
w.setUint16(22, channels, true)
|
|
267
|
+
// WAV holds an integer rate; AIFF's is a float that is almost always whole already.
|
|
268
|
+
const rate = Math.round(p.sampleRate)
|
|
269
|
+
w.setUint32(24, rate, true)
|
|
270
|
+
w.setUint32(28, rate * channels * outN, true)
|
|
271
|
+
w.setUint16(32, channels * outN, true)
|
|
272
|
+
w.setUint16(34, outN * 8, true)
|
|
273
|
+
ascii(36, 'data')
|
|
274
|
+
w.setUint32(40, dataBytes, true)
|
|
275
|
+
|
|
276
|
+
const src = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
277
|
+
const samples = frames * channels
|
|
278
|
+
let i = p.dataStart
|
|
279
|
+
let o = headerBytes
|
|
280
|
+
if (isFloat) {
|
|
281
|
+
for (let s = 0; s < samples; s++, i += inN, o += 4) {
|
|
282
|
+
w.setFloat32(o, inN === 4 ? src.getFloat32(i) : src.getFloat64(i), true)
|
|
283
|
+
}
|
|
284
|
+
} else if (inN === 1) {
|
|
285
|
+
// AIFF 8-bit is signed, WAV 8-bit is unsigned.
|
|
286
|
+
for (let s = 0; s < samples; s++) out[o++] = bytes[i++] ^ 0x80
|
|
287
|
+
} else if (encoding === 'int-le') {
|
|
288
|
+
out.set(bytes.subarray(i, i + samples * inN), o)
|
|
289
|
+
} else {
|
|
290
|
+
for (let s = 0; s < samples; s++, i += inN, o += inN) {
|
|
291
|
+
for (let b = 0; b < inN; b++) out[o + b] = bytes[i + inN - 1 - b]
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (p.id3) {
|
|
295
|
+
ascii(dataEnd, 'id3 ')
|
|
296
|
+
w.setUint32(dataEnd + 4, id3Bytes, true)
|
|
297
|
+
out.set(bytes.subarray(p.id3.start, p.id3.end), dataEnd + 8)
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function writeExtended(view: DataView, at: number, value: number) {
|
|
303
|
+
// Positive, whole sample rates only; enough for the probe below.
|
|
304
|
+
const exp = Math.floor(Math.log2(value))
|
|
305
|
+
view.setUint16(at, exp + 16383)
|
|
306
|
+
const mantissa = value / 2 ** exp
|
|
307
|
+
view.setUint32(at + 2, Math.floor(mantissa * 2 ** 31) >>> 0)
|
|
308
|
+
view.setUint32(at + 6, 0)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A minimal valid AIFF (16-bit mono, 8000 Hz, a few frames of silence): enough for asking a
|
|
313
|
+
* browser whether its own decoder reads AIFF.
|
|
314
|
+
*/
|
|
315
|
+
export function makeProbeAiff(frames = 8): Uint8Array {
|
|
316
|
+
const sampleRate = 8000
|
|
317
|
+
const dataBytes = frames * 2
|
|
318
|
+
const out = new Uint8Array(12 + 26 + 16 + dataBytes)
|
|
319
|
+
const w = new DataView(out.buffer)
|
|
320
|
+
const ascii = (at: number, s: string) => {
|
|
321
|
+
for (let i = 0; i < 4; i++) out[at + i] = s.charCodeAt(i)
|
|
322
|
+
}
|
|
323
|
+
ascii(0, 'FORM')
|
|
324
|
+
w.setUint32(4, out.length - 8)
|
|
325
|
+
ascii(8, 'AIFF')
|
|
326
|
+
ascii(12, 'COMM')
|
|
327
|
+
w.setUint32(16, 18)
|
|
328
|
+
w.setUint16(20, 1)
|
|
329
|
+
w.setUint32(22, frames)
|
|
330
|
+
w.setUint16(26, 16)
|
|
331
|
+
writeExtended(w, 28, sampleRate)
|
|
332
|
+
ascii(38, 'SSND')
|
|
333
|
+
w.setUint32(42, 8 + dataBytes)
|
|
334
|
+
w.setUint32(46, 0)
|
|
335
|
+
w.setUint32(50, 0)
|
|
336
|
+
return out
|
|
337
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Decoding audio bytes in any browser: the browser's own decoder first, and ours for the formats
|
|
2
|
+
// it can't read (AIFF in Chrome and Firefox). Framework-free; needs only a Web Audio context.
|
|
3
|
+
|
|
4
|
+
import { AiffError, isAiff, makeProbeAiff, readAiff } from './aiff.js'
|
|
5
|
+
|
|
6
|
+
export { AiffError, aiffToWav, isAiff, readAiff, readAiffInfo } from './aiff.js'
|
|
7
|
+
export type { AiffAudio, AiffErrorCode, AiffInfo } from './aiff.js'
|
|
8
|
+
|
|
9
|
+
/** The slice of `BaseAudioContext` decoding needs, so tests and workers can pass a stand-in. */
|
|
10
|
+
export interface DecodeContext {
|
|
11
|
+
readonly sampleRate: number
|
|
12
|
+
decodeAudioData(data: ArrayBuffer): Promise<AudioBuffer>
|
|
13
|
+
createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The bytes as an `ArrayBuffer` for `decodeAudioData`, copied only when `copy` or a view needs it. */
|
|
17
|
+
function toArrayBuffer(bytes: ArrayBuffer | ArrayBufferView, copy: boolean): ArrayBuffer {
|
|
18
|
+
if (bytes instanceof ArrayBuffer) return copy ? bytes.slice(0) : bytes
|
|
19
|
+
const view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
20
|
+
if (!copy && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength && bytes.buffer instanceof ArrayBuffer) {
|
|
21
|
+
return bytes.buffer
|
|
22
|
+
}
|
|
23
|
+
return view.slice().buffer
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Bring a buffer to the context's rate, as `decodeAudioData` would have. Without an
|
|
28
|
+
* `OfflineAudioContext` (a worker, a test) the buffer keeps its own rate, which a source node
|
|
29
|
+
* still plays correctly.
|
|
30
|
+
*/
|
|
31
|
+
async function resampleTo(buffer: AudioBuffer, sampleRate: number): Promise<AudioBuffer> {
|
|
32
|
+
const Offline = (globalThis as { OfflineAudioContext?: typeof OfflineAudioContext }).OfflineAudioContext
|
|
33
|
+
if (!Offline || buffer.sampleRate === sampleRate) return buffer
|
|
34
|
+
const length = Math.max(1, Math.round((buffer.length * sampleRate) / buffer.sampleRate))
|
|
35
|
+
const offline = new Offline(buffer.numberOfChannels, length, sampleRate)
|
|
36
|
+
const source = offline.createBufferSource()
|
|
37
|
+
source.buffer = buffer
|
|
38
|
+
source.connect(offline.destination)
|
|
39
|
+
source.start()
|
|
40
|
+
return offline.startRendering()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build an `AudioBuffer` from AIFF bytes with our own decoder. Throws `AiffError`. */
|
|
44
|
+
export async function decodeAiff(ctx: DecodeContext, bytes: ArrayBuffer | ArrayBufferView): Promise<AudioBuffer> {
|
|
45
|
+
const audio = readAiff(bytes)
|
|
46
|
+
if (audio.frames === 0) throw new AiffError('truncated', 'The AIFF file holds no audio')
|
|
47
|
+
const buffer = ctx.createBuffer(audio.channels, audio.frames, audio.sampleRate)
|
|
48
|
+
audio.channelData.forEach((data, c) => buffer.copyToChannel(data as Float32Array<ArrayBuffer>, c))
|
|
49
|
+
return resampleTo(buffer, ctx.sampleRate)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decode audio bytes to an `AudioBuffer`: the browser's decoder first, then, if that fails and the
|
|
54
|
+
* bytes are AIFF, ours. Detected by the bytes, not by `name` (which only labels the error).
|
|
55
|
+
* A compressed AIFF-C rejects with `AiffError` code `'compressed'`; anything else the browser
|
|
56
|
+
* can't read rejects with the browser's own error.
|
|
57
|
+
*
|
|
58
|
+
* Like `decodeAudioData`, this may detach an `ArrayBuffer` it is given: anything that is not AIFF
|
|
59
|
+
* goes to the browser as it is, so a large download is never held twice. AIFF goes straight to
|
|
60
|
+
* ours where the browser is known not to read it (`canDecodeAiffNatively`, asked once); elsewhere
|
|
61
|
+
* it is copied for the browser's try, because ours may still need it.
|
|
62
|
+
*
|
|
63
|
+
* Ours runs on the calling thread, as one linear pass: fine for a file the size of a take, felt as
|
|
64
|
+
* a pause for a long high-rate one.
|
|
65
|
+
*/
|
|
66
|
+
export async function decodeAudio(
|
|
67
|
+
ctx: DecodeContext,
|
|
68
|
+
bytes: ArrayBuffer | ArrayBufferView,
|
|
69
|
+
name?: string,
|
|
70
|
+
): Promise<AudioBuffer> {
|
|
71
|
+
if (!isAiff(bytes)) return ctx.decodeAudioData(toArrayBuffer(bytes, false))
|
|
72
|
+
try {
|
|
73
|
+
if (!(await canDecodeAiffNatively(ctx))) throw new Error('not native')
|
|
74
|
+
return await ctx.decodeAudioData(toArrayBuffer(bytes, true))
|
|
75
|
+
} catch {
|
|
76
|
+
try {
|
|
77
|
+
return await decodeAiff(ctx, bytes)
|
|
78
|
+
} catch (aiffErr) {
|
|
79
|
+
if (aiffErr instanceof AiffError && name) aiffErr.message = `${name}: ${aiffErr.message}`
|
|
80
|
+
throw aiffErr
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let nativeAiff: Promise<boolean> | null = null
|
|
86
|
+
|
|
87
|
+
/** Forget the answer of `canDecodeAiffNatively`; for tests. */
|
|
88
|
+
export function resetAiffProbeForTests(): void {
|
|
89
|
+
nativeAiff = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether this browser's own decoder reads AIFF (Safari does; Chrome and Firefox don't). Asked
|
|
94
|
+
* once with a tiny generated file and remembered for the page's life.
|
|
95
|
+
*/
|
|
96
|
+
export function canDecodeAiffNatively(ctx: DecodeContext): Promise<boolean> {
|
|
97
|
+
nativeAiff ??= ctx
|
|
98
|
+
.decodeAudioData(makeProbeAiff().buffer as ArrayBuffer)
|
|
99
|
+
.then((buffer) => buffer.length > 0)
|
|
100
|
+
.catch(() => false)
|
|
101
|
+
return nativeAiff
|
|
102
|
+
}
|
package/src/faust/recipes.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function readRecipes(value: unknown): Recipes | null {
|
|
|
59
59
|
* How this package composes a program from a shape. Raise it when the same shape would compose to
|
|
60
60
|
* different text: a test keeps a fingerprint of the composed text beside it and fails until then.
|
|
61
61
|
*/
|
|
62
|
-
export const COMPOSER_VERSION =
|
|
62
|
+
export const COMPOSER_VERSION = 2;
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* Where a build's compiled programs are filed: the recipes, the shrink step and the composer
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
*
|
|
31
31
|
* A head rack that plays notes composes as lanes (`lib/ps_notes.lib`):
|
|
32
32
|
*
|
|
33
|
-
* process = nt.
|
|
33
|
+
* process = nt.chainEntry(8) // the lanes the allocator writes
|
|
34
34
|
* : hgroup("s0", nt.bypass(8, 1, library("pitch.dsp")[SLOT=0;].notes(8)))
|
|
35
35
|
* : hgroup("tap0", nt.tap(8)) // the notes leaving s0
|
|
36
|
+
* : nt.toPlay(8) // the beats stay behind
|
|
36
37
|
* : hgroup("s1", library("synth.dsp")[SLOT=1;].play(8)) // a note voice per lane
|
|
37
38
|
* : hgroup("s2", component("filter.dsp"));
|
|
38
39
|
*
|
|
@@ -41,6 +42,11 @@
|
|
|
41
42
|
* instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
|
|
42
43
|
* a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
|
|
43
44
|
* since `notes(n)` is told only n.
|
|
45
|
+
*
|
|
46
|
+
* Between the note modules a lane carries a fourth signal, the beat its note started on (what Random
|
|
47
|
+
* and Velocity draw from): `nt.chainEntry` writes it, `nt.toPlay` drops it, so an instrument takes
|
|
48
|
+
* three signals a lane whether or not anything sits in front of it. An instrument alone keeps the
|
|
49
|
+
* plain `nt.entry`.
|
|
44
50
|
*/
|
|
45
51
|
/** The most note lanes one head plays: what reaches the instrument after every note module has multiplied them. */
|
|
46
52
|
export const MAX_NOTE_LANES = 16;
|
|
@@ -126,7 +132,10 @@ function noteModules(slots: readonly VoiceSlot[], head: number): { parts: string
|
|
|
126
132
|
let lanes = wholeAtLeastOne(instrument.polyphony, `slot ${head}: polyphony`);
|
|
127
133
|
// A tap after every note module: the notes leaving it, for the meters and screens. The entry has
|
|
128
134
|
// none: the main thread knows what it sends in (the keys, MIDI in, the clip's notes reported back).
|
|
129
|
-
|
|
135
|
+
// Note modules in front: lanes carry their beats up to the instrument. An instrument alone takes the
|
|
136
|
+
// plain entry, its committed program unchanged.
|
|
137
|
+
const chain = head > 0;
|
|
138
|
+
const parts = [chain ? `nt.chainEntry(${lanes})` : `nt.entry(${lanes})`];
|
|
130
139
|
slots.slice(0, head).forEach((slot, index) => {
|
|
131
140
|
const notes = slot.notes;
|
|
132
141
|
if (!notes || notes.kind === 'instrument') {
|
|
@@ -143,6 +152,7 @@ function noteModules(slots: readonly VoiceSlot[], head: number): { parts: string
|
|
|
143
152
|
if (lanes > MAX_NOTE_LANES) {
|
|
144
153
|
throw new Error(`[voiceProgram] ${lanes} note lanes reach the instrument; it plays at most ${MAX_NOTE_LANES}`);
|
|
145
154
|
}
|
|
155
|
+
if (chain) parts.push(`nt.toPlay(${lanes})`);
|
|
146
156
|
return { parts, lanes };
|
|
147
157
|
}
|
|
148
158
|
|