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.
This commit is contained in:
webadderall committed 2026-04-12 16:59:06 +10:00
1 parent 1764607ef0
commit 20f42d3fca
5 files changed
+111 -9

No files matched your search

+5 -1
View File
@@ -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;
+25 -3
View File
@@ -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()
@@ -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
+3
View File
@@ -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");
},
+75
View File
@@ -102,6 +102,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const pauseStartedAtMs = useRef<number | null>(null);
const pauseSegmentsRef = useRef<PauseSegment[]>([]);
const recordingFinalizationToastId = useRef<string | number | null>(null);
const micFallbackRecorder = useRef<MediaRecorder | null>(null);
const micFallbackChunks = useRef<Blob[]>([]);
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<Blob | null> => {
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);