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.
- 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/package.json +8 -2
- package/src/audio/aiff.ts +337 -0
- package/src/audio/index.ts +102 -0
|
@@ -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.
|
|
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
|
+
}
|