Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,35 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
]
}

private static func isLikelyDisplayAudioDevice(_ device: AVCaptureDevice) -> Bool {
let normalizedName = device.localizedName
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if normalizedName.isEmpty {
return false
}

let microphoneTokens = ["mic", "microphone", "headset", "airpods", "airpods max"]
let mentionsMicrophone = microphoneTokens.contains { normalizedName.contains($0) }
if normalizedName.contains("sidecar") {
return !mentionsMicrophone
}

if normalizedName.contains("continuity") && !mentionsMicrophone {
return true
}

let displayRouteTokens = ["display", "monitor", "screen", "hdmi", "airplay"]
let audioOutputTokens = ["audio", "speaker", "output"]
let mentionsDisplayRoute = displayRouteTokens.contains { normalizedName.contains($0) }
let mentionsAudioOutput = audioOutputTokens.contains { normalizedName.contains($0) }
return mentionsDisplayRoute && mentionsAudioOutput && !mentionsMicrophone
}

private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? {
let audioDevices = AVCaptureDevice.devices(for: .audio)
let audioDevices = AVCaptureDevice.devices(for: .audio).filter {
!isLikelyDisplayAudioDevice($0)
}

if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty {
if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) {
Expand Down
Binary file not shown.
Binary file modified electron/native/bin/darwin-x64/recordly-screencapturekit-helper
Binary file not shown.
58 changes: 58 additions & 0 deletions src/hooks/useMicrophoneDevices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";

import {
getAvailableMicrophoneDevices,
isLikelyDisplayAudioInputLabel,
} from "./useMicrophoneDevices";

describe("isLikelyDisplayAudioInputLabel", () => {
it("filters Sidecar and display-audio routes", () => {
expect(isLikelyDisplayAudioInputLabel("Sidecar Display Audio")).toBe(true);
expect(isLikelyDisplayAudioInputLabel("LG HDR 4K Display Audio")).toBe(true);
expect(isLikelyDisplayAudioInputLabel("AirPlay Receiver Audio")).toBe(true);
});

it("keeps real microphones even when the label mentions a display", () => {
expect(isLikelyDisplayAudioInputLabel("Studio Display Microphone")).toBe(false);
expect(isLikelyDisplayAudioInputLabel("MacBook Pro Microphone")).toBe(false);
expect(isLikelyDisplayAudioInputLabel("AirPods Max Microphone")).toBe(false);
});
});

describe("getAvailableMicrophoneDevices", () => {
it("excludes likely display-audio endpoints from the microphone list", () => {
const devices = getAvailableMicrophoneDevices([
{
deviceId: "default",
groupId: "builtin-group",
kind: "audioinput",
label: "MacBook Pro Microphone",
},
{
deviceId: "sidecar",
groupId: "sidecar-group",
kind: "audioinput",
label: "Sidecar Display Audio",
},
{
deviceId: "studio-display",
groupId: "display-group",
kind: "audioinput",
label: "Studio Display Microphone",
},
] satisfies MediaDeviceInfo[]);

expect(devices).toEqual([
{
deviceId: "default",
groupId: "builtin-group",
label: "MacBook Pro Microphone",
},
{
deviceId: "studio-display",
groupId: "display-group",
label: "Studio Display Microphone",
},
]);
});
});
64 changes: 49 additions & 15 deletions src/hooks/useMicrophoneDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,48 @@ export interface MicrophoneDevice {
groupId: string;
}

type AudioInputDeviceLike = Pick<MediaDeviceInfo, "deviceId" | "groupId" | "kind" | "label">;

const DISPLAY_AUDIO_ROUTE_TOKENS = ["display", "monitor", "screen", "hdmi", "airplay"];
const AUDIO_OUTPUT_TOKENS = ["audio", "speaker", "output"];
const MICROPHONE_TOKENS = ["mic", "microphone", "headset", "airpods", "airpods max"];

export function isLikelyDisplayAudioInputLabel(label: string): boolean {
const normalizedLabel = label.trim().toLowerCase();
if (!normalizedLabel) {
return false;
}

const mentionsMicrophone = MICROPHONE_TOKENS.some((token) => normalizedLabel.includes(token));
if (normalizedLabel.includes("sidecar")) {
return !mentionsMicrophone;
}

if (normalizedLabel.includes("continuity") && !mentionsMicrophone) {
return true;
}

const mentionsDisplayRoute = DISPLAY_AUDIO_ROUTE_TOKENS.some((token) =>
normalizedLabel.includes(token),
);
const mentionsAudioOutput = AUDIO_OUTPUT_TOKENS.some((token) =>
normalizedLabel.includes(token),
);

return mentionsDisplayRoute && mentionsAudioOutput && !mentionsMicrophone;
}

export function getAvailableMicrophoneDevices(devices: AudioInputDeviceLike[]): MicrophoneDevice[] {
return devices
.filter((device) => device.kind === "audioinput")
.filter((device) => !isLikelyDisplayAudioInputLabel(device.label))
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
}

let hasRequestedMicrophoneLabels = false;

export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId?: string) {
Expand All @@ -29,28 +71,20 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId?
setError(null);

let allDevices = await navigator.mediaDevices.enumerateDevices();
let audioInputs = allDevices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
let audioInputs = getAvailableMicrophoneDevices(allDevices);
const rawAudioInputs = allDevices.filter(
(device): device is MediaDeviceInfo => device.kind === "audioinput",
);

const needsLabelPermission =
audioInputs.length > 0 && audioInputs.every((device) => !device.label.trim());
rawAudioInputs.length > 0 &&
rawAudioInputs.every((device) => !device.label.trim());

if (needsLabelPermission && !hasRequestedMicrophoneLabels) {
hasRequestedMicrophoneLabels = true;
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true });
allDevices = await navigator.mediaDevices.enumerateDevices();
audioInputs = allDevices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
audioInputs = getAvailableMicrophoneDevices(allDevices);
}

