diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index f3ade720277f3..8dff8bdc50396 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -40,7 +40,6 @@ import {
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
-import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
import { restoreSettingsLinkState } from "./features/settings/settings-navigation";
import {
@@ -138,27 +137,25 @@ function AppContent() {
-
-
- {/* native-stack needs the theme's dark flag to select dark native headers. */}
-
-
-
- recordWorkspaceSelections(navigationRef.getRootState())}
- theme={navigationTheme}
- onStateChange={recordWorkspaceSelections}
- />
-
-
-
-
-
-
-
-
+
+ {/* native-stack needs the theme's dark flag to select dark native headers. */}
+
+
+
+ recordWorkspaceSelections(navigationRef.getRootState())}
+ theme={navigationTheme}
+ onStateChange={recordWorkspaceSelections}
+ />
+
+
+
+
+
+
+
diff --git a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx
index 8df3f53899701..76e175e4c96e1 100644
--- a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx
+++ b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx
@@ -144,7 +144,7 @@ export function ComposerDictationToolbar(props: {
}
const WaveformBar = memo(function WaveformBar(props: {
- readonly audioLevels: SharedValue;
+ readonly audioLevels: SharedValue>;
readonly sampleIndex: number;
readonly height?: number;
}) {
@@ -177,7 +177,7 @@ const COMPACT_WAVEFORM_BAR_COUNT = 8;
const COMPACT_WAVEFORM_BAR_HEIGHT = 14;
export const CompactVoiceWaveform = memo(function CompactVoiceWaveform(props: {
- readonly audioLevels: SharedValue;
+ readonly audioLevels: SharedValue>;
}) {
return (
;
+ readonly audioLevels: SharedValue>;
}) {
const [barCount, setBarCount] = useState(0);
const handleLayout = useCallback((event: LayoutChangeEvent) => {
@@ -311,7 +311,7 @@ function VoiceActionButton(props: {
}
export function ComposerDictationStatus(props: {
- readonly audioLevels: SharedValue;
+ readonly audioLevels: SharedValue>;
readonly elapsedSeconds: number;
readonly phase: VoiceInputPhase;
readonly presentation: VoiceComposerPresentation;
diff --git a/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx b/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx
index a9d9544c66368..85e10972189ba 100644
--- a/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx
+++ b/apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx
@@ -1,5 +1,5 @@
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
-import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
+import { useState, type ReactNode } from "react";
import { Linking, Platform, Pressable, useWindowDimensions, View } from "react-native";
import Animated, {
Easing,
@@ -8,7 +8,7 @@ import Animated, {
interpolate,
ReduceMotion,
useAnimatedStyle,
- useSharedValue,
+ useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -35,18 +35,22 @@ const ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
export function GlobalVoiceInputControl(props: { readonly children: ReactNode }) {
const voice = useGlobalVoiceInput();
- const collapseRef = useRef<(() => void) | null>(null);
+ const [expanded, setExpanded] = useState(false);
const presentation = resolveVoiceComposerPresentation(voice.state, voice.elapsedSeconds);
const visible =
presentation.statusLabel !== null &&
!(voice.ownerKey && voice.focusedOwners.has(voice.ownerKey));
- const pill = visible ? : null;
+ // A pill that reappears starts collapsed.
+ if (!visible && expanded) setExpanded(false);
+ const pill = visible ? (
+
+ ) : null;
return (
<>
{
- collapseRef.current?.();
+ if (expanded) setExpanded(false);
return false;
}}
>
@@ -57,24 +61,16 @@ export function GlobalVoiceInputControl(props: { readonly children: ReactNode })
);
}
-function EdgeDictationPill(props: { readonly collapseRef: RefObject<(() => void) | null> }) {
+function EdgeDictationPill(props: {
+ readonly expanded: boolean;
+ readonly onExpandedChange: (expanded: boolean) => void;
+}) {
const voice = useGlobalVoiceInput();
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const { fabSize } = useAndroidControlSizing();
- const [expanded, setExpanded] = useState(false);
- const progress = useSharedValue(0);
- useEffect(() => {
- progress.set(withTiming(expanded ? 1 : 0, MORPH_TIMING));
- }, [expanded, progress]);
- const { collapseRef } = props;
- useEffect(() => {
- if (!expanded) return;
- collapseRef.current = () => setExpanded(false);
- return () => {
- collapseRef.current = null;
- };
- }, [collapseRef, expanded]);
+ const { expanded, onExpandedChange: setExpanded } = props;
+ const progress = useDerivedValue(() => withTiming(expanded ? 1 : 0, MORPH_TIMING));
const expandedWidth = Math.min(width - insets.left - insets.right - 32, EXPANDED_MAX_WIDTH);
const pillStyle = useAnimatedStyle(() => ({
diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx
index 310e7fa8f695e..809ee3a9d21b5 100644
--- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx
+++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx
@@ -1,4 +1,4 @@
-import { RegistryContext, useAtomMount } from "@effect/atom-react";
+import { useAtomValue } from "@effect/atom-react";
import {
AudioModule,
RecordingPresets,
@@ -7,19 +7,10 @@ import {
} from "expo-audio";
import { File } from "expo-file-system";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
-import {
- createContext,
- use,
- useCallback,
- useEffect,
- useId,
- useRef,
- useState,
- type ReactNode,
-} from "react";
+import { useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native";
-import { AsyncResult } from "effect/reactivity";
-import { useSharedValue } from "react-native-reanimated";
+import { Atom, AsyncResult } from "effect/reactivity";
+import { makeMutable } from "react-native-reanimated";
import {
microphoneOrder,
@@ -29,13 +20,9 @@ import {
import { getLocalVoiceTranscriber } from "../../native/voiceTranscription";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene";
-import {
- VOICE_RECORDING_LIMIT_SECONDS,
- voiceInputBlocksSubmission,
- type VoiceInputState,
-} from "@t3tools/client-runtime/voice-input";
+import { voiceInputBlocksSubmission } from "@t3tools/client-runtime/voice-input";
import { createLazyVoiceRecorder } from "./lazyVoiceRecorder";
-import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
+import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
import { VoiceInputSession } from "./voiceInputSession";
import {
acquireAudioSession,
@@ -43,8 +30,6 @@ import {
releaseAudioSession,
} from "../../lib/audioSession";
-const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };
-const VOICE_METERING_INTERVAL_MS = 80;
// The native constructor expects platform-flattened options, as built by useAudioRecorder.
const { ios: IOS_RECORDING_OPTIONS, android: ANDROID_RECORDING_OPTIONS } =
RecordingPresets.HIGH_QUALITY;
@@ -57,194 +42,108 @@ const VOICE_RECORDING_OPTIONS = {
...(Platform.OS === "ios" ? IOS_RECORDING_OPTIONS : ANDROID_RECORDING_OPTIONS),
};
-const VoiceInputContext = createContext | null>(null);
-
-export function VoiceInputProvider({ children }: { readonly children: ReactNode }) {
- const runtime = useVoiceInputRuntime();
- return {children};
+function keepScreenAwake(tag: string): () => void {
+ const activation = activateKeepAwakeAsync(tag);
+ void activation.catch(() => {});
+ // Release after activation settles, even if the recording ends immediately.
+ return () => void activation.then(() => deactivateKeepAwake(tag)).catch(() => {});
}
-export function useGlobalVoiceInput() {
- const context = use(VoiceInputContext);
- if (!context) throw new Error("Voice input provider is missing.");
- return context;
-}
-
-function useVoiceInputRuntime() {
- const registry = use(RegistryContext);
- useAtomMount(mobilePreferencesAtom);
- useAtomMount(updateMobilePreferencesAtom);
- const [{ state, ownerKey, label }, setState] = useState({
- state: INITIAL_STATE,
- ownerKey: null as string | null,
- label: null as string | null,
- });
- const [focusedOwners, setFocusedOwners] = useState>(new Set());
- const setOwnerFocused = useCallback((key: string, focused: boolean) => {
- setFocusedOwners((current) => {
- const next = new Set(current);
- if (focused) next.add(key);
- else next.delete(key);
- return next;
- });
- }, []);
- const [elapsedSeconds, setElapsedSeconds] = useState(0);
- const keepAwakeId = useId();
- const keepAwakeSessionRef = useRef(0);
- const elapsedSecondsRef = useRef(0);
- const [initialLevels] = useState(() => Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0));
- const audioLevelsRef = useRef(initialLevels);
- const audioLevels = useSharedValue(initialLevels);
- const audioToken = useRef | null>(null);
-
- /* oxlint-disable react/refs -- The initializer captures the audio-session ref for later recording callbacks; it does not read it. */
- const [{ session, recorder }] = useState(() => {
- let session: VoiceInputSession | null = null;
- const recorder = createLazyVoiceRecorder({
- create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS),
- onStatus: (status: RecordingStatus) => {
- session?.controller.handleRecorderStatus({
- isFinished: status.isFinished,
- hasError: status.hasError || status.mediaServicesDidReset === true,
- error: status.error,
- url: status.url,
+/**
+ * The app has one microphone and one dictation at a time, so the session
+ * lives for the registry's lifetime rather than any screen's.
+ */
+const voiceInputAtom = Atom.make((get) => {
+ const registry = get.registry;
+ // Mounted so the recorder can read and remember microphones synchronously.
+ get.mount(mobilePreferencesAtom);
+ get.mount(updateMobilePreferencesAtom);
+ const audioLevels = makeMutable>(
+ Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0),
+ );
+ let keepAwakeSequence = 0;
+ let audioToken: ReturnType | null = null;
+ const recorder = createLazyVoiceRecorder({
+ create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS),
+ onStatus: (status: RecordingStatus) => {
+ session.controller.handleRecorderStatus({
+ isFinished: status.isFinished,
+ hasError: status.hasError || status.mediaServicesDidReset === true,
+ error: status.error,
+ url: status.url,
+ });
+ },
+ // Read when recording starts so preference changes do not re-render every composer.
+ selectInput: (inputs) => {
+ const preferences = registry.get(mobilePreferencesAtom);
+ if (!AsyncResult.isSuccess(preferences)) return null;
+ const known = microphoneOrder(preferences.value.microphones);
+ const order = rememberMicrophones(known, inputs);
+ if (order !== known) {
+ registry.set(updateMobilePreferencesAtom, {
+ transform: (current) => ({
+ microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs),
+ }),
});
- },
- // Read when recording starts so preference changes do not re-render every composer.
- selectInput: (inputs) => {
- const preferences = registry.get(mobilePreferencesAtom);
- if (!AsyncResult.isSuccess(preferences)) return null;
- const known = microphoneOrder(preferences.value.microphones);
- const order = rememberMicrophones(known, inputs);
- if (order !== known) {
- registry.set(updateMobilePreferencesAtom, {
- transform: (current) => ({
- microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs),
- }),
- });
- }
- return preferredMicrophoneInput(inputs, order);
- },
- });
- session = new VoiceInputSession({
- recorder,
- getTranscriber: getLocalVoiceTranscriber,
- requestPermission: async () => {
- const permission = await requestRecordingPermissionsAsync();
- return { granted: permission.granted, canAskAgain: permission.canAskAgain };
- },
- configureRecording: async () => {
- const token = acquireAudioSession("recording");
- audioToken.current = token;
- try {
- await activateAudioSession(token);
- } catch (error) {
- await releaseAudioSession(token).catch(() => {});
- throw error;
- }
- },
- releaseRecording: async () => {
- if (audioToken.current) await releaseAudioSession(audioToken.current);
- audioToken.current = null;
- },
- deleteRecording: (uri) => new File(uri).delete(),
- onStateChange: (nextState) =>
- setState({
- state: nextState,
- ownerKey: session?.ownerKey ?? null,
- label: session?.label ?? null,
- }),
- });
- return { session, recorder };
+ }
+ return preferredMicrophoneInput(inputs, order);
+ },
});
- /* oxlint-enable react/refs */
-
- const controller = session.controller;
-
- useEffect(() => {
- const subscription = AppState.addEventListener("change", (nextState) => {
- // iOS permission dialogs report inactive; cancel preparation only on background. Recorder status handles capture interruptions.
- if (nextState === "background") controller.appMovedToBackground();
- });
- return () => subscription.remove();
- }, [controller]);
-
- useEffect(
- () => () => {
- controller.dispose();
- recorder.release();
+ const session: VoiceInputSession = new VoiceInputSession({
+ recorder,
+ getTranscriber: getLocalVoiceTranscriber,
+ requestPermission: async () => {
+ const permission = await requestRecordingPermissionsAsync();
+ return { granted: permission.granted, canAskAgain: permission.canAskAgain };
},
- [controller, recorder],
- );
-
- useEffect(() => {
- if (state.phase !== "recording") return;
-
- const tag = `voice-input:${keepAwakeId}:${++keepAwakeSessionRef.current}`;
- const activation = activateKeepAwakeAsync(tag);
- void activation.catch(() => {});
- return () => {
- // Release after activation settles, even if the recording ends immediately.
- void activation.then(() => deactivateKeepAwake(tag)).catch(() => {});
- };
- }, [keepAwakeId, state.phase]);
-
- useEffect(() => {
- if (state.phase !== "preparing" && state.phase !== "recording") return;
-
- if (audioLevelsRef.current.some((level) => level !== 0)) {
- audioLevelsRef.current = Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0);
- audioLevels.set(audioLevelsRef.current);
- }
- if (elapsedSecondsRef.current !== 0) {
- elapsedSecondsRef.current = 0;
- setElapsedSeconds(0);
- }
- if (state.phase !== "recording") return;
-
- const sampleRecording = () => {
- if (controller.currentState.phase !== "recording") return;
- const status = recorder.getStatus();
- if (!status?.isRecording) return;
-
- const level = normalizeVoiceInputDecibels(status.metering);
- const history = audioLevelsRef.current;
- if (level !== 0 || history.some((sample) => sample !== 0)) {
- const nextLevels = [...history.slice(1), level];
- audioLevelsRef.current = nextLevels;
- audioLevels.set(nextLevels);
+ configureRecording: async () => {
+ const token = acquireAudioSession("recording");
+ audioToken = token;
+ try {
+ await activateAudioSession(token);
+ } catch (error) {
+ await releaseAudioSession(token).catch(() => {});
+ throw error;
}
+ },
+ releaseRecording: async () => {
+ if (audioToken) await releaseAudioSession(audioToken);
+ audioToken = null;
+ },
+ deleteRecording: (uri) => new File(uri).delete(),
+ sampleRecording: () => {
+ const status = recorder.getStatus();
+ return status?.isRecording
+ ? { durationMillis: status.durationMillis, metering: status.metering }
+ : null;
+ },
+ publishAudioLevels: (levels) => audioLevels.set(levels),
+ keepAwake: () => keepScreenAwake(`voice-input:${++keepAwakeSequence}`),
+ });
+ // iOS reports `inactive` while its permission dialog is open. Only the real
+ // background state cancels preparation; recorder status handles calls and
+ // route interruptions during capture.
+ const appStateSubscription = AppState.addEventListener("change", (nextState) => {
+ if (nextState === "background") void session.controller.appMovedToBackground();
+ });
+ get.addFinalizer(() => {
+ appStateSubscription.remove();
+ session.dispose();
+ recorder.release();
+ });
+ return { session, audioLevels };
+}).pipe(Atom.keepAlive, Atom.withLabel("mobile:voice-input"));
- const nextElapsedSeconds = Math.min(
- VOICE_RECORDING_LIMIT_SECONDS,
- Math.max(0, Math.floor(status.durationMillis / 1_000)),
- );
- if (nextElapsedSeconds !== elapsedSecondsRef.current) {
- elapsedSecondsRef.current = nextElapsedSeconds;
- setElapsedSeconds(nextElapsedSeconds);
- }
- };
-
- sampleRecording();
- const intervalId = setInterval(sampleRecording, VOICE_METERING_INTERVAL_MS);
- return () => clearInterval(intervalId);
- }, [audioLevels, controller, recorder, state.phase]);
-
- const stop = useCallback(() => controller.stop(), [controller]);
- const cancel = useCallback(() => controller.cancel(), [controller]);
-
+export function useGlobalVoiceInput() {
+ const { session, audioLevels } = useAtomValue(voiceInputAtom);
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot);
return {
+ ...snapshot,
isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null,
- state,
audioLevels,
- elapsedSeconds,
- isBusy: voiceInputBlocksSubmission(state),
- ownerKey,
- label,
- focusedOwners,
- setOwnerFocused,
+ isBusy: voiceInputBlocksSubmission(snapshot.state),
session,
- stop,
- cancel,
+ stop: () => session.controller.stop(),
+ cancel: () => session.controller.cancel(),
};
}
diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts
index bd3bdc40a520c..c336d68fd9d48 100644
--- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts
+++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts
@@ -22,7 +22,7 @@ export function useVoiceInputController(input: {
readonly onChangeSelection: (selection: ComposerEditorSelection) => void;
}) {
const global = useGlobalVoiceInput();
- const { setOwnerFocused, session } = global;
+ const { session } = global;
const latestInput = useRef(input);
// oxlint-disable-next-line react/refs -- Transcription finishes asynchronously and must reach the composer's current selection setter, not the one captured at start.
latestInput.current = input;
@@ -38,9 +38,9 @@ export function useVoiceInputController(input: {
useCallback(() => {
const ownerKey = input.ownerKey;
if (!ownerKey) return;
- setOwnerFocused(ownerKey, true);
- return () => setOwnerFocused(ownerKey, false);
- }, [input.ownerKey, setOwnerFocused]),
+ session.setOwnerFocused(ownerKey, true);
+ return () => session.setOwnerFocused(ownerKey, false);
+ }, [input.ownerKey, session]),
);
const start = useCallback(() => {
diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts
index 2c265820316fe..9d30e376b54e2 100644
--- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts
+++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts
@@ -22,7 +22,9 @@ vi.mock("expo-audio", () => ({
import {
createVoiceInputTarget,
VoiceInputSession,
+ type VoiceInputSessionDependencies,
type VoiceInputTarget,
+ type VoiceRecordingSample,
} from "./voiceInputSession";
function createTarget(
@@ -34,10 +36,17 @@ function createTarget(
return createVoiceInputTarget(ownerKey, readText, commit, selection, () => () => {});
}
-function createSession(recordingAudio?: {
- configureRecording: () => Promise;
- releaseRecording: () => Promise;
-}) {
+function createSession(
+ overrides?: Partial<
+ Pick<
+ VoiceInputSessionDependencies,
+ "configureRecording" | "releaseRecording" | "sampleRecording"
+ >
+ >,
+) {
+ const releaseKeepAwake = vi.fn();
+ const keepAwake = vi.fn(() => releaseKeepAwake);
+ const publishAudioLevels = vi.fn();
const recorder = {
uri: "file:///voice.m4a",
prepareToRecordAsync: vi.fn(async () => {}),
@@ -54,11 +63,13 @@ function createSession(recordingAudio?: {
requestPermission: async () => ({ granted: true, canAskAgain: true }),
configureRecording: async () => {},
releaseRecording: vi.fn(async () => {}),
- ...recordingAudio,
deleteRecording: vi.fn(),
- onStateChange: vi.fn(),
+ sampleRecording: () => null,
+ publishAudioLevels,
+ keepAwake,
+ ...overrides,
});
- return { session, recorder, prepare };
+ return { session, recorder, prepare, keepAwake, releaseKeepAwake, publishAudioLevels };
}
describe("global voice input", () => {
@@ -344,4 +355,59 @@ describe("global voice input", () => {
expect(oldCommit).not.toHaveBeenCalled();
expect(nextCommit).toHaveBeenCalledWith("second spoken text", { start: 18, end: 18 });
});
+
+ it("holds the screen awake and meters only while recording", async () => {
+ vi.useFakeTimers();
+ try {
+ let sample: VoiceRecordingSample = { durationMillis: 0, metering: -100 };
+ const { session, keepAwake, releaseKeepAwake, publishAudioLevels } = createSession({
+ sampleRecording: () => sample,
+ });
+ const snapshots: number[] = [];
+ session.subscribe(() => snapshots.push(session.getSnapshot().elapsedSeconds));
+
+ await session.start(createTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }));
+ expect(session.getSnapshot().state.phase).toBe("recording");
+ expect(keepAwake).toHaveBeenCalledTimes(1);
+ // Silence on a silent history publishes nothing.
+ expect(publishAudioLevels).not.toHaveBeenCalled();
+
+ sample = { durationMillis: 2_400, metering: 0 };
+ vi.advanceTimersByTime(80);
+ expect(session.getSnapshot().elapsedSeconds).toBe(2);
+ expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.arrayContaining([1]));
+
+ await session.controller.stop();
+ expect(releaseKeepAwake).toHaveBeenCalledTimes(1);
+ const publishedLevels = publishAudioLevels.mock.calls.length;
+ const publishedSnapshots = snapshots.length;
+ vi.advanceTimersByTime(800);
+ expect(publishAudioLevels).toHaveBeenCalledTimes(publishedLevels);
+ expect(snapshots).toHaveLength(publishedSnapshots);
+ // The finished recording's time stays readable until the next one starts.
+ expect(session.getSnapshot().elapsedSeconds).toBe(2);
+
+ sample = { durationMillis: 0, metering: -100 };
+ await session.start(createTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }));
+ expect(session.getSnapshot().elapsedSeconds).toBe(0);
+ expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.not.arrayContaining([1]));
+ expect(keepAwake).toHaveBeenCalledTimes(2);
+ session.dispose();
+ expect(releaseKeepAwake).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("publishes which owners are focused", () => {
+ const { session } = createSession();
+ const listener = vi.fn();
+ session.subscribe(listener);
+ session.setOwnerFocused("first", true);
+ session.setOwnerFocused("first", true);
+ expect(session.getSnapshot().focusedOwners).toEqual(new Set(["first"]));
+ expect(listener).toHaveBeenCalledTimes(1);
+ session.setOwnerFocused("first", false);
+ expect(session.getSnapshot().focusedOwners.size).toBe(0);
+ });
});
diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.ts b/apps/mobile/src/features/voice-input/voiceInputSession.ts
index 372c1081d6747..5bf9395daf59c 100644
--- a/apps/mobile/src/features/voice-input/voiceInputSession.ts
+++ b/apps/mobile/src/features/voice-input/voiceInputSession.ts
@@ -1,10 +1,19 @@
import {
+ VOICE_RECORDING_LIMIT_SECONDS,
VoiceInputController,
voiceInputBlocksSubmission,
type VoiceDraftSnapshot,
type VoiceInputControllerDependencies,
+ type VoiceInputPhase,
+ type VoiceInputState,
} from "@t3tools/client-runtime/voice-input";
+import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
+
+const VOICE_METERING_INTERVAL_MS = 80;
+const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };
+const SILENT_LEVELS: ReadonlyArray = Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0);
+
export type VoiceInputTarget = {
readonly ownerKey: string;
readonly label?: string;
@@ -13,6 +22,31 @@ export type VoiceInputTarget = {
readonly subscribe: () => () => void;
};
+export type VoiceInputSnapshot = {
+ readonly state: VoiceInputState;
+ readonly ownerKey: string | null;
+ readonly label: string | null;
+ readonly elapsedSeconds: number;
+ readonly focusedOwners: ReadonlySet;
+};
+
+export type VoiceRecordingSample = {
+ readonly durationMillis: number;
+ readonly metering: number | undefined;
+};
+
+export type VoiceInputSessionDependencies = Omit<
+ VoiceInputControllerDependencies,
+ "readDraft" | "commitDraft" | "onStateChange"
+> & {
+ /** Reads the live recorder, or null when it is not capturing. */
+ readonly sampleRecording: () => VoiceRecordingSample | null;
+ /** Levels change every metering tick, so they bypass React state. */
+ readonly publishAudioLevels: (levels: ReadonlyArray) => void;
+ /** Holds the screen awake while recording; returns the release. */
+ readonly keepAwake: () => () => void;
+};
+
export function createVoiceInputTarget(
ownerKey: string,
readText: () => string | null,
@@ -42,21 +76,32 @@ export function createVoiceInputTarget(
};
}
+/**
+ * The app's single voice input: it owns the controller, the recording-only
+ * side effects (keep-awake, metering) and publishes what the UI renders.
+ */
export class VoiceInputSession {
readonly controller: VoiceInputController;
+ private readonly dependencies: VoiceInputSessionDependencies;
private target: VoiceInputTarget | null = null;
private unsubscribe: (() => void) | null = null;
+ private snapshot: VoiceInputSnapshot = {
+ state: IDLE_STATE,
+ ownerKey: null,
+ label: null,
+ elapsedSeconds: 0,
+ focusedOwners: new Set(),
+ };
+ private readonly listeners = new Set<() => void>();
+ private audioLevels = SILENT_LEVELS;
+ private releaseKeepAwake: (() => void) | null = null;
+ private meteringInterval: ReturnType | null = null;
- constructor(dependencies: Omit) {
+ constructor(dependencies: VoiceInputSessionDependencies) {
+ this.dependencies = dependencies;
this.controller = new VoiceInputController({
...dependencies,
- onStateChange: (state) => {
- if (!voiceInputBlocksSubmission(state)) {
- this.unsubscribe?.();
- this.unsubscribe = null;
- }
- dependencies.onStateChange(state);
- },
+ onStateChange: (state) => this.handleStateChange(state),
readDraft: () => this.target?.readDraft() ?? null,
commitDraft: (text, selection) => this.target?.commitDraft(text, selection),
});
@@ -70,6 +115,21 @@ export class VoiceInputSession {
return this.target?.label ?? null;
}
+ readonly subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ readonly getSnapshot = (): VoiceInputSnapshot => this.snapshot;
+
+ setOwnerFocused(ownerKey: string, focused: boolean): void {
+ if (this.snapshot.focusedOwners.has(ownerKey) === focused) return;
+ const focusedOwners = new Set(this.snapshot.focusedOwners);
+ if (focused) focusedOwners.add(ownerKey);
+ else focusedOwners.delete(ownerKey);
+ this.publish({ ...this.snapshot, focusedOwners });
+ }
+
cancel(ownerKey: string | null): void {
if (ownerKey !== null && this.ownerKey === ownerKey) this.controller.cancel();
}
@@ -84,4 +144,84 @@ export class VoiceInputSession {
this.unsubscribe = target.subscribe();
return this.controller.start();
}
+
+ dispose(): void {
+ this.controller.dispose();
+ this.stopRecordingActivity();
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ this.listeners.clear();
+ }
+
+ private handleStateChange(state: VoiceInputState): void {
+ if (!voiceInputBlocksSubmission(state)) {
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ }
+ const previousPhase = this.snapshot.state.phase;
+ let elapsedSeconds = this.snapshot.elapsedSeconds;
+ if (state.phase !== previousPhase) {
+ if (previousPhase === "recording") this.stopRecordingActivity();
+ if (capturesAudio(state.phase)) {
+ if (this.audioLevels.some((level) => level !== 0)) this.setAudioLevels(SILENT_LEVELS);
+ elapsedSeconds = 0;
+ }
+ }
+ this.publish({
+ ...this.snapshot,
+ state,
+ ownerKey: this.ownerKey,
+ label: this.label,
+ elapsedSeconds,
+ });
+ if (state.phase === "recording" && previousPhase !== "recording") {
+ this.startRecordingActivity();
+ }
+ }
+
+ private startRecordingActivity(): void {
+ this.releaseKeepAwake = this.dependencies.keepAwake();
+ this.sampleRecording();
+ this.meteringInterval = setInterval(() => this.sampleRecording(), VOICE_METERING_INTERVAL_MS);
+ }
+
+ private stopRecordingActivity(): void {
+ if (this.meteringInterval !== null) clearInterval(this.meteringInterval);
+ this.meteringInterval = null;
+ this.releaseKeepAwake?.();
+ this.releaseKeepAwake = null;
+ }
+
+ private sampleRecording(): void {
+ if (this.controller.currentState.phase !== "recording") return;
+ const sample = this.dependencies.sampleRecording();
+ if (!sample) return;
+
+ const level = normalizeVoiceInputDecibels(sample.metering);
+ if (level !== 0 || this.audioLevels.some((sample) => sample !== 0)) {
+ this.setAudioLevels([...this.audioLevels.slice(1), level]);
+ }
+
+ const elapsedSeconds = Math.min(
+ VOICE_RECORDING_LIMIT_SECONDS,
+ Math.max(0, Math.floor(sample.durationMillis / 1_000)),
+ );
+ if (elapsedSeconds !== this.snapshot.elapsedSeconds) {
+ this.publish({ ...this.snapshot, elapsedSeconds });
+ }
+ }
+
+ private setAudioLevels(levels: ReadonlyArray): void {
+ this.audioLevels = levels;
+ this.dependencies.publishAudioLevels(levels);
+ }
+
+ private publish(snapshot: VoiceInputSnapshot): void {
+ this.snapshot = snapshot;
+ for (const listener of this.listeners) listener();
+ }
+}
+
+function capturesAudio(phase: VoiceInputPhase): boolean {
+ return phase === "preparing" || phase === "recording";
}
diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts
index c862138ce58f4..55c653a281622 100644
--- a/apps/mobile/src/reactCompilerCoverage.test.ts
+++ b/apps/mobile/src/reactCompilerCoverage.test.ts
@@ -18,7 +18,6 @@ const KNOWN_BAILOUTS: Record = {
"src/features/diffs/nativeReviewDiffSurface.ts": "ref read during render",
"src/features/home/thread-swipe-actions.tsx": "ref read during render",
"src/features/threads/ThreadArrangementSheet.tsx": "ref read during render",
- "src/features/voice-input/VoiceInputProvider.tsx": "ref read during render",
"src/features/voice-input/useVoiceInputController.ts": "ref read during render",
"src/native/T3ComposerEditor.ios.tsx": "ref read during render",
"src/native/T3ComposerEditor.native.tsx": "ref read during render",