| 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 | */ |
| 8 | export const MICROPHONE_KINDS = ["wired", "bluetooth", "builtIn", "carPlay"] as const; |
| 9 | |
| 10 | export type MicrophoneKind = (typeof MICROPHONE_KINDS)[number]; |
| 11 | |
| 12 | export 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 | |
| 19 | export type MicrophoneEntry = { readonly kind: MicrophoneKind } | MicrophoneDevice; |
| 20 | |
| 21 | interface 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. |
| 29 | const 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. |
| 41 | const DEVICE_KINDS: ReadonlySet<MicrophoneKind> = new Set(["wired", "bluetooth"]); |
| 42 | |
| 43 | const rank = (kind: MicrophoneKind) => MICROPHONE_KINDS.indexOf(kind); |
| 44 | |
| 45 | export function isMicrophoneKind(value: unknown): value is MicrophoneKind { |
| 46 | return (MICROPHONE_KINDS as ReadonlyArray<unknown>).includes(value); |
| 47 | } |
| 48 | |
| 49 | export function isMicrophoneDevice(entry: MicrophoneEntry): entry is MicrophoneDevice { |
| 50 | return "uid" in entry; |
| 51 | } |
| 52 | |
| 53 | export 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 | */ |
| 62 | export 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 | */ |
| 93 | export 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. */ |
| 118 | export 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 | } |