@mediabunny/aac-encoder 1.35.1

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/src/encoder.ts ADDED
@@ -0,0 +1,306 @@
1
+ /*!
2
+ * Copyright (c) 2026-present, Vanilagy and contributors
3
+ *
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ import {
10
+ type AdtsHeaderTemplate,
11
+ buildAdtsHeaderTemplate,
12
+ parseAacAudioSpecificConfig,
13
+ writeAdtsFrameLength,
14
+ } from '../../../shared/aac-misc';
15
+ import {
16
+ CustomAudioEncoder,
17
+ AudioCodec,
18
+ AudioSample,
19
+ EncodedPacket,
20
+ registerEncoder,
21
+ } from 'mediabunny';
22
+ import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
23
+ // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
24
+ import createWorker from './encode.worker';
25
+
26
+ const AAC_SAMPLE_RATES = [
27
+ 96000, 88200, 64000, 48000, 44100, 32000,
28
+ 24000, 22050, 16000, 12000, 11025, 8000, 7350,
29
+ ];
30
+
31
+ class AacEncoder extends CustomAudioEncoder {
32
+ private worker: Worker | null = null;
33
+ private nextMessageId = 0;
34
+ private pendingMessages = new Map<number, {
35
+ resolve: (value: WorkerResponseData) => void;
36
+ reject: (reason?: unknown) => void;
37
+ }>();
38
+
39
+ private ctx = 0;
40
+ private encoderFrameSize = 0;
41
+ private sampleRate = 0;
42
+ private numberOfChannels = 0;
43
+ private chunkMetadata: EncodedAudioChunkMetadata = {};
44
+ private useAdts = false;
45
+ private adtsHeaderTemplate: AdtsHeaderTemplate | null = null;
46
+ private description: Uint8Array | null = null;
47
+
48
+ // Accumulate interleaved f32 samples until we have a full frame
49
+ private pendingBuffer = new Float32Array(2 ** 16);
50
+ private pendingFrames = 0;
51
+ private nextSampleTimestampInSamples: number | null = null;
52
+ private nextPacketTimestampInSamples: number | null = null;
53
+
54
+ static override supports(codec: AudioCodec, config: AudioEncoderConfig): boolean {
55
+ return codec === 'aac'
56
+ && config.numberOfChannels >= 1
57
+ && config.numberOfChannels <= 8
58
+ && AAC_SAMPLE_RATES.includes(config.sampleRate)
59
+ && config.bitrate !== undefined;
60
+ }
61
+
62
+ async init() {
63
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
64
+ this.worker = (await createWorker()) as Worker;
65
+
66
+ const onMessage = (data: WorkerResponse) => {
67
+ const pending = this.pendingMessages.get(data.id);
68
+ assert(pending !== undefined);
69
+
70
+ this.pendingMessages.delete(data.id);
71
+ if (data.success) {
72
+ pending.resolve(data.data);
73
+ } else {
74
+ pending.reject(data.error);
75
+ }
76
+ };
77
+
78
+ if (this.worker.addEventListener) {
79
+ this.worker.addEventListener('message', event => onMessage(event.data as WorkerResponse));
80
+ } else {
81
+ const nodeWorker = this.worker as unknown as {
82
+ on: (event: string, listener: (data: never) => void) => void;
83
+ };
84
+ nodeWorker.on('message', onMessage);
85
+ }
86
+
87
+ assert(this.config.bitrate !== undefined);
88
+ this.sampleRate = this.config.sampleRate;
89
+ this.numberOfChannels = this.config.numberOfChannels;
90
+
91
+ const result = await this.sendCommand({
92
+ type: 'init',
93
+ data: {
94
+ numberOfChannels: this.config.numberOfChannels,
95
+ sampleRate: this.config.sampleRate,
96
+ bitrate: this.config.bitrate,
97
+ },
98
+ });
99
+
100
+ this.ctx = result.ctx;
101
+ this.encoderFrameSize = result.frameSize;
102
+
103
+ // The ffmpeg encoder provides an AudioSpecificConfig as extradata after init
104
+ const description = new Uint8Array(result.extradata);
105
+
106
+ const aacConfig = (this.config as { aac?: { format?: 'aac' | 'adts' } }).aac;
107
+ this.useAdts = aacConfig?.format === 'adts';
108
+
109
+ if (this.useAdts) {
110
+ const audioSpecificConfig = parseAacAudioSpecificConfig(description);
111
+ this.adtsHeaderTemplate = buildAdtsHeaderTemplate(audioSpecificConfig);
112
+ }
113
+
114
+ this.description = this.useAdts ? null : description;
115
+ this.resetInternalState();
116
+ }
117
+
118
+ private resetInternalState() {
119
+ this.pendingFrames = 0;
120
+ this.nextSampleTimestampInSamples = null;
121
+ this.nextPacketTimestampInSamples = null;
122
+
123
+ this.chunkMetadata = {
124
+ decoderConfig: {
125
+ codec: 'mp4a.40.2',
126
+ numberOfChannels: this.config.numberOfChannels,
127
+ sampleRate: this.config.sampleRate,
128
+ ...(this.description ? { description: this.description } : {}),
129
+ },
130
+ };
131
+ }
132
+
133
+ async encode(audioSample: AudioSample) {
134
+ if (this.nextSampleTimestampInSamples === null) {
135
+ this.nextSampleTimestampInSamples = Math.round(audioSample.timestamp * this.sampleRate);
136
+ this.nextPacketTimestampInSamples = this.nextSampleTimestampInSamples;
137
+ }
138
+
139
+ const channels = this.numberOfChannels;
140
+ const incomingFrames = audioSample.numberOfFrames;
141
+
142
+ // Extract interleaved f32 data
143
+ const totalBytes = audioSample.allocationSize({ format: 'f32', planeIndex: 0 });
144
+ const audioBytes = new Uint8Array(totalBytes);
145
+ audioSample.copyTo(audioBytes, { format: 'f32', planeIndex: 0 });
146
+ const incomingData = new Float32Array(audioBytes.buffer);
147
+
148
+ const requiredSamples = (this.pendingFrames + incomingFrames) * channels;
149
+ if (requiredSamples > this.pendingBuffer.length) {
150
+ let newSize = this.pendingBuffer.length;
151
+ while (newSize < requiredSamples) {
152
+ newSize *= 2;
153
+ }
154
+ const newBuffer = new Float32Array(newSize);
155
+ newBuffer.set(this.pendingBuffer.subarray(0, this.pendingFrames * channels));
156
+ this.pendingBuffer = newBuffer;
157
+ }
158
+ this.pendingBuffer.set(incomingData, this.pendingFrames * channels);
159
+ this.pendingFrames += incomingFrames;
160
+
161
+ while (this.pendingFrames >= this.encoderFrameSize) {
162
+ await this.encodeOneFrame();
163
+ }
164
+ }
165
+
166
+ async flush() {
167
+ // Pad remaining samples with silence to fill a full frame
168
+ if (this.pendingFrames > 0) {
169
+ const channels = this.numberOfChannels;
170
+ const frameSize = this.encoderFrameSize;
171
+ const usedSamples = this.pendingFrames * channels;
172
+ const frameSamples = frameSize * channels;
173
+
174
+ this.pendingBuffer.fill(0, usedSamples, frameSamples);
175
+ this.pendingFrames = frameSize;
176
+
177
+ await this.encodeOneFrame();
178
+ }
179
+
180
+ const result = await this.sendCommand({ type: 'flush', data: { ctx: this.ctx } });
181
+ this.emitPackets(result.packets);
182
+
183
+ this.resetInternalState();
184
+ }
185
+
186
+ close() {
187
+ void this.sendCommand({ type: 'close', data: { ctx: this.ctx } });
188
+ this.worker?.terminate();
189
+ }
190
+
191
+ private async encodeOneFrame() {
192
+ assert(this.nextSampleTimestampInSamples !== null);
193
+ assert(this.nextPacketTimestampInSamples !== null);
194
+
195
+ const channels = this.numberOfChannels;
196
+ const frameSize = this.encoderFrameSize;
197
+ const frameSamples = frameSize * channels;
198
+
199
+ const frameData = this.pendingBuffer.slice(0, frameSamples);
200
+
201
+ // Shift remaining using copyWithin
202
+ this.pendingFrames -= frameSize;
203
+ if (this.pendingFrames > 0) {
204
+ this.pendingBuffer.copyWithin(0, frameSamples, frameSamples + this.pendingFrames * channels);
205
+ }
206
+
207
+ const audioData = frameData.buffer;
208
+ const result = await this.sendCommand({
209
+ type: 'encode',
210
+ data: {
211
+ ctx: this.ctx,
212
+ audioData,
213
+ timestamp: this.nextSampleTimestampInSamples,
214
+ },
215
+ }, [audioData]);
216
+
217
+ this.nextSampleTimestampInSamples += frameSize;
218
+
219
+ this.emitPackets(result.packets);
220
+ }
221
+
222
+ private emitPackets(packets: Array<{ encodedData: ArrayBuffer; pts: number; duration: number }>) {
223
+ assert(this.nextPacketTimestampInSamples !== null);
224
+
225
+ for (const p of packets) {
226
+ let data = new Uint8Array(p.encodedData);
227
+
228
+ if (this.useAdts) {
229
+ assert(this.adtsHeaderTemplate !== null);
230
+ const { header, bitstream } = this.adtsHeaderTemplate;
231
+ const frameLength = header.byteLength + data.byteLength;
232
+ writeAdtsFrameLength(bitstream, frameLength);
233
+
234
+ const adtsFrame = new Uint8Array(frameLength);
235
+ adtsFrame.set(header, 0);
236
+ adtsFrame.set(data, header.byteLength);
237
+ data = adtsFrame;
238
+ }
239
+
240
+ const packet = new EncodedPacket(
241
+ data,
242
+ 'key',
243
+ this.nextPacketTimestampInSamples / this.sampleRate,
244
+ p.duration / this.sampleRate,
245
+ );
246
+
247
+ this.nextPacketTimestampInSamples += p.duration;
248
+
249
+ this.onPacket(
250
+ packet,
251
+ this.chunkMetadata,
252
+ );
253
+
254
+ this.chunkMetadata = {};
255
+ }
256
+ }
257
+
258
+ private sendCommand<T extends string>(
259
+ command: WorkerCommand & { type: T },
260
+ transferables?: Transferable[],
261
+ ) {
262
+ return new Promise<WorkerResponseData & { type: T }>((resolve, reject) => {
263
+ const id = this.nextMessageId++;
264
+ this.pendingMessages.set(id, {
265
+ resolve: resolve as (value: WorkerResponseData) => void,
266
+ reject,
267
+ });
268
+
269
+ assert(this.worker);
270
+
271
+ if (transferables) {
272
+ this.worker.postMessage({ id, command }, transferables);
273
+ } else {
274
+ this.worker.postMessage({ id, command });
275
+ }
276
+ });
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Registers the AAC encoder, which Mediabunny will then use automatically when applicable. Make sure to call this
282
+ * function before starting any encoding task.
283
+ *
284
+ * Preferably, wrap the call in a condition to avoid overriding any native AAC encoder:
285
+ *
286
+ * ```ts
287
+ * import { canEncodeAudio } from 'mediabunny';
288
+ * import { registerAacEncoder } from '@mediabunny/aac-encoder';
289
+ *
290
+ * if (!(await canEncodeAudio('aac'))) {
291
+ * registerAacEncoder();
292
+ * }
293
+ * ```
294
+ *
295
+ * @group \@mediabunny/aac-encoder
296
+ * @public
297
+ */
298
+ export const registerAacEncoder = () => {
299
+ registerEncoder(AacEncoder);
300
+ };
301
+
302
+ function assert(x: unknown): asserts x {
303
+ if (!x) {
304
+ throw new Error('Assertion failed.');
305
+ }
306
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /*!
2
+ * Copyright (c) 2026-present, Vanilagy and contributors
3
+ *
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ const AAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/aac-encoder loaded');
10
+ if ((globalThis as Record<symbol, unknown>)[AAC_ENCODER_LOADED_SYMBOL]) {
11
+ console.error(
12
+ '[WARNING]\n@mediabunny/aac-encoder was loaded twice.'
13
+ + ' This will likely cause the encoder not to work correctly.'
14
+ + ' Check if multiple dependencies are importing different versions of @mediabunny/aac-encoder,'
15
+ + ' or if something is being bundled incorrectly.',
16
+ );
17
+ }
18
+ (globalThis as Record<symbol, unknown>)[AAC_ENCODER_LOADED_SYMBOL] = true;
19
+
20
+ export { registerAacEncoder } from './encoder';
package/src/shared.ts ADDED
@@ -0,0 +1,66 @@
1
+ /*!
2
+ * Copyright (c) 2026-present, Vanilagy and contributors
3
+ *
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ export type WorkerCommand = {
10
+ type: 'init';
11
+ data: {
12
+ numberOfChannels: number;
13
+ sampleRate: number;
14
+ bitrate: number;
15
+ };
16
+ } | {
17
+ type: 'encode';
18
+ data: {
19
+ ctx: number;
20
+ audioData: ArrayBuffer;
21
+ timestamp: number;
22
+ };
23
+ } | {
24
+ type: 'flush';
25
+ data: {
26
+ ctx: number;
27
+ };
28
+ } | {
29
+ type: 'close';
30
+ data: {
31
+ ctx: number;
32
+ };
33
+ };
34
+
35
+ export type WorkerResponseData = {
36
+ type: 'init';
37
+ ctx: number;
38
+ frameSize: number;
39
+ extradata: ArrayBuffer;
40
+ } | {
41
+ type: 'encode';
42
+ packets: Array<{
43
+ encodedData: ArrayBuffer;
44
+ pts: number;
45
+ duration: number;
46
+ }>;
47
+ } | {
48
+ type: 'flush';
49
+ packets: Array<{
50
+ encodedData: ArrayBuffer;
51
+ pts: number;
52
+ duration: number;
53
+ }>;
54
+ } | {
55
+ type: 'close';
56
+ };
57
+
58
+ export type WorkerResponse = {
59
+ id: number;
60
+ } & ({
61
+ success: true;
62
+ data: WorkerResponseData;
63
+ } | {
64
+ success: false;
65
+ error: unknown;
66
+ });