@mediabunny/mp3-encoder 1.6.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.
@@ -0,0 +1,212 @@
1
+ /*!
2
+ * Copyright (c) 2025-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 createModule from '../build/lame';
10
+ import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
11
+
12
+ type ExtendedEmscriptenModule = EmscriptenModule & {
13
+ cwrap: typeof cwrap;
14
+ };
15
+
16
+ let module: ExtendedEmscriptenModule;
17
+ let lamePtr: number;
18
+ let storedNumberOfChannels: number;
19
+ let storedSampleRate: number;
20
+ let storedBitrate: number;
21
+ let needsReset = false;
22
+
23
+ let initLame: (
24
+ numberOfChannels: number,
25
+ sampleRate: number,
26
+ bitrate: number
27
+ ) => number;
28
+ let encodeSamples: (
29
+ lame: number,
30
+ leftPtr: number,
31
+ rightPtr: number,
32
+ sampleCount: number,
33
+ outputPtr: number,
34
+ outputSize: number
35
+ ) => number;
36
+ let flushLame: (lame: number, outputPtr: number, outputSize: number) => number;
37
+ let closeLame: (lame: number) => void;
38
+
39
+ let inputSlice: Slice | null = null;
40
+ let outputSlice: Slice | null = null;
41
+
42
+ const init = async (numberOfChannels: number, sampleRate: number, bitrate: number) => {
43
+ storedNumberOfChannels = numberOfChannels;
44
+ storedSampleRate = sampleRate;
45
+ storedBitrate = bitrate;
46
+
47
+ module = (await createModule()) as ExtendedEmscriptenModule;
48
+
49
+ // Set up the functions
50
+ initLame = module.cwrap('init_lame', 'number', ['number', 'number', 'number']);
51
+ encodeSamples = module.cwrap(
52
+ 'encode_samples',
53
+ 'number',
54
+ ['number', 'number', 'number', 'number', 'number', 'number'],
55
+ );
56
+ flushLame = module.cwrap('flush_lame', 'number', ['number', 'number', 'number']);
57
+ closeLame = module.cwrap('close_lame', null, ['number']);
58
+
59
+ lamePtr = initLame(numberOfChannels, sampleRate, bitrate);
60
+ };
61
+
62
+ const reset = () => {
63
+ closeLame(lamePtr);
64
+ lamePtr = initLame(storedNumberOfChannels, storedSampleRate, storedBitrate);
65
+ };
66
+
67
+ const encode = (audioData: ArrayBuffer, numberOfFrames: number) => {
68
+ if (needsReset) {
69
+ reset();
70
+ needsReset = false;
71
+ }
72
+
73
+ const audioBytes = new Uint8Array(audioData);
74
+ const sizePerChannel = audioBytes.length / storedNumberOfChannels;
75
+
76
+ inputSlice = maybeGrowSlice(inputSlice, audioBytes.length);
77
+ module.HEAPU8.set(audioBytes, inputSlice.ptr);
78
+
79
+ const requiredOutputSize = Math.ceil(1.25 * numberOfFrames + 7200);
80
+ outputSlice = maybeGrowSlice(outputSlice, requiredOutputSize);
81
+
82
+ const bytesWritten = encodeSamples(
83
+ lamePtr,
84
+ inputSlice.ptr,
85
+ inputSlice.ptr + (storedNumberOfChannels - 1) * sizePerChannel,
86
+ numberOfFrames,
87
+ outputSlice.ptr,
88
+ requiredOutputSize,
89
+ );
90
+
91
+ const result = module.HEAPU8.slice(outputSlice.ptr, outputSlice.ptr + bytesWritten);
92
+ return result.buffer;
93
+ };
94
+
95
+ const flush = () => {
96
+ if (needsReset) {
97
+ reset();
98
+ needsReset = false;
99
+ }
100
+
101
+ const requiredOutputSize = 7200;
102
+ outputSlice = maybeGrowSlice(outputSlice, requiredOutputSize);
103
+
104
+ const bytesWritten = flushLame(lamePtr, outputSlice.ptr, requiredOutputSize);
105
+
106
+ const result = module.HEAPU8.slice(outputSlice.ptr, outputSlice.ptr + bytesWritten);
107
+ needsReset = true; // After a flush, the encoder must be prepared to start a new encoding process
108
+
109
+ return result.buffer;
110
+ };
111
+
112
+ /** A "fat pointer" type thing. */
113
+ type Slice = {
114
+ ptr: number;
115
+ size: number;
116
+ };
117
+
118
+ /** Either returns the existing slice, or allocates a new one if there's no existing slice or it was too small. */
119
+ const maybeGrowSlice = (slice: Slice | null, requiredSize: number) => {
120
+ if (!slice || slice.size < requiredSize) {
121
+ if (slice) {
122
+ module._free(slice.ptr);
123
+ }
124
+
125
+ return {
126
+ ptr: module._malloc(requiredSize),
127
+ size: requiredSize,
128
+ };
129
+ }
130
+
131
+ return slice;
132
+ };
133
+
134
+ const onMessage = (data: { id: number; command: WorkerCommand }) => {
135
+ const { id, command } = data;
136
+
137
+ const handleCommand = async (): Promise<void> => {
138
+ try {
139
+ let result: WorkerResponseData;
140
+ const transferables: Transferable[] = [];
141
+
142
+ switch (command.type) {
143
+ case 'init': {
144
+ await init(
145
+ command.data.numberOfChannels,
146
+ command.data.sampleRate,
147
+ command.data.bitrate,
148
+ );
149
+ result = { success: true };
150
+ }; break;
151
+
152
+ case 'encode': {
153
+ const encodedData = encode(
154
+ command.data.audioData,
155
+ command.data.numberOfFrames,
156
+ );
157
+ result = { encodedData };
158
+ transferables.push(encodedData);
159
+ }; break;
160
+
161
+ case 'flush': {
162
+ const flushedData = flush();
163
+ result = { flushedData };
164
+ transferables.push(flushedData);
165
+ }; break;
166
+ }
167
+
168
+ const response: WorkerResponse = {
169
+ id,
170
+ success: true,
171
+ data: result,
172
+ };
173
+ sendMessage(response, transferables);
174
+ } catch (error) {
175
+ const response: WorkerResponse = {
176
+ id,
177
+ success: false,
178
+ error,
179
+ };
180
+ sendMessage(response);
181
+ }
182
+ };
183
+
184
+ void handleCommand();
185
+ };
186
+
187
+ const sendMessage = (data: unknown, transferables?: Transferable[]) => {
188
+ if (parentPort) {
189
+ parentPort.postMessage(data, transferables ?? []);
190
+ } else {
191
+ self.postMessage(data, { transfer: transferables ?? [] });
192
+ }
193
+ };
194
+
195
+ let parentPort: {
196
+ postMessage: (data: unknown, transferables?: Transferable[]) => void;
197
+ on: (event: string, listener: (data: never) => void) => void;
198
+ } | null = null;
199
+
200
+ if (typeof self === 'undefined') {
201
+ // We're in Node.js (or a runtime that mimics it)
202
+ const workerModule = 'worker_threads';
203
+ // eslint-disable-next-line @stylistic/max-len
204
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
205
+ parentPort = require(workerModule).parentPort;
206
+ }
207
+
208
+ if (parentPort) {
209
+ parentPort.on('message', onMessage);
210
+ } else {
211
+ self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
212
+ }
package/src/index.ts ADDED
@@ -0,0 +1,213 @@
1
+ /*!
2
+ * Copyright (c) 2025-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 { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
10
+ import { FRAME_HEADER_SIZE, readFrameHeader } from '../../../shared/mp3-misc';
11
+ import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
12
+ // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
13
+ import createWorker from './encode.worker';
14
+
15
+ class Mp3Encoder extends CustomAudioEncoder {
16
+ private worker: Worker | null = null;
17
+ private nextMessageId = 0;
18
+ private pendingMessages = new Map<number, {
19
+ resolve: (value: WorkerResponseData) => void;
20
+ reject: (reason?: unknown) => void;
21
+ }>();
22
+
23
+ private buffer = new Uint8Array(2 ** 16);
24
+ private currentBufferOffset = 0;
25
+ private currentTimestamp = 0;
26
+ private chunkMetadata: EncodedAudioChunkMetadata = {};
27
+
28
+ static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
29
+ return codec === 'mp3'
30
+ && (config.numberOfChannels === 1 || config.numberOfChannels === 2)
31
+ && (config.sampleRate === 32000 || config.sampleRate === 44100 || config.sampleRate === 48000);
32
+ }
33
+
34
+ async init() {
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
36
+ this.worker = (await createWorker()) as Worker; // The actual encoding takes place in this worker
37
+
38
+ const onMessage = (data: WorkerResponse) => {
39
+ const pending = this.pendingMessages.get(data.id);
40
+ assert(pending !== undefined);
41
+
42
+ this.pendingMessages.delete(data.id);
43
+ if (data.success) {
44
+ pending.resolve(data.data);
45
+ } else {
46
+ pending.reject(data.error);
47
+ }
48
+ };
49
+
50
+ if (this.worker.addEventListener) {
51
+ this.worker.addEventListener('message', event => onMessage(event.data as WorkerResponse));
52
+ } else {
53
+ const nodeWorker = this.worker as unknown as {
54
+ on: (event: string, listener: (data: never) => void) => void;
55
+ };
56
+ nodeWorker.on('message', onMessage);
57
+ }
58
+
59
+ assert(this.config.bitrate);
60
+
61
+ await this.sendCommand({
62
+ type: 'init',
63
+ data: {
64
+ numberOfChannels: this.config.numberOfChannels,
65
+ sampleRate: this.config.sampleRate,
66
+ bitrate: this.config.bitrate,
67
+ },
68
+ });
69
+
70
+ this.chunkMetadata = {
71
+ decoderConfig: {
72
+ codec: 'mp3',
73
+ numberOfChannels: this.config.numberOfChannels,
74
+ sampleRate: this.config.sampleRate,
75
+ },
76
+ };
77
+ }
78
+
79
+ async encode(audioSample: AudioSample) {
80
+ const sizePerChannel = audioSample.allocationSize({
81
+ format: 's16-planar',
82
+ planeIndex: 0,
83
+ });
84
+
85
+ const requiredBytes = audioSample.numberOfChannels * sizePerChannel;
86
+ const audioData = new ArrayBuffer(requiredBytes);
87
+ const audioBytes = new Uint8Array(audioData);
88
+
89
+ for (let i = 0; i < audioSample.numberOfChannels; i++) {
90
+ audioSample.copyTo(audioBytes.subarray(i * sizePerChannel), {
91
+ format: 's16-planar', // LAME wants it in this format
92
+ planeIndex: i,
93
+ });
94
+ }
95
+
96
+ const result = await this.sendCommand({
97
+ type: 'encode',
98
+ data: {
99
+ audioData,
100
+ numberOfFrames: audioSample.numberOfFrames,
101
+ },
102
+ }, [audioData]);
103
+
104
+ assert('encodedData' in result);
105
+ this.digestOutput(new Uint8Array(result.encodedData));
106
+ }
107
+
108
+ async flush() {
109
+ const result = await this.sendCommand({ type: 'flush' });
110
+
111
+ assert('flushedData' in result);
112
+ this.digestOutput(new Uint8Array(result.flushedData));
113
+ }
114
+
115
+ close() {
116
+ this.worker?.terminate();
117
+ }
118
+
119
+ /**
120
+ * LAME returns data in chunks, but a chunk doesn't need to contain a full MP3 frame. Therefore, we must accumulate
121
+ * these chunks and extract the MP3 frames only when they're complete.
122
+ */
123
+ private digestOutput(bytes: Uint8Array) {
124
+ const requiredBufferSize = this.currentBufferOffset + bytes.length;
125
+ if (requiredBufferSize > this.buffer.length) {
126
+ // Grow the buffer to the required size
127
+ const newSize = 1 << Math.ceil(Math.log2(requiredBufferSize));
128
+ const newBuffer = new Uint8Array(newSize);
129
+ newBuffer.set(this.buffer);
130
+ this.buffer = newBuffer;
131
+ }
132
+
133
+ this.buffer.set(bytes, this.currentBufferOffset);
134
+ this.currentBufferOffset = requiredBufferSize;
135
+
136
+ let pos = 0;
137
+ while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
138
+ const word = new DataView(this.buffer.buffer).getUint32(pos, false);
139
+ const header = readFrameHeader(word, { pos, fileSize: null });
140
+ if (!header) {
141
+ break;
142
+ }
143
+
144
+ const fits = header.totalSize <= this.currentBufferOffset - pos;
145
+ if (!fits) {
146
+ // The frame isn't complete yet
147
+ break;
148
+ }
149
+
150
+ const data = this.buffer.slice(pos, pos + header.totalSize);
151
+ const duration = header.audioSamplesInFrame / header.sampleRate;
152
+ this.onPacket(new EncodedPacket(data, 'key', this.currentTimestamp, duration), this.chunkMetadata);
153
+
154
+ if (this.currentTimestamp === 0) {
155
+ this.chunkMetadata = {}; // Mimic WebCodecs-like behavior
156
+ }
157
+
158
+ this.currentTimestamp += duration;
159
+ pos += header.totalSize;
160
+ }
161
+
162
+ if (pos > 0) {
163
+ // Shift the data
164
+ this.buffer.set(this.buffer.subarray(pos, this.currentBufferOffset), 0);
165
+ this.currentBufferOffset -= pos;
166
+ }
167
+ }
168
+
169
+ private sendCommand(
170
+ command: WorkerCommand,
171
+ transferables?: Transferable[],
172
+ ) {
173
+ return new Promise<WorkerResponseData>((resolve, reject) => {
174
+ const id = this.nextMessageId++;
175
+ this.pendingMessages.set(id, { resolve, reject });
176
+
177
+ assert(this.worker);
178
+
179
+ if (transferables) {
180
+ this.worker.postMessage({ id, command }, transferables);
181
+ } else {
182
+ this.worker.postMessage({ id, command });
183
+ }
184
+ });
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Registers the LAME MP3 encoder, which Mediabunny will then use automatically when applicable. Make sure to call this
190
+ * function before starting any encoding task.
191
+ *
192
+ * Preferably, wrap the call in a condition to avoid overriding any native MP3 encoder:
193
+ *
194
+ * ```ts
195
+ * import { canEncodeAudio } from 'mediabunny';
196
+ * import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
197
+ *
198
+ * if (!(await canEncodeAudio('mp3'))) {
199
+ * registerMp3Encoder();
200
+ * }
201
+ * ```
202
+ *
203
+ * @public
204
+ */
205
+ export const registerMp3Encoder = () => {
206
+ registerEncoder(Mp3Encoder);
207
+ };
208
+
209
+ function assert(x: unknown): asserts x {
210
+ if (!x) {
211
+ throw new Error('Assertion failed.');
212
+ }
213
+ }
@@ -0,0 +1,40 @@
1
+ /*!
2
+ * Copyright (c) 2025-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
+ #include <emscripten.h>
10
+ #include "../lib/lame.h"
11
+
12
+ EMSCRIPTEN_KEEPALIVE
13
+ lame_global_flags *init_lame(int number_of_channels, int sample_rate, int bitrate) {
14
+ lame_global_flags *gfp = lame_init();
15
+
16
+ lame_set_num_channels(gfp, number_of_channels);
17
+ lame_set_in_samplerate(gfp, sample_rate);
18
+ lame_set_out_samplerate(gfp, sample_rate);
19
+ lame_set_brate(gfp, bitrate / 1000); // MP3 wants "kilobitrate"
20
+ lame_set_bWriteVbrTag(gfp, 0);
21
+
22
+ int ret_code = lame_init_params(gfp);
23
+
24
+ return gfp;
25
+ }
26
+
27
+ EMSCRIPTEN_KEEPALIVE
28
+ int encode_samples(lame_global_flags *gfp, short int left_buf[], short int right_buf[], int sample_count, unsigned char *dest_buf, int dest_buf_size) {
29
+ return lame_encode_buffer(gfp, left_buf, right_buf, sample_count, dest_buf, dest_buf_size);
30
+ }
31
+
32
+ EMSCRIPTEN_KEEPALIVE
33
+ int flush_lame(lame_global_flags *gfp, unsigned char *dest_buf, int dest_buf_size) {
34
+ return lame_encode_flush(gfp, dest_buf, dest_buf_size);
35
+ }
36
+
37
+ EMSCRIPTEN_KEEPALIVE
38
+ void close_lame(lame_global_flags *gfp) {
39
+ lame_close(gfp);
40
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,42 @@
1
+ /*!
2
+ * Copyright (c) 2025-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
+ audioData: ArrayBuffer;
20
+ numberOfFrames: number;
21
+ };
22
+ } | {
23
+ type: 'flush';
24
+ };
25
+
26
+ export type WorkerResponseData = {
27
+ success: boolean;
28
+ } | {
29
+ encodedData: ArrayBuffer;
30
+ } | {
31
+ flushedData: ArrayBuffer;
32
+ };
33
+
34
+ export type WorkerResponse = {
35
+ id: number;
36
+ } & ({
37
+ success: true;
38
+ data: WorkerResponseData;
39
+ } | {
40
+ success: false;
41
+ error: unknown;
42
+ });
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../dist/modules",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "stripInternal": true,
8
+ "noEmit": false,
9
+ "moduleResolution": "nodenext",
10
+ "module": "nodenext",
11
+ "allowJs": true,
12
+ "paths": {
13
+ "mediabunny": ["../../../src/index.ts"],
14
+ },
15
+ },
16
+ "include": [
17
+ "**/*",
18
+ "../../../shared/**/*"
19
+ ],
20
+ "references": [
21
+ { "path": "../../../src" }
22
+ ]
23
+ }