Skip to content
Merged
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
100 changes: 100 additions & 0 deletions electron/native/ScreenCaptureKitRecorder.microphone.test.ts
Original file line number Diff line number Diff line change
@@ -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())) ?? "<none>")
`,
);
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)", "<none>"],
["Plain", "<none>"],
["Mic (unclosed", "<none>"],
["", "<none>"],
])("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", "<none>"],
["Unknown Mic (Virtual)", "", "<none>"],
["", "", "<none>"],
])("resolves label %j with device ID %j to %j", (label, deviceId, expected) => {
expect(run("match", label, deviceId)).toBe(expected);
});
});
50 changes: 41 additions & 9 deletions electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<suffixStart.lowerBound].trimmingCharacters(in: .whitespaces)
return undecoratedName.isEmpty ? nil : undecoratedName
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool {
let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:")))
let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:")))
Expand Down