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:
- Create a hidden
<video>element and seek to the clip start - Draw each frame onto an
<canvas>viarequestAnimationFrame - Capture the canvas as a
MediaStreamviacanvas.captureStream(30) - Tap audio from the video element via
AudioContext.createMediaElementSource() - Merge video + audio tracks into
MediaRecorder - On stop, combine chunks into a
Bloband 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
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);
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 };
}
Key insight: the source node can have multiple connections. We connect it to both:
- A
GainNodewithgain.value = 0→audioCtx.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];
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);
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)