From 06810e2febe76ad0ad23faf1fe6387ff26c917dd Mon Sep 17 00:00:00 2001 From: AbhishekBarali1 Date: Sat, 1 Aug 2026 16:52:02 +0545 Subject: [PATCH] fix(recording): publish Windows mic sidecar atomically FFmpeg wrote the mic sidecar straight to its final path while the editor was already open. An unfinalized WAV advertises data size 0xFFFFFFFF, so the editor loaded a valid-looking but truncated track. --- electron/electron-env.d.ts | 5 + electron/ipc/constants.ts | 11 ++ electron/ipc/recording/diagnostics.test.ts | 110 ++++++++++++++++++ electron/ipc/recording/diagnostics.ts | 60 +++++++++- electron/ipc/recording/prune.ts | 4 + electron/ipc/register/recording.ts | 59 +++++++++- electron/ipc/utils.ts | 14 ++- .../audio/useSourceAudioFallback.ts | 21 +++- src/hooks/useScreenRecorder.ts | 16 ++- 9 files changed, 290 insertions(+), 10 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..745e47c16 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -564,6 +564,11 @@ interface Window { success: boolean; paths: string[]; startDelayMsByPath?: Record; + /** + * True while a companion audio sidecar for this recording is still being + * written, meaning `paths` may not list it yet. + */ + pending?: boolean; error?: string; }>; setRecordingState: (recording: boolean) => Promise; diff --git a/electron/ipc/constants.ts b/electron/ipc/constants.ts index 2c5cf8f17..9c3659b5a 100644 --- a/electron/ipc/constants.ts +++ b/electron/ipc/constants.ts @@ -26,6 +26,17 @@ export const COMPANION_AUDIO_LAYOUTS = [ { platform: "mac" as const, systemSuffix: ".system.webm", micSuffix: ".mic.webm" }, ]; +/** + * Suffix appended while a companion audio sidecar is still being produced. + * + * Encoders such as FFmpeg write media containers progressively and only patch + * their headers on completion, so a partially written file can still look like a + * complete, shorter recording. Staging under this suffix keeps in-progress files + * outside every sidecar discovery path until they are published with an atomic + * rename. + */ +export const INCOMPLETE_SIDECAR_SUFFIX = ".incomplete"; + export const CURSOR_TELEMETRY_VERSION = 2; export const CURSOR_SAMPLE_INTERVAL_MS = 33; export const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz diff --git a/electron/ipc/recording/diagnostics.test.ts b/electron/ipc/recording/diagnostics.test.ts index 9f7ddeb08..dd1a76243 100644 --- a/electron/ipc/recording/diagnostics.test.ts +++ b/electron/ipc/recording/diagnostics.test.ts @@ -5,6 +5,57 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type ExecFileCallback = (error: Error | null, stdout?: string, stderr?: string) => void; +const WAV_STREAMING_SIZE = 0xffffffff; + +/** + * Builds a 48 kHz mono 16-bit WAV using the chunk layout FFmpeg emits + * (`fmt `, `LIST`, `data`). While FFmpeg is still encoding, the RIFF and data + * sizes hold the 0xFFFFFFFF "unknown length" sentinel and are only patched when + * the file is finalized. + */ +function buildWavFile(frameCount: number, { finalized }: { finalized: boolean }): Buffer { + const listPayload = Buffer.alloc(26); + listPayload.write("INFOISFT", 0, "ascii"); + const pcm = Buffer.alloc(frameCount * 2); + const dataSize = finalized ? pcm.length : WAV_STREAMING_SIZE; + + const header = Buffer.alloc(12 + 8 + 16 + 8 + listPayload.length + 8); + let offset = 0; + header.write("RIFF", offset, "ascii"); + offset += 4; + header.writeUInt32LE(finalized ? header.length - 8 + pcm.length : WAV_STREAMING_SIZE, offset); + offset += 4; + header.write("WAVE", offset, "ascii"); + offset += 4; + header.write("fmt ", offset, "ascii"); + offset += 4; + header.writeUInt32LE(16, offset); + offset += 4; + header.writeUInt16LE(1, offset); // PCM + offset += 2; + header.writeUInt16LE(1, offset); // mono + offset += 2; + header.writeUInt32LE(48000, offset); + offset += 4; + header.writeUInt32LE(96000, offset); // byte rate + offset += 4; + header.writeUInt16LE(2, offset); // block align + offset += 2; + header.writeUInt16LE(16, offset); // bits per sample + offset += 2; + header.write("LIST", offset, "ascii"); + offset += 4; + header.writeUInt32LE(listPayload.length, offset); + offset += 4; + listPayload.copy(header, offset); + offset += listPayload.length; + header.write("data", offset, "ascii"); + offset += 4; + header.writeUInt32LE(dataSize, offset); + + return Buffer.concat([header, pcm]); +} + describe("getCompanionAudioFallbackPaths", () => { let tempRoot: string; let appDataPath: string; @@ -193,6 +244,65 @@ describe("getCompanionAudioFallbackPaths", () => { }); }); + it("ignores a microphone sidecar whose WAV header is not finalized yet", async () => { + const videoPath = path.join(tempRoot, "recording.mp4"); + const systemPath = path.join(tempRoot, "recording.system.wav"); + const micPath = path.join(tempRoot, "recording.mic.wav"); + + await Promise.all([ + fs.writeFile(videoPath, "video"), + fs.writeFile(systemPath, buildWavFile(4800, { finalized: true })), + // Mid-encode: FFmpeg still advertises an unknown data size, so this file + // parses as a valid but far too short recording. + fs.writeFile(micPath, buildWavFile(4800, { finalized: false })), + ]); + + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: Record, + callback: ExecFileCallback, + ) => { + const error = new Error("ffmpeg probe failed") as Error & { stderr?: string }; + error.stderr = "Stream #0:0: Video: h264"; + callback(error, "", error.stderr); + }, + ); + + const { getCompanionAudioFallbackPaths } = await import("./diagnostics"); + + await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([systemPath]); + + // Once the encoder finalizes the header the sidecar becomes usable. + await fs.writeFile(micPath, buildWavFile(4800, { finalized: true })); + await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([ + systemPath, + micPath, + ]); + }); + + it("classifies finalized, unfinalized and non-RIFF sidecars", async () => { + const { isFinalizedWavFile } = await import("./diagnostics"); + + const finalizedPath = path.join(tempRoot, "finalized.wav"); + const streamingPath = path.join(tempRoot, "streaming.wav"); + const notRiffPath = path.join(tempRoot, "not-riff.wav"); + const missingPath = path.join(tempRoot, "missing.wav"); + + await Promise.all([ + fs.writeFile(finalizedPath, buildWavFile(960, { finalized: true })), + fs.writeFile(streamingPath, buildWavFile(960, { finalized: false })), + fs.writeFile(notRiffPath, "mic"), + ]); + + await expect(isFinalizedWavFile(finalizedPath)).resolves.toBe(true); + await expect(isFinalizedWavFile(streamingPath)).resolves.toBe(false); + // Non-WAV companions (m4a/webm) and unreadable files are left to other checks. + await expect(isFinalizedWavFile(notRiffPath)).resolves.toBe(true); + await expect(isFinalizedWavFile(missingPath)).resolves.toBe(true); + }); + it("scales audio mux timeout for long recordings", async () => { const { getRecordingAudioMuxTimeoutMs } = await import("./diagnostics"); diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index edf5f638e..f66e32658 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import { promisify } from "node:util"; import { COMPANION_AUDIO_LAYOUTS } from "../constants"; @@ -416,6 +417,62 @@ export async function writeRecordingDiagnosticsSnapshot( return diagnosticsPath; } +/** + * WAV writers that stream their output advertise an unknown `data` chunk size of + * 0xFFFFFFFF and only patch it in once the file is finalized. Such a file parses + * as a valid WAV that ends wherever the writer happens to have flushed, which is + * why a mid-write microphone sidecar used to surface in the editor as a recording + * that stops after a few seconds. Treat those files as not yet usable. + */ +const WAV_STREAMING_DATA_SIZE = 0xffffffff; +const WAV_HEADER_SCAN_BYTES = 4096; + +export async function isFinalizedWavFile(filePath: string): Promise { + let handle: FileHandle | null = null; + try { + handle = await fs.open(filePath, "r"); + const buffer = Buffer.alloc(WAV_HEADER_SCAN_BYTES); + const { bytesRead } = await handle.read(buffer, 0, WAV_HEADER_SCAN_BYTES, 0); + if (bytesRead < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") { + // Not a RIFF/WAV container, so this check does not apply. + return true; + } + + if (buffer.readUInt32LE(4) === WAV_STREAMING_DATA_SIZE) { + return false; + } + + let offset = 12; + while (offset + 8 <= bytesRead) { + const chunkId = buffer.toString("ascii", offset, offset + 4); + const chunkSize = buffer.readUInt32LE(offset + 4); + if (chunkId === "data") { + return chunkSize !== WAV_STREAMING_DATA_SIZE; + } + if (chunkSize === 0) { + break; + } + offset += 8 + chunkSize + (chunkSize % 2); + } + + // Header is larger than the scanned window; assume the file is usable. + return true; + } catch { + return true; + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function isUsableCompanionAudioFile(companionPath: string): Promise { + const stat = await fs.stat(companionPath); + if (stat.size <= 0) { + return false; + } + + return companionPath.toLowerCase().endsWith(".wav") ? isFinalizedWavFile(companionPath) : true; +} + export async function getUsableCompanionAudioCandidates( videoPath: string, ): Promise { @@ -429,8 +486,7 @@ export async function getUsableCompanionAudioCandidates( for (const companionPath of [systemPath, micPath]) { try { - const stat = await fs.stat(companionPath); - if (stat.size > 0) { + if (await isUsableCompanionAudioFile(companionPath)) { usablePaths.push(companionPath); } } catch { diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index d8004bd14..895548ec1 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -4,6 +4,7 @@ import { AUTO_RECORDING_MAX_AGE_MS, AUTO_RECORDING_RETENTION_COUNT, COMPANION_AUDIO_LAYOUTS, + INCOMPLETE_SIDECAR_SUFFIX, LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION, PROJECTS_DIRECTORY_NAME, @@ -174,6 +175,9 @@ export async function pruneAutoRecordings(exemptPaths: string[] = []) { COMPANION_AUDIO_LAYOUTS.flatMap((layout) => [ layout.systemSuffix, layout.micSuffix, + // Staging files left behind if the app exited mid-conversion. + `${layout.systemSuffix}${INCOMPLETE_SIDECAR_SUFFIX}`, + `${layout.micSuffix}${INCOMPLETE_SIDECAR_SUFFIX}`, ]), ), ); diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 06a33f84f..1dfbd7b62 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -14,7 +14,7 @@ import { } from "electron"; import { showCursor } from "../../cursorHider"; import { getMonitorHandles } from "../monitorResolver"; -import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; +import { ALLOW_RECORDLY_WINDOW_CAPTURE, INCOMPLETE_SIDECAR_SUFFIX } from "../constants"; import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; @@ -171,6 +171,33 @@ async function writeWindowsRecordingDiagnostics( } } +/** + * Recordings whose companion audio sidecar is still being written. + * + * The editor opens as soon as capture stops, while converting the microphone + * sidecar takes a few more seconds. Sidecars are only published once complete, + * so a lookup during that window legitimately reports no microphone track. This + * set lets the editor learn that a track is still on its way and re-check, + * instead of depending solely on the session-changed broadcast. + */ +const pendingCompanionAudioSidecars = new Set(); + +function getCompanionAudioSidecarKey(videoPath: string) { + return path.resolve(videoPath).toLowerCase(); +} + +function markCompanionAudioSidecarPending(videoPath: string) { + pendingCompanionAudioSidecars.add(getCompanionAudioSidecarKey(videoPath)); +} + +function clearCompanionAudioSidecarPending(videoPath: string) { + pendingCompanionAudioSidecars.delete(getCompanionAudioSidecarKey(videoPath)); +} + +function isCompanionAudioSidecarPending(videoPath: string) { + return pendingCompanionAudioSidecars.has(getCompanionAudioSidecarKey(videoPath)); +} + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -1396,7 +1423,12 @@ export function registerRecordingHandlers( rememberApprovedLocalReadPath(videoPath), ...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)), ]); - return { success: true, paths, startDelayMsByPath }; + return { + success: true, + paths, + startDelayMsByPath, + pending: isCompanionAudioSidecarPending(videoPath), + }; } catch (error) { console.error("Failed to resolve companion audio fallback paths:", error); return { success: false, paths: [], startDelayMsByPath: {}, error: String(error) }; @@ -1617,9 +1649,19 @@ export function registerRecordingHandlers( ) => { const baseName = videoPath.replace(/\.[^.]+$/, ""); const sidecarPath = `${baseName}.mic.wav`; + // FFmpeg writes WAV data progressively and only patches the RIFF/data sizes + // when it finalizes the file. A reader that opens the output while the encode + // is still running therefore sees a valid-looking WAV that ends early, because + // the unfinalized `data` chunk advertises 0xFFFFFFFF ("read until EOF"). + // The editor opens right after capture stops, several seconds before this + // conversion finishes, so encoding straight to `sidecarPath` made it load a + // truncated microphone track. Encode to a staging path that no consumer scans + // for, then publish it with a single atomic rename. + const stagingSidecarPath = `${sidecarPath}${INCOMPLETE_SIDECAR_SUFFIX}`; const sourceWebmPath = `${baseName}.mic.source.webm`; const tempWebmPath = `${sourceWebmPath}.tmp`; + markCompanionAudioSidecarPending(videoPath); try { await fs.writeFile(tempWebmPath, Buffer.from(audioData)); await execFileAsync( @@ -1643,7 +1685,11 @@ export function registerRecordingHandlers( ].join(","), "-c:a", "pcm_s16le", - sidecarPath, + // The staging filename intentionally does not end in .wav, so the + // container has to be stated explicitly instead of inferred. + "-f", + "wav", + stagingSidecarPath, ], { timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, ); @@ -1712,6 +1758,8 @@ export function registerRecordingHandlers( }; if (Object.keys(metadata).length > 0) { try { + // Written before the sidecar is published so a consumer that + // discovers the microphone track always finds its timing metadata. await fs.writeFile(`${sidecarPath}.json`, JSON.stringify(metadata)); } catch (metadataError) { console.warn( @@ -1720,6 +1768,8 @@ export function registerRecordingHandlers( ); } } + // Publish atomically: consumers only ever observe the finalized file. + await moveFileWithOverwrite(stagingSidecarPath, sidecarPath); await writeRecordingDiagnosticsSnapshot(videoPath, { backend: "browser-store", phase: "mic-sidecar", @@ -1740,10 +1790,13 @@ export function registerRecordingHandlers( } catch (error) { await Promise.all([ fs.rm(tempWebmPath, { force: true }).catch(() => undefined), + fs.rm(stagingSidecarPath, { force: true }).catch(() => undefined), fs.rm(sidecarPath, { force: true }).catch(() => undefined), ]); console.error("Failed to store microphone sidecar:", error); return { success: false, error: String(error) }; + } finally { + clearCompanionAudioSidecarPending(videoPath); } }, ); diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts index 3f2efb065..a483a4f5f 100644 --- a/electron/ipc/utils.ts +++ b/electron/ipc/utils.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { app } from "electron"; import { RECORDINGS_DIR } from "../appPaths"; -import { AUTO_RECORDING_PREFIX, RECORDINGS_SETTINGS_FILE } from "./constants"; +import { AUTO_RECORDING_PREFIX, INCOMPLETE_SIDECAR_SUFFIX, RECORDINGS_SETTINGS_FILE } from "./constants"; import { approvedLocalReadPaths, customRecordingsDir, @@ -83,7 +83,17 @@ export async function moveFileWithOverwrite(sourcePath: string, destinationPath: throw error; } - await fs.copyFile(sourcePath, destinationPath); + // Cross-volume moves cannot be renamed directly. Copy into a staging file + // next to the destination first, so readers never observe a partially + // copied recording at the final path, then publish it with a rename. + const stagingPath = `${destinationPath}${INCOMPLETE_SIDECAR_SUFFIX}`; + try { + await fs.copyFile(sourcePath, stagingPath); + await fs.rename(stagingPath, destinationPath); + } catch (copyError) { + await fs.rm(stagingPath, { force: true }).catch(() => undefined); + throw copyError; + } await fs.unlink(sourcePath); } } diff --git a/src/components/video-editor/audio/useSourceAudioFallback.ts b/src/components/video-editor/audio/useSourceAudioFallback.ts index bdaf05408..ad859cfe9 100644 --- a/src/components/video-editor/audio/useSourceAudioFallback.ts +++ b/src/components/video-editor/audio/useSourceAudioFallback.ts @@ -2,6 +2,12 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { SOURCE_AUDIO_FALLBACK_TOAST_ID } from "@/components/video-editor/audio/audioTypes"; +// A microphone sidecar is converted after capture stops, so the editor can open +// before it exists. Re-check on a short interval while the main process reports +// the conversion as still running. +const PENDING_SIDECAR_RECHECK_DELAY_MS = 750; +const PENDING_SIDECAR_MAX_RECHECKS = 240; + interface UseSourceAudioFallbackParams { currentSourcePath: string | null; refreshKey?: number; @@ -16,17 +22,21 @@ export function useSourceAudioFallback({ const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState([]); const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] = useState>({}); + const [pendingRecheckCount, setPendingRecheckCount] = useState(0); const previousSourcePathRef = useRef(null); useEffect(() => { let cancelled = false; + let recheckTimeout: ReturnType | null = null; // Refetch when late recording sidecars are finalized after the editor opens. void refreshKey; + void pendingRecheckCount; const sourceChanged = previousSourcePathRef.current !== currentSourcePath; previousSourcePathRef.current = currentSourcePath; if (sourceChanged) { setSourceAudioFallbackPaths([]); setSourceAudioFallbackStartDelayMsByPath({}); + setPendingRecheckCount(0); } if (!currentSourcePath) { @@ -58,6 +68,12 @@ export function useSourceAudioFallback({ toast.dismiss(SOURCE_AUDIO_FALLBACK_TOAST_ID); setSourceAudioFallbackPaths(result.paths ?? []); setSourceAudioFallbackStartDelayMsByPath(result.startDelayMsByPath ?? {}); + + if (result.pending && pendingRecheckCount < PENDING_SIDECAR_MAX_RECHECKS) { + recheckTimeout = setTimeout(() => { + setPendingRecheckCount((count) => count + 1); + }, PENDING_SIDECAR_RECHECK_DELAY_MS); + } } catch (error) { if (!cancelled) { if (sourceChanged) { @@ -74,8 +90,11 @@ export function useSourceAudioFallback({ return () => { cancelled = true; + if (recheckTimeout) { + clearTimeout(recheckTimeout); + } }; - }, [currentSourcePath, refreshKey, summarizeErrorMessage]); + }, [currentSourcePath, pendingRecheckCount, refreshKey, summarizeErrorMessage]); return { sourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath }; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..e6692a0e7 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1152,9 +1152,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn { fallbackTrackSettings, ); - // Perform muxing/renaming if on Windows + // Perform muxing/renaming if on Windows. A failure here must not + // suppress the session broadcast below: the microphone sidecar is + // only published once the conversion above finishes, so the editor + // depends on that notification to pick the track up at all. if (isNativeWindows) { - await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs); + try { + await window.electronAPI.muxNativeWindowsRecording( + expectedDurationMs, + ); + } catch (muxError) { + console.error( + "Failed to mux native Windows recording audio:", + muxError, + ); + } } console.log(