DEV Community

Cover image for Record Your Own Sound Button in the Browser: MediaRecorder, Silence Trimming and a Tiny WAV Encoder
Abid Ali
Abid Ali

Posted on

Record Your Own Sound Button in the Browser: MediaRecorder, Silence Trimming and a Tiny WAV Encoder

Every soundboard eventually gets the same request: "Can I add my own?" Someone wants their own catchphrase as a button, or a short voice note they can replay for the group.

Recording audio in the browser is easy to start and fiddly to finish. The first version records fine, but every clip starts with half a second of silence (the time it takes to press the button and start talking), ends with the sound of a finger hitting the screen, and comes out in a different format on iPhone than on Android.

This post builds the whole flow without libraries:

  1. Ask for the microphone properly.
  2. Record with MediaRecorder, picking a format the browser actually supports.
  3. Decode the recording into raw samples.
  4. Trim silence from the start and end.
  5. Normalise the peak level.
  6. Encode a 16-bit WAV file you can play, download or upload.

All the code runs in current Chrome, Firefox and Safari. Microphone access requires a secure context, so serve the page over HTTPS (or localhost while developing).

Step 1: Ask for the microphone

async function getMic() {
  if (!navigator.mediaDevices?.getUserMedia) {
    throw new Error("This browser can't record audio.");
  }
  try {
    return await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
      },
    });
  } catch (err) {
    if (err.name === "NotAllowedError") throw new Error("Microphone permission was denied.");
    if (err.name === "NotFoundError") throw new Error("No microphone found.");
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Only call this from a button press. Asking for the microphone on page load is the fastest way to get a permanent "Block", and on some browsers the prompt is suppressed without a user gesture anyway.

The three constraints are hints. For voice clips, the browser's built-in echo cancellation and noise suppression usually help. If you're recording music or sound effects, turn them off, because they're tuned for speech and will mangle anything else.

Step 2: Record with a supported format

MediaRecorder encodes for you, but different browsers support different containers. Chrome and Firefox record WebM/Opus; Safari records MP4/AAC. Ask rather than assume:

function pickMimeType() {
  const candidates = [
    "audio/webm;codecs=opus",
    "audio/webm",
    "audio/mp4",
    "audio/ogg;codecs=opus",
  ];
  return candidates.find((t) => MediaRecorder.isTypeSupported(t)) ?? "";
}

function record(stream, maxMs = 10_000) {
  return new Promise((resolve, reject) => {
    const mimeType = pickMimeType();
    const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
    const chunks = [];

    recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
    recorder.onerror = (e) => reject(e.error ?? new Error("Recording failed"));
    recorder.onstop = () => {
      stream.getTracks().forEach((t) => t.stop()); // turn the mic indicator off
      resolve(new Blob(chunks, { type: recorder.mimeType }));
    };

    recorder.start();
    const timer = setTimeout(() => recorder.state === "recording" && recorder.stop(), maxMs);

    // Return a stop function the UI can call.
    record.stop = () => { clearTimeout(timer); if (recorder.state === "recording") recorder.stop(); };
  });
}
Enter fullscreen mode Exit fullscreen mode

Things worth noticing:

  • Always stop the tracks. If you don't, the browser's "recording" indicator stays on after the user is done, which looks alarming.
  • Cap the length. A sound button doesn't need to be a minute long. A hard limit protects you from someone forgetting to press stop.
  • Use recorder.mimeType for the Blob type, not the one you asked for. The browser may add codec details.

Wiring it to a button:

recordBtn.addEventListener("click", async () => {
  if (recordBtn.dataset.state === "recording") {
    record.stop();
    return;
  }
  const stream = await getMic();
  recordBtn.dataset.state = "recording";
  recordBtn.textContent = "Stop";
  const blob = await record(stream);
  recordBtn.dataset.state = "";
  recordBtn.textContent = "Record";
  await processRecording(blob);
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Decode to raw samples

To trim or normalise, you need the actual sample values. decodeAudioData understands whatever the same browser recorded:

const audioCtx = new AudioContext();

async function decode(blob) {
  const arrayBuffer = await blob.arrayBuffer();
  return await audioCtx.decodeAudioData(arrayBuffer);
}
Enter fullscreen mode Exit fullscreen mode

The result is an AudioBuffer: a sample rate (often 44.1 or 48 kHz), a number of channels, and a Float32Array of samples per channel, each between -1 and 1. Microphones are usually mono, but some report two identical channels. For a voice clip, mono is all you need, so we mix down:

function toMono(buffer) {
  const { numberOfChannels, length } = buffer;
  if (numberOfChannels === 1) return buffer.getChannelData(0).slice();
  const out = new Float32Array(length);
  for (let c = 0; c < numberOfChannels; c++) {
    const data = buffer.getChannelData(c);
    for (let i = 0; i < length; i++) out[i] += data[i] / numberOfChannels;
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Trim silence at both ends

"Silence" in a real recording is never zero; it's low-level room noise. So we measure loudness in short windows and find the first and last window that's clearly louder than the background.

RMS (root mean square) over a window of about 10 ms is a good loudness measure:

function trimSilence(samples, sampleRate, {
  thresholdDb = -45,   // anything quieter than this counts as silence
  windowMs = 10,
  padMs = 60,          // keep a little air so words aren't clipped
} = {}) {
  const win = Math.max(1, Math.round((sampleRate * windowMs) / 1000));
  const threshold = Math.pow(10, thresholdDb / 20);

  const loudWindow = (start) => {
    let sum = 0;
    const end = Math.min(start + win, samples.length);
    for (let i = start; i < end; i++) sum += samples[i] * samples[i];
    return Math.sqrt(sum / (end - start)) > threshold;
  };

  let first = -1;
  for (let i = 0; i < samples.length; i += win) {
    if (loudWindow(i)) { first = i; break; }
  }
  if (first === -1) return null; // the whole clip is silence

  let last = first + win;
  for (let i = samples.length - win; i > first; i -= win) {
    if (loudWindow(i)) { last = i + win; break; }
  }

  const pad = Math.round((sampleRate * padMs) / 1000);
  return samples.subarray(Math.max(0, first - pad), Math.min(samples.length, last + pad));
}
Enter fullscreen mode Exit fullscreen mode

Tuning notes:

  • -45 dB works for a phone held close in a quiet room. In a noisy room, raise it to -35 dB. You can also measure the first 100 ms (usually silence before speaking) and set the threshold a few dB above that.
  • The padding matters. Words that start with soft consonants ("f", "s", "h") are quiet at the start. Without padding, "fine" becomes "ine".
  • Return null for all-silence clips and tell the user nothing was recorded, rather than saving an empty button.

Step 5: Normalise the peak

Clips recorded on different phones come out at very different levels. Peak normalisation scales the clip so the loudest sample hits a target, usually just below full scale:

function normalizePeak(samples, targetDb = -1) {
  let peak = 0;
  for (let i = 0; i < samples.length; i++) {
    const v = Math.abs(samples[i]);
    if (v > peak) peak = v;
  }
  if (peak === 0) return samples;
  const gain = Math.pow(10, targetDb / 20) / peak;
  const out = new Float32Array(samples.length);
  for (let i = 0; i < samples.length; i++) out[i] = samples[i] * gain;
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Peak normalisation evens out "too quiet" recordings, but it doesn't make clips sound equally loud, because perceived loudness depends on more than the peak. For a library of uploads you'd do loudness normalisation on the server; for a user's own button, peak normalisation is a big improvement for very little code.

Add a short fade at each end to avoid clicks where the trim cut mid-waveform:

function fadeEdges(samples, sampleRate, ms = 8) {
  const n = Math.min(Math.round((sampleRate * ms) / 1000), samples.length >> 1);
  for (let i = 0; i < n; i++) {
    const g = i / n;
    samples[i] *= g;
    samples[samples.length - 1 - i] *= g;
  }
  return samples;
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Encode a WAV file

WAV is the simplest format that plays everywhere: a 44-byte header followed by raw PCM samples. Here's a mono, 16-bit encoder:

function encodeWav(samples, sampleRate) {
  const bytesPerSample = 2;
  const dataSize = samples.length * bytesPerSample;
  const buffer = new ArrayBuffer(44 + dataSize);
  const view = new DataView(buffer);

  const writeString = (offset, str) => {
    for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
  };

  writeString(0, "RIFF");
  view.setUint32(4, 36 + dataSize, true);
  writeString(8, "WAVE");
  writeString(12, "fmt ");
  view.setUint32(16, 16, true);            // fmt chunk size
  view.setUint16(20, 1, true);             // PCM
  view.setUint16(22, 1, true);             // mono
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * bytesPerSample, true); // byte rate
  view.setUint16(32, bytesPerSample, true); // block align
  view.setUint16(34, 16, true);            // bits per sample
  writeString(36, "data");
  view.setUint32(40, dataSize, true);

  let offset = 44;
  for (let i = 0; i < samples.length; i++, offset += 2) {
    const s = Math.max(-1, Math.min(1, samples[i]));
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
  }
  return new Blob([buffer], { type: "audio/wav" });
}
Enter fullscreen mode Exit fullscreen mode

WAV is uncompressed, so it's larger than the original recording. For a button that's a few seconds long, that's fine. If you upload clips, convert to MP3 or Opus on the server.

Putting it together

async function processRecording(blob) {
  const buffer = await decode(blob);
  const mono = toMono(buffer);
  const trimmed = trimSilence(mono, buffer.sampleRate);
  if (!trimmed) {
    showMessage("Didn't catch anything. Try again a bit closer to the mic.");
    return;
  }
  const finished = fadeEdges(normalizePeak(trimmed), buffer.sampleRate);
  const wav = encodeWav(finished, buffer.sampleRate);

  const url = URL.createObjectURL(wav);
  preview.src = url;           // an <audio controls> element
  downloadLink.href = url;
  downloadLink.download = "my-sound.wav";
}
Enter fullscreen mode Exit fullscreen mode

Let the user listen before saving. Nobody gets a catchphrase right on the first take, so make "record again" a single tap.

Where this fits

On Meme Tap's voice record soundboard you'll find the kind of short voice clips this pipeline produces: a few seconds, no dead air, ready to play the moment you tap. The code here is my own generic version of the technique, not the site's source.

Recap

  • Ask for the mic on a click, over HTTPS, and stop the tracks when done.
  • Pick a MediaRecorder type with isTypeSupported; Safari and Chrome differ.
  • Decode with decodeAudioData, mix to mono, trim by windowed RMS with a little padding.
  • Normalise the peak, fade the edges, and encode a 16-bit WAV.
  • Always offer a preview and a quick retake.

Abid Ali is a full-stack web developer in Pakistan. He builds Meme Tap, a free browser soundboard with one-tap meme sound buttons.

Top comments (0)