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",