import { createContext, useContext, useEffect, useRef, useSyncExternalStore, type ReactNode, } from "react"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useSessionStore } from "@/stores/session-store"; import { createAudioEngine } from "@/voice/audio-engine"; import type { AudioEngine } from "@/voice/audio-engine-types"; import { createVoiceRuntime, type VoiceRuntime, type VoiceRuntimeSnapshot, type VoiceRuntimeTelemetrySnapshot, } from "@/voice/voice-runtime"; interface VoiceContextValue extends VoiceRuntimeSnapshot { startVoice: (serverId: string, agentId: string) => Promise; stopVoice: () => Promise; isVoiceModeForAgent: (serverId: string, agentId: string) => boolean; toggleMute: () => void; } const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = { phase: "disabled", isVoiceMode: false, isVoiceSwitching: false, isMuted: false, activeServerId: null, activeAgentId: null, }; const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = { volume: 0, isSpeaking: false, segmentDuration: 0, }; const VoiceRuntimeContext = createContext(null); const VoiceAudioEngineContext = createContext(null); const noopSubscribe = () => () => {}; const getEmptySnapshot = () => EMPTY_SNAPSHOT; const getEmptyTelemetry = () => EMPTY_TELEMETRY; export function useVoice() { const value = useVoiceOptional(); if (!value) { throw new Error("useVoice must be used within VoiceProvider"); } return value; } export function useVoiceOptional(): VoiceContextValue | null { const runtime = useContext(VoiceRuntimeContext); const snapshot = useSyncExternalStore( runtime ? runtime.subscribe : noopSubscribe, runtime ? runtime.getSnapshot : getEmptySnapshot, runtime ? runtime.getSnapshot : getEmptySnapshot, ); if (!runtime) { return null; } return { ...snapshot, startVoice: runtime.startVoice, stopVoice: runtime.stopVoice, isVoiceModeForAgent: runtime.isVoiceModeForAgent, toggleMute: runtime.toggleMute, }; } export function useVoiceTelemetry() { const telemetry = useVoiceTelemetryOptional(); if (!telemetry) { throw new Error("useVoiceTelemetry must be used within VoiceProvider"); } return telemetry; } export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null { const runtime = useContext(VoiceRuntimeContext); const snapshot = useSyncExternalStore( runtime ? runtime.subscribeTelemetry : noopSubscribe, runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry, runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry, ); return runtime ? snapshot : null; } export function useVoiceRuntimeOptional(): VoiceRuntime | null { return useContext(VoiceRuntimeContext); } export function useVoiceAudioEngineOptional(): AudioEngine | null { return useContext(VoiceAudioEngineContext); } interface VoiceProviderProps { children: ReactNode; } export function VoiceProvider({ children }: VoiceProviderProps) { const engineRef = useRef(null); const runtimeRef = useRef(null); if (!engineRef.current) { let runtime: VoiceRuntime | null = null; const engine = createAudioEngine({ onCaptureData: (pcm) => { runtime?.handleCapturePcm(pcm); }, onVolumeLevel: (level) => { runtime?.handleCaptureVolume(level); }, onError: (error) => { console.error("[VoiceEngine] Capture error:", error); }, }); runtime = createVoiceRuntime({ engine, getServerInfo: (serverId) => useSessionStore.getState().getSession(serverId)?.serverInfo ?? null, activateKeepAwake: async (tag) => { await activateKeepAwakeAsync(tag); }, deactivateKeepAwake: async (tag) => { await deactivateKeepAwake(tag); }, }); engineRef.current = engine; runtimeRef.current = runtime; } const engine = engineRef.current!; const runtime = runtimeRef.current!; useEffect(() => { return () => { void runtime.destroy().catch((error) => { console.error("[VoiceProvider] Failed to destroy voice runtime", error); }); }; }, [runtime]); return ( {children} ); }