Encoding MP3 in the Browser with lamejs
Adversarial fact-check of the lamejs MP3 encoding blog draft against the real codebase. Corrected the main inaccuracy: AudioFormatConverter does NOT use the lamejs audioEncoder.ts path, it uses FFmpeg (libmp3lame WASM) via convertAudioFormat in src/lib/utils/ffmpeg.ts. Reworded all prose that implied the converter shares the lamejs export path, while keeping the accurate file-size estimate claim. Stripped all em/en dashes. All other implementation claims (lamejs ^1.2.1, CDN script-tag load, 192 default, asymmetric float-to-int scaling, 1152 block size, mono channel reuse, mp3buf guards, flush, audio/mpeg MIME, subarray views, constructor mapping, Merger/SpeedAdjuster calling at default 192) verified true against src/lib/utils/audioEncoder.ts, AudioMerger.tsx, AudioSpeedAdjuster.tsx, AudioFormatConverter.tsx, and package.json.
Browsers are great at decoding audio. Give the Web Audio API a File of almost any format (MP3, WAV, OGG, M4A) and decodeAudioData hands you back a tidy AudioBuffer full of floating point samples. The trouble starts when you want to go the other way. There is no built-in encodeToMp3 anywhere in the browser. The platform will happily turn an MP3 into PCM, but it will not turn your PCM back into an MP3.
That asymmetry is the whole reason a library like lamejs exists. It is a hand-port of the venerable LAME MP3 encoder to pure JavaScript, so it runs entirely client side with no server and no upload of the user's audio. On loopaloo a couple of audio tools rely on it to produce a downloadable .mp3 after they have finished mangling the samples in some interesting way.
This post walks through how that actually works in our codebase: how an AudioBuffer becomes a stream of MP3 frames, why the samples have to be converted from float to 16-bit integers first, what the magic number 1152 is doing in the loop, and what "192 kbps" really buys you. Along the way I will be honest about the one caveat that matters most, which is that re-encoding already-lossy audio is never free.
The shape of the problem
Web Audio always decodes to 32-bit float PCM. Each channel is a Float32Array where every sample sits in the range -1.0 to +1.0. Float is convenient for DSP because you can multiply, add, and filter samples without worrying about clipping or integer overflow mid-calculation; intermediate values can briefly exceed the nominal range and you clamp only at the end.
MP3, on the other hand, is built on top of 16-bit integer PCM as its input convention. LAME (and therefore lamejs) expects each sample as an Int16, in the range -32768 to +32767. So before any encoding can happen there is an unavoidable conversion step: float to int.
Here is the exact converter we use in src/lib/utils/audioEncoder.ts:
function float32ToInt16(float32Array: Float32Array): Int16Array {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
// Clamp and convert
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Array;
}
Two details are worth slowing down for. First, the Math.max(-1, Math.min(1, ...)) clamp. After DSP work like gain changes or merging, individual samples can drift outside [-1, 1]. If you let a value of 1.4 through, multiplying by 0x7FFF would overflow the Int16Array and wrap around into a loud, ugly click. Clamping first is what keeps a too-hot mix sounding like distortion rather than a glitch.
Second, the asymmetric scaling: negative samples multiply by 0x8000 (32768) and positive samples by 0x7FFF (32767). That is not a typo. Two's complement 16-bit integers are not symmetric; they run from -32768 to +32767. Using the right maximum on each side uses the full available range without overflowing the positive end.
From AudioBuffer to encoder
Once we have integers, the encode itself is a short pipeline. The public entry point is audioBufferToMp3:
export async function audioBufferToMp3(
audioBuffer: AudioBuffer,
kbps: number = 192,
): Promise<Blob> {
await loadLamejs();
const numChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const mp3encoder = new window.lamejs.Mp3Encoder(numChannels, sampleRate, kbps);
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel =
numChannels > 1 ? audioBuffer.getChannelData(1) : leftChannel;
const leftInt16 = float32ToInt16(leftChannel);
const rightInt16 = numChannels > 1 ? float32ToInt16(rightChannel) : leftInt16;
// ...block loop below
}
The encoder constructor takes three things and they map directly to properties of the buffer:
| Argument | Source | Meaning |
|---|---|---|
channels | audioBuffer.numberOfChannels | 1 for mono, 2 for stereo |
samplerate | audioBuffer.sampleRate | usually 44100 or 48000 Hz |
kbps | caller, defaults to 192 | target bitrate of the output |
Notice the mono shortcut: when there is only one channel we reuse the left channel as the "right" one (rightInt16 = leftInt16). The encoder is still constructed with numChannels = 1, so it is not secretly doubling anything; the duplicate reference just avoids a second allocation and a second null check downstream.
There is also a loading quirk worth flagging. lamejs has historically not played nicely with Next.js bundling, so instead of import-ing it we inject it as a <script> tag from a CDN at runtime and read it off window.lamejs:
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js';
script.async = true;
script.onload = () => {
lamejsLoaded = true;
resolve();
};
The version pinned in the script URL matches the "lamejs": "^1.2.1" entry in package.json. The promise is cached (lamejsPromise) so the script is fetched at most once per page, no matter how many times you hit the encode button. The TypeScript shape of the global is declared inline (a declare global block describing Mp3Encoder, encodeBuffer, and flush) so the rest of the file stays type-checked even though the actual implementation arrives at runtime.
Why 1152? The sample block loop
This is the part that surprises people. You do not feed the whole song to the encoder at once. You feed it in blocks of 1152 samples per channel:
const sampleBlockSize = 1152;
const mp3Data: Int8Array[] = [];
for (let i = 0; i < leftInt16.length; i += sampleBlockSize) {
const leftChunk = leftInt16.subarray(i, Math.min(i + sampleBlockSize, leftInt16.length));
const rightChunk = rightInt16.subarray(i, Math.min(i + sampleBlockSize, rightInt16.length));
const mp3buf = mp3encoder.encodeBuffer(leftChunk, rightChunk);
if (mp3buf.length > 0) {
mp3Data.push(mp3buf);
}
}
const mp3buf = mp3encoder.flush();
if (mp3buf.length > 0) {
mp3Data.push(mp3buf);
}
1152 is not arbitrary. It is the number of PCM samples that make up a single MP3 frame in MPEG-1 Layer III. Internally the format works on two granules of 576 samples each (576 times 2 is 1152). Handing the encoder data in 1152-sample slices means each call lines up cleanly with one frame's worth of input, which is why the comment in the code notes that lamejs recommends this size for best quality.
A few mechanical points about the loop:
subarrayis a view, not a copy. It returns a window onto the same underlyingInt16Arraybuffer, so slicing the audio into hundreds of chunks does not allocate hundreds of new sample arrays. Only the resulting MP3 bytes get collected.encodeBuffercan return zero bytes. MP3 encoding has internal latency; the encoder buffers a little before it emits a complete frame. So early calls may return an emptyInt8Array, which is why every push is guarded byif (mp3buf.length > 0).flush()is mandatory. When the input runs out, the encoder is still holding the tail of the last partial frame.flush()drains that and writes the final bytes. Skip it and you lose the end of your audio.
Finally the collected Int8Array chunks are stitched into one contiguous Uint8Array and wrapped in a Blob with the audio/mpeg MIME type, ready to be turned into a download link:
const totalLength = mp3Data.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of mp3Data) {
result.set(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.length), offset);
offset += chunk.length;
}
return new Blob([result], { type: 'audio/mpeg' });
The new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.length) dance matters: it re-wraps each chunk respecting its byte offset so that copying a subarray-style view never accidentally grabs neighbouring bytes.
What 192 kbps actually means
The kbps argument defaults to 192, and that is the value the lamejs-backed tools on loopaloo pass when they call audioBufferToMp3 without overriding it. So what is a bitrate?
Bitrate is the size of the output measured in time, not in samples. 192 kbps means roughly 192,000 bits, or about 24 kilobytes, of MP3 data per second of audio, regardless of how many samples that second contained. It is a budget. The encoder gets to spend that many bits per second describing the sound, and the psychoacoustic model decides where to spend them, throwing away frequency content that human hearing is least likely to notice.
That gives a useful, format-independent way to estimate file size. The AudioFormatConverter tool computes exactly this for lossy targets:
// Lossy: bitrate in kbps * duration in seconds / 8 / 1024 = MB
return (bitrate * audioInfo.duration) / 8 / 1024;
A three-minute (180 second) track at 192 kbps works out to 192 * 180 / 8 / 1024, roughly 4.2 MB, and the sample rate barely changes that. Compare the common rungs (these are arithmetic from the same formula, not measured outputs):
| Bitrate | Rough use | Size per 3 min |
|---|---|---|
| 128 kbps | acceptable, smaller files | ~2.8 MB |
| 192 kbps | the default here, good general quality | ~4.2 MB |
| 256 kbps | high quality | ~5.6 MB |
| 320 kbps | the MP3 ceiling | ~7.0 MB |
192 is a deliberate middle. It is widely considered transparent enough for casual listening on most material while keeping files small, which is why it is the default both for audioBufferToMp3 and as the "Standard Quality" preset in the format converter (whose presets run 128 for low, 192 for standard, 256 for high, and 320 for maximum).
One nuance: passing a higher kbps to the encoder does not add detail that was already thrown away upstream. It only raises the ceiling on how much the encoder is allowed to keep. If your source is a 128 kbps MP3 that you decoded and processed, encoding the result at 320 kbps gives you a bigger file, not a better-sounding one.
The lossy re-encode caveat
This is the honest part. MP3 is a lossy codec. Encoding to MP3 permanently discards audio information that the psychoacoustic model bets you will not miss. Decoding it back to PCM does not recover that information; it just reconstructs an approximation.
So when one of these tools loads an MP3, decodes it to an AudioBuffer, does something to the samples, and encodes back to MP3, you are stacking two lossy passes. The first happened whenever the file was originally made; the second happens on the way out. Each generation compounds small artifacts, the same way photocopying a photocopy slowly smears the page. For a single round trip at 192 kbps the difference is usually inaudible on typical playback gear, but it is real, and it is cumulative if you keep running the same file through repeatedly.
There are two ways to avoid the second loss entirely when you do not need MP3:
- Keep the output as WAV, which stores PCM directly with no perceptual compression. The format converter offers WAV as an output, and it is the right choice if the file is an intermediate step.
- Re-encode from the highest-quality source you have, once, rather than processing an already-compressed export multiple times.
If MP3 is genuinely the target format (for compatibility, for sharing, for a device that only speaks MP3), then a single 192 kbps pass through lamejs is a perfectly reasonable trade. Just go in knowing it is a one-way door, not a lossless container.
Where this runs on loopaloo
The encoder in audioEncoder.ts is the shared MP3 export path for the browser-side tools that build an AudioBuffer in JavaScript and need to hand the user a .mp3:
- Audio Merger stitches multiple clips into one
AudioBuffer, then callsaudioBufferToMp3at the default 192 kbps so the combined result downloads as a single MP3. - Audio Speed Adjuster retimes the buffer to change playback speed, then calls the same
audioBufferToMp3with no bitrate argument, so it too lands on the 192 default.
The Audio Format Converter is a different animal worth distinguishing. It does not route through audioEncoder.ts or lamejs; its actual encoding goes through convertAudioFormat in src/lib/utils/ffmpeg.ts, which runs FFmpeg (with libmp3lame) compiled to WebAssembly. That is what lets it target several formats, expose an adjustable bitrate slider and quality presets, and write a true PCM WAV when you want to dodge the lossy round trip. The bitrate-to-size estimate quoted above lives in that tool, but the bytes themselves are produced by FFmpeg, not the lamejs path described here.
Either way, all of it happens in the tab. Your audio never leaves the browser, and for the lamejs tools the only network request is the one-time fetch of the lamejs script itself, after which the MP3 you download was assembled frame by frame from your own samples.
Related Tools
Audio Format Converter
Convert audio files between WAV, MP3, OGG, AAC, M4A, FLAC formats online. Adjust bitrate and quality settings. Free browser-based conversion with no file uploads to servers.
Audio Merger & Joiner
Combine multiple audio files into one track. Drag and drop to reorder, merge MP3s, WAVs, and other formats. Create seamless audio compilations online.
Audio Speed Changer
Speed up or slow down audio playback from 0.25x to 4x without changing pitch. Perfect for transcription, music practice, podcast speed adjustment, and audiobook listening.
Related Articles
Try Our Free Tools
200+ browser-based tools for developers and creators. No uploads, complete privacy.
Explore All Tools