diff --git a/electron/ipc/recording/diagnostics.test.ts b/electron/ipc/recording/diagnostics.test.ts index 21192e912..9f7ddeb08 100644 --- a/electron/ipc/recording/diagnostics.test.ts +++ b/electron/ipc/recording/diagnostics.test.ts @@ -134,6 +134,32 @@ describe("getCompanionAudioFallbackPaths", () => { ]); }); + it("prefers the mac mic companion alone when embedded audio already exists and no system sidecar is present", async () => { + const videoPath = path.join(tempRoot, "recording.mp4"); + const micPath = path.join(tempRoot, "recording.mic.m4a"); + + await Promise.all([fs.writeFile(videoPath, "video"), fs.writeFile(micPath, "mic")]); + + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: Record, + callback: ExecFileCallback, + ) => { + const error = new Error("ffmpeg probe found embedded audio") as Error & { + stderr?: string; + }; + error.stderr = "Stream #0:1: Audio: aac"; + callback(error, "", error.stderr); + }, + ); + + const { getCompanionAudioFallbackPaths } = await import("./diagnostics"); + + await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([micPath]); + }); + it("loads saved sidecar timing metadata alongside companion audio paths", async () => { const videoPath = path.join(tempRoot, "recording.mp4"); const micPath = path.join(tempRoot, "recording.mic.webm"); diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index 811e0920e..1f0003a9a 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -508,20 +508,41 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) { let paths: string[]; if (await hasEmbeddedAudioStream(videoPath)) { - const companionPaths = Array.from( + const hasUsableMacSystemCompanion = companionCandidates.some( + (candidate) => + candidate.platform === "mac" && + candidate.usablePaths.includes(candidate.systemPath), + ); + const usableMacMicOnlyCompanions = Array.from( new Set( companionCandidates.flatMap((candidate) => - candidate.usablePaths.filter( - (companionPath) => companionPath === candidate.micPath, - ), + candidate.platform === "mac" && + !candidate.usablePaths.includes(candidate.systemPath) && + candidate.usablePaths.includes(candidate.micPath) + ? [candidate.micPath] + : [], ), ), ); - if (companionPaths.length === 0) { - return { paths: [], startDelayMsByPath: {} }; - } - paths = [videoPath, ...companionPaths]; + if (!hasUsableMacSystemCompanion && usableMacMicOnlyCompanions.length > 0) { + paths = usableMacMicOnlyCompanions; + } else { + const companionPaths = Array.from( + new Set( + companionCandidates.flatMap((candidate) => + candidate.usablePaths.filter( + (companionPath) => companionPath === candidate.micPath, + ), + ), + ), + ); + if (companionPaths.length === 0) { + return { paths: [], startDelayMsByPath: {} }; + } + + paths = [videoPath, ...companionPaths]; + } } else { paths = Array.from( new Set(companionCandidates.flatMap((candidate) => candidate.usablePaths)), diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index c5cd8fabf..1e2a397aa 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -77,9 +77,6 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } writesSystemAudioToSeparateTrack = capturesSystemAudio writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone - if capturesMicrophone && !capturesSystemAudio { - writesMicrophoneToSeparateTrack = true - } let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) streamConfig.queueDepth = 6 diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 91b54570d..259f18848 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 3696e45df..09e0ed5d6 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/src/lib/exporter/audioEncoder.test.ts b/src/lib/exporter/audioEncoder.test.ts index 6047b5488..92a1a8872 100644 --- a/src/lib/exporter/audioEncoder.test.ts +++ b/src/lib/exporter/audioEncoder.test.ts @@ -161,6 +161,28 @@ describe("AudioProcessor offline render preparation", () => { expect(renderAndMuxOfflineAudio).toHaveBeenCalled(); }); + it("avoids the single-sidecar fast path for legacy mac mic sidecars that still need embedded audio", async () => { + const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness; + const loadAudioFileDemuxer = vi.spyOn(processor, "loadAudioFileDemuxer"); + const renderAndMuxOfflineAudio = vi + .spyOn(processor, "renderAndMuxOfflineAudio") + .mockResolvedValue(); + + await processor.process( + {} as never, + {} as never, + "file:///tmp/recording.mp4", + [], + [], + undefined, + [], + ["/tmp/recording.mic.m4a"], + ); + + expect(loadAudioFileDemuxer).not.toHaveBeenCalled(); + expect(renderAndMuxOfflineAudio).toHaveBeenCalled(); + }); + it("soft-limits mixed peaks before encoding or WAV conversion", () => { const samples = new Float32Array([ -1.6, diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index d66e0beac..6f1ca0757 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -263,12 +263,18 @@ export class AudioProcessor { videoUrl, sortedSourceAudioFallbackPaths, ); + const requiresLegacyMacMicSidecarMix = + routingPolicy.includeEmbeddedInExport && + !routingPolicy.hasEmbeddedSourceAudio && + routingPolicy.playbackPaths.length === 1 && + routingPolicy.playbackPaths[0]?.toLowerCase().endsWith(".mic.m4a") === true; const hasTimedCompanionAudio = routingPolicy.playbackPaths.some( (audioPath) => (sourceAudioFallbackStartDelayMsByPath?.[audioPath] ?? 0) > 0, ); const needsSourceAudioMixing = routingPolicy.playbackPaths.length > 1 || (routingPolicy.hasEmbeddedSourceAudio && routingPolicy.playbackPaths.length > 0) || + requiresLegacyMacMicSidecarMix || hasTimedCompanionAudio; // When speed edits, audio regions, or multiple audio sources need mixing, use offline AudioContext pipeline.