Skip to content

fix(audio): replace ffmpeg probes with ffprobe for duration probing#216

Merged
webadderall merged 1 commit intowebadderall:mainfrom
MohamedV0:fix/audio-sync-corruption
Apr 11, 2026
Merged

fix(audio): replace ffmpeg probes with ffprobe for duration probing#216
webadderall merged 1 commit intowebadderall:mainfrom
MohamedV0:fix/audio-sync-corruption

Conversation

@MohamedV0
Copy link
Copy Markdown
Contributor

@MohamedV0 MohamedV0 commented Apr 10, 2026

Summary

Fix system audio playing at wrong timestamps in recordings that use both system audio (Windows audio) and microphone. The root cause was a probing function that always returned duration 0, silently disabling all audio sync correction.

The Problem

When recording with both system audio and mic enabled, all system audio events — YouTube audio, notification sounds, UI clicks — appear at incorrect timestamps in the final recording and editor timeline.

How to reproduce

  1. Start recording with both system audio (Windows audio) and microphone enabled
  2. Talk into the mic for a few seconds (no YouTube playing)
  3. Stop talking, then play a YouTube video for a few seconds
  4. Stop the YouTube video, continue talking into the mic
  5. Stop recording, open the recording in the editor

What you see before this fix

  • YouTube audio from the middle of the recording bleeds into the beginning segment where only mic was active
  • All system audio events appear shifted earlier in the timeline by 1-2 seconds
  • The audio does not match what was happening on screen at that point in the video

What you see after this fix

  • System audio appears at the correct timestamp aligned with the video
  • Mic-only sections contain only mic audio — no YouTube bleeding in
  • YouTube audio starts and stops at the exact moment it played on screen

Why it happened

On Windows, WASAPI loopback (system audio capture) starts recording slightly after the video capture begins — typically 0.5-2 seconds late. The application is supposed to detect this delay and align the system audio track with the video. Instead, the duration detection function always returned 0, so the alignment step was silently skipped entirely. System audio and mic audio were mixed into the video with zero correction.

Root Cause

probeMediaDurationSeconds() used ffmpeg -i file -f null - which decodes every frame just to read the container duration. FFmpeg writes metadata to stderr and can return exit code 0 on valid files — the code only captured stderr inside the catch block, so when ffmpeg succeeded, stderr was discarded and duration always returned 0.

Diagnostic logging confirmed:

[probe] ffmpeg exited code 0 (SUCCESS), stderr contains Duration: 00:00:24.92.
This means stderr is LOST — probeMediaDurationSeconds will return 0.

This caused the if (videoDuration > 0) gate in muxNativeWindowsVideoWithAudio() to always be false, silently skipping all audio sync correction. hasEmbeddedAudioStream() had the same misuse — it decoded an entire audio frame just to check if an audio stream exists.

Changes

  • [audio probing] Replace probeMediaDurationSeconds() with ffprobe JSON parsing — reads container headers only (O(1), ~84ms vs ~70s for a 1-hour recording)
  • [audio probing] Replace hasEmbeddedAudioStream() with ffprobe -show_streams -select_streams a — metadata-only, zero frame decoding
  • [benchmark] Replace inspectOutput() in scripts/benchmark-export-queues.mjs with the same ffprobe approach
  • [dependency] Add @derhuerst/ffprobe-static@^5.3.0 (same FFmpeg b6.1.1 build as ffmpeg-static)
  • [packaging] Add asarUnpack entry, vite external entry, and binary path resolver for ffprobe

Testing

  • Manual recording with system audio + USB mic — sync correction now active (previously skipped)
  • A/B test with old ffmpeg approach — confirmed echo is from pre-existing WASAPI silence gap bug, not from this change
  • TypeScript compilation — no errors
  • Vite renderer build — no errors
  • Electron builder packaging — ffprobe binary verified in app.asar.unpacked
  • Benchmark script — inspectOutput() returns correct duration values

Manual test — Windows 11 Pro (Build 26200):

  1. Built with npx vite build && npx electron-builder --win --dir
  2. Recorded with system audio + USB mic enabled, no headphones
  3. Confirmed [mux-win] log shows sync correction active
  4. Opened recording in editor — system audio aligned with video timeline

Risks & Impact

  • No breaking changes — function signatures unchanged, callers unaffected
  • validateRecordedVideo() and parseFfmpegDurationSeconds() intentionally unchanged — they decode frames for codec validation (correct use of ffmpeg)
  • All 9 other ffmpeg usage points (encoding, capturing, listing encoders) unchanged
  • Cross-platform: ffprobe flags are platform-agnostic
  • Package size increase: ~25MB for ffprobe binary
  • Known follow-up (echo with external mic): When recording with system audio + external mic without headphones, YouTube audio may play twice in the recording (echo). This happens because the external mic picks up speaker output at the correct time, while the system audio channel is shifted later in the timeline due to a WASAPI loopback bug that doesn't write silence during quiet periods. This makes the WAV shorter than the video, and the muxer misinterprets the gap as a late start. The fix requires a C++ change in wasapi_loopback.cpp and is tracked separately.

