1/**
2 * The order voice input picks a microphone in. An entry is either a kind,
3 * which covers every connected microphone of that kind not listed by name, or
4 * a device remembered the first time dictation saw it connected. iOS lists
5 * only connected inputs, and only while a recording session is set up, so a
6 * device cannot be listed before then.
7 */
8export const MICROPHONE_KINDS = ["wired", "bluetooth", "builtIn", "carPlay"] as const;
9
10export type MicrophoneKind = (typeof MICROPHONE_KINDS)[number];
11
12export interface MicrophoneDevice {
13 readonly kind: MicrophoneKind;
14 /** iOS's port identifier, stable across reconnects of the same device. */
15 readonly uid: string;
16 readonly name: string;
17}
18
19export type MicrophoneEntry = { readonly kind: MicrophoneKind } | MicrophoneDevice;
20
21interface MicrophoneInput {
22 readonly uid: string;
23 readonly name: string;
24 readonly type: string;
25}
26
27// Raw values of AVAudioSession.Port. A car without CarPlay connects as a
28// Bluetooth headset, so it is a Bluetooth device.
29const KIND_BY_PORT_TYPE: Readonly<Record<string, MicrophoneKind>> = {
30 MicrophoneWired: "wired",
31 USBAudio: "wired",
32 LineIn: "wired",
33 BluetoothHFP: "bluetooth",
34 BluetoothLE: "bluetooth",
35 MicrophoneBuiltIn: "builtIn",
36 CarAudio: "carPlay",
37};
38
39// Built-in and CarPlay are listed only as kinds: a phone has one built-in
40// microphone, and CarPlay is the car.
41const DEVICE_KINDS: ReadonlySet<MicrophoneKind> = new Set(["wired", "bluetooth"]);
42
43const rank = (kind: MicrophoneKind) => MICROPHONE_KINDS.indexOf(kind);
44
45export function isMicrophoneKind(value: unknown): value is MicrophoneKind {
46 return (MICROPHONE_KINDS as ReadonlyArray<unknown>).includes(value);
47}
48
49export function isMicrophoneDevice(entry: MicrophoneEntry): entry is MicrophoneDevice {
50 return "uid" in entry;
51}
52
53export function microphoneEntryKey(entry: MicrophoneEntry): string {
54 return isMicrophoneDevice(entry) ? entry.uid : `kind:${entry.kind}`;
55}
56
57/**
58 * The saved order with each kind listed once. A missing kind goes after the
59 * devices of that kind, else above the first kind ranked below it by default,
60 * which puts CarPlay below the built-in microphone.
61 */
62export function microphoneOrder(
63 saved: ReadonlyArray<MicrophoneEntry> = [],
64): ReadonlyArray<MicrophoneEntry> {
65 const order: MicrophoneEntry[] = [];
66 const keys = new Set<string>();
67 for (const entry of saved) {
68 const row =
69 isMicrophoneDevice(entry) && DEVICE_KINDS.has(entry.kind) ? entry : { kind: entry.kind };
70 const key = microphoneEntryKey(row);
71 if (keys.has(key)) continue;
72 keys.add(key);
73 order.push(row);
74 }
75 for (const kind of MICROPHONE_KINDS) {
76 if (keys.has(`kind:${kind}`)) continue;
77 const lastDevice = order.map((entry) => entry.kind).lastIndexOf(kind);
78 const below = order.findIndex(
79 (entry) => !isMicrophoneDevice(entry) && rank(entry.kind) > rank(kind),
80 );
81 order.splice(lastDevice !== -1 ? lastDevice + 1 : below !== -1 ? below : order.length, 0, {
82 kind,
83 });
84 }
85 return order;
86}
87
88/**
89 * Lists connected wired and Bluetooth microphones by name and refreshes
90 * renamed ones. A new device goes just above its kind, keeping the rank it had
91 * while unlisted. Returns `order` itself when nothing changed.
92 */
93export function rememberMicrophones(
94 order: ReadonlyArray<MicrophoneEntry>,
95 inputs: ReadonlyArray<MicrophoneInput>,
96): ReadonlyArray<MicrophoneEntry> {
97 let next: MicrophoneEntry[] | null = null;
98 for (const input of inputs) {
99 const kind = KIND_BY_PORT_TYPE[input.type];
100 if (!kind || !DEVICE_KINDS.has(kind)) continue;
101 const current = next ?? order;
102 const index = current.findIndex((entry) => microphoneEntryKey(entry) === input.uid);
103 const existing = current[index];
104 if (existing && isMicrophoneDevice(existing) && existing.name === input.name) continue;
105 next ??= [...order];
106 const device = { kind, uid: input.uid, name: input.name };
107 if (index !== -1) {
108 next[index] = device;
109 continue;
110 }
111 const kindRow = next.findIndex((entry) => microphoneEntryKey(entry) === `kind:${kind}`);
112 next.splice(kindRow === -1 ? next.length : kindRow, 0, device);
113 }
114 return next ?? order;
115}
116
117/** The connected input of the first entry that has one, or null to leave the route to iOS. */
118export function preferredMicrophoneInput<Input extends MicrophoneInput>(
119 inputs: ReadonlyArray<Input>,
120 order: ReadonlyArray<MicrophoneEntry>,
121): Input | null {
122 const listed = new Set(order.filter(isMicrophoneDevice).map((device) => device.uid));
123 for (const entry of order) {
124 const input = inputs.find((candidate) =>
125 isMicrophoneDevice(entry)
126 ? candidate.uid === entry.uid
127 : KIND_BY_PORT_TYPE[candidate.type] === entry.kind && !listed.has(candidate.uid),
128 );
129 if (input) return input;
130 }
131 return null;
132}