DEV Community

코딩나우(하늘아래)
코딩나우(하늘아래)

Posted on Originally published at coding-now.com

The browser has no MP3 encoder, so a client-side audio trimmer writes WAV by hand

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)]));
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)