DEV Community

nareshipme
nareshipme

Posted on

Why Your Browser Video Export Has No Audio (And the Fix Using AudioContext + Gain Node)

When we built client-side video clip export using Canvas + MediaRecorder, everything looked great — the video rendered correctly with captions, progress worked, files downloaded. But every exported clip was completely silent.

Here's the bug and how we fixed it.

The Setup

The pipeline works like this:

  1. Create a hidden <video> element and seek to the clip start
  2. Draw each frame onto an <canvas> via requestAnimationFrame
  3. Capture the canvas as a MediaStream via canvas.captureStream(30)
  4. Tap audio from the video element via AudioContext.createMediaElementSource()
  5. Merge video + audio tracks into MediaRecorder
  6. On stop, combine chunks into a Blob and trigger download

Step 4 is where things went wrong.

The Bug: Capturing from a Muted Element

We started the video element as muted (correct — we don't want the user to hear the export render playing back):

const video = document.createElement('video');
video.src = videoUrl;
video.muted = true; // so the user doesn't hear it
Enter fullscreen mode Exit fullscreen mode

Then later, we wired up the AudioContext:

// ❌ Bug: video is still muted — this source captures silence
const audioCtx = new AudioContext();
const source = audioCtx.createMediaElementSource(video);
const dest = audioCtx.createMediaStreamDestination();
source.connect(dest);
Enter fullscreen mode Exit fullscreen mode

The problem: createMediaElementSource captures the decoded PCM audio from the element, but video.muted = true zeros out the audio before it reaches the Web Audio graph. So you get a valid-looking MediaStream with an audio track that is entirely silent.

This is a surprisingly subtle footgun — the MediaStreamDestination node reports it has an audio track, MediaRecorder picks it up, and the resulting file has an audio stream with no content.

The Fix: Unmute, Then Silence the Output Yourself

The correct approach is to unmute the video element so AudioContext gets real PCM data, but silence the output through a GainNode so the user's speakers stay quiet:

function setupAudio(video: HTMLVideoElement): { audioCtx: AudioContext; stream: MediaStream } {
  // Unmute so AudioContext can tap PCM data;
  // route output through gain=0 so speakers stay silent.
  video.muted = false;
  const audioCtx = new AudioContext();
  const source = audioCtx.createMediaElementSource(video);

  // Silent path to speakers (gain=0 keeps speakers muted)
  const silencer = audioCtx.createGain();
  silencer.gain.value = 0;
  source.connect(silencer);
  silencer.connect(audioCtx.destination);

  // Live path to recorder (full volume)
  const dest = audioCtx.createMediaStreamDestination();
  source.connect(dest);

  return { audioCtx, stream: dest.stream };
}
Enter fullscreen mode Exit fullscreen mode

Key insight: the source node can have multiple connections. We connect it to both:

  • A GainNode with gain.value = 0audioCtx.destination (speakers hear silence)
  • A MediaStreamDestination → recorder (recorder gets full-volume audio)

This is the Web Audio graph equivalent of tee-ing a stream.

Why Not Just Use createMediaStreamSource?

You might think: "can't I just grab the stream from the video and pipe it to the recorder?"

// ⚠️ Doesn't work reliably
const stream = video.captureStream();
const audioTrack = stream.getAudioTracks()[0];
Enter fullscreen mode Exit fullscreen mode

HTMLVideoElement.captureStream() is non-standard and inconsistently implemented. In our testing it worked on Chrome but not in all contexts. createMediaElementSource via Web Audio is the standard path.

Bonus: The Hidden Timing Issue

We also discovered that the video element should start muted, and only be unmuted right before createMediaElementSource is called — not before. If you unmute it too early (e.g., in the constructor), some browsers may flag it as an autoplay policy violation before the AudioContext is created by user gesture.

So the initialization order matters:

// 1. Create video element muted (autoplay-policy safe)
const video = document.createElement('video');
video.muted = true;
video.src = url;

// 2. Wait for user interaction or button click to trigger export...

// 3. Then, right before setting up AudioContext:
video.muted = false; // ← unmute just in time
const audioCtx = new AudioContext(); // ← must be created in user gesture context
const source = audioCtx.createMediaElementSource(video);
Enter fullscreen mode Exit fullscreen mode

The Takeaway

If you're building browser-side video export with Canvas + MediaRecorder + AudioContext:

  • Start the video element muted to comply with autoplay policy
  • Unmute before calling createMediaElementSource — otherwise you capture silence
  • Use a gain=0 node to silence speakers without muting the audio source
  • Connect one source to multiple destinations to split recording vs playback paths

The Web Audio graph model is powerful once you understand that nodes can fan out — same source, multiple outputs, each with independent gain.

Full pipeline code available in ClipCrafter.

Top comments (0)