diff --git a/electron/native/ScreenCaptureKitRecorder.microphone.test.ts b/electron/native/ScreenCaptureKitRecorder.microphone.test.ts new file mode 100644 index 000000000..b1d35ed44 --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder.microphone.test.ts @@ -0,0 +1,100 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Compile the production matching functions and run them against a fixed +// device list, so no real microphone or permission prompt is involved. +describe.skipIf(process.platform !== "darwin")("recorder microphone selection", () => { + let directory: string; + let executable: string; + beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), "recordly-microphone-")); + executable = join(directory, "matcher"); + const source = readFileSync( + new URL("./ScreenCaptureKitRecorder.swift", import.meta.url), + "utf8", + ); + const matching = source.slice( + source.indexOf("private static func matchMicrophoneDeviceID"), + source.indexOf("private func supportsNativeMicrophoneCapture"), + ); + writeFileSync( + join(directory, "main.swift"), + ` +import Foundation +enum Matcher { +${matching} +} +extension Matcher { + static func run(_ arguments: [String]) -> String? { + switch arguments[0] { + case "undecorated": + return undecoratedMicrophoneName(forLabel: arguments[1]) + default: + return matchMicrophoneDeviceID( + label: arguments[1], + deviceId: arguments[2], + devices: [ + (name: "Shure MV7", uniqueID: "usb-mv7"), + (name: "MacBook Pro Microphone", uniqueID: "built-in"), + (name: "Foo", uniqueID: "foo"), + (name: "Foo (Pro)", uniqueID: "foo-pro"), + ] + ) + } + } +} +print(Matcher.run(Array(CommandLine.arguments.dropFirst())) ?? "") +`, + ); + const build = spawnSync( + "swiftc", + [ + "-module-cache-path", + join(directory, "cache"), + join(directory, "main.swift"), + "-o", + executable, + ], + { encoding: "utf8", timeout: 60_000 }, + ); + expect(build.status, build.stderr).toBe(0); + }, 60_000); + afterAll(() => { + if (directory) rmSync(directory, { recursive: true, force: true }); + }); + + const run = (...args: string[]) => { + const result = spawnSync(executable, args, { encoding: "utf8", timeout: 5_000 }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + + it.each([ + ["Shure MV7 (14ed:1012)", "Shure MV7"], + ["Mic (Built-in)", "Mic"], + ["Name (A) (B)", "Name (A)"], + ["(Only)", ""], + ["Plain", ""], + ["Mic (unclosed", ""], + ["", ""], + ])("strips the Chromium suffix from %j", (label, expected) => { + expect(run("undecorated", label)).toBe(expected); + }); + + it.each([ + // Chromium's deviceId is a per-origin hash that never matches a uniqueID. + ["Shure MV7 (14ed:1012)", "d4488ddaa3f0f10c", "usb-mv7"], + ["Foo (Pro)", "", "foo-pro"], + ["Shure MV7", "built-in", "usb-mv7"], + ["Foo (USB)", "built-in", "built-in"], + ["Foo (USB)", "", "foo"], + ["Default - MacBook Pro Microphone (Built-in)", "default", ""], + ["Unknown Mic (Virtual)", "", ""], + ["", "", ""], + ])("resolves label %j with device ID %j to %j", (label, deviceId, expected) => { + expect(run("match", label, deviceId)).toBe(expected); + }); +}); diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 707813a0a..a9da8f91b 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -855,23 +855,55 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { - let audioDevices = AVCaptureDevice.devices(for: .audio) + let audioDevices = AVCaptureDevice.devices(for: .audio).map { + (name: $0.localizedName, uniqueID: $0.uniqueID) + } + return matchMicrophoneDeviceID( + label: config.microphoneLabel, + deviceId: config.microphoneDeviceId, + devices: audioDevices + ) + } - if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { - if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) { - return matchedDevice.uniqueID - } + /// Matches the exact label first, then a known device ID, and only then the + /// label without its transport suffix, because that last step is a name + /// heuristic and must not override a precise identifier. + private static func matchMicrophoneDeviceID( + label: String?, + deviceId: String?, + devices: [(name: String, uniqueID: String)] + ) -> String? { + let label = label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !label.isEmpty, let matchedDevice = devices.first(where: { $0.name == label }) { + return matchedDevice.uniqueID } - if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { - if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { - return microphoneDeviceId - } + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !deviceId.isEmpty, devices.contains(where: { $0.uniqueID == deviceId }) { + return deviceId + } + + if let undecoratedName = undecoratedMicrophoneName(forLabel: label), + let matchedDevice = devices.first(where: { $0.name == undecoratedName }) { + return matchedDevice.uniqueID } return nil } + /// The label comes from Chromium's `enumerateDevices()`, which appends a + /// transport suffix to the CoreAudio name — "Shure MV7 (14ed:1012)", + /// "MacBook Pro Microphone (Built-in)", "BlackHole 16ch (Virtual)" — so it + /// rarely equals `localizedName` as-is. Without the undecorated name the + /// selection is silently ignored and the system default input is recorded. + private static func undecoratedMicrophoneName(forLabel label: String) -> String? { + guard label.hasSuffix(")"), let suffixStart = label.range(of: " (", options: .backwards) else { + return nil + } + let undecoratedName = label[.. Bool { let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:")))