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(