1const MICROPHONE_KINDS = ["wired", "bluetooth", "builtIn", "carPlay"] as const;
2
3export type MicrophoneKind = (typeof MICROPHONE_KINDS)[number];
4
5export interface MicrophoneDevice {
6 readonly kind: MicrophoneKind;
7 readonly uid: string;
8 readonly name: string;
9}
10
11export type MicrophoneEntry = { readonly kind: MicrophoneKind } | MicrophoneDevice;
12
13interface MicrophoneInput {
14 readonly uid: string;
15 readonly name: string;
16 readonly type: string;
17}
18
19// A car without CarPlay, and Android Auto, routes its microphone over Bluetooth, so it is a Bluetooth device.
20const KIND_BY_PORT_TYPE: Readonly<Record<string, MicrophoneKind>> = {
21 MicrophoneWired: "wired",
22 USBAudio: "wired",
23 LineIn: "wired",
24 BluetoothHFP: "bluetooth",
25 BluetoothSCO: "bluetooth",
26 BluetoothLE: "bluetooth",
27 MicrophoneBuiltIn: "builtIn",
28 CarAudio: "carPlay",
29};
30
31const DEVICE_KINDS: ReadonlySet<MicrophoneKind> = new Set(["wired", "bluetooth"]);
32
33const rank = (kind: MicrophoneKind) => MICROPHONE_KINDS.indexOf(kind);
34
35export function isMicrophoneKind(value: unknown): value is MicrophoneKind {
36 return (MICROPHONE_KINDS as ReadonlyArray<unknown>).includes(value);
37}
38
39export function isMicrophoneDevice(entry: MicrophoneEntry): entry is MicrophoneDevice {
40 return "uid" in entry;
41}
42
43export function microphoneEntryKey(entry: MicrophoneEntry): string {
44 return isMicrophoneDevice(entry) ? entry.uid : `kind:${entry.kind}`;
45}
46
47export function microphoneOrder(
48 saved: ReadonlyArray<MicrophoneEntry> = [],
49 platform: "ios" | "android" = "ios",
50): ReadonlyArray<MicrophoneEntry> {
51 const kinds = MICROPHONE_KINDS.filter((kind) => platform === "ios" || kind !== "carPlay");
52 const order: MicrophoneEntry[] = [];
53 const keys = new Set<string>();
54 for (const entry of saved) {
55 if (!kinds.includes(entry.kind)) continue;
56 const row =
57 isMicrophoneDevice(entry) && DEVICE_KINDS.has(entry.kind) ? entry : { kind: entry.kind };
58 const key = microphoneEntryKey(row);
59 if (keys.has(key)) continue;
60 keys.add(key);
61 order.push(row);
62 }
63 for (const kind of kinds) {
64 if (keys.has(`kind:${kind}`)) continue;
65 const lastDevice = order.map((entry) => entry.kind).lastIndexOf(kind);
66 const below = order.findIndex(
67 (entry) => !isMicrophoneDevice(entry) && rank(entry.kind) > rank(kind),
68 );
69 order.splice(lastDevice !== -1 ? lastDevice + 1 : below !== -1 ? below : order.length, 0, {
70 kind,
71 });
72 }
73 return order;
74}
75
76export function rememberMicrophones(
77 order: ReadonlyArray<MicrophoneEntry>,
78 inputs: ReadonlyArray<MicrophoneInput>,
79): ReadonlyArray<MicrophoneEntry> {
80 let next: MicrophoneEntry[] | null = null;
81 for (const input of inputs) {
82 const kind = KIND_BY_PORT_TYPE[input.type];
83 if (!kind || !DEVICE_KINDS.has(kind)) continue;
84 const current = next ?? order;
85 const index = current.findIndex((entry) => microphoneEntryKey(entry) === input.uid);
86 const existing = current[index];
87 if (existing && isMicrophoneDevice(existing) && existing.name === input.name) continue;
88 next ??= [...order];
89 const device = { kind, uid: input.uid, name: input.name };
90 if (index !== -1) {
91 next[index] = device;
92 continue;
93 }
94 const kindRow = next.findIndex((entry) => microphoneEntryKey(entry) === `kind:${kind}`);
95 next.splice(kindRow === -1 ? next.length : kindRow, 0, device);
96 }
97 return next ?? order;
98}
99
100/** The connected input of the first entry that has one, or null for the system default. */
101export function preferredMicrophoneInput<Input extends MicrophoneInput>(
102 inputs: ReadonlyArray<Input>,
103 order: ReadonlyArray<MicrophoneEntry>,
104): Input | null {
105 const listed = new Set(order.filter(isMicrophoneDevice).map((device) => device.uid));
106 for (const entry of order) {
107 const input = inputs.find((candidate) =>
108 isMicrophoneDevice(entry)
109 ? candidate.uid === entry.uid
110 : KIND_BY_PORT_TYPE[candidate.type] === entry.kind && !listed.has(candidate.uid),
111 );
112 if (input) return input;
113 }
114 return null;
115}