Checklist

  • Self-reviewed the diff
  • No mixed concerns — all changes relate to replacing ffmpeg probing with ffprobe
  • Follows existing loadFfmpegStatic()/getFfmpegBinaryPath() patterns
  • Packaging config updated (asarUnpack, vite external, package.json)

Summary by CodeRabbit

  • Bug Fixes
    • Improved media duration detection for media files.

@github-actions github-actions bot added the Slop label Apr 10, 2026
@github-actions
Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Invalid regex pattern for base branch. Received: "*" at "reviews.auto_review.base_branches[0]"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f9fceda0-4ce8-4429-a0b0-ed02ed2f8d42

📥 Commits

Reviewing files that changed from the base of the PR and between f5f8388 and 0054beb.

📒 Files selected for processing (1)
  • electron/ipc/handlers.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • electron/ipc/handlers.ts

📝 Walkthrough

Walkthrough

A simplification was made to the FFmpeg command invocation in the media duration probe function, replacing a null format output approach with a basic input-only specification while retaining the existing duration parsing fallback logic.

Changes

Cohort / File(s) Summary
FFmpeg Duration Probe Command
electron/ipc/handlers.ts
Simplified FFmpeg invocation by removing null format output arguments (-f null -), retaining only input file specification while preserving existing duration parsing from stderr.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~3 minutes

Poem

🐰 FFmpeg commands, sleek and refined,
Null formats removed, simplicity signed,
One -i flag does the trick,
Duration parsing, swift and slick! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(audio): replace ffmpeg probes with ffprobe for duration probing' directly and clearly describes the main change: replacing ffmpeg-based probing with ffprobe for audio duration detection to fix audio sync issues.
Description check ✅ Passed The PR description comprehensively covers all key sections from the template: detailed problem statement with reproduction steps, root cause analysis, specific changes made, testing verification, and risk assessment. It far exceeds the minimum requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions
Copy link
Copy Markdown
Contributor

⚠️ This pull request has been flagged by Anti-Slop.
Our automated checks detected patterns commonly associated with
low-quality or automated/AI submissions (failure count reached).
No automatic closure — a maintainer will review it.
If this is legitimate work, please add more context, link issues, or ping us.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@electron/ipc/handlers.ts`:
- Around line 1197-1199: In the ffprobe path adjustment inside the
app.isPackaged branch, remove the unnecessary escape of the forward slash in the
regex character class used in ffprobeStatic.replace (change the capture group
from ([\/\\]) to ([/\\]) in the regex /\.asar([/\\])/), leaving the rest of the
replace call and logic (ffprobeStatic.replace and app.isPackaged) intact.

In `@scripts/benchmark-export-queues.mjs`:
- Line 10: Add a fail-fast validation for the imported ffprobeStatic at the
start of main(): check that ffprobeStatic is available (truthy) similar to the
existing ffmpegStatic/electron guards, and if missing log an error (use the same
logger used elsewhere, e.g., processLogger or console.error) and exit with a
non-zero status (process.exit(1)) so inspectOutput() can’t silently return null
and hide a setup failure; reference ffprobeStatic and main() when making the
change.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ddbf42a0-ee86-41fa-89ae-8020b7a2a688

📥 Commits

Reviewing files that changed from the base of the PR and between d5a17d3 and f5f8388.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (5)
  • electron-builder.json5
  • electron/ipc/handlers.ts
  • package.json
  • scripts/benchmark-export-queues.mjs
  • vite.config.ts

Comment on lines +1197 to +1199
if (app.isPackaged) {
return ffprobeStatic.replace(/\.asar([\/\\])/, '.asar.unpacked$1')
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and read the relevant lines
if [ -f "electron/ipc/handlers.ts" ]; then
  echo "File found. Reading lines 1190-1210:"
  sed -n '1190,1210p' "electron/ipc/handlers.ts"
else
  echo "File not found at electron/ipc/handlers.ts"
  # Try to find similar files
  find . -name "handlers.ts" -type f 2>/dev/null | head -5
fi

Repository: webadderall/Recordly

Length of output: 695


🌐 Web query:

Biome regex character class escaping lint rules unnecessary escape forward slash

💡 Result:

Web search failed: Server error: no LLM provider could handle the message


Remove unnecessary escape in regex character class.

Line 1198 has an unnecessary escape of the forward slash inside the character class. The forward slash is not a special character within character classes and doesn't need escaping.

Proposed fix
-    return ffprobeStatic.replace(/\.asar([\/\\])/, '.asar.unpacked$1')
+    return ffprobeStatic.replace(/\.asar([/\\])/, '.asar.unpacked$1')
📝 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
if (app.isPackaged) {
return ffprobeStatic.replace(/\.asar([\/\\])/, '.asar.unpacked$1')
}
if (app.isPackaged) {
return ffprobeStatic.replace(/\.asar([/\\])/, '.asar.unpacked$1')
}
🧰 Tools
🪛 Biome (2.4.10)

[error] 1198-1198: The character doesn't need to be escaped.

(lint/complexity/noUselessEscapeInRegex)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.ts` around lines 1197 - 1199, In the ffprobe path
adjustment inside the app.isPackaged branch, remove the unnecessary escape of
the forward slash in the regex character class used in ffprobeStatic.replace
(change the capture group from ([\/\\]) to ([/\\]) in the regex
/\.asar([/\\])/), leaving the rest of the replace call and logic
(ffprobeStatic.replace and app.isPackaged) intact.

