I wanted a trimmer that cuts a clip out of a song without uploading the file anywhere. Decoding is easy — decodeAudioData takes an ArrayBuffer of almost any format the browser can play and hands back a Float32 AudioBuffer. Getting audio back out is where the platform stops helping.
MediaRecorder will not give you MP3 (or WAV)
I checked what Chrome 152 actually admits to:
const types = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/aac',
'audio/ogg;codecs=opus', 'audio/webm;codecs=opus',
'audio/mp4;codecs=mp4a.40.2'];
Object.fromEntries(types.map(t => [t, MediaRecorder.isTypeSupported(t)]));
audio/mpeg false
audio/mp3 false
audio/wav false
audio/aac false
audio/ogg;codecs=opus false
audio/webm;codecs=opus true
audio/mp4;codecs=mp4a.40.2 true
So the only containers on offer are WebM/Opus and MP4/AAC — neither of which is what someone expects to receive when they asked to "cut an MP3". MP3 output means shipping an encoder: lamejs, or ffmpeg.wasm at 25MB+. For a tool whose entire pitch is "nothing is uploaded and nothing is installed", that is a lot of weight for a format conversion nobody asked for.
So you write the WAV header yourself
It's 44 bytes. This is the whole encoder from the trimmer I run:
function encodeWAV(buffer, startSample, endSample) {
const numCh = buffer.numberOfChannels;
const len = endSample - startSample;
const sampleRate = buffer.sampleRate;
const blockAlign = numCh * 2;
const dataSize = len * blockAlign;
const ab = new ArrayBuffer(44 + dataSize);
const view = new DataView(ab);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, numCh, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true); // bits per sample
writeString(view, 36, "data");
view.setUint32(40, dataSize, true);
// then interleave the channels as 16-bit LE from offset 44
}
Two details that matter more than they look:
Slice by sample index, not by seconds. The selection times become indices once — Math.floor(selStart * sr) and Math.floor(selEnd * sr), clamped to buffer.length — and everything downstream counts samples. Carrying seconds around and converting in two places is how you end up a sample off on one edge, which is audible as a click when a ringtone loops.
Use buffer.sampleRate, not the AudioContext's. On the machine I tested, new AudioContext().sampleRate is 48000 while a typical MP3 decodes at 44100. If you render the selection through an AudioContext (or an OfflineAudioContext created with the context's rate) you silently resample the whole clip for no reason. Read the samples straight out of the decoded buffer and copy them.
The file size nobody expects
PCM is uncompressed, so the arithmetic is the file:
44 + 44100 × 30 × 2ch × 2bytes = 5,292,044
I generated exactly that clip to check, and the file is 5,292,044 bytes — 5.0 MB for 30 seconds. The same 30 seconds as a 192kbps MP3 is about 0.7 MB. Users notice this the moment they try to move the file to a phone, so it's worth saying out loud in the UI rather than letting them discover it in a transfer dialog.
Where the format actually bites: ringtones
This is the use case that sends people to an audio trimmer in the first place, and the platform rules are asymmetric:
| What it accepts | |
|---|---|
| Android | MP3, WAV (PCM), AAC/M4A, OGG, FLAC — per the platform's supported-formats table, so the WAV works as-is |
| iOS | goes through GarageBand, and Apple's documentation caps ringtones at 30 seconds |
So on Android the raw WAV is fine and the only complaint is the 5MB. On iOS the format matters less than the length — and if the clip is longer, the export offers to shorten it for you, choosing the part it keeps. Which means the length constraint belongs in the trimmer, not in the phone: let people type exact start/end times and show the selection length while they drag.
That last point is the actual design lesson here. The encoder question (MediaRecorder versus a hand-written header) has one answer and you implement it once. The 30 seconds is what people get wrong every time, and only the UI can prevent it.
The non-developer walkthrough — cutting the clip, moving it, and the GarageBand and Files-app steps on each platform: https://www.coding-now.com/en/guides/make-ringtone-from-mp3?utm_source=devto
Has anyone shipped lamejs in production for this? I'd like to know what the bundle cost looked like in practice.
Top comments (0)