if (mounted) {
Expand Down
24 changes: 13 additions & 11 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
selectRecordingMimeType,
selectWebcamRecordingMimeType,
} from "./recordingMimeType";
import { getAvailableMicrophoneDevices } from "./useMicrophoneDevices";

const TARGET_FRAME_RATE = 60;
const TARGET_WIDTH = 3840;
Expand Down Expand Up @@ -380,13 +381,11 @@ async function createAudioInputDeviceSnapshot(): Promise<
}

const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
deviceId: device.deviceId,
...(device.groupId ? { groupId: device.groupId } : {}),
label: device.label,
}));
const audioInputs = getAvailableMicrophoneDevices(devices).map((device) => ({
deviceId: device.deviceId,
...(device.groupId ? { groupId: device.groupId } : {}),
label: device.label,
}));

return audioInputs.length > 0 ? audioInputs : null;
}
Expand Down Expand Up @@ -1521,12 +1520,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (useNativeMacScreenCapture || useNativeWindowsCapture) {
// Resolve the selected mic label for native capture backends.
let micLabel: string | undefined;
let resolvedMicrophoneDeviceId = microphoneDeviceId;
if (microphoneEnabled) {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const mic = devices.find(
(d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput",
const availableMicrophones = getAvailableMicrophoneDevices(devices);
const mic = availableMicrophones.find(
(device) => device.deviceId === microphoneDeviceId,
);
resolvedMicrophoneDeviceId = mic?.deviceId;
micLabel = mic?.label || undefined;
} catch {
// Fall through — native process will use the default mic
Expand All @@ -1538,7 +1540,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
{
capturesSystemAudio: systemAudioEnabled,
capturesMicrophone: microphoneEnabled,
microphoneDeviceId,
microphoneDeviceId: resolvedMicrophoneDeviceId,
microphoneLabel: micLabel,
},
);
Expand Down Expand Up @@ -1589,7 +1591,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
console.info("Using browser microphone processing for this recording.");
try {
const microphoneConstraints = createProcessedMicrophoneConstraints(
microphoneDeviceId,
resolvedMicrophoneDeviceId,
browserMicrophoneProfile.current,
);
micFallbackRequestedConstraints.current = microphoneConstraints;
Expand Down