From 20f42d3fca4b03608b19abb6c1ad8c1bc93efeff Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:18:16 +1000 Subject: [PATCH] fix(audio): add browser microphone fallback for macOS < 14 On macOS versions before Sonoma (14.0), SCStreamConfiguration does not support the captureMicrophone/microphoneCaptureDeviceID selectors. The native helper was throwing a fatal error, blocking recording entirely when microphone was enabled. Now the Swift helper logs MICROPHONE_CAPTURE_UNAVAILABLE and continues capture without microphone. The Node handler detects this signal and returns microphoneFallbackRequired to the renderer, which starts a browser-side MediaRecorder capturing the mic via getUserMedia. On stop, the audio blob is saved as a .mic.webm sidecar alongside the native recording, where the existing companion audio detection picks it up. Fixes: 'Native microphone capture is unavailable on this macOS/Xcode runtime' error on older macOS versions. --- electron/electron-env.d.ts | 6 +- electron/ipc/handlers.ts | 28 ++++++- .../native/ScreenCaptureKitRecorder.swift | 8 +- electron/preload.ts | 3 + src/hooks/useScreenRecorder.ts | 75 +++++++++++++++++++ 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d25fbafe..440e3a0a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -88,7 +88,7 @@ interface Window { microphoneDeviceId?: string; microphoneLabel?: string; }, - ) => Promise<{ success: boolean; path?: string; message?: string; error?: string; userNotified?: boolean }>; + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string; userNotified?: boolean; microphoneFallbackRequired?: boolean }>; stopNativeScreenRecording: () => Promise<{ success: boolean; path?: string; @@ -128,6 +128,10 @@ interface Window { videoData: ArrayBuffer, fileName: string, ) => Promise<{ success: boolean; path?: string; message?: string }>; + storeMicrophoneSidecar: ( + audioData: ArrayBuffer, + videoPath: string, + ) => Promise<{ success: boolean; path?: string; error?: string }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; listAssetDirectory: (relativeDir: string) => Promise<{ success: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4b6753b5..37186c0c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -48,6 +48,7 @@ const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, 'ggml-small.bin') const COMPANION_AUDIO_LAYOUTS = [ { platform: 'mac' as const, systemSuffix: '.system.m4a', micSuffix: '.mic.m4a' }, { platform: 'win' as const, systemSuffix: '.system.wav', micSuffix: '.mic.wav' }, + { platform: 'mac' as const, systemSuffix: '.system.webm', micSuffix: '.mic.webm' }, ] function getAssetRootPath() { @@ -868,7 +869,7 @@ async function pruneAutoRecordings(exemptPaths: string[] = []) { await fs.rm(getTelemetryPathForVideo(entry.filePath), { force: true }) // Clean up companion audio files left from recording (macOS .m4a, Windows .wav) const base = entry.filePath.replace(/\.(mp4|mov|webm)$/i, '') - for (const suffix of ['.system.m4a', '.mic.m4a', '.system.wav', '.mic.wav']) { + for (const suffix of ['.system.m4a', '.mic.m4a', '.system.wav', '.mic.wav', '.mic.webm', '.system.webm']) { await fs.rm(base + suffix, { force: true }).catch(() => {}) } } catch (error) { @@ -4388,6 +4389,15 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} await waitForNativeCaptureStart(nativeCaptureProcess) nativeScreenRecordingActive = true + + // If the native helper reported MICROPHONE_CAPTURE_UNAVAILABLE, it started + // capture without microphone. Clear the mic path so the renderer can fall + // back to a browser-side sidecar recording for the microphone track. + const micUnavailableNatively = nativeCaptureOutputBuffer.includes('MICROPHONE_CAPTURE_UNAVAILABLE') + if (micUnavailableNatively) { + nativeCaptureMicrophonePath = null + } + recordNativeCaptureDiagnostics({ backend: 'mac-screencapturekit', phase: 'start', @@ -4397,10 +4407,10 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} helperPath, outputPath, systemAudioPath: systemAudioOutputPath, - microphonePath: microphoneOutputPath, + microphonePath: nativeCaptureMicrophonePath, processOutput: nativeCaptureOutputBuffer.trim() || undefined, }) - return { success: true } + return { success: true, microphoneFallbackRequired: micUnavailableNatively } } catch (error) { console.error('Failed to start native ScreenCaptureKit recording:', error) const errorStr = String(error) @@ -4949,6 +4959,18 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} + ipcMain.handle('store-microphone-sidecar', async (_, audioData: ArrayBuffer, videoPath: string) => { + try { + const baseName = videoPath.replace(/\.[^.]+$/, '') + const sidecarPath = `${baseName}.mic.webm` + await fs.writeFile(sidecarPath, Buffer.from(audioData)) + return { success: true, path: sidecarPath } + } catch (error) { + console.error('Failed to store microphone sidecar:', error) + return { success: false, error: String(error) } + } + }) + ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => { try { const recordingsDir = await getRecordingsDir() diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 5424e166..437bfdaa 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -71,11 +71,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { capturesSystemAudio = config.capturesSystemAudio ?? false capturesMicrophone = config.capturesMicrophone ?? false if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) { - throw NSError( - domain: "RecordlyCapture", - code: 10, - userInfo: [NSLocalizedDescriptionKey: "Native microphone capture is unavailable on this macOS/Xcode runtime"] - ) + fputs("MICROPHONE_CAPTURE_UNAVAILABLE\n", stderr) + fflush(stderr) + capturesMicrophone = false } writesSystemAudioToSeparateTrack = capturesSystemAudio writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone diff --git a/electron/preload.ts b/electron/preload.ts index 7f6e69bc..4478a02b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -222,6 +222,9 @@ contextBridge.exposeInMainWorld("electronAPI", { storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); }, + storeMicrophoneSidecar: (audioData: ArrayBuffer, videoPath: string) => { + return ipcRenderer.invoke("store-microphone-sidecar", audioData, videoPath); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); }, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index b9db5c23..b86ca81d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -102,6 +102,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const pauseStartedAtMs = useRef(null); const pauseSegmentsRef = useRef([]); const recordingFinalizationToastId = useRef(null); + const micFallbackRecorder = useRef(null); + const micFallbackChunks = useRef([]); const showRecordingFinalizationToast = useCallback((message = "Preparing recording...") => { recordingFinalizationToastId.current = toast.loading(message, { @@ -307,6 +309,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { mixingContext.current.close().catch(() => {}); mixingContext.current = null; } + + if (micFallbackRecorder.current) { + try { + if (micFallbackRecorder.current.state !== "inactive") { + micFallbackRecorder.current.stop(); + } + micFallbackRecorder.current.stream?.getTracks().forEach((track) => track.stop()); + } catch { /* ignore */ } + micFallbackRecorder.current = null; + micFallbackChunks.current = []; + } }, []); const resolveBrowserCaptureSource = useCallback(async (source: any) => { @@ -526,6 +539,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void (async () => { showRecordingFinalizationToast(); + const micFallbackBlobPromise = stopMicFallbackRecorder(); const webcamPath = await stopWebcamRecorder(); const isNativeWindows = nativeWindowsRecording.current; markRecordingResumed(Date.now()); @@ -567,6 +581,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalPath = muxResult?.path ?? result.path; } + // Save the browser-captured microphone sidecar if one was recorded. + const micFallbackBlob = await micFallbackBlobPromise; + if (micFallbackBlob && finalPath) { + try { + const arrayBuffer = await micFallbackBlob.arrayBuffer(); + await window.electronAPI.storeMicrophoneSidecar(arrayBuffer, finalPath); + } catch (e) { + console.warn("Failed to store microphone sidecar:", e); + } + } + await finalizeRecordingSession(finalPath, webcamPath); })(); return; @@ -702,6 +727,31 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; }, [cleanupCapturedMedia, recoverNativeRecordingSession]); + const stopMicFallbackRecorder = useCallback((): Promise => { + return new Promise((resolve) => { + const recorder = micFallbackRecorder.current; + if (!recorder || recorder.state === "inactive") { + micFallbackRecorder.current = null; + resolve(null); + return; + } + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + micFallbackChunks.current.push(event.data); + } + }; + recorder.onstop = () => { + const blob = micFallbackChunks.current.length > 0 + ? new Blob(micFallbackChunks.current, { type: recorder.mimeType }) + : null; + micFallbackChunks.current = []; + micFallbackRecorder.current = null; + resolve(blob); + }; + recorder.stop(); + }); + }, []); + const startRecording = async () => { if (startInFlight.current) { return; @@ -812,6 +862,31 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamTimeOffsetMs.current = webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; + + // When native mic capture is unavailable (macOS < 14), record mic + // via browser getUserMedia so it can be saved as a sidecar file. + if (nativeResult.microphoneFallbackRequired && microphoneEnabled) { + try { + const micStream = await navigator.mediaDevices.getUserMedia({ + audio: microphoneDeviceId + ? { deviceId: { exact: microphoneDeviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + video: false, + }); + micFallbackChunks.current = []; + const recorder = new MediaRecorder(micStream, { mimeType: "audio/webm;codecs=opus" }); + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + micFallbackChunks.current.push(event.data); + } + }; + recorder.start(1000); + micFallbackRecorder.current = recorder; + } catch (micError) { + console.warn("Browser microphone fallback failed:", micError); + } + } + setRecording(true); window.electronAPI?.setRecordingState(true);