mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
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:
1 parent
1764607ef0
commit
20f42d3fca
5 files changed
+111
-9
No files matched your search
Vendored
+5
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user