Skip to content

fix(recording): parallelize screen/mic capture and fade in mic gain#58

Open
EtienneLescot wants to merge 1 commit into
mainfrom
fix/mic-sync-at-start-57
Open

fix(recording): parallelize screen/mic capture and fade in mic gain#58
EtienneLescot wants to merge 1 commit into
mainfrom
fix/mic-sync-at-start-57

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Mic recordings were laggy and clicking at the start of recordings, then visibly drifting out of sync with the video track throughout (#57). Three things combined to produce the symptom on the browser capture path:

  1. Screen and mic were acquired sequentially, so the mic track only started capturing after the screen track had been running for the full IPC + permission roundtrip.
  2. The AudioContext (which routes the mic into the recorder's destination stream) was constructed only after both streams were in hand, so any audio the mic captured before then was silently dropped.
  3. The mic gain was a constant 1.4 with no fade-in, so the first audio packet clicked from the echo-canceller warm-up.

Changes

  • src/hooks/useScreenRecorder.ts — Capture screen and microphone with Promise.all in startRecording so both tracks start at the same wall-clock instant.
  • src/lib/audioMix.ts (new) — Extract the AudioContext + GainNode + MediaStreamDestination wiring into a small helper, and apply a 20 ms linearRampToValueAtTime on the mic gain from 0MIC_GAIN_BOOST so the destination stream's first packet doesn't click.
  • src/lib/audioMix.test.ts (new) — Unit test for the mixer: verbatim passthrough when only one track is present, fresh AudioContext + fade-in + connections when both are present.

Why not also fix the per-clock-domain A/V drift?

The native Windows (audio_sample_utils.cpp:289) and macOS (main.swift:301) paths already anchor audio to the first video frame in the same clock domain. The browser path inherits MediaRecorder's shared clock, so once the screen and mic tracks both feed it from t≈0, they stay aligned. Long-run drift correction between mismatched endpoint formats is a separate, much harder fix tracked in docs/engineering/windows-native-recorder-roadmap.md:116, 144 — not in scope here.

Native paths

Unchanged. They already anchor audio to the first video frame.

Test plan

  • npx tsc --noEmit clean
  • npm run lint clean (Biome)
  • npm run test — 295/295 pass, including 5 new tests for mixAudioTracks
  • Manual smoke test on Linux (browser path) — verify first ~1 s of mic audio is clean and A/V stays in sync over a 30 s recording
  • Manual smoke test on Windows + macOS (native paths) — confirm no regression

Fixes #57

Summary by CodeRabbit

  • New Features

    • Improved screen recording to capture screen and microphone audio in parallel for a smoother start.
    • Added better audio handling when both system sound and mic input are available, producing a cleaner combined recording.
  • Bug Fixes

    • Added fallback handling for different browser and platform recording paths to make audio capture more reliable.

)

The mic track was lagging and clicking at the start of recordings, then
running visibly behind the video track throughout. Three things combined
to produce the symptom on the browser capture path:

- Screen and mic were acquired sequentially in startRecording, so the
  mic track started capturing only after the screen track had been
  running for the full IPC + permission roundtrip.
- The AudioContext (which routes the mic to the recorder's destination
  stream) was constructed only after both streams were in hand, so any
  audio the mic captured before then was dropped on the floor.
- The mic gain was a constant 1.4 with no fade-in, so the very first
  packet produced a click/pop from the echo-canceller warm-up.

Two changes:

- Capture screen and microphone with Promise.all so they both start at
  the same wall-clock instant.
- Extract the AudioContext + GainNode + MediaStreamDestination wiring
  into src/lib/audioMix.ts and add a 20 ms linearRampToValueAtTime on
  the mic gain from 0 -> MIC_GAIN_BOOST, so the destination stream's
  first audio packet doesn't click.

The native Windows and macOS paths are unaffected (they already anchor
audio to the first video frame).
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Extracts audio-mixing logic from useScreenRecorder into a new src/lib/audioMix.ts module that exports MIC_GAIN_BOOST, MIC_FADE_IN_S, and mixAudioTracks. useScreenRecorder is refactored to capture screen and microphone streams in parallel via Promise.all before constructing the AudioContext, then delegates mixing to mixAudioTracks. A Vitest suite covers all input combinations of the new helper.

Changes

Audio Mix Extraction and Parallel Capture

Layer / File(s) Summary
audioMix module: constants, types, and implementation
src/lib/audioMix.ts
Adds MIC_GAIN_BOOST, MIC_FADE_IN_S, MixAudioTracksInput/MixAudioTracksResult types, and mixAudioTracks which builds an AudioContext graph with mic gain fade-in for dual-track input, returns a single track directly for single-track input, or returns nulls when neither track is provided.
audioMix tests
src/lib/audioMix.test.ts
Vitest suite with fake AudioContext/MediaStream stubs covering system-only, mic-only, neither, and both-tracks cases; asserts gain fade scheduling with MIC_FADE_IN_S/MIC_GAIN_BOOST and node connection wiring.
useScreenRecorder: parallel capture and mixAudioTracks call
src/hooks/useScreenRecorder.ts
Imports mixAudioTracks and removes local MIC_GAIN_BOOST; introduces screenCapture and micCapture async functions executed concurrently via Promise.all; replaces inline AudioContext wiring with a mixAudioTracks call whose returned context is stored in mixingContext.current.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Two streams leap forth at once, side by side,
No more shall the mic track lag behind!
A gain node fades in with a gentle glide,
And clicks at the start are left far behind.
The rabbit extracted the mix with great care —
Now audio and video float through the air! 🎙️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the parallel capture and mic gain fade-in changes.
Description check ✅ Passed The description mostly follows the template, covering summary, related issue, rationale, and testing, though some checklist sections are not fully filled.
Linked Issues check ✅ Passed The changes address #57 by parallelizing screen and mic capture and adding a mic gain fade-in, matching the reported browser-path bug.
Out of Scope Changes check ✅ Passed No unrelated code changes are evident; the new helper, tests, and recorder updates all support the linked recording fix.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mic-sync-at-start-57

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/hooks/useScreenRecorder.ts (1)

1186-1204: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Await the system-audio request before returning. getUserMedia() returns a promise, so returning it directly from the try bypasses this catch; a rejection will skip the video-only fallback and fail recording startup instead of degrading gracefully.

Suggested fix
-						return navigator.mediaDevices.getUserMedia({
+						return await navigator.mediaDevices.getUserMedia({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useScreenRecorder.ts` around lines 1186 - 1204, The system-audio
branch in useScreenRecorder is returning the getUserMedia promise directly, so
the existing try/catch never handles a rejected request and the video-only
fallback is skipped. Update the system-audio path to await the
navigator.mediaDevices.getUserMedia call before returning, keeping the fallback
logic in the catch so a failure in the system-audio request gracefully retries
with audio disabled. Use the existing systemAudioEnabled block,
selectedSource.id, and videoConstraints flow to keep the behavior unchanged
aside from the async handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 1240-1248: The parallel startup in useScreenRecorder can leave a
successfully acquired screen or mic stream running if Promise.all() fails or the
countdown aborts before the refs are assigned. Update the startup flow around
the Promise.all and the isCountdownRunActive(countdownRunToken) early return so
any locally acquired streams are explicitly stopped on failure/cancellation
before returning, not just via teardownMedia(). Use the screenStream and
microphoneStream refs as the cleanup targets, and make sure the successful local
captures are always released even when one branch of the parallel acquisition
completes first.

In `@src/lib/audioMix.ts`:
- Around line 36-55: The mic fade-in is only applied in mixAudioTracks when both
systemAudioTrack and micAudioTrack are present, so mic-only recordings still
return the raw micAudioTrack and can pop at the start. Update mixAudioTracks to
route the mic-only branch through the same AudioContext, createGain, and
linearRampToValueAtTime flow used for mixed recordings, using the existing
MIC_GAIN_BOOST and MIC_FADE_IN_S logic, while keeping the return shape
consistent with the current MixAudioTracksResult.

---

Outside diff comments:
In `@src/hooks/useScreenRecorder.ts`:
- Around line 1186-1204: The system-audio branch in useScreenRecorder is
returning the getUserMedia promise directly, so the existing try/catch never
handles a rejected request and the video-only fallback is skipped. Update the
system-audio path to await the navigator.mediaDevices.getUserMedia call before
returning, keeping the fallback logic in the catch so a failure in the
system-audio request gracefully retries with audio disabled. Use the existing
systemAudioEnabled block, selectedSource.id, and videoConstraints flow to keep
the behavior unchanged aside from the async handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 639fe4f5-9586-4a3a-a42c-7672e1daf4fa

📥 Commits

Reviewing files that changed from the base of the PR and between 72cb541 and 259189e.

📒 Files selected for processing (3)
  • src/hooks/useScreenRecorder.ts
  • src/lib/audioMix.test.ts
  • src/lib/audioMix.ts

Comment on lines +1240 to +1248
const [screenMediaStream, micMediaStream] = await Promise.all([screenCapture, micCapture]);

if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}

if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
audio: microphoneDeviceId
? {
deviceId: { exact: microphoneDeviceId },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
} catch (audioError) {
console.warn("Failed to get microphone access:", audioError);
toast.error(t("recording.microphoneDenied"));
setMicrophoneEnabled(false);
}
}
screenStream.current = screenMediaStream;
microphoneStream.current = micMediaStream;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Stop locally acquired streams before any early exit from parallel startup.

At Line 1240, Promise.all() can reject after one capture has already succeeded, and the cancellation branch at Line 1242 returns before either local stream is copied into screenStream.current / microphoneStream.current. teardownMedia() only stops ref-held streams, so the successful local capture can stay live with the screen/mic indicator on after startup fails or the countdown is cancelled.

Suggested fix
-			const [screenMediaStream, micMediaStream] = await Promise.all([screenCapture, micCapture]);
-
-			if (!isCountdownRunActive(countdownRunToken)) {
-				teardownMedia();
-				return;
-			}
-
-			screenStream.current = screenMediaStream;
-			microphoneStream.current = micMediaStream;
+			let screenMediaStream: MediaStream | null = null;
+			let micMediaStream: MediaStream | null = null;
+			try {
+				[screenMediaStream, micMediaStream] = await Promise.all([screenCapture, micCapture]);
+				if (!isCountdownRunActive(countdownRunToken)) {
+					screenMediaStream.getTracks().forEach((track) => track.stop());
+					micMediaStream?.getTracks().forEach((track) => track.stop());
+					return;
+				}
+
+				screenStream.current = screenMediaStream;
+				microphoneStream.current = micMediaStream;
+			} catch (error) {
+				screenMediaStream?.getTracks().forEach((track) => track.stop());
+				micMediaStream?.getTracks().forEach((track) => track.stop());
+				throw error;
+			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [screenMediaStream, micMediaStream] = await Promise.all([screenCapture, micCapture]);
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
audio: microphoneDeviceId
? {
deviceId: { exact: microphoneDeviceId },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
} catch (audioError) {
console.warn("Failed to get microphone access:", audioError);
toast.error(t("recording.microphoneDenied"));
setMicrophoneEnabled(false);
}
}
screenStream.current = screenMediaStream;
microphoneStream.current = micMediaStream;
let screenMediaStream: MediaStream | null = null;
let micMediaStream: MediaStream | null = null;
try {
[screenMediaStream, micMediaStream] = await Promise.all([screenCapture, micCapture]);
if (!isCountdownRunActive(countdownRunToken)) {
screenMediaStream.getTracks().forEach((track) => track.stop());
micMediaStream?.getTracks().forEach((track) => track.stop());
return;
}
screenStream.current = screenMediaStream;
microphoneStream.current = micMediaStream;
} catch (error) {
screenMediaStream?.getTracks().forEach((track) => track.stop());
micMediaStream?.getTracks().forEach((track) => track.stop());
throw error;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useScreenRecorder.ts` around lines 1240 - 1248, The parallel
startup in useScreenRecorder can leave a successfully acquired screen or mic
stream running if Promise.all() fails or the countdown aborts before the refs
are assigned. Update the startup flow around the Promise.all and the
isCountdownRunActive(countdownRunToken) early return so any locally acquired
streams are explicitly stopped on failure/cancellation before returning, not
just via teardownMedia(). Use the screenStream and microphoneStream refs as the
cleanup targets, and make sure the successful local captures are always released
even when one branch of the parallel acquisition completes first.

Comment thread src/lib/audioMix.ts
Comment on lines +36 to +55
export function mixAudioTracks({
systemAudioTrack,
micAudioTrack,
}: MixAudioTracksInput): MixAudioTracksResult {
if (systemAudioTrack && micAudioTrack) {
const context = new AudioContext();
const systemSource = context.createMediaStreamSource(new MediaStream([systemAudioTrack]));
const micSource = context.createMediaStreamSource(new MediaStream([micAudioTrack]));
const micGain = context.createGain();
micGain.gain.setValueAtTime(0, context.currentTime);
micGain.gain.linearRampToValueAtTime(MIC_GAIN_BOOST, context.currentTime + MIC_FADE_IN_S);
const destination = context.createMediaStreamDestination();
systemSource.connect(destination);
micSource.connect(micGain).connect(destination);
return { context, track: destination.stream.getAudioTracks()[0] };
}
return {
context: null,
track: systemAudioTrack ?? micAudioTrack ?? null,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply the fade-in on mic-only recordings too.

Lines 40-50 only ramp the gain when both inputs exist. If the user records microphone-only, Line 54 returns the raw micAudioTrack, so the start-of-stream click/pop this PR is meant to suppress still survives in that supported mode. Route the mic-only path through the same AudioContext/GainNode flow as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/audioMix.ts` around lines 36 - 55, The mic fade-in is only applied in
mixAudioTracks when both systemAudioTrack and micAudioTrack are present, so
mic-only recordings still return the raw micAudioTrack and can pop at the start.
Update mixAudioTracks to route the mic-only branch through the same
AudioContext, createGain, and linearRampToValueAtTime flow used for mixed
recordings, using the existing MIC_GAIN_BOOST and MIC_FADE_IN_S logic, while
keeping the return shape consistent with the current MixAudioTracksResult.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Mic recording is laggy/quirky at the start and desynced from the video track

1 participant