| /*! | |
| * Copyright (c) 2026-present, Vanilagy and contributors | |
| * | |
| * This Source Code Form is subject to the terms of the Mozilla Public | |
| * License, v. 2.0. If a copy of the MPL was not distributed with this | |
| * file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| */ | |
| import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, Logging, registerEncoder } from 'mediabunny'; | |
| import { MP3_DECODER_DELAY, MP3_FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc'; | |
| import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; | |
| // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand | |
| import createWorker from './encode.worker'; | |
| const MP3_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/mp3-encoder loaded'); | |
| if ((globalThis as Record<symbol, unknown>)[MP3_ENCODER_LOADED_SYMBOL]) { | |
| Logging._error( | |
| '[WARNING]\n@mediabunny/mp3-encoder was loaded twice.' | |
| + ' This will likely cause the encoder not to work correctly.' | |
| + ' Check if multiple dependencies are importing different versions of @mediabunny/mp3-encoder,' | |
| + ' or if something is being bundled incorrectly.', | |
| ); | |
| } | |
| (globalThis as Record<symbol, unknown>)[MP3_ENCODER_LOADED_SYMBOL] = true; | |
| class Mp3Encoder extends CustomAudioEncoder { | |
| private worker: Worker | null = null; | |
| private workerError: Error | null = null; | |
| private nextMessageId = 0; | |
| private pendingMessages = new Map<number, { | |
| resolve: (value: WorkerResponseData) => void; | |
| reject: (reason?: unknown) => void; | |
| }>(); | |
| private buffer = new Uint8Array(2 ** 16); | |
| private currentBufferOffset = 0; | |
| private currentTimestamp: number | null = null; | |
| private chunkMetadata: EncodedAudioChunkMetadata = {}; | |
| static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean { | |
| return codec === 'mp3' | |
| && (config.numberOfChannels === 1 || config.numberOfChannels === 2) | |
| && Object.values(SAMPLING_RATES).some(x => | |
| x === config.sampleRate || (x / 2) === config.sampleRate || (x / 4) === config.sampleRate, | |
| ); | |
| } | |
| async init() { | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-call | |
| this.worker = (await createWorker()) as Worker; // The actual encoding takes place in this worker | |
| const onMessage = (data: WorkerResponse) => { | |
| const pending = this.pendingMessages.get(data.id); | |
| assert(pending !== undefined); | |
| this.pendingMessages.delete(data.id); | |
| if (data.success) { | |
| pending.resolve(data.data); | |
| } else { | |
| pending.reject(data.error); | |
| } | |
| }; | |
| // Treats any worker error (like CSP errors) as fatal, rejecting pending and future commands | |
| const onError = (error: Error) => { | |
| this.workerError = error; | |
| for (const pending of this.pendingMessages.values()) { | |
| pending.reject(error); | |
| } | |
| this.pendingMessages.clear(); | |
| this.worker?.terminate(); | |
| this.worker = null; | |
| }; | |
| if (this.worker.addEventListener) { | |
| this.worker.addEventListener('message', event => onMessage(event.data as WorkerResponse)); | |
| this.worker.addEventListener('error', (event) => { | |
| onError(new Error(event.message || 'MP3 encoder worker failed to load or crashed.')); | |
| }); | |
| } else { | |
| const nodeWorker = this.worker as unknown as { | |
| on: (event: string, listener: (data: never) => void) => void; | |
| }; | |
| nodeWorker.on('message', onMessage); | |
| nodeWorker.on('error', onError); | |
| } | |
| assert(this.config.bitrate); | |
| await this.sendCommand({ | |
| type: 'init', | |
| data: { | |
| numberOfChannels: this.config.numberOfChannels, | |
| sampleRate: this.config.sampleRate, | |
| bitrate: this.config.bitrate, | |
| }, | |
| }); | |
| this.resetInternalState(); | |
| } | |
| private resetInternalState() { | |
| this.currentBufferOffset = 0; | |
| this.currentTimestamp = null; | |
| this.chunkMetadata = { | |
| decoderConfig: { | |
| codec: 'mp3', | |
| numberOfChannels: this.config.numberOfChannels, | |
| sampleRate: this.config.sampleRate, | |
| }, | |
| }; | |
| } | |
| async encode(audioSample: AudioSample) { | |
| if (this.currentTimestamp === null) { | |
| // LAME adds 576 samples of encoder delay on top of inherent delay | |
| this.currentTimestamp = audioSample.timestamp - (576 + MP3_DECODER_DELAY) / this.config.sampleRate; | |
| } | |
| const sizePerChannel = audioSample.allocationSize({ | |
| format: 's16-planar', | |
| planeIndex: 0, | |
| }); | |
| const requiredBytes = audioSample.numberOfChannels * sizePerChannel; | |
| const audioData = new ArrayBuffer(requiredBytes); | |
| const audioBytes = new Uint8Array(audioData); | |
| for (let i = 0; i < audioSample.numberOfChannels; i++) { | |
| audioSample.copyTo(audioBytes.subarray(i * sizePerChannel), { | |
| format: 's16-planar', // LAME wants it in this format | |
| planeIndex: i, | |
| }); | |
| } | |
| const result = await this.sendCommand({ | |
| type: 'encode', | |
| data: { | |
| audioData, | |
| numberOfFrames: audioSample.numberOfFrames, | |
| }, | |
| }, [audioData]); | |
| this.digestOutput(new Uint8Array(result.encodedData)); | |
| } | |
| async flush() { | |
| const result = await this.sendCommand({ type: 'flush' }); | |
| this.digestOutput(new Uint8Array(result.flushedData)); | |
| this.resetInternalState(); | |
| } | |
| close() { | |
| this.worker?.terminate(); | |
| } | |
| /** | |
| * LAME returns data in chunks, but a chunk doesn't need to contain a full MP3 frame. Therefore, we must accumulate | |
| * these chunks and extract the MP3 frames only when they're complete. | |
| */ | |
| private digestOutput(bytes: Uint8Array) { | |
| assert(this.currentTimestamp !== null); | |
| const requiredBufferSize = this.currentBufferOffset + bytes.length; | |
| if (requiredBufferSize > this.buffer.length) { | |
| // Grow the buffer to the required size | |
| const newSize = 1 << Math.ceil(Math.log2(requiredBufferSize)); | |
| const newBuffer = new Uint8Array(newSize); | |
| newBuffer.set(this.buffer); | |
| this.buffer = newBuffer; | |
| } | |
| this.buffer.set(bytes, this.currentBufferOffset); | |
| this.currentBufferOffset = requiredBufferSize; | |
| let pos = 0; | |
| while (pos <= this.currentBufferOffset - MP3_FRAME_HEADER_SIZE) { | |
| const word = new DataView(this.buffer.buffer).getUint32(pos, false); | |
| const header = readMp3FrameHeader(word, null).header; | |
| if (!header) { | |
| break; | |
| } | |
| const fits = header.totalSize <= this.currentBufferOffset - pos; | |
| if (!fits) { | |
| // The frame isn't complete yet | |
| break; | |
| } | |
| const data = this.buffer.slice(pos, pos + header.totalSize); | |
| const duration = header.audioSamplesInFrame / header.sampleRate; | |
| this.onPacket(new EncodedPacket(data, 'key', this.currentTimestamp, duration), this.chunkMetadata); | |
| this.chunkMetadata = {}; // Mimic WebCodecs-like behavior | |
| this.currentTimestamp += duration; | |
| pos += header.totalSize; | |
| } | |
| if (pos > 0) { | |
| // Shift the data | |
| this.buffer.set(this.buffer.subarray(pos, this.currentBufferOffset), 0); | |
| this.currentBufferOffset -= pos; | |
| } | |
| } | |
| private sendCommand<T extends string>( | |
| command: WorkerCommand & { type: T }, | |
| transferables?: Transferable[], | |
| ) { | |
| return new Promise<WorkerResponseData & { type: T }>((resolve, reject) => { | |
| if (this.workerError) { | |
| reject(this.workerError); | |
| return; | |
| } | |
| const id = this.nextMessageId++; | |
| this.pendingMessages.set(id, { | |
| resolve: resolve as (value: WorkerResponseData) => void, | |
| reject, | |
| }); | |
| assert(this.worker); | |
| if (transferables) { | |
| this.worker.postMessage({ id, command }, transferables); | |
| } else { | |
| this.worker.postMessage({ id, command }); | |
| } | |
| }); | |
| } | |
| } | |
| let registered = false; | |
| /** | |
| * Registers the LAME MP3 encoder, which Mediabunny will then use automatically when applicable. Make sure to call this | |
| * function before starting any encoding task. | |
| * | |
| * Preferably, wrap the call in a condition to avoid overriding any native MP3 encoder: | |
| * | |
| * ```ts | |
| * import { canEncodeAudio } from 'mediabunny'; | |
| * import { registerMp3Encoder } from '@mediabunny/mp3-encoder'; | |
| * | |
| * if (!(await canEncodeAudio('mp3'))) { | |
| * registerMp3Encoder(); | |
| * } | |
| * ``` | |
| * | |
| * @group \@mediabunny/mp3-encoder | |
| * @public | |
| */ | |
| export const registerMp3Encoder = () => { | |
| if (registered) { | |
| return; | |
| } | |
| registered = true; | |
| registerEncoder(Mp3Encoder); | |
| }; | |
| function assert(x: unknown): asserts x { | |
| if (!x) { | |
| throw new Error('Assertion failed.'); | |
| } | |
| } | |
Xet Storage Details
- Size:
- 8.1 kB
- Xet hash:
- a4654cfcaebf06dbe908ffeb703518f256997de992aa283e01dc600648a0f6ea
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.