entangled-worlds-orbiters-shared 2.15.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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.15.0",
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
+ }