mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 23:43:35 +00:00
Prefer a known device ID over the stripped-label match
Resolve the microphone by exact label, then by a device ID that names a real capture device, and only then by the label without Chromium's transport suffix, so the name heuristic cannot override a precise identifier. Move the matching into a pure function and replace the source-text assertions with a test that compiles it with swiftc and checks the results against a fixed device list, following the lifecycle test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e029d11663
commit
13edd56a44
3 files changed
+134
-40
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -855,20 +855,37 @@ 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 {
|
||||
for candidateName in microphoneNameCandidates(forLabel: microphoneLabel) {
|
||||
if let matchedDevice = audioDevices.first(where: { $0.localizedName == candidateName }) {
|
||||
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
|
||||
@@ -877,17 +894,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
/// 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 candidate the
|
||||
/// rarely equals `localizedName` as-is. Without the undecorated name the
|
||||
/// selection is silently ignored and the system default input is recorded.
|
||||
private static func microphoneNameCandidates(forLabel label: String) -> [String] {
|
||||
var candidates = [label]
|
||||
if label.hasSuffix(")"), let suffixStart = label.range(of: " (", options: .backwards) {
|
||||
let undecoratedName = label[..<suffixStart.lowerBound].trimmingCharacters(in: .whitespaces)
|
||||
if !undecoratedName.isEmpty {
|
||||
candidates.append(undecoratedName)
|
||||
}
|
||||
private static func undecoratedMicrophoneName(forLabel label: String) -> String? {
|
||||
guard label.hasSuffix(")"), let suffixStart = label.range(of: " (", options: .backwards) else {
|
||||
return nil
|
||||
}
|
||||
return candidates
|
||||
let undecoratedName = label[..<suffixStart.lowerBound].trimmingCharacters(in: .whitespaces)
|
||||
return undecoratedName.isEmpty ? nil : undecoratedName
|
||||
}
|
||||
|
||||
private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool {
|
||||
|
||||
@@ -117,23 +117,3 @@ describe("ScreenCaptureKitRecorder first frame timing", () => {
|
||||
expect(audioGuard).toBeLessThan(callback.indexOf("if outputType == .audio"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScreenCaptureKitRecorder microphone selection", () => {
|
||||
const resolver = recorderSource.slice(
|
||||
recorderSource.indexOf("private static func resolveMicrophoneCaptureDeviceID"),
|
||||
recorderSource.indexOf("private func supportsNativeMicrophoneCapture"),
|
||||
);
|
||||
|
||||
it("matches the selected microphone by its undecorated Chromium label", () => {
|
||||
// Chromium reports "Shure MV7 (14ed:1012)"; CoreAudio names it "Shure MV7".
|
||||
expect(resolver).toContain(
|
||||
"for candidateName in microphoneNameCandidates(forLabel: microphoneLabel)",
|
||||
);
|
||||
expect(resolver).toContain("$0.localizedName == candidateName");
|
||||
expect(resolver).toContain('label.range(of: " (", options: .backwards)');
|
||||
});
|
||||
|
||||
it("prefers the raw label so device names ending in parentheses still match", () => {
|
||||
expect(resolver).toContain("var candidates = [label]");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user