import { promisify } from "node:util";
import electron from "electron";
import ffmpegStatic from "ffmpeg-static";
import ffprobeStatic from "@derhuerst/ffprobe-static";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's locate the file and check its structure
fd -t f benchmark-export-queues.mjs

Repository: webadderall/Recordly

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Read the file to verify the import, usage, and main() function
# Get file size first to ensure it's not too large
wc -l scripts/benchmark-export-queues.mjs

Repository: webadderall/Recordly

Length of output: 103


🏁 Script executed:

#!/bin/bash
# Read the import section and first part of the file
head -n 30 scripts/benchmark-export-queues.mjs | cat -n

Repository: webadderall/Recordly

Length of output: 1952


🏁 Script executed:

#!/bin/bash
# Find the main function definition
grep -n "async function main\|function main" scripts/benchmark-export-queues.mjs

Repository: webadderall/Recordly

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Read around line 725 to see ffprobeStatic usage
sed -n '715,735p' scripts/benchmark-export-queues.mjs | cat -n

Repository: webadderall/Recordly

Length of output: 866


🏁 Script executed:

#!/bin/bash
# Read the main() function (starting around line 825)
sed -n '825,900p' scripts/benchmark-export-queues.mjs | cat -n

Repository: webadderall/Recordly

Length of output: 3150


🏁 Script executed:

#!/bin/bash
# Find the inspectOutput function
grep -n "function inspectOutput\|const inspectOutput" scripts/benchmark-export-queues.mjs

Repository: webadderall/Recordly

Length of output: 123


🏁 Script executed:

#!/bin/bash
# Read the inspectOutput function
sed -n '306,340p' scripts/benchmark-export-queues.mjs | cat -n

Repository: webadderall/Recordly

Length of output: 1195


Add validation guard for ffprobeStatic in main() to fail fast when unavailable.

The ffprobeStatic import at line 10 is currently never validated in the main() function, unlike ffmpegStatic (line 826) and electron (line 830). When ffprobeStatic is invalid or unavailable, the inspectOutput() function silently catches the error and returns null, causing benchmark duration metrics to degrade without surfacing the setup failure.

Add this validation to the beginning of main():

if (typeof ffmpegStatic !== "string" || ffmpegStatic.length === 0) {
  throw new Error("ffmpeg-static is unavailable for this platform");
}

+if (typeof ffprobeStatic !== "string" || ffprobeStatic.length === 0) {
+  throw new Error("@derhuerst/ffprobe-static is unavailable for this platform");
+}
+
if (typeof electron !== "string" || electron.length === 0) {
  throw new Error("The Electron binary is unavailable in this workspace");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/benchmark-export-queues.mjs` at line 10, Add a fail-fast validation
for the imported ffprobeStatic at the start of main(): check that ffprobeStatic
is available (truthy) similar to the existing ffmpegStatic/electron guards, and
if missing log an error (use the same logger used elsewhere, e.g., processLogger
or console.error) and exit with a non-zero status (process.exit(1)) so
inspectOutput() can’t silently return null and hide a setup failure; reference
ffprobeStatic and main() when making the change.

@webadderall
Copy link
Copy Markdown
Owner

This PR is great, but ffprobe isn't bundled. We only have ffmpeg-static. Easy fix is drop -f null - from the existing ffmpeg call so it reads the header instead of decoding full file

- Remove -f null - from probeMediaDurationSeconds() ffmpeg args
  so the command always exits non-zero (no output file specified)
- Without -f null -, ffmpeg exits code 1 and stderr lands in the
  catch block where Duration is parsed, fixing the bug where
  duration returned 0 on valid recordings
- probeMediaDurationSeconds returning 0 caused the
  videoDuration > 0 gate to skip all audio sync correction,
  leaving system audio misaligned in the final recording
@MohamedV0 MohamedV0 force-pushed the fix/audio-sync-corruption branch from f5f8388 to 0054beb Compare April 10, 2026 17:34
webadderall added a commit that referenced this pull request Apr 11, 2026
- Remove dead time= progress matching (no decode pass = no progress output)
- Remove stale comments about ffmpeg success/fallback behavior
- Lower timeout from 30s to 5s (header read is near-instant)
- Drop maxBuffer override (minimal stderr output now)
- Add -hide_banner to reduce stderr noise
@webadderall webadderall merged commit 0054beb into webadderall:main Apr 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants