<p align="center"><img src="docs/banner.svg" alt="micromp2.js: an MP2 audio encoder and decoder in plain JavaScript" width="100%"></p>

<p align="center">
  <a href="LICENSE"><img alt="MIT licence" src="https://img.shields.io/badge/licence-MIT-4da3ff"></a>
  <img alt="Zero dependencies" src="https://img.shields.io/badge/dependencies-0-4f8a5e">
  <img alt="No WebAssembly" src="https://img.shields.io/badge/WebAssembly-none-6c7f96">
  <img alt="About 15 KB gzipped" src="https://img.shields.io/badge/gzipped-~15%20KB-2f6fbd">
  <a href="https://dialersapps.pages.dev/lab/notes/micromp2.html"><img alt="Read the blog post" src="https://img.shields.io/badge/blog-field%20note-14202e"></a>
</p>

**micromp2.js** is an **MP2** (MPEG-1/2 Audio Layer II) **encoder and decoder** in plain JavaScript. No WebAssembly, no
dependencies, no DOM: it runs in a page, in a Web Worker or in Node. It makes constant-bitrate `.mp2` files at 16 to 48 kHz,
mono or stereo, with joint stereo, an optional CRC and a psychoacoustic bit allocator, and it also writes and reads Layer I (MP1).

> [!NOTE]
> **Honest scope.** It is a compact encoder with a simple masking model of our own. We have **not** compared its sound against
> TwoLAME, FFmpeg or any other encoder, and we have **not** run a listening test. It is tested for correct, decodable streams
> (see [Tests](#tests)); judge the sound with your own ears using [`demo.html`](demo.html). See [Known limits](#known-limits).

**Contents:** [Why](#why) · [Quick start](#use) · [API](#api) · [How it works](#how-it-works) · [Numbers](#numbers-what-we-measured) · [Tests](#tests) · [Limits](#known-limits) · [Licence](#licence)

## Why

MP2 is the audio format of digital radio (DAB), DVB and many broadcast and archive workflows, and it is still what plenty of
automation systems expect. Browsers can sometimes play it, but nothing in a browser **encodes** it: WebCodecs has no MP2 encoder,
and the MP3 encoders in JavaScript (lamejs and friends) make Layer III, a different format.

When we looked for an MP2 encoder for JavaScript we found none (npm and GitHub, October 2026): the MP2 encoders that exist are C
(TwoLAME, LGPL) or part of FFmpeg, and the web MP2 code we found (for example mpg123-decoder, a WebAssembly build) decodes only. We needed one to export MP2 from a browser tool,
so we wrote our own, and we are releasing it for anyone to use. "The only one we found" is a statement about our search, not a promise.

| | micromp2.js |
|---|---|
| Ships as | one .js file (14.8 KB gzipped, encoder + decoder) plus an identical ES module |
| Loading | plain script, ES module or `require()`; nothing to fetch |
| Encodes | MPEG-1 and MPEG-2 (LSF) Layer II and Layer I, mono and stereo, 16 to 48 kHz, CBR |
| Decodes | the same two layers, with CRC checking, resync, ID3v2 skipping |
| Speed (our PC) | encodes 10 s of stereo in about 0.26 s (38x real time); decodes in about 0.05 s |

## Use

```html
<script src="micromp2.js"></script>
<script>
  // channels: Float32Array[] (or Int16Array[]), one per channel; sampleRate in Hz
  const mp2 = micromp2.encode({ channels: [left, right], sampleRate: 44100, bitrate: 192 });
  // mp2 is a Uint8Array: save it, upload it, or new Blob([mp2], { type: "audio/mpeg" })
</script>
```

ES module (`micromp2.mjs`, same code):

```js
import micromp2 from "./micromp2.mjs";            // or: import { encode, decode } from "./micromp2.mjs";
```

Node / CommonJS: `const micromp2 = require("./micromp2.js");`

Try it without writing code: serve this folder with any static server and open [`demo.html`](demo.html), then pick any audio file your
browser can decode. Nothing is uploaded.

## API

| | |
|---|---|
| `micromp2.encode(options)` | Returns a `Uint8Array` with the whole `.mp2` file. Blocks the thread while it runs. |
| `micromp2.encodeAsync(options)` | Same bytes, as a Promise; yields to the event loop between chunks. `options.onProgress(0..1)` is called after each. |
| `micromp2.createEncoder(options)` | Streaming: `encode([ch0, ch1])` returns the frames finished so far, `flush()` returns the rest, `free()` releases it. |
| `micromp2.decode(bytes, { checkCrc })` | Returns `{ sampleRate, channels, channelData: Float32Array[], layer, frames, badFrames, crcErrors, bitrate, delay }`. |
| `micromp2.delay` | `481`: the filterbank delay in samples. Decoded audio starts that many samples late. |
| `micromp2.version` | `"1.0.0"` |

### Options

| Option | Default | What it does |
|---|---|---|
| `channels` | | Array of typed arrays, one per channel (all the same length): `Float32Array` (nominally -1 to 1, clamped) or `Int16Array`. Or give `interleaved` plus `numChannels`. |
| `sampleRate` | required | 16000, 22050, 24000, 32000, 44100 or 48000 |
| `bitrate` | 192 stereo / 128 mono (MPEG-1), 128 / 64 (MPEG-2) | kbit/s, constant. Snapped to the nearest value the standard allows for that layer, rate and channel mode (for example, Layer II stereo needs at least 64, mono at most 192). |
| `layer` | `2` | `2` for MP2, `1` for MP1 |
| `mode` | `"auto"` | `"auto"` (joint stereo only at low bitrates), `"stereo"`, `"joint"`, `"dual"`, `"mono"` (mixes a stereo input down) |
| `bound` | | Joint-stereo bound, 0 to 3 (bands 4, 8, 12, 16 and up are shared) |
| `crc` | `false` | Add the 16-bit CRC to every frame |
| `copyright`, `original`, `emphasis` | | Header flag bits |

## How it works

Every choice is made for one frame (1152 samples) at a time, so the output has a fixed size per frame and a constant bitrate. The samples go
through a 32-band polyphase filterbank. For each band the encoder finds a scalefactor for each third of the frame, estimates how loud the
band is, spreads that level across neighbouring bands on the Bark scale to guess what is masked, compares it with the threshold in quiet, and
then hands out the frame's bit budget one step at a time to the band where the quantisation noise is closest to audible. It then quantises
and packs the frame.

<p align="center"><img src="docs/pipeline.svg" alt="micromp2 encoder pipeline: PCM samples, polyphase filterbank, frame of 1152, scalefactors, masking model, greedy bit allocation within a fixed budget, quantise, pack the frame" width="100%"></p>

What goes into a frame, in stream order. Each frame is self-describing, so a decoder can pick up a stream anywhere:

<p align="center"><img src="docs/frame-layout.svg" alt="Layout of an MP2 frame: header, optional CRC, bit allocation, scale-factor select, scalefactors, quantised samples" width="100%"></p>

## Numbers (what we measured)

Measured in Node 24 on one Windows PC with **synthetic, seeded signals** (a voice-like harmonic signal and decaying chord tones; see
[`tools/measure.mjs`](tools/measure.mjs)), reproducible with `npm run measure`. No real music or speech, no listening test.

<p align="center"><img src="docs/spectrograms.svg" alt="Spectrograms of the original synthetic voice and the same clip decoded from MP2 at 192, 128 and 64 kbps mono" width="100%"></p>

<p align="center"><img src="docs/snr-vs-bitrate.svg" alt="Signal-to-noise ratio against bitrate for mono, for synthetic voice and chord tones: rising overall with a jump at 48 kbps and a dip at 56 kbps" width="100%"></p>

<p align="center"><img src="docs/speed.svg" alt="Speed: encoding is about 38 times real time in stereo at 192 kbps and about 110 times in mono at 128 kbps; decoding is faster" width="100%"></p>

- **Constant bitrate is exact**: for 7 sample-rate, channel and bitrate combinations the file size was exactly bitrate x frames x 1152 / sample rate / 8, to the byte (ratio 1.00000 in every case, in [`docs/results.json`](docs/results.json)).
- **Signal-to-noise ratio** (decoded with micromp2's own decoder, aligned for the 481-sample delay) rises from about 10 to 27 dB for the voice-like signal and 15 to 36 dB for the chords as mono goes from 32 to 192 kbps. **It is not a quality score**: a psychoacoustic encoder deliberately lets noise sit under loud sounds, and the curve is not smooth because the standard switches bit-allocation table with bitrate. At 48 kbps mono the 8-band low-rate table puts all the bits into the low bands (a jump), at 56 kbps the 27-band table spreads them thinner (a dip). The spectrograms show the other side: at 64 kbps the top of the spectrum is gone.
- **Speed**: encoding 10 s of 44.1 kHz stereo at 192 kbps took about 262 ms (38x real time); mono at 128 kbps about 91 ms (110x). Decoding took about 51 ms and 28 ms. Median of 5 runs.
- **Size of the code**: `micromp2.js` is 50.1 KB with licence header and comments, about 35.9 KB with comments and whitespace stripped (a crude strip, not a real minifier), and 14.8 KB gzipped as shipped (10.8 KB stripped and gzipped). Without the decoder it would be about 26.3 KB stripped and 8.4 KB gzipped (an estimate: the file is not split).

## Tests

Serve this folder with any static server and open [`test.html`](test.html), or run `npm test` (Node 18 or later). Both run the same 46 checks: for
eight sample-rate, channel and bitrate combinations an **independent frame walker** (its own tables, not the library's) confirms the stream is a sequence of valid
Layer II frames with the right count, bitrate, rate and channel mode and an exact constant size; round trips through the bundled decoder (the
format, a signal-to-noise floor, more bits giving a closer match, stereo channels staying separate); streaming in odd-sized pieces, `encodeAsync`, interleaved
and `Int16Array` input all giving the same bytes as one call; determinism; mono down-mix; bitrate snapping; joint stereo; Layer I; CRC protected streams and
detecting a damaged frame; empty, tiny, silent and out-of-range input; the errors for bad options; and decoder resilience to an ID3v2 tag, leading junk and a
truncated last frame. `test.html` also checks the ES module build against the script build and asks the browser's own decoder to read our files (browsers we tried could not
decode raw MP2, so that check skips itself). All 46 passed at the time of writing in Node 24 and Chromium.

## Known limits

- **Sound quality is unmeasured against other encoders.** The masking model is our own and deliberately simple; at low bitrates it drops the top of the spectrum (see the spectrograms). No listening test, no PEAQ or similar objective score, no comparison with TwoLAME or FFmpeg. If you need maximum quality at a given bitrate, use one of those.
- **Not checked against a second decoder in this repository.** The bundled decoder and the frame walker agree with the encoder, but we did not run the files through FFmpeg, VLC or a hardware decoder here, and browsers we tried cannot decode raw MP2. Please tell us if a player rejects a file.
- **CBR only** (no VBR, no free-format output), 1 or 2 channels (no 5.1), no MPEG-2.5 (8, 11.025 and 12 kHz), no Layer III.
- **Raw frames only**: the output has no ID3 tag, no Xing/Info header and no container; wrap it yourself if you need one. The decoder reads raw frames (and skips an ID3v2 tag), not MP4, WAV or other containers.
- **The whole file is built in memory**; use `createEncoder` or `encodeAsync` for long audio.
- **Delay**: decoded audio lags the input by 481 samples (the filterbank). `flush()` adds enough frames to cover the tail.
- **Tested with synthetic signals** in Node and Chromium only. We have not made a patent search and make no legal claims about the format.

## Where the code comes from

micromp2.js was written by Dialer's Apps from the published standards (ISO/IEC 11172-3 and 13818-3): the polyphase filterbank, the Layer II
bit-allocation and scalefactor rules, the CRC, and the Layer I/II bitstream syntax. The numeric tables (the 512-point synthesis window and the
bit-allocation tables) are the standards' own data. The masking model and the bit allocator are our own simple design, not a port of the standard's example
psychoacoustic models or of TwoLAME. The code contains no code copied from another project.

## Licence

MIT, see [`LICENSE`](LICENSE). Copyright (c) 2026 Dialer's Apps.

Made by [Dialer's Apps](https://dialersapps.pages.dev/). Read the story behind it in the
[blog post](https://dialersapps.pages.dev/lab/notes/micromp2.html).