Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Local-First Voice Recording in the Browser: MediaRecorder, Waveforms, and iOS Safari Quirks

A surprising amount of in-browser voice recording never needs a server. getUserMedia, MediaRecorder, and the Web Audio API can capture a take, draw a live waveform, let someone replay or download it, and drop the bytes w

A surprising amount of in-browser voice recording never needs a server. getUserMedia, MediaRecorder, and the Web Audio API can capture a take, draw a live waveform, let someone replay or download it, and drop the bytes when the tab closes.

I used that pattern while building Choicer Voicer Hub, a free browser stage for short voice-acting scenes. It is an independent, unofficial fan project and is not affiliated with YeahMaybe. Players pick one of 20+ scene packs, hear an acting cue and a reference pitch, then dub each line β€” or load a local MP4, WebM, or MP3 and write their own script. There is no account wall, and recordings are not uploaded.

This is not a product tour. It is the Web API path, the permission UX, and the mobile Safari surprises that usually break a first demo.

Ask for the microphone at the moment it matters

getUserMedia only works in a secure context (HTTPS or localhost). A prompt on page load is easy to deny before anyone knows why a dubbing page needs a mic, and a blocked permission is harder to recover from than a delayed one.

Request the stream from a clearly labeled control, such as a β€œRecord line” button, and keep the constraints modest:

async function openMic() {
  return navigator.mediaDevices.getUserMedia({
    audio: {
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl: true,
    },
  });
}

Those constraints are hints, not guarantees, but they are a sound default for spoken lines. On NotAllowedError, point people at the lock or tune icon beside the URL, set Microphone to Allow, and reload. On NotFoundError, there is no input device at all.

A second failure mode looks like success: permission is granted, but the OS default input is a monitor, a disconnected USB mic, or a Bluetooth headset that is not capturing. Your UI cannot fix those settings, but a waveform can make the problem obvious.

Record into a Blob, not into a backend

MediaRecorder writes the live MediaStream into chunks you already hold in memory. Safari and Chromium do not agree on container formats, so pick a type at runtime instead of hard-coding audio/webm:

const candidates = [
  "audio/webm;codecs=opus",
  "audio/webm",
  "audio/mp4",
  "audio/mp4;codecs=mp4a.40.2",
];
const mimeType = candidates.find((t) => MediaRecorder.isTypeSupported(t)) || "";
const chunks = [];
const recorder = mimeType
  ? new MediaRecorder(stream, { mimeType })
  : new MediaRecorder(stream);

