authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 13:04:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 13:04:09-07:00
log2a4ee339827e480da6ad0139585cf634a46ff73e
treec512bb96178cf608a0a0e2ea60abdc12662993ad
parent61eb4af69c38c1f39e58ed76aa695b751f9f9baf
signature Signed by PGP key B5690EEEBB952194

feat(mobile): choose the microphone order for voice input (#17896)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

14 files changed, 737 insertions(+), 66 deletions(-)

apps/mobile/src/Stack.tsx+8
......@@ -102,6 +102,7 @@ import {
102102import { ScheduledTaskEditorProvider } from "./features/settings/scheduled-task-editor";
103103import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen";
104104import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
105import { SettingsMicrophoneRouteScreen } from "./features/settings/SettingsMicrophoneRouteScreen";
105106import {
106107 SettingsOpenSourceLicenseRouteScreen,
107108 SettingsOpenSourceLicensesRouteScreen,
......@@ -310,6 +311,13 @@ const SettingsContentStack = createV5SheetStackNavigator({
310311 title: "Keyboard",
311312 },
312313 }),
314 SettingsMicrophone: createNativeStackScreen({
315 screen: SettingsMicrophoneRouteScreen,
316 linking: "microphone",
317 options: {
318 title: "Microphone",
319 },
320 }),
313321 SettingsFollowUp: createNativeStackScreen({
314322 screen: SettingsFollowUpRouteScreen,
315323 linking: "follow-ups",
apps/mobile/src/components/AppSymbol.tsx+7
......@@ -28,6 +28,7 @@ import IconBolt from "@tabler/icons-react-native/IconBolt";
2828import IconBox from "@tabler/icons-react-native/IconBox";
2929import IconBrain from "@tabler/icons-react-native/IconBrain";
3030import IconCamera from "@tabler/icons-react-native/IconCamera";
31import IconCar from "@tabler/icons-react-native/IconCar";
3132import IconChartBar from "@tabler/icons-react-native/IconChartBar";
3233import IconCheck from "@tabler/icons-react-native/IconCheck";
3334import IconCloud from "@tabler/icons-react-native/IconCloud";
......@@ -58,6 +59,7 @@ import IconFilter from "@tabler/icons-react-native/IconFilter";
5859import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled";
5960import IconFolder from "@tabler/icons-react-native/IconFolder";
6061import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen";
62import IconHeadphones from "@tabler/icons-react-native/IconHeadphones";
6163import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus";
6264import IconGitBranch from "@tabler/icons-react-native/IconGitBranch";
6365import IconGitMerge from "@tabler/icons-react-native/IconGitMerge";
......@@ -83,6 +85,7 @@ import IconMoon from "@tabler/icons-react-native/IconMoon";
8385import IconNetwork from "@tabler/icons-react-native/IconNetwork";
8486import IconPalette from "@tabler/icons-react-native/IconPalette";
8587import IconPencil from "@tabler/icons-react-native/IconPencil";
88import IconPlug from "@tabler/icons-react-native/IconPlug";
8689import IconPhoto from "@tabler/icons-react-native/IconPhoto";
8790import IconPin from "@tabler/icons-react-native/IconPin";
8891import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";
......@@ -146,7 +149,9 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
146149 "bolt.circle": IconBolt,
147150 "bolt.horizontal.circle": IconBolt,
148151 brain: IconBrain,
152 "cable.connector": IconPlug,
149153 camera: IconCamera,
154 car: IconCar,
150155 "chart.bar.xaxis": IconChartBar,
151156 checkmark: IconCheck,
152157 "checkmark.circle": IconCircleCheck,
......@@ -178,9 +183,11 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
178183 gearshape: IconSettings,
179184 globe: IconWorld,
180185 hammer: IconHammer,
186 headphones: IconHeadphones,
181187 house: IconHome,
182188 "info.circle": IconInfoCircle,
183189 internaldrive: IconDatabase,
190 iphone: IconDeviceMobile,
184191 keyboard: IconKeyboard,
185192 laptopcomputer: IconDeviceLaptop,
186193 link: IconLink,
apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx+3-62
......@@ -13,9 +13,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
1313import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
1414import { AsyncResult } from "effect/reactivity";
1515import * as Option from "effect/Option";
16import { useEffect, useMemo, useRef, useState } from "react";
16import { useState } from "react";
1717import { Alert, Platform, Pressable, View } from "react-native";
18import { Gesture, GestureDetector } from "react-native-gesture-handler";
1918import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
2019
2120import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
......@@ -27,6 +26,7 @@ import { environmentSession } from "../../state/session";
2726import { useAtomCommand } from "../../state/use-atom-command";
2827import { connectionTone } from "../connection/connectionTone";
2928import { SettingsActionRow } from "./components/SettingsActionRow";
29import { SettingsDragHandle } from "./components/SettingsDragHandle";
3030import { SettingsSection } from "./components/SettingsSection";
3131
3232const ICON_SIZE = Platform.OS === "android" ? 24 : 22;
......@@ -324,7 +324,7 @@ function RouteRow(props: {
324324 </View>
325325 </View>
326326 {props.editing ? (
327 <DragHandle
327 <SettingsDragHandle
328328 title={label}
329329 canMoveUp={props.position > 1}
330330 canMoveDown={props.position < props.count}
......@@ -337,62 +337,3 @@ function RouteRow(props: {
337337 </Reanimated.View>
338338 );
339339}
340
341/** Pan recognition wins over the settings scroll view only inside the handle. */
342function DragHandle(props: {
343 readonly title: string;
344 readonly canMoveUp: boolean;
345 readonly canMoveDown: boolean;
346 readonly onStart: () => void;
347 readonly onMove: (translation: number) => void;
348 readonly onEnd: (translation: number, cancelled: boolean) => void;
349 readonly onStep: (direction: "up" | "down") => void;
350}) {
351 const latest = useRef(props);
352 useEffect(() => {
353 latest.current = props;
354 });
355 const translation = useRef(0);
356 const gesture = useMemo(
357 () =>
358 Gesture.Pan()
359 .minDistance(0)
360 .shouldCancelWhenOutside(false)
361 .runOnJS(true)
362 .onStart(() => {
363 translation.current = 0;
364 latest.current.onStart();
365 })
366 .onUpdate((event) => {
367 translation.current = event.translationY;
368 latest.current.onMove(event.translationY);
369 })
370 .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)),
371 [],
372 );
373 return (
374 <GestureDetector gesture={gesture}>
375 <View
376 collapsable={false}
377 accessible
378 accessibilityRole="adjustable"
379 accessibilityLabel={`Reorder ${props.title}`}
380 accessibilityActions={[
381 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
382 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
383 ]}
384 onAccessibilityAction={({ nativeEvent }) => {
385 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
386 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
387 }}
388 style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }}
389 >
390 <SymbolView
391 name="line.3.horizontal"
392 size={20}
393 tintColorClassName="accent-foreground-muted"
394 />
395 </View>
396 </GestureDetector>
397 );
398}
apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx created+323
......@@ -0,0 +1,323 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio";
4import { useCallback, useEffect, useState } from "react";
5import { AppState, Linking, Pressable, ScrollView, View } from "react-native";
6import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8
9import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
10import { AppText as Text } from "../../components/AppText";
11import { cn } from "../../lib/cn";
12import {
13 isMicrophoneDevice,
14 type MicrophoneEntry,
15 type MicrophoneKind,
16 microphoneEntryKey,
17 microphoneOrder,
18} from "../../lib/microphonePriority";
19import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
20import { SettingsActionRow } from "./components/SettingsActionRow";
21import { SettingsDragHandle } from "./components/SettingsDragHandle";
22import { SettingsSection } from "./components/SettingsSection";
23
24const REMOVE_SIZE = 20;
25
26type MicrophonePermission = "checking" | "granted" | "ask" | "denied";
27
28/** Re-reads microphone access whenever the app returns from system Settings. */
29function useMicrophonePermission() {
30 const [permission, setPermission] = useState<MicrophonePermission>("checking");
31 const apply = useCallback(
32 (response: { readonly granted: boolean; readonly canAskAgain: boolean }) =>
33 setPermission(response.granted ? "granted" : response.canAskAgain ? "ask" : "denied"),
34 [],
35 );
36 const refresh = useCallback(
37 () => void getRecordingPermissionsAsync().then(apply, () => setPermission("ask")),
38 [apply],
39 );
40 useEffect(() => {
41 refresh();
42 const subscription = AppState.addEventListener("change", (state) => {
43 if (state === "active") refresh();
44 });
45 return () => subscription.remove();
46 }, [refresh]);
47 const request = useCallback(
48 () => void requestRecordingPermissionsAsync().then(apply, refresh),
49 [apply, refresh],
50 );
51 return { permission, request };
52}
53
54const KINDS: Record<
55 MicrophoneKind,
56 {
57 /** Shown under a remembered device's name. */
58 readonly label: string;
59 readonly row: string;
60 readonly rowDescription: string;
61 readonly icon: AppSymbolName;
62 }
63> = {
64 wired: {
65 label: "Wired",
66 row: "Other wired",
67 rowDescription: "Headsets and USB microphones not listed",
68 icon: "cable.connector",
69 },
70 bluetooth: {
71 label: "Bluetooth",
72 row: "Other Bluetooth",
73 rowDescription: "AirPods, headsets, and cars not listed",
74 icon: "headphones",
75 },
76 builtIn: {
77 label: "Built-in",
78 row: "Built-in microphone",
79 rowDescription: "The microphone on this device",
80 icon: "iphone",
81 },
82 carPlay: {
83 label: "CarPlay",
84 row: "CarPlay",
85 rowDescription: "The car’s microphone",
86 icon: "car",
87 },
88};
89
90/**
91 * Microphone kinds and remembered devices, in the order voice input prefers
92 * them. iOS only. A device joins the list the first time it is connected
93 * during dictation; until then its kind's row ranks it.
94 */
95export function SettingsMicrophoneRouteScreen() {
96 const insets = useSafeAreaInsets();
97 const preferencesResult = useAtomValue(mobilePreferencesAtom);
98 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
99 const ready = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
100 const entries = microphoneOrder(
101 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined,
102 );
103 const hasDevices = entries.some(isMicrophoneDevice);
104 const { permission, request: requestPermission } = useMicrophonePermission();
105 const [editing, setEditing] = useState(false);
106 const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>(
107 null,
108 );
109 const [rowHeight, setRowHeight] = useState(0);
110 // Each drop remounts the rows so the new order and the cleared offsets land in one frame.
111 const [drops, setDrops] = useState(0);
112
113 // Transforms apply to the stored order, so a device recorded meanwhile is kept.
114 const move = (key: string, steps: number) =>
115 savePreferences({
116 transform: (current) => {
117 const next = [...microphoneOrder(current.microphones)];
118 const from = next.findIndex((entry) => microphoneEntryKey(entry) === key);
119 const to = Math.min(next.length - 1, Math.max(0, from + steps));
120 if (from === -1 || from === to) return {};
121 const [moved] = next.splice(from, 1);
122 next.splice(to, 0, moved!);
123 return { microphones: next };
124 },
125 });
126 const forget = (key: string) =>
127 savePreferences({
128 transform: (current) => ({
129 microphones: microphoneOrder(current.microphones).filter(
130 (entry) => microphoneEntryKey(entry) !== key,
131 ),
132 }),
133 });
134 // A lifted row takes a neighbour's slot once it has moved past half of that row.
135 const dropSteps = (from: number, translation: number) =>
136 rowHeight === 0
137 ? 0
138 : Math.min(entries.length - 1, Math.max(0, from + Math.round(translation / rowHeight))) -
139 from;
140
141 const dragFrom =
142 drag === null ? -1 : entries.findIndex((entry) => microphoneEntryKey(entry) === drag.key);
143 const dragTo = drag === null ? -1 : dragFrom + dropSteps(dragFrom, drag.translation);
144
145 return (
146 <View collapsable={false} className="flex-1 bg-sheet">
147 <ScrollView
148 contentInsetAdjustmentBehavior="automatic"
149 showsVerticalScrollIndicator={false}
150 className="flex-1"
151 contentContainerClassName="gap-3 px-5 pt-4"
152 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
153 >
154 {permission === "ask" || permission === "denied" ? (
155 <>
156 <SettingsSection title="Microphone access">
157 <SettingsActionRow
158 icon="mic"
159 label={permission === "ask" ? "Allow microphone access" : "Open Settings"}
160 onPress={
161 permission === "ask" ? requestPermission : () => void Linking.openSettings()
162 }
163 />
164 </SettingsSection>
165 <Text className="px-2 text-sm text-foreground-muted">
166 {permission === "ask"
167 ? "Voice input needs microphone access before it can record or list your microphones."
168 : "Microphone access is off for T3 Code. Turn it on in Settings to use voice input."}
169 </Text>
170 </>
171 ) : null}
172 <SettingsSection
173 title="Preferred order"
174 trailing={
175 hasDevices ? (
176 <Pressable
177 accessibilityRole="button"
178 onPress={() => setEditing((value) => !value)}
179 className="px-2 py-1 active:opacity-70"
180 >
181 <Text className="text-sm font-t3-medium text-foreground">
182 {editing ? "Done" : "Edit"}
183 </Text>
184 </Pressable>
185 ) : undefined
186 }
187 >
188 {entries.map((entry, index) => {
189 const key = microphoneEntryKey(entry);
190 const shift =
191 dragFrom === -1 || index === dragFrom
192 ? 0
193 : dragFrom < dragTo && index > dragFrom && index <= dragTo
194 ? -rowHeight
195 : dragFrom > dragTo && index < dragFrom && index >= dragTo
196 ? rowHeight
197 : 0;
198 return (
199 <MicrophoneRow
200 key={`${key}:${drops}`}
201 entry={entry}
202 position={index + 1}
203 count={entries.length}
204 reorderable={ready}
205 editing={editing && hasDevices}
206 offset={index === dragFrom ? (drag?.translation ?? 0) : shift}
207 lifted={index === dragFrom}
208 onHeight={setRowHeight}
209 onDragStart={() => setDrag({ key, translation: 0 })}
210 onDragMove={(translation) => setDrag({ key, translation })}
211 onDragEnd={(translation, cancelled) => {
212 setDrag(null);
213 setDrops((count) => count + 1);
214 if (!cancelled) move(key, dropSteps(index, translation));
215 }}
216 onStep={(direction) => move(key, direction === "up" ? -1 : 1)}
217 onForget={isMicrophoneDevice(entry) ? () => forget(key) : undefined}
218 />
219 );
220 })}
221 </SettingsSection>
222 <Text className="px-2 text-sm text-foreground-muted">
223 Voice input records from the first connected microphone in this list. Wired and Bluetooth
224 devices are added by name after you use voice input with them connected; until then, the
225 Other row ranks them.
226 </Text>
227 </ScrollView>
228 </View>
229 );
230}
231
232function MicrophoneRow(props: {
233 readonly entry: MicrophoneEntry;
234 readonly position: number;
235 readonly count: number;
236 readonly reorderable: boolean;
237 readonly editing: boolean;
238 readonly offset: number;
239 readonly lifted: boolean;
240 readonly onHeight: (height: number) => void;
241 readonly onDragStart: () => void;
242 readonly onDragMove: (translation: number) => void;
243 readonly onDragEnd: (translation: number, cancelled: boolean) => void;
244 readonly onStep: (direction: "up" | "down") => void;
245 readonly onForget: (() => void) | undefined;
246}) {
247 const { lifted, offset, entry } = props;
248 const kind = KINDS[entry.kind];
249 const title = isMicrophoneDevice(entry) ? entry.name : kind.row;
250 const style = useAnimatedStyle(() => ({
251 transform: [
252 {
253 translateY: lifted
254 ? offset
255 : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }),
256 },
257 ],
258 zIndex: lifted ? 1 : 0,
259 }));
260 return (
261 <Reanimated.View
262 style={style}
263 onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)}
264 className={cn(
265 "flex-row items-center gap-4 pl-4",
266 props.position > 1 && !lifted && "border-t border-border-subtle",
267 lifted && "bg-grouped-card shadow-md",
268 !props.reorderable && "pr-4",
269 )}
270 >
271 {props.editing && props.onForget ? (
272 <Pressable
273 accessibilityRole="button"
274 accessibilityLabel={`Forget ${title}`}
275 hitSlop={8}
276 onPress={props.onForget}
277 className="active:opacity-70"
278 >
279 <SymbolView
280 name="xmark.circle.fill"
281 size={REMOVE_SIZE}
282 tintColorClassName="accent-danger-foreground"
283 type="monochrome"
284 />
285 </Pressable>
286 ) : props.editing ? (
287 <View style={{ width: REMOVE_SIZE }} />
288 ) : null}
289 <View
290 accessible
291 accessibilityLabel={`${title}, ${kind.label}, ${props.position} of ${props.count}`}
292 className="min-w-0 flex-1 flex-row items-center gap-4 py-4"
293 >
294 <SymbolView
295 name={kind.icon}
296 size={22}
297 tintColorClassName="accent-icon"
298 type="monochrome"
299 weight="regular"
300 />
301 <View className="min-w-0 flex-1 gap-0.5">
302 <Text numberOfLines={1} className="text-lg text-foreground">
303 {title}
304 </Text>
305 <Text numberOfLines={1} className="text-sm text-foreground-muted">
306 {isMicrophoneDevice(entry) ? kind.label : kind.rowDescription}
307 </Text>
308 </View>
309 </View>
310 {props.reorderable ? (
311 <SettingsDragHandle
312 title={title}
313 canMoveUp={props.position > 1}
314 canMoveDown={props.position < props.count}
315 onStart={props.onDragStart}
316 onMove={props.onDragMove}
317 onEnd={props.onDragEnd}
318 onStep={props.onStep}
319 />
320 ) : null}
321 </Reanimated.View>
322 );
323}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+4-1
......@@ -154,7 +154,10 @@ function SettingsIndexSections() {
154154 <SettingsSection title="Interface">
155155 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
156156 {Platform.OS === "ios" ? (
157 <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" />
157 <>
158 <SettingsRow icon="keyboard" label="Keyboard" target="SettingsKeyboard" />
159 <SettingsRow icon="mic" label="Microphone" target="SettingsMicrophone" />
160 </>
158161 ) : null}
159162 </SettingsSection>
160163
apps/mobile/src/features/settings/components/SettingsDragHandle.tsx created+64
......@@ -0,0 +1,64 @@
1import { useEffect, useMemo, useRef } from "react";
2import { View } from "react-native";
3import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
5import { SymbolView } from "../../../components/AppSymbol";
6
7/** Pan recognition wins over the settings scroll view only inside the handle. */
8export function SettingsDragHandle(props: {
9 readonly title: string;
10 readonly canMoveUp: boolean;
11 readonly canMoveDown: boolean;
12 readonly onStart: () => void;
13 readonly onMove: (translation: number) => void;
14 readonly onEnd: (translation: number, cancelled: boolean) => void;
15 readonly onStep: (direction: "up" | "down") => void;
16}) {
17 const latest = useRef(props);
18 useEffect(() => {
19 latest.current = props;
20 });
21 const translation = useRef(0);
22 const gesture = useMemo(
23 () =>
24 Gesture.Pan()
25 .minDistance(0)
26 .shouldCancelWhenOutside(false)
27 .runOnJS(true)
28 .onStart(() => {
29 translation.current = 0;
30 latest.current.onStart();
31 })
32 .onUpdate((event) => {
33 translation.current = event.translationY;
34 latest.current.onMove(event.translationY);
35 })
36 .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)),
37 [],
38 );
39 return (
40 <GestureDetector gesture={gesture}>
41 <View
42 collapsable={false}
43 accessible
44 accessibilityRole="adjustable"
45 accessibilityLabel={`Reorder ${props.title}`}
46 accessibilityActions={[
47 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
48 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
49 ]}
50 onAccessibilityAction={({ nativeEvent }) => {
51 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
52 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
53 }}
54 style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }}
55 >
56 <SymbolView
57 name="line.3.horizontal"
58 size={20}
59 tintColorClassName="accent-foreground-muted"
60 />
61 </View>
62 </GestureDetector>
63 );
64}
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
......@@ -13,6 +13,7 @@ export type SettingsSheetTarget =
1313 | "SettingsEnvironmentMaintenance"
1414 | "SettingsProviderAccounts"
1515 | "SettingsKeyboard"
16 | "SettingsMicrophone"
1617 | "SettingsFollowUp"
1718 | "SettingsScheduledTasks"
1819 | "SettingsProjectGrouping"
apps/mobile/src/features/voice-input/VoiceInputProvider.tsx+27
......@@ -1,3 +1,4 @@
1import { RegistryContext, useAtomMount } from "@effect/atom-react";
12import {
23 AudioModule,
34 RecordingPresets,
......@@ -20,9 +21,16 @@ import {
2021 type ReactNode,
2122} from "react";
2223import { AppState, Platform } from "react-native";
24import { AsyncResult } from "effect/reactivity";
2325import { useSharedValue } from "react-native-reanimated";
2426
27import {
28 microphoneOrder,
29 preferredMicrophoneInput,
30 rememberMicrophones,
31} from "../../lib/microphonePriority";
2532import { getLocalVoiceTranscriber } from "../../native/voiceTranscription";
33import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
2634import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene";
2735import {
2836 VOICE_RECORDING_LIMIT_SECONDS,
......@@ -91,6 +99,10 @@ export function useGlobalVoiceInput() {
9199}
92100
93101function useVoiceInputRuntime() {
102 const registry = use(RegistryContext);
103 // Dictation reads and records microphones without subscribing the provider to preferences.
104 useAtomMount(mobilePreferencesAtom);
105 useAtomMount(updateMobilePreferencesAtom);
94106 const [{ state, ownerKey, label }, setState] = useState({
95107 state: INITIAL_STATE,
96108 ownerKey: null as string | null,
......@@ -126,6 +138,21 @@ function useVoiceInputRuntime() {
126138 url: status.url,
127139 });
128140 },
141 // Read when recording starts so preference changes do not re-render every composer.
142 selectInput: (inputs) => {
143 const preferences = registry.get(mobilePreferencesAtom);
144 if (!AsyncResult.isSuccess(preferences)) return null;
145 const known = microphoneOrder(preferences.value.microphones);
146 const order = rememberMicrophones(known, inputs);
147 if (order !== known) {
148 registry.set(updateMobilePreferencesAtom, {
149 transform: (current) => ({
150 microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs),
151 }),
152 });
153 }
154 return preferredMicrophoneInput(inputs, order);
155 },
129156 });
130157 recorderRef.current = recorder;
131158 sessionRef.current = new VoiceInputSession({
apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts+33-2
......@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
22
33import { VoiceInputController } from "@t3tools/client-runtime/voice-input";
44
5import { createLazyVoiceRecorder } from "./lazyVoiceRecorder";
5import { createLazyVoiceRecorder, type VoiceRecordingInput } from "./lazyVoiceRecorder";
66
77type Status = { readonly isFinished: boolean };
88type State = { readonly isRecording: boolean };
......@@ -30,12 +30,19 @@ function createNativeRecorder(uri: string) {
3030 listeners.add(listener);
3131 return { remove: () => listeners.delete(listener) };
3232 }),
33 getAvailableInputs: vi.fn((): ReadonlyArray<VoiceRecordingInput> => [
34 { uid: "car", name: "CarPlay", type: "CarAudio" },
35 { uid: "built-in", name: "iPhone Microphone", type: "MicrophoneBuiltIn" },
36 ]),
37 setInput: vi.fn((_uid: string) => undefined),
3338 release: vi.fn(),
3439 emit: (status: Status) => listeners.forEach((listener) => listener(status)),
3540 };
3641}
3742
38function createHarness() {
43function createHarness(
44 selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null,
45) {
3946 const created: Array<ReturnType<typeof createNativeRecorder>> = [];
4047 const statuses: Status[] = [];
4148 const recorder = createLazyVoiceRecorder({
......@@ -45,6 +52,7 @@ function createHarness() {
4552 return native;
4653 },
4754 onStatus: (status) => statuses.push(status),
55 ...(selectInput ? { selectInput } : {}),
4856 });
4957 return { recorder, created, statuses };
5058}
......@@ -79,6 +87,29 @@ describe("createLazyVoiceRecorder", () => {
7987 expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2);
8088 });
8189
90 it("selects the preferred input after preparing, when the session lists inputs", async () => {
91 const { recorder, created } = createHarness(
92 (inputs) => inputs.find((input) => input.type === "MicrophoneBuiltIn") ?? null,
93 );
94
95 await recorder.prepareToRecordAsync();
96
97 expect(created[0]!.setInput).toHaveBeenCalledWith("built-in");
98 });
99
100 it("keeps the system input when none is preferred or the preferred one disconnects", async () => {
101 const unpreferred = createHarness(() => null);
102 await unpreferred.recorder.prepareToRecordAsync();
103 expect(unpreferred.created[0]!.setInput).not.toHaveBeenCalled();
104
105 const disconnected = createHarness((inputs) => inputs[1] ?? null);
106 await disconnected.recorder.prepareToRecordAsync();
107 disconnected.created[0]!.setInput.mockImplementationOnce(() => {
108 throw new Error("Preferred input not found");
109 });
110 await expect(disconnected.recorder.prepareToRecordAsync()).resolves.toBeUndefined();
111 });
112
82113 it("forwards status events until the recorder is released", async () => {
83114 const { recorder, created, statuses } = createHarness();
84115 await recorder.prepareToRecordAsync();
apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts+22-1
......@@ -2,6 +2,12 @@ import type { VoiceRecorder } from "@t3tools/client-runtime/voice-input";
22
33type Subscription = { remove(): void };
44
5export interface VoiceRecordingInput {
6 readonly uid: string;
7 readonly name: string;
8 readonly type: string;
9}
10
511// The subset of expo-audio's native `AudioRecorder` the voice input hook uses.
612export interface NativeVoiceRecorder<TStatus, TRecorderState> {
713 readonly uri: string | null;
......@@ -13,6 +19,8 @@ export interface NativeVoiceRecorder<TStatus, TRecorderState> {
1319 eventName: "recordingStatusUpdate",
1420 listener: (status: TStatus) => void,
1521 ): Subscription;
22 getAvailableInputs(): ReadonlyArray<VoiceRecordingInput>;
23 setInput(uid: string): void;
1624 release(): void;
1725}
1826
......@@ -34,6 +42,8 @@ export interface LazyVoiceRecorder<TRecorderState> extends VoiceRecorder {
3442export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: {
3543 readonly create: () => NativeVoiceRecorder<TStatus, TRecorderState>;
3644 readonly onStatus: (status: TStatus) => void;
45 /** Picks the input to record from once the session lists them, or null to keep the system's. */
46 readonly selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null;
3747}): LazyVoiceRecorder<TRecorderState> {
3848 let recorder: NativeVoiceRecorder<TStatus, TRecorderState> | null = null;
3949 let subscription: Subscription | null = null;
......@@ -57,7 +67,18 @@ export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: {
5767 recorder = input.create();
5868 subscription = recorder.addListener("recordingStatusUpdate", input.onStatus);
5969 }
60 return track(recorder.prepareToRecordAsync());
70 const prepared = recorder;
71 return track(
72 prepared.prepareToRecordAsync().then(() => {
73 if (!input.selectInput) return;
74 try {
75 const selected = input.selectInput(prepared.getAvailableInputs());
76 if (selected) prepared.setInput(selected.uid);
77 } catch {
78 // The input can disconnect in between. The system's route still records.
79 }
80 }),
81 );
6182 },
6283 record(options) {
6384 if (!recorder) throw new Error("Voice recorder is not prepared.");
apps/mobile/src/lib/microphonePriority.test.ts created+93
......@@ -0,0 +1,93 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 type MicrophoneEntry,
5 microphoneEntryKey,
6 microphoneOrder,
7 preferredMicrophoneInput,
8 rememberMicrophones,
9} from "./microphonePriority";
10
11const iPhone = { uid: "Built-In Microphone", name: "iPhone Microphone", type: "MicrophoneBuiltIn" };
12const airPods = { uid: "airpods", name: "Julius’s AirPods Pro", type: "BluetoothHFP" };
13const bose = { uid: "bose", name: "Julius’s Bose QC", type: "BluetoothHFP" };
14const civic = { uid: "civic", name: "Honda Civic", type: "BluetoothHFP" };
15const carPlay = { uid: "carplay", name: "CarPlay", type: "CarAudio" };
16const usb = { uid: "usb", name: "Shure MV7", type: "USBAudio" };
17
18const keys = (order: ReadonlyArray<MicrophoneEntry>) => order.map(microphoneEntryKey);
19
20describe("microphoneOrder", () => {
21 it("lists every kind once, CarPlay below the built-in microphone by default", () => {
22 expect(keys(microphoneOrder())).toEqual([
23 "kind:wired",
24 "kind:bluetooth",
25 "kind:builtIn",
26 "kind:carPlay",
27 ]);
28 });
29
30 it("keeps a saved order and files missing kinds after their devices", () => {
31 const saved: MicrophoneEntry[] = [
32 { kind: "builtIn" },
33 { kind: "bluetooth", uid: "airpods", name: "AirPods" },
34 { kind: "builtIn" },
35 ];
36 expect(keys(microphoneOrder(saved))).toEqual([
37 "kind:wired",
38 "kind:builtIn",
39 "airpods",
40 "kind:bluetooth",
41 "kind:carPlay",
42 ]);
43 });
44});
45
46describe("rememberMicrophones", () => {
47 it("lists a new device just above its kind and leaves built-in and CarPlay as kinds", () => {
48 const order = microphoneOrder();
49 expect(keys(rememberMicrophones(order, [iPhone, carPlay, airPods]))).toEqual([
50 "kind:wired",
51 "airpods",
52 "kind:bluetooth",
53 "kind:builtIn",
54 "kind:carPlay",
55 ]);
56 });
57
58 it("refreshes renamed devices and returns the same order when nothing changed", () => {
59 const order = rememberMicrophones(microphoneOrder(), [airPods]);
60 expect(rememberMicrophones(order, [airPods, iPhone])).toBe(order);
61
62 const renamed = rememberMicrophones(order, [{ ...airPods, name: "AirPods Max" }]);
63 expect(renamed.find((entry) => microphoneEntryKey(entry) === "airpods")).toEqual({
64 kind: "bluetooth",
65 uid: "airpods",
66 name: "AirPods Max",
67 });
68 });
69});
70
71describe("preferredMicrophoneInput", () => {
72 // Built-in, Bose, Wired, other Bluetooth, AirPods, CarPlay.
73 const order: MicrophoneEntry[] = [
74 { kind: "builtIn" },
75 { kind: "bluetooth", uid: "bose", name: bose.name },
76 { kind: "wired" },
77 { kind: "bluetooth" },
78 { kind: "bluetooth", uid: "airpods", name: airPods.name },
79 { kind: "carPlay" },
80 ];
81
82 it("ranks unlisted devices of a kind at that kind, ahead of a device listed below it", () => {
83 expect(preferredMicrophoneInput([airPods, civic], order)).toBe(civic);
84 expect(preferredMicrophoneInput([airPods, bose], order)).toBe(bose);
85 expect(preferredMicrophoneInput([airPods, usb], order)).toBe(usb);
86 expect(preferredMicrophoneInput([airPods, carPlay], order)).toBe(airPods);
87 expect(preferredMicrophoneInput([airPods, iPhone], order)).toBe(iPhone);
88 });
89
90 it("leaves inputs of unknown kinds to iOS", () => {
91 expect(preferredMicrophoneInput([{ uid: "x", name: "x", type: "Virtual" }], order)).toBeNull();
92 });
93});
apps/mobile/src/lib/microphonePriority.ts created+132
......@@ -0,0 +1,132 @@
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}
apps/mobile/src/persistence/mobile-preferences.ts+13
......@@ -8,6 +8,7 @@ import * as Semaphore from "effect/Semaphore";
88import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts";
99import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
1010import type { FollowUpBehavior } from "../lib/followUpBehavior";
11import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
1112import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
1213import * as MobileDatabase from "./mobile-database";
1314import * as MobileSecureStorage from "./mobile-secure-storage";
......@@ -36,6 +37,8 @@ export interface Preferences {
3637 * message sent during a running turn queues behind it or steers it.
3738 */
3839 readonly followUpBehavior?: FollowUpBehavior;
40 /** Microphone kinds and remembered devices, most preferred first. iOS only. */
41 readonly microphones?: ReadonlyArray<MicrophoneEntry>;
3942 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
4043 readonly projectGroupingEnabled?: boolean;
4144 readonly projectGroupingMode?: SidebarProjectGroupingMode;
......@@ -107,6 +110,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
107110 collapsedProjectGroups?: readonly string[];
108111 composerEnterBehavior?: ComposerEnterBehavior;
109112 followUpBehavior?: FollowUpBehavior;
113 microphones?: ReadonlyArray<MicrophoneEntry>;
110114 projectGroupingEnabled?: boolean;
111115 projectGroupingMode?: SidebarProjectGroupingMode;
112116 planModeEnabled?: boolean;
......@@ -172,6 +176,15 @@ function sanitizePreferences(parsed: Preferences): Preferences {
172176 if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") {
173177 preferences.followUpBehavior = parsed.followUpBehavior;
174178 }
179 if (Array.isArray(parsed.microphones)) {
180 preferences.microphones = parsed.microphones.flatMap((entry): MicrophoneEntry[] => {
181 if (typeof entry !== "object" || entry === null || !isMicrophoneKind(entry.kind)) return [];
182 if (!("uid" in entry)) return [{ kind: entry.kind }];
183 return typeof entry.uid === "string" && typeof entry.name === "string"
184 ? [{ kind: entry.kind, uid: entry.uid, name: entry.name }]
185 : [];
186 });
187 }
175188 if (typeof parsed.projectGroupingEnabled === "boolean") {
176189 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
177190 }
docs/user/composer.md+7
......@@ -164,6 +164,13 @@ minutes long. Canceling, leaving the screen, or an audio interruption discards t
164164recording and preserves your existing draft. While recording, the screen stays
165165awake; it can sleep normally once recording stops.
166166
167Voice input records from the first connected microphone in **Settings → Microphone**.
168The list mixes kinds (built-in, other wired, other Bluetooth, CarPlay) with wired and
169Bluetooth devices you have dictated with, listed by name. Drag to rank them together:
170for example, put **Other Bluetooth** above your AirPods so a new headset is preferred
171while the AirPods microphone stays low. By default, CarPlay comes after the built-in
172microphone.
173
167174Transcription runs on your device. T3 Code deletes the temporary audio after
168175transcription or cancellation; only the message text is sent when you submit.
169176