Beam search, not a greedy loop
Scalefactors and global gain are searched jointly, keeping several candidate paths alive per band instead of committing to the first one that fits the budget. Width is set by the effort level.
MPEG-1/2/2.5 Layer III encoder · C++20 · WebAssembly
rmps searches for the quantization that sounds best, not the one that happens to fit. A beam search over scalefactors and global gain, scored against a masking model rather than raw error, decides every band of every granule.
Every MP3 encoder answers the same question — how coarsely can each band be quantized before it becomes audible. These are the four places rmps answers it differently.
Scalefactors and global gain are searched jointly, keeping several candidate paths alive per band instead of committing to the first one that fits the budget. Width is set by the effort level.
The search minimizes a mix of noise-to-mask ratio and A-weighted SDR. Distortion under the threshold is free; distortion above it is what gets paid for.
Ask for a perceptual SDR and the encoder moves the bitrate across the full legal range to hold it. CBR and PID-controlled ABR are there when you need a size instead.
Window switching triggers on high-frequency attacks — hats, snare snaps, cymbals. Bass-heavy hits stay on long blocks, where they keep the frequency resolution their sustain needs.
Each stage feeds the next; the masking model is what makes the last two stages differ from a textbook encoder.
The whole encoder compiles to WebAssembly. Audio is read, encoded and downloaded on the page — there is no upload and no server.
import { encodeAudioBuffer } from './rmps.js'; const ac = new AudioContext(); const buf = await ac.decodeAudioData(bytes); const blob = await encodeAudioBuffer(buf, { quality: 70, // 1..100 mode: 'jointStereo', onProgress: ({ ratio }) => bar.value = ratio, });
Encoding is CPU-bound and single-threaded. Run it in the bundled module worker unless the clip is very short — a few minutes of audio is seconds of solid compute, and the main thread will visibly freeze.
const enc = await RmpsEncoder.create({ channels: 2, sampleRate: 44100, bitrate: 192, }); for (let p = 0; p < L.length; p += enc.samplesPerPass) { sink(enc.encode(L.subarray(p, p + spp), R.subarray(p, p + spp))); } const blob = enc.finish(); // flush + Xing header enc.destroy();
encode() returning zero bytes is normal. The encoder holds one
frame back for lookahead, and reservoir emission lags. Nothing is
dropped — the bytes arrive on a later call or in finish(),
which also rewrites the Xing header with the real duration and seek
table.