recorder.ondataavailable = (event) => {
  if (event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = () => {
  const blob = new Blob(chunks, { type: recorder.mimeType || mimeType });
  const url = URL.createObjectURL(blob);
  // Assign url to <audio> or an <a download> link. Do not upload the blob.
};
recorder.start();

Stop the recorder when the line is done, then revoke the object URL when a new take replaces it so long sessions do not leak blob URLs. The bytes never leave the machine unless you later fetch them somewhere.

The same idea works in reverse. A hidden <input type="file" accept="audio/*,video/mp4,video/webm"> plus URL.createObjectURL(file) is enough to load a local clip for a custom dub. The file stays in the tab; you do not need a multipart upload to support custom media.

Prove the mic is hearing something

A permission badge that says β€œallowed” does not tell a player their headset is muted. A time-domain waveform does. Pipe the same MediaStream through an AnalyserNode and read getByteTimeDomainData on animation frames. In that array, 128 is silence; values that stay parked there mean the graph is alive but the input is not.

async function startMonitor(stream, canvas) {
  const ctx = new AudioContext();
  if (ctx.state === "suspended") await ctx.resume();

  const source = ctx.createMediaStreamSource(stream);
  const analyser = ctx.createAnalyser();
  analyser.fftSize = 2048;
  source.connect(analyser);
  // Do not connect to ctx.destination unless you intend live monitor playback.

  const data = new Uint8Array(analyser.fftSize);
  const context = canvas.getContext("2d");

  function draw() {
    requestAnimationFrame(draw);
    analyser.getByteTimeDomainData(data);
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.beginPath();
    for (let i = 0; i < data.length; i++) {
      const x = (i / data.length) * canvas.width;
      const y = (data[i] / 255) * canvas.height;
      i === 0 ? context.moveTo(x, y) : context.lineTo(x, y);
    }
    context.stroke();
  }

  draw();
  return ctx;
}

Two details matter more than the drawing code. Resume the AudioContext in the same user-gesture handler that starts recording; otherwise mobile browsers leave it suspended and the monitor looks dead. And do not connect the live source to destination unless you want headphone monitoring β€” on speakers that path is a feedback loop.

The split also debugs a common β€œit recorded silence” report. If the waveform moved, MediaRecorder almost certainly got audio and the problem is playback. If it never moved, start with the OS input device, not the recorder options.

iOS Safari will humble a desktop-only demo

MediaRecorder on iPhone and iPad is usable, but playback and routing fail in ways that look like application bugs:

The hardware Silent/Mute switch. On iPhone and iPad, that physical switch can mute media playback even when a recording clearly exists. If the live waveform moved and the recorded clip still plays as silence, ask the player to flip the switch off and raise Media Volume β€” not Ringer volume. Document it early; the mobile guide covers the Silent switch and Media Volume because the failure looks exactly like a broken recorder.

Bluetooth routing. Earbuds may capture the take and then hand playback to a different output, or the reverse. If a clip β€œvanishes” only over Bluetooth, retry with the built-in mic and speaker before you change code.

Container support. audio/webm;codecs=opus is the Chromium default and is often unsupported in Safari. Feature-detect with MediaRecorder.isTypeSupported, and treat audio/mp4 as first-class when you set Blob types and download names.

AudioContext and autoplay policy. Creating an AudioContext outside a tap, or forgetting ctx.resume(), leaves a monitor that never leaves silence. Keep creation and resume() in the record-button handler, and start playback from a gesture rather than autoplaying object URLs.

Tight controls. A desktop stage can be clumsy in one-handed portrait mode. Landscape layout is also a recording-quality decision: a missed Stop control leaves a running MediaRecorder. If the graph gets stuck, stop the active recorder and drop in-memory buffers β€” that is safe when you are not persisting audio anywhere.

iOS 15 or later is a reasonable baseline to mention; earlier releases had a much bumpier MediaRecorder story.

Keep the rest of the experience equally local

Once the take is a Blob, the rest can stay on the device too. Simple voice coloring β€” a slower, deeper playback, an unprocessed take, a higher gremlin-like rate β€” can use an AudioBufferSourceNode and playbackRate, or a short filter chain. Heavier pitch shifting is a separate project; you do not need it to prove the pipeline works.

A party round can stay local as well. A host-and-judge, pass-the-phone or screen-share format β€” one device, friends taking turns on assigned lines β€” needs a roster and a scoreboard, not a sync protocol. That is a different design from a networked room, and it is honest about what a tab can do without uploading anyone’s voice.

The privacy pitch only holds if it is literally true: no media upload, no account, and a refresh clears the buffers. If you later add a save-to-account path, make it an explicit export.

A short checklist

  • Prompt for the mic from a record action, on HTTPS, and explain how to undo a blocked permission.
  • Feature-detect MIME types and treat audio/mp4 as normal, not as a Safari exception.
  • Use an AnalyserNode time-domain monitor to separate a wrong input device from muted playback.
  • Resume AudioContext in a user gesture, and document the iOS Silent switch plus Media Volume.
  • Keep takes as Blobs. If you promise recordings stay on the device, do not quietly POST the file.

If you want to see the same loop in a finished page β€” reference cues, per-line recording, Deep / Normal / Gremlin playback, custom local clips, and a pass-and-play party mode β€” that is the stage linked at the start of this article. It is a free, unofficial fan project and is not affiliated with YeahMaybe.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.