From 80a2f8052c0e4c786085e9ecb7116907ec27aaef Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 13 Mar 2026 20:53:55 +0700 Subject: [PATCH] refactor(app): replace voice hooks with voice runtime and audio engine architecture --- packages/app/assets/audio/thinking-tone.wav | Bin 0 -> 262914 bytes packages/app/src/app/_layout.tsx | 2 +- .../app/src/components/agent-input-area.tsx | 30 + packages/app/src/components/left-sidebar.tsx | 66 +- packages/app/src/components/message-input.tsx | 91 +- packages/app/src/components/message.tsx | 8 +- .../src/components/realtime-voice-overlay.tsx | 23 +- .../src/components/sidebar-workspace-list.tsx | 25 +- .../src/components/stream-strategy-web.tsx | 8 +- packages/app/src/components/ui/combobox.tsx | 19 +- .../use-bottom-anchor-controller.test.ts | 9 - .../use-bottom-anchor-controller.ts | 132 --- .../components/voice-compact-indicator.tsx | 22 +- packages/app/src/components/voice-panel.tsx | 21 +- packages/app/src/components/volume-meter.tsx | 79 +- packages/app/src/contexts/session-context.tsx | 359 +++++--- packages/app/src/contexts/voice-context.tsx | 582 ++++-------- packages/app/src/hooks/use-audio-player.d.ts | 1 - .../app/src/hooks/use-audio-player.native.ts | 366 -------- .../app/src/hooks/use-audio-player.web.ts | 275 ------ .../use-dictation-audio-source.native.ts | 120 ++- .../hooks/use-dictation-audio-source.web.ts | 79 +- .../src/hooks/use-sidebar-workspaces-list.ts | 43 +- .../app/src/hooks/use-speechmatics-audio.d.ts | 1 - .../hooks/use-speechmatics-audio.native.ts | 274 ------ .../src/hooks/use-speechmatics-audio.web.ts | 410 --------- packages/app/src/runtime/host-runtime.test.ts | 9 +- packages/app/src/runtime/host-runtime.ts | 9 + .../src/screens/agent/agent-ready-screen.tsx | 10 +- .../screens/workspace/workspace-screen.tsx | 217 +++-- packages/app/src/stores/session-store.ts | 16 +- packages/app/src/utils/pcm16-wav.ts | 88 ++ .../app/src/utils/thinking-tone.native-pcm.ts | 2 + packages/app/src/utils/thinking-tone.test.ts | 76 ++ packages/app/src/utils/thinking-tone.ts | 33 + .../app/src/utils/tool-call-display.test.ts | 34 + packages/app/src/voice/audio-engine-types.ts | 29 + packages/app/src/voice/audio-engine.d.ts | 1 + packages/app/src/voice/audio-engine.native.ts | 640 +++++++++++++ packages/app/src/voice/audio-engine.web.ts | 548 +++++++++++ packages/app/src/voice/speech-segmenter.ts | 22 + packages/app/src/voice/voice-runtime.test.ts | 243 +++++ packages/app/src/voice/voice-runtime.ts | 859 ++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 142 ++- .../providers/codex-rollout-parsing.test.ts | 62 +- .../agent/providers/codex-rollout-timeline.ts | 101 +- .../agent/providers/tool-call-mapper-utils.ts | 10 + packages/server/src/server/session.ts | 232 +++-- .../src/server/session.workspaces.test.ts | 220 ++++- .../src/shared/tool-call-display.test.ts | 34 + .../server/src/shared/tool-call-display.ts | 6 + 51 files changed, 4308 insertions(+), 2380 deletions(-) create mode 100644 packages/app/assets/audio/thinking-tone.wav delete mode 100644 packages/app/src/hooks/use-audio-player.d.ts delete mode 100644 packages/app/src/hooks/use-audio-player.native.ts delete mode 100644 packages/app/src/hooks/use-audio-player.web.ts delete mode 100644 packages/app/src/hooks/use-speechmatics-audio.d.ts delete mode 100644 packages/app/src/hooks/use-speechmatics-audio.native.ts delete mode 100644 packages/app/src/hooks/use-speechmatics-audio.web.ts create mode 100644 packages/app/src/utils/pcm16-wav.ts create mode 100644 packages/app/src/utils/thinking-tone.native-pcm.ts create mode 100644 packages/app/src/utils/thinking-tone.test.ts create mode 100644 packages/app/src/utils/thinking-tone.ts create mode 100644 packages/app/src/voice/audio-engine-types.ts create mode 100644 packages/app/src/voice/audio-engine.d.ts create mode 100644 packages/app/src/voice/audio-engine.native.ts create mode 100644 packages/app/src/voice/audio-engine.web.ts create mode 100644 packages/app/src/voice/voice-runtime.test.ts create mode 100644 packages/app/src/voice/voice-runtime.ts diff --git a/packages/app/assets/audio/thinking-tone.wav b/packages/app/assets/audio/thinking-tone.wav new file mode 100644 index 0000000000000000000000000000000000000000..aed18cce68d1b91d43119bf89b8914a15bc3ab82 GIT binary patch literal 262914 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%0Dh8c5G@ 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
(OagPnaYZN`7 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 + {children} ); @@ -582,7 +583,6 @@ export default function RootLayout() { - diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index abec6dd01..55153a952 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -103,6 +103,23 @@ export function AgentInputArea({ const { client, isConnected, snapshot } = useHostRuntimeSession(serverId) const toast = useToast() const voice = useVoiceOptional() + console.log("[AgentInputArea] render", { + serverId, + agentId, + isConnected, + isVoiceMode: voice?.isVoiceMode ?? false, + isVoiceSwitching: voice?.isVoiceSwitching ?? false, + isMuted: voice?.isMuted ?? false, + }) + console.log("[AgentInputArea] voice_snapshot", { + serverId, + agentId, + isVoiceMode: voice?.isVoiceMode ?? false, + isVoiceSwitching: voice?.isVoiceSwitching ?? false, + isMuted: voice?.isMuted ?? false, + activeServerId: voice?.activeServerId ?? null, + activeAgentId: voice?.activeAgentId ?? null, + }) const isDictationReady = isConnected && (snapshot?.agentDirectoryStatus === 'ready' || @@ -151,6 +168,19 @@ export function AgentInputArea({ `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}` ) + useEffect(() => { + console.log("[AgentInputArea] mount", { + serverId, + agentId, + }) + return () => { + console.log("[AgentInputArea] unmount", { + serverId, + agentId, + }) + } + }, [agentId, serverId]) + const autocomplete = useAgentAutocomplete({ userInput, cursorIndex, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 8c304ff8c..9add6a26f 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -144,6 +144,70 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false) + useEffect(() => { + console.log('[LeftSidebar] isOpen_changed', { + isOpen, + isMobile, + mobileView, + desktopAgentListOpen, + }) + }, [desktopAgentListOpen, isMobile, isOpen, mobileView]) + + useEffect(() => { + console.log('[LeftSidebar] pathname_changed', { + pathname, + activeServerIdFromPath, + activeServerId, + }) + }, [activeServerId, activeServerIdFromPath, pathname]) + + useEffect(() => { + console.log('[LeftSidebar] hosts_changed', { + hostCount: daemons.length, + serverIds: daemons.map((daemon) => daemon.serverId), + runtimeConnectionStatusSignature, + }) + }, [daemons, runtimeConnectionStatusSignature]) + + useEffect(() => { + console.log('[LeftSidebar] active_host_changed', { + activeServerId, + activeHostLabel, + activeHostStatus, + }) + }, [activeHostLabel, activeHostStatus, activeServerId]) + + useEffect(() => { + console.log('[LeftSidebar] projects_changed', { + activeServerId, + projectCount: projects.length, + projectKeys: projects.map((project) => project.projectKey), + workspaceCounts: projects.map((project) => ({ + projectKey: project.projectKey, + workspaceCount: project.workspaces.length, + })), + isInitialLoad, + isRevalidating, + }) + }, [activeServerId, isInitialLoad, isRevalidating, projects]) + + useEffect(() => { + console.log('[LeftSidebar] collapsed_or_shortcuts_changed', { + collapsedProjectKeys: Array.from(collapsedProjectKeys), + shortcutCount: shortcutIndexByWorkspaceKey.size, + }) + }, [collapsedProjectKeys, shortcutIndexByWorkspaceKey]) + + useEffect(() => { + console.log('[LeftSidebar] animation_context_changed', { + windowWidth, + translateXValue: translateX.value, + backdropOpacityValue: backdropOpacity.value, + isGesturing: isGesturing.value, + hasCloseGestureRef: Boolean(closeGestureRef.current), + }) + }, [backdropOpacity, closeGestureRef, isGesturing, translateX, windowWidth]) + const handleRefresh = useCallback(() => { setIsManualRefresh(true) refreshAll() @@ -354,7 +418,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr ) : ( ) : ( + _event: string, + _details: Record ): void { - if (!IS_DEV || !IS_WEB) { - return - } - console.log('[WebStickyBottom]', event, details) + // Intentionally disabled: this path is too noisy during voice debugging. } function getDebugNow(): number | null { @@ -220,6 +216,18 @@ export const MessageInput = forwardRef(funct markScrollInvestigationRender(investigationComponentId) const toast = useToast() const voice = useVoiceOptional() + console.log("[MessageInput] render", { + voiceServerId: voiceServerId ?? null, + voiceAgentId: voiceAgentId ?? null, + hasVoice: Boolean(voice), + isVoiceMode: voice?.isVoiceMode ?? false, + isVoiceSwitching: voice?.isVoiceSwitching ?? false, + isMuted: voice?.isMuted ?? false, + disabled, + isSubmitLoading, + valueLength: value.length, + imageCount: images.length, + }) const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT) const rootRef = useRef(null) const inputWrapperRef = useRef(null) @@ -293,6 +301,19 @@ export const MessageInput = forwardRef(funct ) ) + useEffect(() => { + console.log("[MessageInput] mount", { + voiceServerId: voiceServerId ?? null, + voiceAgentId: voiceAgentId ?? null, + }) + return () => { + console.log("[MessageInput] unmount", { + voiceServerId: voiceServerId ?? null, + voiceAgentId: voiceAgentId ?? null, + }) + } + }, [voiceAgentId, voiceServerId]) + useEffect(() => { valueRef.current = value }, [value]) @@ -407,6 +428,26 @@ export const MessageInput = forwardRef(funct const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent const showOverlay = showDictationOverlay || showRealtimeOverlay + useEffect(() => { + console.log("[MessageInput] overlay_state", { + showDictationOverlay, + showRealtimeOverlay, + showOverlay, + isDictating, + isDictationProcessing, + dictationStatus, + isRealtimeVoiceForCurrentAgent, + }) + }, [ + dictationStatus, + isDictating, + isDictationProcessing, + isRealtimeVoiceForCurrentAgent, + showDictationOverlay, + showOverlay, + showRealtimeOverlay, + ]) + useEffect(() => { if (isDictating || isDictationProcessing) { return @@ -443,6 +484,11 @@ export const MessageInput = forwardRef(funct })) const handleVoicePress = useCallback(async () => { + console.log("[MessageInput] handleVoicePress", { + isRealtimeVoiceForCurrentAgent, + isDictating, + hasVoice: Boolean(voice), + }) if (isRealtimeVoiceForCurrentAgent && voice) { voice.toggleMute() return @@ -462,28 +508,39 @@ export const MessageInput = forwardRef(funct ]) const handleCancelRecording = useCallback(async () => { + console.log("[MessageInput] handleCancelRecording") await cancelDictation() }, [cancelDictation]) const handleAcceptRecording = useCallback(async () => { + console.log("[MessageInput] handleAcceptRecording") sendAfterTranscriptRef.current = false await confirmDictation() }, [confirmDictation]) const handleAcceptAndSendRecording = useCallback(async () => { + console.log("[MessageInput] handleAcceptAndSendRecording") sendAfterTranscriptRef.current = true await confirmDictation() }, [confirmDictation]) const handleRetryFailedRecording = useCallback(() => { + console.log("[MessageInput] handleRetryFailedRecording") void retryFailedDictation() }, [retryFailedDictation]) const handleDiscardFailedRecording = useCallback(() => { + console.log("[MessageInput] handleDiscardFailedRecording") discardFailedDictation() }, [discardFailedDictation]) const handleStopRealtimeVoice = useCallback(async () => { + console.log("[MessageInput] handleStopRealtimeVoice", { + hasVoice: Boolean(voice), + isRealtimeVoiceForCurrentAgent, + isAgentRunning, + voiceAgentId: voiceAgentId ?? null, + }) if (!voice || !isRealtimeVoiceForCurrentAgent) { return } @@ -503,6 +560,18 @@ export const MessageInput = forwardRef(funct }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]) const handleToggleRealtimeVoiceShortcut = useCallback(() => { + console.log("[MessageInput] handleToggleRealtimeVoiceShortcut", { + hasVoice: Boolean(voice), + voiceServerId: voiceServerId ?? null, + voiceAgentId: voiceAgentId ?? null, + isConnected, + disabled, + isVoiceSwitching: voice?.isVoiceSwitching ?? false, + isVoiceModeForAgent: + voice && voiceServerId && voiceAgentId + ? voice.isVoiceModeForAgent(voiceServerId, voiceAgentId) + : false, + }) if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { return } @@ -524,6 +593,11 @@ export const MessageInput = forwardRef(funct }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]) const handleSendMessage = useCallback(() => { + console.log("[MessageInput] handleSendMessage", { + valueLength: value.length, + imageCount: images.length, + isAgentRunning, + }) const trimmed = value.trim() if (!trimmed && images.length === 0) return const payload = { @@ -1070,10 +1144,7 @@ export const MessageInput = forwardRef(funct /> ) : showRealtimeOverlay && voice ? ( { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 821593b22..6dbf5b1c6 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1926,9 +1926,11 @@ export const ToolCall = memo(function ToolCall({ const hasDetails = Boolean(error) || (effectiveDetail - ? effectiveDetail.type !== "unknown" || - effectiveDetail.input !== null || - effectiveDetail.output !== null + ? effectiveDetail.type === "plain_text" + ? Boolean(effectiveDetail.text) + : effectiveDetail.type !== "unknown" || + effectiveDetail.input !== null || + effectiveDetail.output !== null : false); const handleToggle = useCallback(() => { diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 2f0b34b3d..2901caf31 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -1,14 +1,13 @@ import { ActivityIndicator, Pressable, View } from "react-native"; +import { useEffect } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; import { FOOTER_HEIGHT } from "@/constants/layout"; +import { useVoiceTelemetry } from "@/contexts/voice-context"; import { VolumeMeter } from "./volume-meter"; interface RealtimeVoiceOverlayProps { - volume: number; isMuted: boolean; - isDetecting: boolean; - isSpeaking: boolean; isSwitching: boolean; onToggleMute: () => void; onStop: () => void; @@ -18,15 +17,27 @@ const OVERLAY_BUTTON_SIZE = 44; const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2; export function RealtimeVoiceOverlay({ - volume, isMuted, - isDetecting, - isSpeaking, isSwitching, onToggleMute, onStop, }: RealtimeVoiceOverlayProps) { const { theme } = useUnistyles(); + const { volume, isDetecting, isSpeaking } = useVoiceTelemetry(); + console.log("[RealtimeVoiceOverlay] render", { + isMuted, + isSwitching, + volume, + isDetecting, + isSpeaking, + }); + + useEffect(() => { + console.log("[RealtimeVoiceOverlay] mount"); + return () => { + console.log("[RealtimeVoiceOverlay] unmount"); + }; + }, []); return ( diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index ae2f18a23..8aeb78624 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -78,8 +78,12 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): return `data:${icon.mimeType};base64,${icon.data}` } +const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => + workspace.workspaceKey + +const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey + interface SidebarWorkspaceListProps { - isOpen?: boolean projects: SidebarProjectEntry[] serverId: string | null collapsedProjectKeys: ReadonlySet @@ -1156,6 +1160,13 @@ function ProjectBlock({ [renderWorkspaceRow] ) + const handleWorkspaceDragEnd = useCallback( + (workspaces: SidebarWorkspaceEntry[]) => { + onWorkspaceReorder(project.projectKey, workspaces) + }, + [onWorkspaceReorder, project.projectKey] + ) + return ( {flattenedWorkspace ? ( @@ -1201,9 +1212,9 @@ function ProjectBlock({ workspace.workspaceKey} + keyExtractor={workspaceKeyExtractor} renderItem={renderWorkspace} - onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)} + onDragEnd={handleWorkspaceDragEnd} scrollEnabled={false} useDragHandle nestable={useNestable} @@ -1218,7 +1229,6 @@ function ProjectBlock({ } export function SidebarWorkspaceList({ - isOpen = true, projects, serverId, collapsedProjectKeys, @@ -1258,7 +1268,7 @@ export function SidebarWorkspaceList({ }, [pathname]) const projectIconRequests = useMemo(() => { - if (!isOpen || !serverId) { + if (!serverId) { return [] } const unique = new Map() @@ -1270,7 +1280,7 @@ export function SidebarWorkspaceList({ unique.set(`${serverId}:${cwd}`, { serverId, cwd }) } return Array.from(unique.values()) - }, [isOpen, projects, serverId]) + }, [projects, serverId]) const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ @@ -1285,7 +1295,6 @@ export function SidebarWorkspaceList({ }, select: toProjectIconDataUri, enabled: Boolean( - isOpen && getHostRuntimeStore().getClient(request.serverId) && isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && request.cwd @@ -1444,7 +1453,7 @@ export function SidebarWorkspaceList({ project.projectKey} + keyExtractor={projectKeyExtractor} renderItem={renderProject} onDragEnd={handleProjectDragEnd} scrollEnabled={false} diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 21d360bfb..1b3d4c8c3 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -24,7 +24,6 @@ const USER_SCROLL_DELTA_EPSILON = 1 const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64 const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1 const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style' -const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__) const WEB_STREAM_SCROLLBAR_STYLE = ` #agent-chat-scroll-web-dom-scroll, #agent-chat-scroll-web-dom-virtualized { @@ -40,11 +39,8 @@ const WEB_STREAM_SCROLLBAR_STYLE = ` } ` -function logWebStickyBottom(event: string, details: Record): void { - if (!IS_DEV) { - return - } - console.log('[WebStickyBottom]', event, details) +function logWebStickyBottom(_event: string, _details: Record): void { + // Intentionally disabled: this path is too noisy during voice debugging. } function getDebugNow(): number | null { diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index cdc963e41..648ec3859 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -64,6 +64,7 @@ export interface ComboboxProps { title?: string open?: boolean onOpenChange?: (open: boolean) => void + enableDismissOnClose?: boolean desktopPlacement?: 'top-start' | 'bottom-start' /** * Prevents an initial frame at 0,0 by hiding desktop content until floating @@ -224,6 +225,7 @@ export function Combobox({ title = 'Select', open, onOpenChange, + enableDismissOnClose, desktopPlacement = 'top-start', desktopPreventInitialFlash = true, anchorRef, @@ -235,6 +237,7 @@ export function Combobox({ !isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search' const { height: windowHeight } = useWindowDimensions() const bottomSheetRef = useRef(null) + const hasPresentedBottomSheetRef = useRef(false) const snapPoints = useMemo(() => ['60%', '90%'], []) const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( null @@ -379,11 +382,20 @@ export function Combobox({ useEffect(() => { if (!isMobile) return if (isOpen) { - bottomSheetRef.current?.present() + if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) { + bottomSheetRef.current?.snapToIndex(0) + } else { + hasPresentedBottomSheetRef.current = true + bottomSheetRef.current?.present() + } } else { - bottomSheetRef.current?.dismiss() + if (enableDismissOnClose === false) { + bottomSheetRef.current?.close() + } else { + bottomSheetRef.current?.dismiss() + } } - }, [isOpen, isMobile]) + }, [enableDismissOnClose, isOpen, isMobile]) const handleSheetChange = useCallback( (index: number) => { @@ -622,6 +634,7 @@ export function Combobox({ onChange={handleSheetChange} backdropComponent={renderBackdrop} enablePanDownToClose + enableDismissOnClose={enableDismissOnClose} backgroundComponent={ComboboxSheetBackground} handleIndicatorStyle={styles.bottomSheetHandle} keyboardBehavior="extend" diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/components/use-bottom-anchor-controller.test.ts index 84120424f..7844e5c4d 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.test.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.test.ts @@ -132,8 +132,6 @@ function createDriverHarness(input?: { context.measurementState.offsetY = 720; }); const modeChanges: BottomAnchorMode[] = []; - const warnings: Array<{ agentId: string; reason: string }> = []; - const logs: Array<{ event: string; details: Record }> = []; const driver = __private__.createBottomAnchorControllerDriver({ getAgentId: () => context.agentId, getIsAuthoritativeHistoryReady: () => context.authoritativeReady, @@ -145,10 +143,6 @@ function createDriverHarness(input?: { onModeChange: (mode) => { modeChanges.push(mode); }, - log: (event, details) => { - logs.push({ event, details }); - }, - warn: (details) => warnings.push(details), scheduleFrame: (params) => scheduler.schedule(params), cancelFrame: (handle) => scheduler.cancel(handle), }); @@ -159,8 +153,6 @@ function createDriverHarness(input?: { scheduler, scrollToBottom, modeChanges, - logs, - warnings, }; } @@ -367,7 +359,6 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushAll(); expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); - expect(harness.warnings).toEqual([]); expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); }); diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts index 99867341d..db98ac849 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.ts @@ -110,8 +110,6 @@ type CreateBottomAnchorControllerDriverInput = { isNearBottom: () => boolean; scrollToBottom: (animated: boolean) => void; onModeChange: (mode: BottomAnchorMode) => void; - log: (event: BottomAnchorEvent, details: Record) => void; - warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void; scheduleFrame: (params: { kind: "attempt" | "verification"; callback: () => void; @@ -123,17 +121,6 @@ type CreateBottomAnchorControllerDriverInput = { const MAX_VERIFICATION_RETRIES = 3; const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1; const USER_SCROLL_AWAY_DELTA_PX = 24; -const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); - -function logBottomAnchorEvent( - event: BottomAnchorEvent, - details: Record -): void { - if (!IS_DEV) { - return; - } - console.debug("[BottomAnchor]", event, details); -} function scheduleAnimationFrameWithDelay(input: { callback: () => void; @@ -323,10 +310,6 @@ function createBottomAnchorControllerDriver( return; } blockedReason = nextBlockedReason; - input.log( - "blocked_reason_changed", - getLogContext({ nextBlockedReason }) - ); }; const setModeInternal = (nextMode: BottomAnchorMode) => { @@ -367,13 +350,6 @@ function createBottomAnchorControllerDriver( setBlockedReason(null); return; } - input.log( - "request_cancelled", - getLogContext({ - cancelledRequestReason: currentRequest.reason, - cancelReason: reason, - }) - ); pendingRequest = null; cancelPendingAttempt(); setBlockedReason(null); @@ -398,19 +374,6 @@ function createBottomAnchorControllerDriver( if (verificationHandle) { input.cancelFrame(verificationHandle); } - input.log( - "verification_scheduled", - getLogContext({ - retries: attemptContext.retries, - startedContentHeight: attemptContext.startedContentHeight ?? null, - startedOffsetY: attemptContext.startedOffsetY ?? null, - startedViewportHeight: attemptContext.startedViewportHeight ?? null, - scheduledMeasurementState: - getDetailedMeasurementState(scheduledMeasurementState), - verificationDelayFrames: - delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames, - }) - ); verificationHandle = input.scheduleFrame({ kind: "verification", delayFrames: @@ -427,15 +390,6 @@ function createBottomAnchorControllerDriver( }); if (verificationBlockedReason) { - input.log( - "attempt_verified", - getLogContext({ - verificationPhase: "blocked", - verificationBlockedReason, - retries: attemptContext.retries, - measurementState: getDetailedMeasurementState(measurementState), - }) - ); pendingVerification = attemptContext; setBlockedReason(verificationBlockedReason); return; @@ -451,26 +405,6 @@ function createBottomAnchorControllerDriver( input.getTransportBehavior().verificationRetryMode, }); - input.log( - "attempt_verified", - getLogContext({ - verifiedNearBottom, - retries: attemptContext.retries, - retryDisposition, - contentHeightDeltaSinceAttempt: - measurementState.contentHeight - - (attemptContext.startedContentHeight ?? measurementState.contentHeight), - offsetDeltaSinceAttempt: - measurementState.offsetY - - (attemptContext.startedOffsetY ?? measurementState.offsetY), - viewportHeightDeltaSinceAttempt: - measurementState.viewportHeight - - (attemptContext.startedViewportHeight ?? - measurementState.viewportHeight), - measurementState: getDetailedMeasurementState(measurementState), - }) - ); - if (verifiedNearBottom) { if ( isRequestAttempt && @@ -494,7 +428,6 @@ function createBottomAnchorControllerDriver( pendingVerification = null; markStickyMeasurementVerified(); if (isRequestAttempt) { - input.log("request_fulfilled", getLogContext()); pendingRequest = null; } setBlockedReason(null); @@ -520,21 +453,7 @@ function createBottomAnchorControllerDriver( return; } - input.log( - "attempt_failed", - getLogContext({ - retries: attemptContext.retries, - retryDisposition, - measurementState: getDetailedMeasurementState(measurementState), - }) - ); pendingVerification = null; - if (isRequestAttempt && currentRequest) { - input.warn({ - agentId: input.getAgentId(), - reason: currentRequest.reason, - }); - } setBlockedReason( isRequestAttempt ? "waiting_for_post_layout_verification" : null ); @@ -552,14 +471,6 @@ function createBottomAnchorControllerDriver( startedViewportHeight: measurementState.viewportHeight, }; pendingVerification = attemptContext; - input.log( - "attempt_started", - getLogContext({ - animated, - retries: attemptContext.retries, - measurementState: getDetailedMeasurementState(measurementState), - }) - ); input.scrollToBottom(animated); scheduleVerification(attemptContext); setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState())); @@ -576,18 +487,6 @@ function createBottomAnchorControllerDriver( | "manual_reevaluate" | "retry_scroll" ) => { - input.log( - "evaluate_called", - getLogContext({ - evaluateReason: reason, - animated, - hasAttemptHandle: attemptHandle !== null, - hasVerificationHandle: verificationHandle !== null, - pendingVerificationRequestId: pendingVerification?.requestId ?? null, - pendingVerificationRetries: pendingVerification?.retries ?? null, - measurementState: getDetailedMeasurementState(input.getMeasurementState()), - }) - ); if (attemptHandle) { return; } @@ -610,17 +509,6 @@ function createBottomAnchorControllerDriver( !shouldAttemptForPendingRequest && !shouldAttemptForStickyVerification ) { - input.log( - "attempt_started", - getLogContext({ - attemptPhase: "skipped", - evaluateReason: reason, - nextBlockedReason, - shouldAttemptForPendingRequest, - shouldAttemptForStickyVerification, - measurementState: getDetailedMeasurementState(measurementState), - }) - ); return; } @@ -630,17 +518,6 @@ function createBottomAnchorControllerDriver( }; const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => { - const existing = pendingRequest; - if (existing) { - input.log( - "request_cancelled", - getLogContext({ - cancelledRequestReason: existing.reason, - cancelReason: "replaced_by_new_request", - }) - ); - } - cancelPendingAttempt(); const nextRequest: BottomAnchorRequest = { id: requestSequence + 1, @@ -659,10 +536,6 @@ function createBottomAnchorControllerDriver( ? "sticky-bottom" : __private__.deriveModeForLocalRequest({ reason: request.reason }) ); - input.log( - "request_created", - getLogContext({ requestReason: request.reason }) - ); evaluate(request.reason === "jump-to-bottom", "request_created"); }; @@ -707,7 +580,6 @@ function createBottomAnchorControllerDriver( } cancelPendingRequest("user_scrolled_away"); setModeInternal("detached"); - input.log("detached_by_user", getLogContext()); }, handleViewportMetricsChange(params) { if ( @@ -905,10 +777,6 @@ export function useBottomAnchorController(input: { isNearBottom: () => isNearBottomRef.current(), scrollToBottom: (animated) => scrollToBottomRef.current(animated), onModeChange: (nextMode) => setMode(nextMode), - log: (event, details) => logBottomAnchorEvent(event, details), - warn: (details) => { - console.warn("[BottomAnchor] request could not be fulfilled", details); - }, scheduleFrame: ({ callback, delayFrames }) => scheduleAnimationFrameWithDelay({ callback, delayFrames }), cancelFrame: (handle) => cancelScheduledAnimationFrame(handle), diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx index b882313c9..c4bd2f0fb 100644 --- a/packages/app/src/components/voice-compact-indicator.tsx +++ b/packages/app/src/components/voice-compact-indicator.tsx @@ -1,21 +1,35 @@ import { ActivityIndicator, Alert, Pressable, View } from "react-native"; +import { useEffect } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; import { VolumeMeter } from "@/components/volume-meter"; -import { useVoice } from "@/contexts/voice-context"; +import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context"; export function VoiceCompactIndicator() { const { theme } = useUnistyles(); + const { volume, isDetecting, isSpeaking } = useVoiceTelemetry(); const { isVoiceMode, isVoiceSwitching, - volume, isMuted, - isDetecting, - isSpeaking, toggleMute, stopVoice, } = useVoice(); + console.log("[VoiceCompactIndicator] render", { + isVoiceMode, + isVoiceSwitching, + isMuted, + volume, + isDetecting, + isSpeaking, + }); + + useEffect(() => { + console.log("[VoiceCompactIndicator] mount"); + return () => { + console.log("[VoiceCompactIndicator] unmount"); + }; + }, []); if (!isVoiceMode) { return null; diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx index c1ed3fda2..d5b8e2645 100644 --- a/packages/app/src/components/voice-panel.tsx +++ b/packages/app/src/components/voice-panel.tsx @@ -1,22 +1,35 @@ import { View, Pressable } from "react-native"; +import { useEffect } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { MicOff, Square } from "lucide-react-native"; import { VolumeMeter } from "./volume-meter"; -import { useVoice } from "@/contexts/voice-context"; +import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context"; import { useHosts } from "@/runtime/host-runtime"; export function VoicePanel() { const { theme } = useUnistyles(); const daemons = useHosts(); + const { volume, isDetecting, isSpeaking } = useVoiceTelemetry(); const { - volume, isMuted, - isDetecting, - isSpeaking, stopVoice, toggleMute, activeServerId, } = useVoice(); + console.log("[VoicePanel] render", { + activeServerId, + isMuted, + volume, + isDetecting, + isSpeaking, + }); + + useEffect(() => { + console.log("[VoicePanel] mount"); + return () => { + console.log("[VoicePanel] unmount"); + }; + }, []); const hostLabel = activeServerId ? daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx index 6c87be3af..f3cb74e2e 100644 --- a/packages/app/src/components/volume-meter.tsx +++ b/packages/app/src/components/volume-meter.tsx @@ -3,10 +3,9 @@ import { View } from "react-native"; import ReanimatedAnimated, { useSharedValue, useAnimatedStyle, - withSpring, + withTiming, withRepeat, withSequence, - withTiming, Easing, } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -41,9 +40,7 @@ export function VolumeMeter({ orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20; // Create shared values for 3 dots unconditionally - const line1Height = useSharedValue(MIN_HEIGHT); - const line2Height = useSharedValue(MIN_HEIGHT); - const line3Height = useSharedValue(MIN_HEIGHT); + const animatedVolume = useSharedValue(0); const line1Pulse = useSharedValue(1); const line2Pulse = useSharedValue(1); const line3Pulse = useSharedValue(1); @@ -90,54 +87,19 @@ export function VolumeMeter({ ); }, [isMuted]); - // Update heights based on volume with different responsiveness for all dots + // Drive a single animated volume value and derive the individual bar heights + // on the UI thread instead of scheduling three independent springs per sample. useEffect(() => { if (isMuted) { - // When muted, keep all lines at minimum height without animation - line1Height.value = MIN_HEIGHT; - line2Height.value = MIN_HEIGHT; - line3Height.value = MIN_HEIGHT; + animatedVolume.value = 0; return; } - if (volume > 0.001) { - // Active volume - animate heights based on volume - const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2); - const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05); - const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9); - - line1Height.value = withSpring(target1, { - damping: 10, - stiffness: 200, - }); - - line2Height.value = withSpring(target2, { - damping: 12.5, - stiffness: 175, - }); - - line3Height.value = withSpring(target3, { - damping: 15, - stiffness: 150, - }); - } else { - // No volume - return to minimum - line1Height.value = withSpring(MIN_HEIGHT, { - damping: 20, - stiffness: 150, - }); - - line2Height.value = withSpring(MIN_HEIGHT, { - damping: 20, - stiffness: 150, - }); - - line3Height.value = withSpring(MIN_HEIGHT, { - damping: 20, - stiffness: 150, - }); - } - }, [volume, isMuted]); + animatedVolume.value = withTiming(volume, { + duration: volume > animatedVolume.value ? 70 : 140, + easing: Easing.out(Easing.cubic), + }); + }, [animatedVolume, isMuted, volume]); const lineColor = color ?? theme.colors.foreground; const containerHeight = @@ -147,9 +109,12 @@ export function VolumeMeter({ const line1Style = useAnimatedStyle(() => { const isActive = isSpeaking; const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; - const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3; + const currentVolume = isMuted ? 0 : animatedVolume.value; + const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.2); + const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; return { - height: line1Height.value * (isMuted || volume > 0.001 ? 1 : line1Pulse.value), + height: + currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line1Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -157,9 +122,12 @@ export function VolumeMeter({ const line2Style = useAnimatedStyle(() => { const isActive = isSpeaking; const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; - const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3; + const currentVolume = isMuted ? 0 : animatedVolume.value; + const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.05); + const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; return { - height: line2Height.value * (isMuted || volume > 0.001 ? 1 : line2Pulse.value), + height: + currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line2Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -167,9 +135,12 @@ export function VolumeMeter({ const line3Style = useAnimatedStyle(() => { const isActive = isSpeaking; const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; - const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3; + const currentVolume = isMuted ? 0 : animatedVolume.value; + const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 0.9); + const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; return { - height: line3Height.value * (isMuted || volume > 0.001 ? 1 : line3Pulse.value), + height: + currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line3Pulse.value), opacity: baseOpacity + volumeBoost, }; }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 77ff03e23..b2d3aac6a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,7 +1,7 @@ import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; +import { Buffer } from "buffer"; import { AppState, Platform } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; -import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; @@ -29,8 +29,13 @@ import type { DaemonClient } from "@server/client/daemon-client"; import { File } from "expo-file-system"; import { getHostRuntimeStore, - useHostRuntimeSession, + useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; +import { + useVoiceAudioEngineOptional, + useVoiceRuntimeOptional, +} from "@/contexts/voice-context"; +import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; import { useSessionStore, type Agent, @@ -73,6 +78,66 @@ export type { const HISTORY_STALE_AFTER_MS = 60_000; const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300; +type AudioOutputPayload = Extract< + SessionOutboundMessage, + { type: "audio_output" } +>["payload"]; + +interface BufferedAudioChunk { + chunkIndex: number; + audio: string; + format: string; + id: string; +} + +function decodeBase64Chunk(base64: string): Uint8Array { + return Buffer.from(base64, "base64"); +} + +function buildAudioPlaybackSource( + chunks: BufferedAudioChunk[] +): AudioPlaybackSource { + const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio)); + const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const output = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of decodedChunks) { + output.set(chunk, offset); + offset += chunk.length; + } + + const format = chunks[0]?.format ?? "pcm"; + const mimeType = + format === "pcm" + ? "audio/pcm;rate=24000;bits=16" + : format === "mp3" + ? "audio/mpeg" + : `audio/${format}`; + + const bytes = output.slice(); + return { + size: bytes.byteLength, + type: mimeType, + async arrayBuffer() { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ); + }, + }; +} + +function logVoiceSession( + event: string, + details?: Record +): void { + if (details) { + console.log(`[VoiceSession] ${event}`, details); + return; + } + console.log(`[VoiceSession] ${event}`); +} + const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { for (let i = items.length - 1; i >= 0; i -= 1) { const item = items[i]; @@ -206,8 +271,15 @@ function SessionProviderInternal({ serverId, client, }: SessionProviderClientProps) { + const voiceRuntime = useVoiceRuntimeOptional(); + const voiceAudioEngine = useVoiceAudioEngineOptional(); + console.log("[SessionProvider] render", { + serverId, + hasVoiceRuntime: Boolean(voiceRuntime), + hasVoiceAudioEngine: Boolean(voiceAudioEngine), + }); const queryClient = useQueryClient(); - const { isConnected } = useHostRuntimeSession(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); // Zustand store actions const initializeSession = useSessionStore((state) => state.initializeSession); @@ -276,16 +348,6 @@ function SessionProviderInternal({ (state) => state.sessions[serverId]?.agents ); - // State for voice detection flags (will be set by RealtimeContext) - const isDetectingRef = useRef(false); - const isSpeakingRef = useRef(false); - - const audioPlayer = useAudioPlayer({ - isDetecting: () => isDetectingRef.current, - isSpeaking: () => isSpeakingRef.current, - }); - - const activeAudioGroupsRef = useRef>(new Set()); const previousAgentStatusRef = useRef>( new Map() ); @@ -308,6 +370,10 @@ function SessionProviderInternal({ const revalidationInFlightRef = useRef | null>(null); const revalidationQueuedRef = useRef(false); const wasConnectedRef = useRef(isConnected); + const audioOutputBuffersRef = useRef>( + new Map() + ); + const activeAudioGroupsRef = useRef>(new Set()); useEffect(() => { const subscription = AppState.addEventListener("change", (nextState) => { @@ -617,24 +683,83 @@ function SessionProviderInternal({ [serverId] ); - // Buffer for streaming audio chunks - interface AudioChunk { - chunkIndex: number; - audio: string; // base64 - format: string; - id: string; - } - const audioChunkBuffersRef = useRef>(new Map()); - // Initialize session in store useEffect(() => { - initializeSession(serverId, client, audioPlayer); - }, [serverId, client, audioPlayer, initializeSession]); + console.log("[SessionProvider] mount", { serverId }); + return () => { + console.log("[SessionProvider] unmount", { serverId }); + }; + }, [serverId]); + + useEffect(() => { + initializeSession(serverId, client); + }, [serverId, client, initializeSession]); useEffect(() => { updateSessionClient(serverId, client); }, [serverId, client, updateSessionClient]); + useEffect(() => { + if (!voiceRuntime) { + return; + } + + console.log("[SessionProvider] register_voice_session", { serverId }); + return voiceRuntime.registerSession({ + serverId, + setVoiceMode: async (enabled, agentId) => { + console.log("[SessionProvider] setVoiceMode", { + serverId, + enabled, + agentId: agentId ?? null, + }); + if (!client) { + throw new Error("Daemon unavailable"); + } + await client.setVoiceMode(enabled, agentId); + }, + sendVoiceAudioChunk: async (audioData, mimeType, isLast) => { + console.log("[SessionProvider] sendVoiceAudioChunk", { + serverId, + audioDataLength: audioData.length, + mimeType, + isLast, + }); + if (!client) { + throw new Error("Daemon unavailable"); + } + await client.sendVoiceAudioChunk(audioData, mimeType, isLast); + }, + abortRequest: async () => { + console.log("[SessionProvider] abortRequest", { serverId }); + if (!client) { + throw new Error("Daemon unavailable"); + } + await client.abortRequest(); + }, + setAssistantAudioPlaying: (isPlaying) => { + console.log("[SessionProvider] setAssistantAudioPlaying", { + serverId, + isPlaying, + }); + setIsPlayingAudio(serverId, isPlaying); + }, + }); + }, [ + client, + serverId, + setIsPlayingAudio, + voiceRuntime, + ]); + + useEffect(() => { + console.log("[SessionProvider] updateSessionConnection", { + serverId, + isConnected, + }); + voiceRuntime?.updateSessionConnection(serverId, isConnected); + }, [isConnected, serverId, voiceRuntime]); + // If the client drops mid-initialization, clear pending flags useEffect(() => { if (!isConnected) { @@ -962,6 +1087,14 @@ function SessionProviderInternal({ const { agentId, event, timestamp, seq, epoch } = message.payload; const parsedTimestamp = new Date(timestamp); const streamEvent = event as AgentStreamEventPayload; + if ( + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + voiceRuntime?.onTurnEvent(serverId, agentId, event.type); + } // Attention notification stays in React (not extractable to pure reducer) if (event.type === "attention_required") { @@ -1161,92 +1294,102 @@ function SessionProviderInternal({ const unsubAudioOutput = client.on("audio_output", async (message) => { if (message.type !== "audio_output") return; - const data = message.payload; - const playbackGroupId = data.groupId ?? data.id; - const isFinalChunk = data.isLastChunk ?? true; - const chunkIndex = data.chunkIndex ?? 0; + if (!voiceAudioEngine) { + return; + } + + const payload: AudioOutputPayload = message.payload; + console.log("[SessionProvider] audio_output", { + serverId, + id: payload.id, + groupId: payload.groupId ?? null, + chunkIndex: payload.chunkIndex ?? 0, + isLastChunk: payload.isLastChunk ?? true, + format: payload.format, + isVoiceMode: payload.isVoiceMode ?? false, + audioLength: payload.audio.length, + }); + const playbackGroupId = payload.groupId ?? payload.id; + const chunkIndex = payload.chunkIndex ?? 0; + const isFinalChunk = payload.isLastChunk ?? true; + + if (!audioOutputBuffersRef.current.has(playbackGroupId)) { + audioOutputBuffersRef.current.set(playbackGroupId, []); + } + + const bufferedChunks = audioOutputBuffersRef.current.get(playbackGroupId)!; + bufferedChunks.push({ + chunkIndex, + audio: payload.audio, + format: payload.format, + id: payload.id, + }); activeAudioGroupsRef.current.add(playbackGroupId); setIsPlayingAudio(serverId, true); - if (!audioChunkBuffersRef.current.has(playbackGroupId)) { - audioChunkBuffersRef.current.set(playbackGroupId, []); - } - - const buffer = audioChunkBuffersRef.current.get(playbackGroupId)!; - buffer.push({ - chunkIndex, - audio: data.audio, - format: data.format, - id: data.id, - }); - if (!isFinalChunk) { return; } - buffer.sort((a, b) => a.chunkIndex - b.chunkIndex); - let playbackFailed = false; - const chunkIds = buffer.map((chunk) => chunk.id); - - const confirmAudioPlayed = (ids: string[]) => { - if (!client) { - console.warn("[Session] audio_played skipped: daemon unavailable"); - return; - } - ids.forEach((chunkId) => { - void client.audioPlayed(chunkId).catch((error) => { - console.warn("[Session] Failed to confirm audio playback:", error); - }); + bufferedChunks.sort((left, right) => left.chunkIndex - right.chunkIndex); + const chunkIds = bufferedChunks.map((chunk) => chunk.id); + const shouldPlay = + !payload.isVoiceMode || + (voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false); + const audioBlob = buildAudioPlaybackSource(bufferedChunks); + if (payload.isVoiceMode) { + logVoiceSession("audio_output_ready", { + serverId, + playbackGroupId, + chunkCount: bufferedChunks.length, + format: payload.format, + blobBytes: audioBlob.size, + shouldPlay, }); + } + const confirmAudioPlayed = async () => { + await Promise.all( + chunkIds.map((chunkId) => + client.audioPlayed(chunkId).catch((error) => { + console.warn("[Session] Failed to confirm audio playback:", error); + }) + ) + ); }; + let startedVoicePlayback = false; try { - const mimeType = - data.format === "mp3" ? "audio/mpeg" : `audio/${data.format}`; - - const decodedChunks: Uint8Array[] = []; - let totalSize = 0; - - for (const chunk of buffer) { - const binaryString = atob(chunk.audio); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); + if (shouldPlay) { + if (payload.isVoiceMode) { + startedVoicePlayback = true; + voiceRuntime?.onAssistantAudioStarted(serverId); } - decodedChunks.push(bytes); - totalSize += bytes.length; + await voiceAudioEngine.play(audioBlob); + if (payload.isVoiceMode) { + logVoiceSession("audio_output_played", { + serverId, + playbackGroupId, + chunkCount: bufferedChunks.length, + }); + } + } else if (payload.isVoiceMode) { + logVoiceSession("audio_output_suppressed", { + serverId, + playbackGroupId, + }); } - - const concatenatedBytes = new Uint8Array(totalSize); - let offset = 0; - for (const chunk of decodedChunks) { - concatenatedBytes.set(chunk, offset); - offset += chunk.length; - } - - const audioBlob = { - type: mimeType, - size: totalSize, - arrayBuffer: async () => { - return concatenatedBytes.buffer; - }, - } as Blob; - - await audioPlayer.play(audioBlob); - - confirmAudioPlayed(chunkIds); - } catch (error: any) { - playbackFailed = true; + await confirmAudioPlayed(); + } catch (error) { console.error("[Session] Audio playback error:", error); - - confirmAudioPlayed(chunkIds); + await confirmAudioPlayed(); } finally { - audioChunkBuffersRef.current.delete(playbackGroupId); + audioOutputBuffersRef.current.delete(playbackGroupId); activeAudioGroupsRef.current.delete(playbackGroupId); + setIsPlayingAudio(serverId, activeAudioGroupsRef.current.size > 0); - if (activeAudioGroupsRef.current.size === 0) { - setIsPlayingAudio(serverId, false); + if (startedVoicePlayback) { + voiceRuntime?.onAssistantAudioFinished(serverId); } } }); @@ -1254,6 +1397,12 @@ function SessionProviderInternal({ const unsubActivity = client.on("activity_log", (message) => { if (message.type !== "activity_log") return; const data = message.payload; + console.log("[SessionProvider] activity_log", { + serverId, + type: data.type, + contentPreview: + typeof data.content === "string" ? data.content.slice(0, 80) : null, + }); if (data.type === "system" && data.content.includes("Transcribing")) { return; @@ -1374,13 +1523,18 @@ function SessionProviderInternal({ if (message.type !== "transcription_result") return; const transcriptText = message.payload.text.trim(); + console.log("[SessionProvider] transcription_result", { + serverId, + textLength: transcriptText.length, + textPreview: transcriptText.slice(0, 80), + }); if (!transcriptText) { - } else { - audioPlayer.stop(); - setIsPlayingAudio(serverId, false); - setCurrentAssistantMessage(serverId, ""); + voiceRuntime?.onTranscriptionResult(serverId, transcriptText); + return; } + + setCurrentAssistantMessage(serverId, ""); }); const unsubAgentDeleted = client.on("agent_deleted", (message) => { @@ -1498,7 +1652,6 @@ function SessionProviderInternal({ }; }, [ client, - audioPlayer, queryClient, serverId, setIsPlayingAudio, @@ -1521,6 +1674,8 @@ function SessionProviderInternal({ requestCanonicalCatchUp, applyAgentUpdatePayload, applyTimelineResponse, + voiceRuntime, + voiceAudioEngine, ]); const sendAgentMessage = useCallback( @@ -1742,20 +1897,12 @@ function SessionProviderInternal({ [client] ); - const setVoiceDetectionFlags = useCallback( - (isDetecting: boolean, isSpeaking: boolean) => { - isDetectingRef.current = isDetecting; - isSpeakingRef.current = isSpeaking; - }, - [] - ); - // Cleanup on unmount useEffect(() => { return () => { clearSession(serverId); }; - }, [serverId, clearSession]); + }, [clearSession, serverId]); return children; } diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index a160ee7b7..748b78358 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -1,41 +1,119 @@ -import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef } from "react"; -import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio"; -import type { SessionState } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; -import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; +import { + createContext, + useContext, + useEffect, + useRef, + useSyncExternalStore, + type ReactNode, +} from "react"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; -import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; +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"; -const KEEP_AWAKE_TAG = "paseo:voice"; -interface VoiceContextValue { - isVoiceMode: boolean; - isVoiceSwitching: boolean; - volume: number; - isMuted: boolean; - isDetecting: boolean; - isSpeaking: boolean; - segmentDuration: number; +interface VoiceContextValue extends VoiceRuntimeSnapshot { startVoice: (serverId: string, agentId: string) => Promise; stopVoice: () => Promise; isVoiceModeForAgent: (serverId: string, agentId: string) => boolean; toggleMute: () => void; - activeServerId: string | null; - activeAgentId: string | null; } -const VoiceContext = createContext(null); +const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = { + phase: "disabled", + isVoiceMode: false, + isVoiceSwitching: false, + isMuted: false, + activeServerId: null, + activeAgentId: null, +}; + +const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = { + volume: 0, + isDetecting: false, + isSpeaking: false, + segmentDuration: 0, +}; + +const VoiceRuntimeContext = createContext(null); +const VoiceAudioEngineContext = createContext(null); + +const noopSubscribe = () => () => {}; +const getEmptySnapshot = () => EMPTY_SNAPSHOT; +const getEmptyTelemetry = () => EMPTY_TELEMETRY; +let nextVoiceProviderInstanceId = 1; + +function getProviderTraceStack(): string | undefined { + const stack = new Error().stack; + if (!stack) { + return undefined; + } + return stack + .split("\n") + .slice(2, 7) + .map((line) => line.trim()) + .join(" | "); +} export function useVoice() { - const context = useContext(VoiceContext); - if (!context) { + const value = useVoiceOptional(); + if (!value) { throw new Error("useVoice must be used within VoiceProvider"); } - return context; + return value; } export function useVoiceOptional(): VoiceContextValue | null { - return useContext(VoiceContext); + 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 { @@ -43,405 +121,87 @@ interface VoiceProviderProps { } export function VoiceProvider({ children }: VoiceProviderProps) { - const getSession = useSessionStore((state) => state.getSession); - const [activeServerId, setActiveServerId] = useState(null); - const [activeAgentId, setActiveAgentId] = useState(null); - const { client: runtimeClient, isConnected: activeRuntimeConnected } = useHostRuntimeSession( - activeServerId ?? "" - ); - const activeSession = useSessionStore( - useCallback( - (state: ReturnType) => { - if (!activeServerId) { - return null; - } - return state.sessions[activeServerId] ?? null; + const providerIdRef = useRef(null); + if (providerIdRef.current === null) { + providerIdRef.current = nextVoiceProviderInstanceId++; + console.log("[VoiceProvider] instance_created", { + providerId: providerIdRef.current, + stack: getProviderTraceStack(), + }); + } + + const providerId = providerIdRef.current; + const engineRef = useRef(null); + const runtimeRef = useRef(null); + console.log("[VoiceProvider] render", { + providerId, + hasEngine: Boolean(engineRef.current), + hasRuntime: Boolean(runtimeRef.current), + }); + + if (!engineRef.current) { + let runtime: VoiceRuntime | null = null; + console.log("[VoiceProvider] create_engine_and_runtime"); + const engine = createAudioEngine({ + onCaptureData: (pcm) => { + console.log("[VoiceProvider] onCaptureData", { + providerId, + bytes: pcm.byteLength, + }); + runtime?.handleCapturePcm(pcm); }, - [activeServerId] - ) - ); - const realtimeSessionRef = useRef(null); - const [isVoiceMode, setIsVoiceMode] = useState(false); - const [isVoiceSwitching, setIsVoiceSwitching] = useState(false); - const bargeInPlaybackStopRef = useRef(null); - const wasVoiceSocketConnectedRef = useRef(false); - const lastVoiceModeSyncedClientRef = useRef(null); - const voiceTransportReadyRef = useRef(false); - const voiceResyncInFlightRef = useRef(false); - const silenceGraceStartMsRef = useRef(null); - const speechInterruptTimerRef = useRef | null>(null); - const speechInterruptStartMsRef = useRef(null); - const speechStartInterruptSentRef = useRef(false); - const isVoiceModeRef = useRef(false); - const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({ - isDetecting: false, - isSpeaking: false, - }); - - const clearSpeechStartInterruptTimer = useCallback((reason: string) => { - const timer = speechInterruptTimerRef.current; - if (timer) { - clearTimeout(timer); - speechInterruptTimerRef.current = null; - const startedAt = speechInterruptStartMsRef.current; - speechInterruptStartMsRef.current = null; - if (startedAt !== null) { - console.log("[Voice] Cleared speech-start interrupt timer", { - reason, - elapsedMs: Date.now() - startedAt, + onVolumeLevel: (level) => { + console.log("[VoiceProvider] onVolumeLevel", { + providerId, + level, }); - } else { - console.log("[Voice] Cleared speech-start interrupt timer", { reason }); - } - return; - } - speechInterruptStartMsRef.current = null; - }, []); + runtime?.handleCaptureVolume(level); + }, + onError: (error) => { + console.error("[VoiceEngine] Capture error:", error); + }, + }, { + traceLabel: `voice-provider:${providerId}`, + }); - const interruptActiveVoiceTurn = useCallback((source: string) => { - const session = realtimeSessionRef.current; - const sessionAudioPlayer = session?.audioPlayer ?? null; - const sessionClient = session?.client ?? null; - const sessionIsPlayingAudio = session?.isPlayingAudio ?? false; + runtime = createVoiceRuntime({ + engine, + getServerInfo: (serverId) => + useSessionStore.getState().getSession(serverId)?.serverInfo ?? null, + activateKeepAwake: async (tag) => { + await activateKeepAwakeAsync(tag); + }, + deactivateKeepAwake: async (tag) => { + await deactivateKeepAwake(tag); + }, + }); - if (sessionIsPlayingAudio && sessionAudioPlayer) { - if (bargeInPlaybackStopRef.current === null) { - bargeInPlaybackStopRef.current = Date.now(); - } - sessionAudioPlayer.stop(); - } + engineRef.current = engine; + runtimeRef.current = runtime; + } - try { - if (sessionClient) { - void sessionClient.abortRequest().catch((error) => { - console.error("[Voice] Failed to send abort_request:", error); - }); - } - console.log("[Voice] Sent abort_request before streaming audio", { source }); - } catch (error) { - console.error("[Voice] Failed to send abort_request:", error); - } - }, []); - - const realtimeAudio = useSpeechmaticsAudio({ - onSpeechStart: () => { - console.log("[Voice] Segment started (speech confirmed)"); - }, - onSpeechEnd: () => { - const silenceMs = - silenceGraceStartMsRef.current === null - ? null - : Date.now() - silenceGraceStartMsRef.current; - if (silenceMs === null) { - console.log("[Voice] Segment finalized"); - } else { - console.log("[Voice] Segment finalized", { silenceMs }); - } - silenceGraceStartMsRef.current = null; - clearSpeechStartInterruptTimer("speech ended"); - speechStartInterruptSentRef.current = false; - }, - onAudioSegment: ({ audioData, isLast }) => { - if (!voiceTransportReadyRef.current) { - console.log("[Voice] Skipping audio segment: voice transport not ready"); - return; - } - console.log( - "[Voice] Sending audio segment, length:", - audioData.length, - "isLast:", - isLast - ); - - // Send audio segment to server (realtime always goes to orchestrator) - const session = realtimeSessionRef.current; - try { - if (session?.client) { - void session.client - .sendVoiceAudioChunk( - audioData, - "audio/pcm;rate=16000;bits=16", - isLast - ) - .catch((error) => { - console.error("[Voice] Failed to send audio segment:", error); - }); - } - } catch (error) { - console.error("[Voice] Failed to send audio segment:", error); - } - }, - onError: (error) => { - console.error("[Voice] Audio error:", error); - const session = realtimeSessionRef.current; - if (session?.client) { - // Send error through websocket instead of directly manipulating messages - console.error("[Voice] Cannot handle error - setMessages not available from SessionState"); - } - }, - volumeThreshold: REALTIME_VOICE_VAD_CONFIG.volumeThreshold, - confirmedDropGracePeriod: REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs, - silenceDuration: REALTIME_VOICE_VAD_CONFIG.silenceDurationMs, - speechConfirmationDuration: REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs, - detectionGracePeriod: REALTIME_VOICE_VAD_CONFIG.detectionGracePeriodMs, - }); - - useEffect(() => { - realtimeSessionRef.current = activeSession; - }, [activeSession]); - - useEffect(() => { - isVoiceModeRef.current = isVoiceMode; - }, [isVoiceMode]); - - useEffect(() => { - if (!isVoiceMode) { - clearSpeechStartInterruptTimer("voice mode disabled"); - speechStartInterruptSentRef.current = false; - return; - } - - if (realtimeAudio.isSpeaking) { - if ( - speechStartInterruptSentRef.current || - speechInterruptTimerRef.current !== null - ) { - return; - } - speechInterruptStartMsRef.current = Date.now(); - speechInterruptTimerRef.current = setTimeout(() => { - speechInterruptTimerRef.current = null; - speechInterruptStartMsRef.current = null; - if ( - !isVoiceModeRef.current || - !vadStateRef.current.isSpeaking || - speechStartInterruptSentRef.current - ) { - return; - } - speechStartInterruptSentRef.current = true; - console.log("[Voice] Speech persisted beyond grace; interrupting turn", { - graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs, - }); - interruptActiveVoiceTurn("speech_start_grace_elapsed"); - }, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs); - return; - } - - clearSpeechStartInterruptTimer("speech stopped before grace"); - }, [ - clearSpeechStartInterruptTimer, - interruptActiveVoiceTurn, - isVoiceMode, - realtimeAudio.isSpeaking, - ]); + const engine = engineRef.current!; + const runtime = runtimeRef.current!; useEffect(() => { + console.log("[VoiceProvider] mount", { + providerId, + }); return () => { - clearSpeechStartInterruptTimer("voice provider unmounted"); - }; - }, [clearSpeechStartInterruptTimer]); - - useEffect(() => { - const next = { - isDetecting: realtimeAudio.isDetecting, - isSpeaking: realtimeAudio.isSpeaking, - }; - const prev = vadStateRef.current; - - if (!isVoiceMode) { - silenceGraceStartMsRef.current = null; - vadStateRef.current = next; - return; - } - - // Confirmed speech dropped below threshold: grace window starts. - if (prev.isSpeaking && !next.isSpeaking && next.isDetecting) { - silenceGraceStartMsRef.current = Date.now(); - console.log("[Voice] Grace started (speech dropped below threshold)"); - } - - // User resumed speaking before grace timeout elapsed. - if (!prev.isSpeaking && next.isSpeaking && silenceGraceStartMsRef.current !== null) { - const resumedAfterMs = Date.now() - silenceGraceStartMsRef.current; - console.log("[Voice] Speech resumed during grace", { resumedAfterMs }); - silenceGraceStartMsRef.current = null; - } - - // Fully idle (neither detecting nor speaking). - if (!next.isDetecting && !next.isSpeaking) { - silenceGraceStartMsRef.current = null; - speechStartInterruptSentRef.current = false; - } - - vadStateRef.current = next; - }, [isVoiceMode, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]); - - useEffect(() => { - const connected = activeRuntimeConnected; - const client = runtimeClient; - if (!connected) { - voiceTransportReadyRef.current = false; - } - - if (!isVoiceMode || !activeServerId || !activeAgentId || !client) { - wasVoiceSocketConnectedRef.current = connected; - if (!isVoiceMode) { - lastVoiceModeSyncedClientRef.current = null; - } - voiceTransportReadyRef.current = false; - return; - } - - const connectionRecovered = connected && !wasVoiceSocketConnectedRef.current; - const clientChanged = lastVoiceModeSyncedClientRef.current !== client; - if (connected && (connectionRecovered || clientChanged)) { - if (!voiceResyncInFlightRef.current) { - voiceResyncInFlightRef.current = true; - voiceTransportReadyRef.current = false; - setIsVoiceSwitching(true); - void client.setVoiceMode(true, activeAgentId).then( - () => { - console.log("[Voice] Re-synced voice mode after reconnect"); - lastVoiceModeSyncedClientRef.current = client; - voiceTransportReadyRef.current = true; - }, - (error) => { - console.error("[Voice] Failed to re-sync voice mode:", error); - } - ).finally(() => { - voiceResyncInFlightRef.current = false; - setIsVoiceSwitching(false); - }); - } - } - - wasVoiceSocketConnectedRef.current = connected; - }, [activeAgentId, activeRuntimeConnected, activeServerId, isVoiceMode, runtimeClient]); - - const isPlayingAudio = activeSession?.isPlayingAudio ?? false; - - useEffect(() => { - if (!isPlayingAudio && bargeInPlaybackStopRef.current !== null) { - const latencyMs = Date.now() - bargeInPlaybackStopRef.current; - console.log("[Telemetry] barge_in.playback_stop_latency", { - latencyMs, - startedAt: new Date(bargeInPlaybackStopRef.current).toISOString(), - completedAt: new Date().toISOString(), + console.log("[VoiceProvider] unmount", { + providerId, }); - bargeInPlaybackStopRef.current = null; - } - }, [isPlayingAudio]); - - const startVoice = useCallback( - async (serverId: string, agentId: string) => { - const session = getSession(serverId) ?? null; - if (!session) { - throw new Error(`Host ${serverId} is not connected`); - } - const unavailableMessage = resolveVoiceUnavailableMessage({ - serverInfo: session.serverInfo, - mode: "voice", + void runtime.destroy().catch((error) => { + console.error("[VoiceProvider] Failed to destroy voice runtime", error); }); - if (unavailableMessage) { - throw new Error(unavailableMessage); - } - - setIsVoiceSwitching(true); - voiceTransportReadyRef.current = false; - try { - const previousSession = realtimeSessionRef.current; - if ( - isVoiceMode && - previousSession?.client && - (activeServerId !== serverId || activeAgentId !== agentId) - ) { - await previousSession.client.setVoiceMode(false); - } - - realtimeSessionRef.current = session; - setActiveServerId(serverId); - setActiveAgentId(agentId); - await activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch((error) => { - console.warn("[Voice] Failed to activate keep-awake:", error); - }); - if (session?.client) { - await session.client.setVoiceMode(true, agentId); - } else { - console.warn("[Voice] setVoiceMode skipped: daemon unavailable"); - } - await session.audioPlayer?.warmup?.(); - await realtimeAudio.start(); - voiceTransportReadyRef.current = true; - setIsVoiceMode(true); - lastVoiceModeSyncedClientRef.current = session.client; - console.log("[Voice] Mode enabled"); - } catch (error: any) { - console.error("[Voice] Failed to start:", error); - await realtimeAudio.stop().catch(() => undefined); - setActiveServerId((current) => (current === serverId ? null : current)); - setActiveAgentId((current) => (current === agentId ? null : current)); - await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); - throw error; - } finally { - setIsVoiceSwitching(false); - } - }, - [activeAgentId, activeServerId, getSession, isVoiceMode, realtimeAudio] - ); - - const stopVoice = useCallback(async () => { - setIsVoiceSwitching(true); - voiceTransportReadyRef.current = false; - try { - const session = realtimeSessionRef.current; - session?.audioPlayer?.stop(); - if (session?.client) { - await session.client.setVoiceMode(false); - lastVoiceModeSyncedClientRef.current = session.client; - } else { - console.warn("[Voice] setVoiceMode skipped: daemon unavailable"); - } - await realtimeAudio.stop(); - setIsVoiceMode(false); - setActiveServerId(null); - setActiveAgentId(null); - await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); - console.log("[Voice] Mode disabled"); - } catch (error: any) { - console.error("[Voice] Failed to stop:", error); - await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); - throw error; - } finally { - setIsVoiceSwitching(false); - } - }, [realtimeAudio]); - - const isVoiceModeForAgent = useCallback( - (serverId: string, agentId: string) => - isVoiceMode && activeServerId === serverId && activeAgentId === agentId, - [activeAgentId, activeServerId, isVoiceMode] - ); - - const value: VoiceContextValue = { - isVoiceMode, - isVoiceSwitching, - volume: realtimeAudio.volume, - isMuted: realtimeAudio.isMuted, - isDetecting: realtimeAudio.isDetecting, - isSpeaking: realtimeAudio.isSpeaking, - segmentDuration: realtimeAudio.segmentDuration, - startVoice, - stopVoice, - isVoiceModeForAgent, - toggleMute: realtimeAudio.toggleMute, - activeServerId, - activeAgentId, - }; + }; + }, [providerId, runtime]); return ( - - {children} - + + + {children} + + ); } diff --git a/packages/app/src/hooks/use-audio-player.d.ts b/packages/app/src/hooks/use-audio-player.d.ts deleted file mode 100644 index 432d966da..000000000 --- a/packages/app/src/hooks/use-audio-player.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-audio-player.native"; diff --git a/packages/app/src/hooks/use-audio-player.native.ts b/packages/app/src/hooks/use-audio-player.native.ts deleted file mode 100644 index 5e7b94ba9..000000000 --- a/packages/app/src/hooks/use-audio-player.native.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { useState, useRef } from "react"; -import { - initialize, - playPCMData, - stopPlayback, - pausePlayback, - resumePlayback, -} from "@boudra/expo-two-way-audio"; - -interface QueuedAudio { - audioData: Blob; - resolve: (duration: number) => void; - reject: (error: Error) => void; -} - -/** - * Resample PCM16 audio between sample rates. - * Speechmatics expects 16kHz. - */ -function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array { - if (fromRate === toRate) { - return pcm; - } - - const inputSamples = Math.floor(pcm.length / 2); - const outputSamples = Math.floor((inputSamples * toRate) / fromRate); - const out = new Uint8Array(outputSamples * 2); - - const ratio = fromRate / toRate; - - const readInt16 = (sampleIndex: number): number => { - const i = sampleIndex * 2; - if (i + 1 >= pcm.length) { - return 0; - } - const lo = pcm[i]!; - const hi = pcm[i + 1]!; - let value = (hi << 8) | lo; - if (value & 0x8000) { - value = value - 0x10000; - } - return value; - }; - - const writeInt16 = (sampleIndex: number, value: number): void => { - const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); - const i = sampleIndex * 2; - out[i] = clamped & 0xff; - out[i + 1] = (clamped >> 8) & 0xff; - }; - - for (let i = 0; i < outputSamples; i++) { - const srcPos = i * ratio; - const i0 = Math.floor(srcPos); - const frac = srcPos - i0; - const s0 = readInt16(i0); - const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); - writeInt16(i, s0 + (s1 - s0) * frac); - } - - return out; -} - -function parsePcmSampleRate(mimeType: string): number | null { - const match = /rate=(\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; -} - -export interface AudioPlayerOptions { - isDetecting?: () => boolean; - isSpeaking?: () => boolean; -} - -/** - * Hook for audio playback using Speechmatics two-way audio with echo cancellation - */ -export function useAudioPlayer(options?: AudioPlayerOptions) { - const [isPlaying, setIsPlaying] = useState(false); - const [audioInitialized, setAudioInitialized] = useState(false); - const queueRef = useRef([]); - const suppressedQueueRef = useRef([]); - const isProcessingQueueRef = useRef(false); - const activePlaybackRef = useRef<{ - resolve: (duration: number) => void; - reject: (error: Error) => void; - } | null>(null); - const playbackTimeoutRef = useRef | null>(null); - const checkIntervalRef = useRef | null>(null); - - async function play(audioData: Blob): Promise { - return new Promise((resolve, reject) => { - // Check if we should suppress playback due to voice detection/speaking - const shouldSuppress = - (options?.isDetecting && options.isDetecting()) || - (options?.isSpeaking && options.isSpeaking()); - - if (shouldSuppress) { - console.log("[AudioPlayer] Suppressing playback - voice detection/speaking active"); - // Add to suppressed queue instead - suppressedQueueRef.current.push({ audioData, resolve, reject }); - - // Start checking for when flags clear - startCheckingForClearFlags(); - return; - } - - // Add to queue with its promise handlers - queueRef.current.push({ audioData, resolve, reject }); - - // Start processing queue if not already processing - if (!isProcessingQueueRef.current) { - processQueue(); - } - }); - } - - function startCheckingForClearFlags(): void { - // Already checking - if (checkIntervalRef.current !== null) { - return; - } - - console.log("[AudioPlayer] Starting to check for clear flags"); - - checkIntervalRef.current = setInterval(() => { - const isStillBlocked = - (options?.isDetecting && options.isDetecting()) || - (options?.isSpeaking && options.isSpeaking()); - - if (!isStillBlocked && suppressedQueueRef.current.length > 0) { - console.log("[AudioPlayer] Flags cleared - moving suppressed queue to main queue"); - - // Move all suppressed items to main queue - const suppressedItems = [...suppressedQueueRef.current]; - suppressedQueueRef.current = []; - - // Add to front of main queue (they were waiting) - queueRef.current = [...suppressedItems, ...queueRef.current]; - - // Stop checking - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - - // Start processing if not already - if (!isProcessingQueueRef.current) { - processQueue(); - } - } else if (!isStillBlocked && suppressedQueueRef.current.length === 0) { - // No more suppressed items and flags are clear - stop checking - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - } - }, 100); // Check every 100ms - } - - async function processQueue(): Promise { - if (isProcessingQueueRef.current || queueRef.current.length === 0) { - return; - } - - isProcessingQueueRef.current = true; - - while (queueRef.current.length > 0) { - // Before processing each item, check if flags became active - const shouldSuppress = - (options?.isDetecting && options.isDetecting()) || - (options?.isSpeaking && options.isSpeaking()); - - if (shouldSuppress) { - console.log("[AudioPlayer] Flags became active during processing - moving remaining queue to suppressed"); - // Move remaining queue to suppressed - suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current]; - queueRef.current = []; - startCheckingForClearFlags(); - break; - } - - const item = queueRef.current.shift()!; - try { - const duration = await playAudio(item.audioData); - item.resolve(duration); - } catch (error) { - item.reject(error as Error); - } - } - - isProcessingQueueRef.current = false; - } - - async function processNextInQueue(): Promise { - if (queueRef.current.length > 0) { - const item = queueRef.current.shift()!; - try { - const duration = await playAudio(item.audioData); - item.resolve(duration); - } catch (error) { - item.reject(error as Error); - } - } else { - isProcessingQueueRef.current = false; - } - } - - async function playAudio(audioData: Blob): Promise { - return new Promise(async (resolve, reject) => { - activePlaybackRef.current = { resolve, reject }; - try { - console.log( - `[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})` - ); - - // Initialize audio if not already initialized - if (!audioInitialized) { - console.log("[AudioPlayer] Initializing audio..."); - await initialize(); - setAudioInitialized(true); - console.log( - "[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)" - ); - } - - // Workaround: Resume playback before playing new audio to ensure the audio engine is ready - // This fixes the issue where playback doesn't work after calling stopPlayback() - console.log("[AudioPlayer] Resuming playback engine..."); - resumePlayback(); - - // Get PCM data from blob (server sends PCM16) - const arrayBuffer = await audioData.arrayBuffer(); - const pcm = new Uint8Array(arrayBuffer); - - const inputRate = parsePcmSampleRate(audioData.type || "") ?? 24000; - const pcm16k = resamplePcm16(pcm, inputRate, 16000); - - // Calculate total duration - const samples = pcm16k.length / 2; // 16-bit = 2 bytes per sample - const durationSec = samples / 16000; // 16kHz sample rate - - const audioSizeKb = (pcm16k.length / 1024).toFixed(2); - console.log( - "[AudioPlayer] 🔊 Playing audio:", - audioSizeKb, - "KB, duration:", - durationSec.toFixed(2), - "s" - ); - - setIsPlaying(true); - - // Play entire PCM data at once through Speechmatics - playPCMData(pcm16k); - - // Clear any existing timeout - if (playbackTimeoutRef.current) { - clearTimeout(playbackTimeoutRef.current); - } - - // Wait for playback to finish (estimate based on duration) - playbackTimeoutRef.current = setTimeout(() => { - console.log("[AudioPlayer] ✅ Playback finished"); - setIsPlaying(false); - playbackTimeoutRef.current = null; - activePlaybackRef.current = null; - resolve(durationSec); - }, durationSec * 1000); - } catch (error) { - console.error("[AudioPlayer] Error playing audio:", error); - - // Clear timeout on error - if (playbackTimeoutRef.current) { - clearTimeout(playbackTimeoutRef.current); - playbackTimeoutRef.current = null; - } - - setIsPlaying(false); - activePlaybackRef.current = null; - reject(error); - } - }); - } - - function stop(): void { - if (isPlaying) { - console.log("[AudioPlayer] 🛑 Stopping playback (interrupted)"); - - // Stop native playback - stopPlayback(); - - // Clear playback timeout - if (playbackTimeoutRef.current) { - clearTimeout(playbackTimeoutRef.current); - playbackTimeoutRef.current = null; - } - } - - setIsPlaying(false); - - // Reject the currently playing promise, if any. - if (activePlaybackRef.current) { - activePlaybackRef.current.reject(new Error("Playback stopped")); - activePlaybackRef.current = null; - } - - // Reject all pending promises in the main queue - while (queueRef.current.length > 0) { - const item = queueRef.current.shift()!; - item.reject(new Error("Playback stopped")); - } - - // Reject all pending promises in the suppressed queue - while (suppressedQueueRef.current.length > 0) { - const item = suppressedQueueRef.current.shift()!; - item.reject(new Error("Playback stopped")); - } - - // Clear check interval - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - - isProcessingQueueRef.current = false; - } - - function clearQueue(): void { - // Reject all pending promises in the main queue - while (queueRef.current.length > 0) { - const item = queueRef.current.shift()!; - item.reject(new Error("Queue cleared")); - } - - // Reject all pending promises in the suppressed queue - while (suppressedQueueRef.current.length > 0) { - const item = suppressedQueueRef.current.shift()!; - item.reject(new Error("Queue cleared")); - } - - // Clear check interval - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - } - - return { - play, - stop, - isPlaying: () => isPlaying, - clearQueue, - warmup: async () => { - if (!audioInitialized) { - await initialize(); - setAudioInitialized(true); - } - // Ensure playback engine isn't suspended after a previous stop. - resumePlayback(); - }, - }; -} diff --git a/packages/app/src/hooks/use-audio-player.web.ts b/packages/app/src/hooks/use-audio-player.web.ts deleted file mode 100644 index d42ab847d..000000000 --- a/packages/app/src/hooks/use-audio-player.web.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { useMemo, useRef, useState } from "react"; - -export interface AudioPlayerOptions { - isDetecting?: () => boolean; - isSpeaking?: () => boolean; -} - -/** - * Web audio player for server-provided audio chunks. - * - * Supports: - * - `audio/pcm` (assumed PCM16 LE, mono, default 24kHz unless `rate=` is present) - * - formats supported by `AudioContext.decodeAudioData` (e.g. mp3) - */ -export function useAudioPlayer(options?: AudioPlayerOptions) { - const [isPlayingState, setIsPlayingState] = useState(false); - - type QueuedAudio = { - audioData: Blob; - resolve: (duration: number) => void; - reject: (error: Error) => void; - }; - - const audioContextRef = useRef(null); - const queueRef = useRef([]); - const suppressedQueueRef = useRef([]); - const isProcessingQueueRef = useRef(false); - const checkIntervalRef = useRef | null>(null); - const activePlaybackRef = useRef<{ - source: AudioBufferSourceNode; - resolve: (duration: number) => void; - reject: (error: Error) => void; - settled: boolean; - } | null>(null); - - const decodeAudioData = async (context: AudioContext, buffer: ArrayBuffer): Promise => { - const maybePromise = context.decodeAudioData(buffer); - if (maybePromise && typeof (maybePromise as Promise).then === "function") { - return maybePromise as Promise; - } - return await new Promise((resolve, reject) => { - context.decodeAudioData(buffer, resolve, reject); - }); - }; - - const parsePcmSampleRate = (mimeType: string): number | null => { - const match = /rate=(\\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; - }; - - const pcm16LeToAudioBuffer = (context: AudioContext, bytes: Uint8Array, sampleRate: number): AudioBuffer => { - const sampleCount = Math.floor(bytes.length / 2); - const audioBuffer = context.createBuffer(1, sampleCount, sampleRate); - const channel = audioBuffer.getChannelData(0); - for (let i = 0; i < sampleCount; i++) { - const lo = bytes[i * 2]!; - const hi = bytes[i * 2 + 1]!; - let value = (hi << 8) | lo; - if (value & 0x8000) { - value = value - 0x10000; - } - channel[i] = value / 0x8000; - } - return audioBuffer; - }; - - const ensureContext = async (): Promise => { - if (audioContextRef.current) { - if (audioContextRef.current.state === "suspended") { - try { - await audioContextRef.current.resume(); - } catch { - // Best effort. If this fails due to autoplay policies, a later user gesture - // (or explicit warmup call) can unlock it. - } - } - return audioContextRef.current; - } - const context = new AudioContext(); - if (context.state === "suspended") { - try { - await context.resume(); - } catch { - // See note above. - } - } - audioContextRef.current = context; - return context; - }; - - const shouldSuppressPlayback = (): boolean => { - return Boolean(options?.isDetecting?.()) || Boolean(options?.isSpeaking?.()); - }; - - const startCheckingForClearFlags = (): void => { - if (checkIntervalRef.current !== null) { - return; - } - - checkIntervalRef.current = setInterval(() => { - const isStillBlocked = shouldSuppressPlayback(); - if (!isStillBlocked && suppressedQueueRef.current.length > 0) { - const suppressedItems = [...suppressedQueueRef.current]; - suppressedQueueRef.current = []; - queueRef.current = [...suppressedItems, ...queueRef.current]; - - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - - if (!isProcessingQueueRef.current) { - void processQueue(); - } - } else if (!isStillBlocked && suppressedQueueRef.current.length === 0) { - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - } - }, 100); - }; - - const playAudio = async (audioData: Blob): Promise => { - const context = await ensureContext(); - const arrayBuffer = await audioData.arrayBuffer(); - - let audioBuffer: AudioBuffer; - const type = (audioData.type || "").toLowerCase(); - - if (type.startsWith("audio/pcm")) { - const sampleRate = parsePcmSampleRate(type) ?? 24000; - audioBuffer = pcm16LeToAudioBuffer(context, new Uint8Array(arrayBuffer), sampleRate); - } else { - audioBuffer = await decodeAudioData(context, arrayBuffer); - } - - const durationSec = audioBuffer.duration; - const source = context.createBufferSource(); - source.buffer = audioBuffer; - source.connect(context.destination); - - return await new Promise((resolve, reject) => { - activePlaybackRef.current = { source, resolve, reject, settled: false }; - setIsPlayingState(true); - - const settleOnce = (fn: () => void) => { - const active = activePlaybackRef.current; - if (!active || active.source !== source || active.settled) { - return; - } - active.settled = true; - activePlaybackRef.current = null; - setIsPlayingState(false); - fn(); - }; - - source.onended = () => { - settleOnce(() => resolve(durationSec)); - }; - - try { - source.start(); - } catch (e) { - settleOnce(() => reject(e instanceof Error ? e : new Error(String(e)))); - } - }); - }; - - const processQueue = async (): Promise => { - if (isProcessingQueueRef.current || queueRef.current.length === 0) { - return; - } - - isProcessingQueueRef.current = true; - - while (queueRef.current.length > 0) { - if (shouldSuppressPlayback()) { - suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current]; - queueRef.current = []; - startCheckingForClearFlags(); - break; - } - - const item = queueRef.current.shift()!; - try { - const duration = await playAudio(item.audioData); - item.resolve(duration); - } catch (error) { - item.reject(error as Error); - } - } - - isProcessingQueueRef.current = false; - }; - - const play = async (audioData: Blob): Promise => { - return await new Promise((resolve, reject) => { - if (shouldSuppressPlayback()) { - suppressedQueueRef.current.push({ audioData, resolve, reject }); - startCheckingForClearFlags(); - return; - } - - queueRef.current.push({ audioData, resolve, reject }); - if (!isProcessingQueueRef.current) { - void processQueue(); - } - }); - }; - - const stop = (): void => { - // Stop currently playing audio (and reject its promise) - if (activePlaybackRef.current) { - const active = activePlaybackRef.current; - activePlaybackRef.current = null; - try { - active.source.onended = null; - active.source.stop(); - } catch { - // ignore - } - if (!active.settled) { - active.settled = true; - active.reject(new Error("Playback stopped")); - } - } - - setIsPlayingState(false); - - while (queueRef.current.length > 0) { - queueRef.current.shift()!.reject(new Error("Playback stopped")); - } - while (suppressedQueueRef.current.length > 0) { - suppressedQueueRef.current.shift()!.reject(new Error("Playback stopped")); - } - - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - - isProcessingQueueRef.current = false; - }; - - const clearQueue = (): void => { - while (queueRef.current.length > 0) { - queueRef.current.shift()!.reject(new Error("Queue cleared")); - } - while (suppressedQueueRef.current.length > 0) { - suppressedQueueRef.current.shift()!.reject(new Error("Queue cleared")); - } - if (checkIntervalRef.current !== null) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; - } - }; - - return useMemo( - () => ({ - play, - stop, - isPlaying: () => isPlayingState, - clearQueue, - warmup: async () => { - await ensureContext(); - }, - }), - [isPlayingState] - ); -} diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts index 5d3aff7e1..895020704 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.native.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -1,44 +1,124 @@ import { useCallback, useEffect, useRef } from "react"; +import { Buffer } from "buffer"; +import { useState } from "react"; -import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio"; +import { createAudioEngine } from "@/voice/audio-engine"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; +let nextDictationAudioSourceInstanceId = 1; + +function getDictationTraceStack(): string | undefined { + const stack = new Error().stack; + if (!stack) { + return undefined; + } + return stack + .split("\n") + .slice(2, 7) + .map((line) => line.trim()) + .join(" | "); +} + export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { const onPcmSegmentRef = useRef(config.onPcmSegment); const onErrorRef = useRef(config.onError); + const [volume, setVolume] = useState(0); + const sourceIdRef = useRef(null); + const engineRef = useRef | null>(null); + + if (sourceIdRef.current === null) { + sourceIdRef.current = nextDictationAudioSourceInstanceId++; + console.log("[DictationAudioSource] instance_created", { + sourceId: sourceIdRef.current, + stack: getDictationTraceStack(), + }); + } + + const sourceId = sourceIdRef.current; + + console.log("[DictationAudioSource] render", { + sourceId, + hasEngine: Boolean(engineRef.current), + }); + + const getOrCreateEngine = useCallback(() => { + if (engineRef.current) { + return engineRef.current; + } + + console.log("[DictationAudioSource] create_engine", { + sourceId, + stack: getDictationTraceStack(), + }); + engineRef.current = createAudioEngine( + { + onCaptureData: (pcm) => { + onPcmSegmentRef.current(Buffer.from(pcm).toString("base64")); + }, + onVolumeLevel: (level) => { + setVolume(level); + }, + onError: (error) => { + onErrorRef.current?.(error); + }, + }, + { + traceLabel: `dictation:${sourceId}`, + } + ); + return engineRef.current; + }, [sourceId]); useEffect(() => { onPcmSegmentRef.current = config.onPcmSegment; onErrorRef.current = config.onError; }, [config.onPcmSegment, config.onError]); - const speechmatics = useSpeechmaticsAudio({ - enableContinuousStreaming: true, - onAudioSegment: ({ audioData }) => { - onPcmSegmentRef.current(audioData); - }, - onError: (err) => { - onErrorRef.current?.(err); - }, - volumeThreshold: 0.3, - silenceDuration: 2000, - speechConfirmationDuration: 300, - detectionGracePeriod: 200, - }); + useEffect(() => { + console.log("[DictationAudioSource] mount", { + sourceId, + }); + return () => { + console.log("[DictationAudioSource] unmount", { + sourceId, + }); + }; + }, [sourceId]); const start = useCallback(async () => { - await speechmatics.start(); - }, [speechmatics]); + console.log("[DictationAudioSource] start", { + sourceId, + }); + const engine = getOrCreateEngine(); + await engine.initialize(); + await engine.startCapture(); + }, [getOrCreateEngine, sourceId]); const stop = useCallback(async () => { - await speechmatics.stop(); - }, [speechmatics]); + console.log("[DictationAudioSource] stop", { + sourceId, + hasEngine: Boolean(engineRef.current), + }); + await engineRef.current?.stopCapture(); + setVolume(0); + }, [sourceId]); + + useEffect(() => { + return () => { + const engine = engineRef.current; + engineRef.current = null; + console.log("[DictationAudioSource] destroy_engine", { + sourceId, + hadEngine: Boolean(engine), + }); + void engine?.destroy().catch(() => undefined); + }; + }, [sourceId]); return { start, stop, - volume: speechmatics.volume, + volume, }; } - diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index c465d6ad2..3a3e275eb 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { getTauri } from "@/utils/tauri"; import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; @@ -65,84 +66,6 @@ const int16ToFloat32 = (input: Int16Array): Float32Array => { return out; }; -type Pcm16Wav = { - sampleRate: number; - samples: Int16Array; -}; - -const parsePcm16Wav = (buffer: ArrayBuffer): Pcm16Wav | null => { - if (buffer.byteLength < 44) { - return null; - } - - const view = new DataView(buffer); - const readAscii = (offset: number, length: number): string => { - let out = ""; - for (let i = 0; i < length; i += 1) { - out += String.fromCharCode(view.getUint8(offset + i)); - } - return out; - }; - - if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") { - return null; - } - - let offset = 12; - let channels = 0; - let sampleRate = 0; - let bitsPerSample = 0; - let dataOffset = 0; - let dataSize = 0; - - while (offset + 8 <= buffer.byteLength) { - const chunkId = readAscii(offset, 4); - const chunkSize = view.getUint32(offset + 4, true); - const chunkDataOffset = offset + 8; - if (chunkDataOffset + chunkSize > buffer.byteLength) { - break; - } - - if (chunkId === "fmt " && chunkSize >= 16) { - const audioFormat = view.getUint16(chunkDataOffset, true); - channels = view.getUint16(chunkDataOffset + 2, true); - sampleRate = view.getUint32(chunkDataOffset + 4, true); - bitsPerSample = view.getUint16(chunkDataOffset + 14, true); - if (audioFormat !== 1) { - return null; - } - } else if (chunkId === "data") { - dataOffset = chunkDataOffset; - dataSize = chunkSize; - break; - } - - offset = chunkDataOffset + chunkSize + (chunkSize % 2); - } - - if (!dataOffset || !dataSize || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) { - return null; - } - - const sampleCount = Math.floor(dataSize / 2); - const interleaved = new Int16Array(buffer, dataOffset, sampleCount); - - if (channels === 1) { - return { sampleRate, samples: new Int16Array(interleaved) }; - } - - const frameCount = Math.floor(interleaved.length / channels); - const mono = new Int16Array(frameCount); - for (let frame = 0; frame < frameCount; frame += 1) { - let sum = 0; - for (let ch = 0; ch < channels; ch += 1) { - sum += interleaved[frame * channels + ch] ?? 0; - } - mono[frame] = Math.round(sum / channels); - } - return { sampleRate, samples: mono }; -}; - const int16ToBase64 = (pcm: Int16Array): string => { const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength); let binary = ""; diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 712e1d82a..e4e4a45ff 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -267,8 +267,6 @@ export function useSidebarWorkspacesList(options?: { const value = options?.serverId return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null }, [options?.serverId]) - const enabled = options?.enabled ?? true - const persistedProjectOrder = useSidebarOrderStore((state) => serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER ) @@ -304,6 +302,13 @@ export function useSidebarWorkspacesList(options?: { ) const projects = useMemo(() => { + console.log('[useSidebarWorkspacesList] build_projects', { + serverId, + hasSessionWorkspaces: Boolean(sessionWorkspaces), + workspaceCount: sessionWorkspaces?.size ?? 0, + projectOrderLength: persistedProjectOrder.length, + workspaceOrderScopeCount: Object.keys(persistedWorkspaceOrderByScope).length, + }) if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) { return EMPTY_PROJECTS } @@ -315,6 +320,36 @@ export function useSidebarWorkspacesList(options?: { }) }, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]) + useEffect(() => { + console.log('[useSidebarWorkspacesList] inputs_changed', { + serverId, + connectionStatus, + hasHydratedWorkspaces, + workspaceCount: sessionWorkspaces?.size ?? 0, + projectOrderLength: persistedProjectOrder.length, + workspaceOrderScopeCount: Object.keys(persistedWorkspaceOrderByScope).length, + }) + }, [ + connectionStatus, + hasHydratedWorkspaces, + persistedProjectOrder, + persistedWorkspaceOrderByScope, + serverId, + sessionWorkspaces, + ]) + + useEffect(() => { + console.log('[useSidebarWorkspacesList] projects_changed', { + serverId, + projectCount: projects.length, + projectKeys: projects.map((project) => project.projectKey), + workspaceCounts: projects.map((project) => ({ + projectKey: project.projectKey, + workspaceCount: project.workspaces.length, + })), + }) + }, [projects, serverId]) + useEffect(() => { if (!serverId || projects.length === 0) { return @@ -345,7 +380,7 @@ export function useSidebarWorkspacesList(options?: { }, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]) const refreshAll = useCallback(() => { - if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) { + if (!isActive || !serverId || connectionStatus !== 'online') { return } const client = runtime.getClient(serverId) @@ -377,7 +412,7 @@ export function useSidebarWorkspacesList(options?: { // ignore explicit refresh failures; hook keeps existing data } })() - }, [connectionStatus, enabled, isActive, runtime, serverId]) + }, [connectionStatus, isActive, runtime, serverId]) const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces const isInitialLoad = isLoading && projects.length === 0 diff --git a/packages/app/src/hooks/use-speechmatics-audio.d.ts b/packages/app/src/hooks/use-speechmatics-audio.d.ts deleted file mode 100644 index d634fc55b..000000000 --- a/packages/app/src/hooks/use-speechmatics-audio.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-speechmatics-audio.native"; diff --git a/packages/app/src/hooks/use-speechmatics-audio.native.ts b/packages/app/src/hooks/use-speechmatics-audio.native.ts deleted file mode 100644 index 144f3a8e9..000000000 --- a/packages/app/src/hooks/use-speechmatics-audio.native.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { - initialize, - useMicrophonePermissions, - toggleRecording, - tearDown, - useExpoTwoWayAudioEventListener, - type MicrophoneDataCallback, - type VolumeLevelCallback, -} from "@boudra/expo-two-way-audio"; - -import { SpeechSegmenter } from "@/voice/speech-segmenter"; - -export interface SpeechmaticsAudioConfig { - onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void; - onSpeechStart?: () => void; - onSpeechEnd?: () => void; - onError?: (error: Error) => void; - /** When true, stream microphone PCM continuously without VAD gating. */ - enableContinuousStreaming?: boolean; - volumeThreshold: number; // Volume threshold for speech detection (0-1) - /** ms dip debounce before VAD transitions from confirmed speaking to non-speaking */ - confirmedDropGracePeriod?: number; - silenceDuration: number; // ms of silence before ending segment - speechConfirmationDuration: number; // ms of sustained speech before confirming - detectionGracePeriod: number; // ms grace period for volume dips during detection -} - -export interface SpeechmaticsAudio { - start: () => Promise; - stop: () => Promise; - toggleMute: () => void; - isActive: boolean; - isSpeaking: boolean; - isDetecting: boolean; - isMuted: boolean; - volume: number; - segmentDuration: number; -} - -/** - * Hook for audio capture with echo cancellation using Speechmatics expo-two-way-audio - */ -export function useSpeechmaticsAudio( - config: SpeechmaticsAudioConfig -): SpeechmaticsAudio { - const [microphonePermission, requestMicrophonePermission] = - useMicrophonePermissions(); - const [isActive, setIsActive] = useState(false); - const [isSpeaking, setIsSpeaking] = useState(false); - const [isDetecting, setIsDetecting] = useState(false); - const [audioInitialized, setAudioInitialized] = useState(false); - const [volume, setVolume] = useState(0); - const [isMuted, setIsMuted] = useState(false); - const [segmentDuration, setSegmentDuration] = useState(0); - - const enableContinuousStreaming = config.enableContinuousStreaming === true; - - const isActiveRef = useRef(isActive); - useEffect(() => { - isActiveRef.current = isActive; - }, [isActive]); - - const isMutedRef = useRef(isMuted); - useEffect(() => { - isMutedRef.current = isMuted; - }, [isMuted]); - - const callbacksRef = useRef({ - onAudioSegment: config.onAudioSegment, - onSpeechStart: config.onSpeechStart, - onSpeechEnd: config.onSpeechEnd, - }); - useEffect(() => { - callbacksRef.current = { - onAudioSegment: config.onAudioSegment, - onSpeechStart: config.onSpeechStart, - onSpeechEnd: config.onSpeechEnd, - }; - }, [config.onAudioSegment, config.onSpeechStart, config.onSpeechEnd]); - - const segmenterRef = useRef(null); - if (segmenterRef.current === null) { - segmenterRef.current = new SpeechSegmenter( - { - enableContinuousStreaming, - volumeThreshold: config.volumeThreshold, - confirmedDropGracePeriodMs: config.confirmedDropGracePeriod, - silenceDurationMs: config.silenceDuration, - speechConfirmationMs: config.speechConfirmationDuration, - detectionGracePeriodMs: config.detectionGracePeriod, - }, - { - onAudioSegment: (segment) => callbacksRef.current.onAudioSegment?.(segment), - onSpeechStart: () => callbacksRef.current.onSpeechStart?.(), - onSpeechEnd: () => callbacksRef.current.onSpeechEnd?.(), - onDetectingChange: (next) => setIsDetecting(next), - onSpeakingChange: (next) => setIsSpeaking(next), - } - ); - } - - useEffect(() => { - segmenterRef.current?.updateConfig({ - enableContinuousStreaming, - volumeThreshold: config.volumeThreshold, - confirmedDropGracePeriodMs: config.confirmedDropGracePeriod, - silenceDurationMs: config.silenceDuration, - speechConfirmationMs: config.speechConfirmationDuration, - detectionGracePeriodMs: config.detectionGracePeriod, - }); - }, [ - enableContinuousStreaming, - config.volumeThreshold, - config.confirmedDropGracePeriod, - config.silenceDuration, - config.speechConfirmationDuration, - config.detectionGracePeriod, - ]); - - // Update segment duration timer - useEffect(() => { - if (!isDetecting && !isSpeaking) { - setSegmentDuration(0); - return; - } - - const startTime = segmenterRef.current?.getSpeechDetectionStartMs() ?? Date.now(); - const interval = setInterval(() => { - const elapsed = Date.now() - startTime; - setSegmentDuration(elapsed); - }, 100); - - return () => clearInterval(interval); - }, [isDetecting, isSpeaking]); - - // Listen to microphone data - useExpoTwoWayAudioEventListener( - "onMicrophoneData", - useCallback( - (event) => { - if (!isActiveRef.current || isMutedRef.current) return; - - const pcmData: Uint8Array = event.data; - segmenterRef.current?.pushPcmChunk(pcmData); - }, - [] - ) - ); - - // Listen to volume level for VAD - useExpoTwoWayAudioEventListener( - "onInputVolumeLevelData", - useCallback( - (event) => { - if (!isActiveRef.current) return; - - const volumeLevel: number = event.data; - setVolume(volumeLevel); - - if (isMutedRef.current) return; - segmenterRef.current?.pushVolumeLevel(volumeLevel, Date.now()); - }, - [] - ) - ); - - const ensureMicrophonePermission = useCallback(async () => { - let permissionStatus = microphonePermission; - - if (!permissionStatus?.granted) { - try { - permissionStatus = await requestMicrophonePermission(); - } catch (err) { - throw new Error("Failed to request microphone permission"); - } - } - - if (!permissionStatus?.granted) { - throw new Error( - "Microphone permission is required to capture audio. Please enable microphone access in system settings." - ); - } - }, [microphonePermission, requestMicrophonePermission]); - - async function start(): Promise { - if (isActive) { - console.log("[SpeechmaticsAudio] Already active"); - return; - } - - try { - await ensureMicrophonePermission(); - - // Initialize audio if not already initialized - if (!audioInitialized) { - console.log("[SpeechmaticsAudio] Initializing audio..."); - await initialize(); - setAudioInitialized(true); - console.log("[SpeechmaticsAudio] Audio initialized"); - } - - console.log("[SpeechmaticsAudio] Starting audio capture..."); - - // Start recording - toggleRecording(true); - - setIsActive(true); - console.log("[SpeechmaticsAudio] Audio capture started successfully"); - } catch (error) { - console.error("[SpeechmaticsAudio] Start error:", error); - const err = error instanceof Error ? error : new Error(String(error)); - config.onError?.(err); - await stop(); - throw error; - } - } - - async function stop(): Promise { - console.log("[SpeechmaticsAudio] Stopping audio capture..."); - - // Stop recording - if (isActive) { - toggleRecording(false); - } - - segmenterRef.current?.stop(Date.now()); - - // Tear down audio session - if (audioInitialized) { - tearDown(); - setAudioInitialized(false); - console.log("[SpeechmaticsAudio] Audio torn down"); - } - - // Reset state - segmenterRef.current?.reset(); - setIsActive(false); - setIsSpeaking(false); - setIsDetecting(false); - setVolume(0); - setIsMuted(false); - - console.log("[SpeechmaticsAudio] Audio capture stopped"); - } - - function toggleMute(): void { - setIsMuted((prev) => { - const newMuted = !prev; - console.log("[SpeechmaticsAudio] Mute toggled:", newMuted); - - if (newMuted) { - // Clear any ongoing speech detection/speaking state - segmenterRef.current?.reset(); - setIsSpeaking(false); - setIsDetecting(false); - } - - return newMuted; - }); - } - - return { - start, - stop, - toggleMute, - isActive, - isSpeaking, - isDetecting, - isMuted, - volume, - segmentDuration, - }; -} diff --git a/packages/app/src/hooks/use-speechmatics-audio.web.ts b/packages/app/src/hooks/use-speechmatics-audio.web.ts deleted file mode 100644 index 8f6f312cb..000000000 --- a/packages/app/src/hooks/use-speechmatics-audio.web.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { SpeechSegmenter } from "@/voice/speech-segmenter"; -import { getTauri } from "@/utils/tauri"; - -export interface SpeechmaticsAudioConfig { - onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void; - onSpeechStart?: () => void; - onSpeechEnd?: () => void; - onError?: (error: Error) => void; - /** When true, stream microphone PCM continuously without VAD gating. */ - enableContinuousStreaming?: boolean; - volumeThreshold: number; // 0-1 - /** ms dip debounce before VAD transitions from confirmed speaking to non-speaking */ - confirmedDropGracePeriod?: number; - silenceDuration: number; // ms of silence before ending segment - speechConfirmationDuration: number; // ms of sustained speech before confirming - detectionGracePeriod: number; // ms grace period for volume dips during detection -} - -export interface SpeechmaticsAudio { - start: () => Promise; - stop: () => Promise; - toggleMute: () => void; - isActive: boolean; - isSpeaking: boolean; - isDetecting: boolean; - isMuted: boolean; - volume: number; - segmentDuration: number; -} - -const getAudioContextCtor = (): (typeof AudioContext) | null => { - if (typeof window === "undefined") { - return null; - } - const ctor = - (window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext || - (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - return ctor ?? null; -}; - -const floatToInt16 = (sample: number): number => { - const clamped = Math.max(-1, Math.min(1, sample)); - return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); -}; - -const resampleToPcm16 = (input: Float32Array, inputRate: number, outputRate: number): Int16Array => { - if (input.length === 0) { - return new Int16Array(0); - } - if (inputRate === outputRate) { - const out = new Int16Array(input.length); - for (let i = 0; i < input.length; i++) { - out[i] = floatToInt16(input[i]); - } - return out; - } - - const ratio = inputRate / outputRate; - const outputLength = Math.max(1, Math.round(input.length / ratio)); - const out = new Int16Array(outputLength); - for (let i = 0; i < outputLength; i++) { - const sourceIndex = i * ratio; - const i0 = Math.floor(sourceIndex); - const i1 = Math.min(input.length - 1, i0 + 1); - const frac = sourceIndex - i0; - const sample = input[i0] * (1 - frac) + input[i1] * frac; - out[i] = floatToInt16(sample); - } - return out; -}; - -export function useSpeechmaticsAudio(config: SpeechmaticsAudioConfig): SpeechmaticsAudio { - const [isActive, setIsActive] = useState(false); - const [isSpeaking, setIsSpeaking] = useState(false); - const [isDetecting, setIsDetecting] = useState(false); - const [volume, setVolume] = useState(0); - const [isMuted, setIsMuted] = useState(false); - const [segmentDuration, setSegmentDuration] = useState(0); - - const enableContinuousStreaming = config.enableContinuousStreaming === true; - - const callbacksRef = useRef({ - onAudioSegment: config.onAudioSegment, - onSpeechStart: config.onSpeechStart, - onSpeechEnd: config.onSpeechEnd, - onError: config.onError, - }); - - useEffect(() => { - callbacksRef.current = { - onAudioSegment: config.onAudioSegment, - onSpeechStart: config.onSpeechStart, - onSpeechEnd: config.onSpeechEnd, - onError: config.onError, - }; - }, [config.onAudioSegment, config.onSpeechStart, config.onSpeechEnd, config.onError]); - - const segmenterRef = useRef(null); - if (segmenterRef.current === null) { - segmenterRef.current = new SpeechSegmenter( - { - enableContinuousStreaming, - volumeThreshold: config.volumeThreshold, - confirmedDropGracePeriodMs: config.confirmedDropGracePeriod, - silenceDurationMs: config.silenceDuration, - speechConfirmationMs: config.speechConfirmationDuration, - detectionGracePeriodMs: config.detectionGracePeriod, - }, - { - onAudioSegment: (segment) => callbacksRef.current.onAudioSegment?.(segment), - onSpeechStart: () => callbacksRef.current.onSpeechStart?.(), - onSpeechEnd: () => callbacksRef.current.onSpeechEnd?.(), - onDetectingChange: (next) => setIsDetecting(next), - onSpeakingChange: (next) => setIsSpeaking(next), - } - ); - } - - useEffect(() => { - segmenterRef.current?.updateConfig({ - enableContinuousStreaming, - volumeThreshold: config.volumeThreshold, - confirmedDropGracePeriodMs: config.confirmedDropGracePeriod, - silenceDurationMs: config.silenceDuration, - speechConfirmationMs: config.speechConfirmationDuration, - detectionGracePeriodMs: config.detectionGracePeriod, - }); - }, [ - enableContinuousStreaming, - config.volumeThreshold, - config.confirmedDropGracePeriod, - config.silenceDuration, - config.speechConfirmationDuration, - config.detectionGracePeriod, - ]); - - const refs = useRef<{ - started: boolean; - stream: MediaStream | null; - context: AudioContext | null; - source: MediaStreamAudioSourceNode | null; - processor: ScriptProcessorNode | null; - gain: GainNode | null; - }>({ - started: false, - stream: null, - context: null, - source: null, - processor: null, - gain: null, - }); - - const isMutedRef = useRef(isMuted); - useEffect(() => { - isMutedRef.current = isMuted; - }, [isMuted]); - - const vadConfigRef = useRef({ - volumeThreshold: config.volumeThreshold, - }); - useEffect(() => { - vadConfigRef.current = { - volumeThreshold: config.volumeThreshold, - }; - }, [config.volumeThreshold]); - - const vadLogRef = useRef({ - lastLogMs: 0, - lastSpeaking: false, - lastDetecting: false, - }); - - // Update segment duration timer - useEffect(() => { - if (!isDetecting && !isSpeaking) { - setSegmentDuration(0); - return; - } - - const startTime = segmenterRef.current?.getSpeechDetectionStartMs() ?? Date.now(); - const interval = setInterval(() => { - setSegmentDuration(Date.now() - startTime); - }, 100); - - return () => clearInterval(interval); - }, [isDetecting, isSpeaking]); - - const stopInternal = useCallback(async () => { - refs.current.started = false; - - try { - refs.current.processor?.disconnect(); - refs.current.source?.disconnect(); - refs.current.gain?.disconnect(); - } catch { - // best-effort teardown - } - - if (refs.current.stream) { - for (const track of refs.current.stream.getTracks()) { - try { - track.stop(); - } catch { - // ignore - } - } - } - - const context = refs.current.context; - if (context && context.state !== "closed") { - try { - await context.close(); - } catch { - // ignore - } - } - - refs.current.stream = null; - refs.current.context = null; - refs.current.source = null; - refs.current.processor = null; - refs.current.gain = null; - - segmenterRef.current?.stop(Date.now()); - setIsActive(false); - setIsSpeaking(false); - setIsDetecting(false); - setVolume(0); - setIsMuted(false); - }, []); - - const start = useCallback(async () => { - if (refs.current.started) { - return; - } - - const missingNavigator = - typeof navigator === "undefined" || - !navigator.mediaDevices || - typeof navigator.mediaDevices.getUserMedia !== "function"; - - const secureContext = - typeof window !== "undefined" && typeof window.isSecureContext === "boolean" - ? window.isSecureContext - : true; - const currentOrigin = typeof window !== "undefined" && window.location ? window.location.origin : "unknown"; - const isTauri = getTauri() !== null; - - try { - if (missingNavigator) { - throw new Error("Microphone capture is not supported in this environment"); - } - console.log("[Voice][Web] Microphone preflight", { - secureContext, - currentOrigin, - isTauri, - hasMediaDevices: - typeof navigator !== "undefined" && - !!navigator.mediaDevices && - typeof navigator.mediaDevices.getUserMedia === "function", - }); - if (!secureContext && !isTauri) { - throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); - } - if (!secureContext && isTauri) { - console.warn( - "[Voice][Web] Insecure context reported under Tauri; attempting getUserMedia anyway", - { currentOrigin } - ); - } - - const AudioContextCtor = getAudioContextCtor(); - if (!AudioContextCtor) { - throw new Error("AudioContext unavailable"); - } - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - noiseSuppression: true, - echoCancellation: true, - autoGainControl: true, - }, - }); - - const context = new AudioContextCtor(); - if (context.state === "suspended") { - await context.resume(); - } - - const source = context.createMediaStreamSource(stream); - const processor = context.createScriptProcessor(4096, 1, 1); - const gain = context.createGain(); - gain.gain.value = 0; - - refs.current = { - started: true, - stream, - context, - source, - processor, - gain, - }; - - processor.onaudioprocess = (event) => { - if (!refs.current.started) { - return; - } - - const input = event.inputBuffer.getChannelData(0); - let sumSquares = 0; - for (let i = 0; i < input.length; i++) { - const sample = input[i]; - sumSquares += sample * sample; - } - const rms = Math.sqrt(sumSquares / Math.max(1, input.length)); - const normalized = Math.min(1, Math.max(0, rms * 2)); - setVolume(normalized); - - if (isMutedRef.current) { - return; - } - - const nowMs = Date.now(); - segmenterRef.current?.pushVolumeLevel(normalized, nowMs); - const segmenter = segmenterRef.current; - if (segmenter) { - const speakingNow = segmenter.getIsSpeaking(); - const detectingNow = segmenter.getIsDetecting(); - const shouldLog = - nowMs - vadLogRef.current.lastLogMs >= 150 || - speakingNow !== vadLogRef.current.lastSpeaking || - detectingNow !== vadLogRef.current.lastDetecting; - if (shouldLog) { - const threshold = vadConfigRef.current.volumeThreshold; - const releaseThreshold = segmenter.getVolumeReleaseThreshold(); - console.log("[Voice][Web][VAD] level", { - volume: Number(normalized.toFixed(3)), - threshold: Number(threshold.toFixed(3)), - releaseThreshold: Number(releaseThreshold.toFixed(3)), - confirmedDropGraceMs: config.confirmedDropGracePeriod ?? 250, - isSpeaking: speakingNow, - isDetecting: detectingNow, - }); - vadLogRef.current.lastLogMs = nowMs; - vadLogRef.current.lastSpeaking = speakingNow; - vadLogRef.current.lastDetecting = detectingNow; - } - } - - const pcm16 = resampleToPcm16(input, context.sampleRate, 16000); - const pcmBytes = new Uint8Array(pcm16.buffer, pcm16.byteOffset, pcm16.byteLength); - segmenterRef.current?.pushPcmChunk(pcmBytes); - }; - - source.connect(processor); - processor.connect(gain); - gain.connect(context.destination); - - setIsActive(true); - return; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - callbacksRef.current.onError?.(err); - await stopInternal(); - throw err; - } - }, [stopInternal]); - - const stop = useCallback(async () => { - await stopInternal(); - }, [stopInternal]); - - const toggleMute = useCallback(() => { - setIsMuted((prev) => { - const nextMuted = !prev; - if (nextMuted) { - segmenterRef.current?.reset(); - setIsSpeaking(false); - setIsDetecting(false); - } - return nextMuted; - }); - }, []); - - useEffect(() => { - return () => { - if (refs.current.started) { - void stopInternal(); - } - }; - }, [stopInternal]); - - return useMemo( - () => ({ - start, - stop, - toggleMute, - isActive, - isSpeaking, - isDetecting, - isMuted, - volume, - segmentDuration, - }), - [start, stop, toggleMute, isActive, isSpeaking, isDetecting, isMuted, volume, segmentDuration] - ); -} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index c0c45d346..ba91f8777 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -922,8 +922,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().initializeSession( host.serverId, - fakeClient as unknown as DaemonClient, - null as any + fakeClient as unknown as DaemonClient ); store.syncHosts([host]); @@ -1046,8 +1045,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().initializeSession( host.serverId, - fakeClient as unknown as DaemonClient, - null as any + fakeClient as unknown as DaemonClient ); store.syncHosts([host]); @@ -1122,8 +1120,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().initializeSession( host.serverId, - fakeClient as unknown as DaemonClient, - null as any + fakeClient as unknown as DaemonClient ); store.syncHosts([host]); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index c26e94736..f1babb070 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1822,6 +1822,15 @@ export function useHostRuntimeSession(serverId: string): { }; } +export function useHostRuntimeIsConnected(serverId: string): boolean { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => isHostRuntimeConnected(store.getSnapshot(serverId)), + () => isHostRuntimeConnected(store.getSnapshot(serverId)) + ); +} + export function useHosts(): HostProfile[] { const store = getHostRuntimeStore(); return useSyncExternalStore( diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 8a0b10580..c2492704d 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -69,11 +69,11 @@ function logAgentExplorer(event: string, details: Record): void console.log(`[AgentExplorer] ${event}`, details); } -function logWebStickyBottom(event: string, details: Record): void { - if (!IS_DEV || Platform.OS !== "web") { - return; - } - console.log("[WebStickyBottom]", event, details); +function logWebStickyBottom( + _event: string, + _details: Record +): void { + // Intentionally disabled: this path is too noisy during voice debugging. } export function AgentReadyScreen({ diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index fc35a918b..ff3eb9c63 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, BackHandler, @@ -25,7 +25,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; -import { Combobox } from "@/components/ui/combobox"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import { DropdownMenu, @@ -80,6 +80,7 @@ import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agen import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; import { deriveWorkspaceTabPresentation, + type WorkspaceTabPresentation, WorkspaceTabIcon, WorkspaceTabOptionRow, } from "@/screens/workspace/workspace-tab-presentation"; @@ -141,6 +142,120 @@ function buildOpenIntentKey(input: { return `${input.serverId}:${input.workspaceId}:${openParam}`; } +type MobileWorkspaceTabSwitcherProps = { + activeTabKey: string; + activeTabLabel: string; + activeTabPresentation: WorkspaceTabPresentation | null; + tabSwitcherOptions: ComboboxOption[]; + tabByKey: Map; + tabPresentationsByKey: Map; + onSelectSwitcherTab: (key: string) => void; + onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void; +}; + +const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ + activeTabKey, + activeTabLabel, + activeTabPresentation, + tabSwitcherOptions, + tabByKey, + tabPresentationsByKey, + onSelectSwitcherTab, + onSelectNewTabOption, +}: MobileWorkspaceTabSwitcherProps) { + const { theme } = useUnistyles(); + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + + return ( + + [ + styles.switcherTrigger, + (hovered || pressed || isOpen) && styles.switcherTriggerActive, + { borderWidth: 0, borderColor: "transparent" }, + Platform.OS === "web" + ? { + outlineStyle: "solid", + outlineWidth: 0, + outlineColor: "transparent", + } + : null, + ]} + onPress={() => setIsOpen(true)} + > + + + {activeTabPresentation ? ( + + ) : null} + + + + {activeTabLabel} + + + + + + + + + onSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)} + accessibilityRole="button" + accessibilityLabel="New agent tab" + style={({ hovered, pressed }) => [ + styles.newTabActionButton, + (hovered || pressed) && styles.newTabActionButtonHovered, + ]} + > + + + + + New agent tab + + + + + + + { + const tab = tabByKey.get(option.id); + if (!tab) { + return ; + } + const presentation = + tabPresentationsByKey.get(option.id) ?? + deriveWorkspaceTabPresentation({ tab }); + return ( + + ); + }} + /> + + ); +}); + export function WorkspaceScreen({ serverId, workspaceId, @@ -767,12 +882,10 @@ function WorkspaceScreenContent({ [handleOpenFileFromExplorer] ); - const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false); const [hoveredTabKey, setHoveredTabKey] = useState(null); const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState( null ); - const tabSwitcherAnchorRef = useRef(null); const tabByKey = useMemo(() => { const map = new Map(); @@ -837,7 +950,6 @@ function WorkspaceScreenContent({ const handleSelectSwitcherTab = useCallback( (key: string) => { - setIsTabSwitcherOpen(false); navigateToTabId(key); }, [navigateToTabId] @@ -1522,91 +1634,16 @@ function WorkspaceScreenContent({ /> {isMobile ? ( - - [ - styles.switcherTrigger, - (hovered || pressed || isTabSwitcherOpen) && styles.switcherTriggerActive, - { borderWidth: 0, borderColor: "transparent" }, - Platform.OS === "web" - ? { - outlineStyle: "solid", - outlineWidth: 0, - outlineColor: "transparent", - } - : null, - ]} - onPress={() => setIsTabSwitcherOpen(true)} - > - - - {activeTabPresentation ? ( - - ) : null} - - - - {activeTabLabel} - - - - - - - - - handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)} - accessibilityRole="button" - accessibilityLabel="New agent tab" - style={({ hovered, pressed }) => [ - styles.newTabActionButton, - (hovered || pressed) && styles.newTabActionButtonHovered, - ]} - > - - - - - New agent tab - - - - - - - - { - const tab = tabByKey.get(option.id); - if (!tab) { - return ; - } - const presentation = - tabPresentationsByKey.get(option.id) ?? - deriveWorkspaceTabPresentation({ tab }); - return ( - - ); - }} - /> - + ) : ( | null; - // Hydration status hasHydratedAgents: boolean; hasHydratedWorkspaces: boolean; @@ -264,7 +260,7 @@ interface SessionStoreState { // Action types interface SessionStoreActions { // Session management - initializeSession: (serverId: string, client: DaemonClient, audioPlayer: ReturnType) => void; + initializeSession: (serverId: string, client: DaemonClient) => void; clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient) => void; @@ -359,12 +355,14 @@ type SessionStore = SessionStoreState & SessionStoreActions; const agentLastActivityCoalescer = createAgentLastActivityCoalescer(); // Helper to create initial session state -function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType): SessionState { +function createInitialSessionState( + serverId: string, + client: DaemonClient +): SessionState { return { serverId, client, serverInfo: null, - audioPlayer, hasHydratedAgents: false, hasHydratedWorkspaces: false, isPlayingAudio: false, @@ -424,7 +422,7 @@ export const useSessionStore = create()( agentLastActivity: new Map(), // Session management - initializeSession: (serverId, client, audioPlayer) => { + initializeSession: (serverId, client) => { set((prev) => { if (prev.sessions[serverId]) { return prev; @@ -433,7 +431,7 @@ export const useSessionStore = create()( ...prev, sessions: { ...prev.sessions, - [serverId]: createInitialSessionState(serverId, client, audioPlayer), + [serverId]: createInitialSessionState(serverId, client), }, }; }); diff --git a/packages/app/src/utils/pcm16-wav.ts b/packages/app/src/utils/pcm16-wav.ts new file mode 100644 index 000000000..e099f4eeb --- /dev/null +++ b/packages/app/src/utils/pcm16-wav.ts @@ -0,0 +1,88 @@ +export interface Pcm16Wav { + sampleRate: number; + samples: Int16Array; +} + +export function parsePcm16Wav(buffer: ArrayBuffer): Pcm16Wav | null { + if (buffer.byteLength < 44) { + return null; + } + + const view = new DataView(buffer); + + function readAscii(offset: number, length: number): string { + let out = ""; + for (let i = 0; i < length; i += 1) { + out += String.fromCharCode(view.getUint8(offset + i)); + } + return out; + } + + if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") { + return null; + } + + let offset = 12; + let channels = 0; + let sampleRate = 0; + let bitsPerSample = 0; + let dataOffset = 0; + let dataSize = 0; + + while (offset + 8 <= buffer.byteLength) { + const chunkId = readAscii(offset, 4); + const chunkSize = view.getUint32(offset + 4, true); + const chunkDataOffset = offset + 8; + + if (chunkDataOffset + chunkSize > buffer.byteLength) { + return null; + } + + if (chunkId === "fmt " && chunkSize >= 16) { + const audioFormat = view.getUint16(chunkDataOffset, true); + channels = view.getUint16(chunkDataOffset + 2, true); + sampleRate = view.getUint32(chunkDataOffset + 4, true); + bitsPerSample = view.getUint16(chunkDataOffset + 14, true); + if (audioFormat !== 1) { + return null; + } + } + + if (chunkId === "data") { + dataOffset = chunkDataOffset; + dataSize = chunkSize; + break; + } + + offset = chunkDataOffset + chunkSize + (chunkSize % 2); + } + + if (!dataOffset || dataSize <= 0 || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) { + return null; + } + + const sampleCount = Math.floor(dataSize / 2); + const interleaved = new Int16Array(buffer, dataOffset, sampleCount); + + if (channels === 1) { + return { + sampleRate, + samples: new Int16Array(interleaved), + }; + } + + const frameCount = Math.floor(interleaved.length / channels); + const mono = new Int16Array(frameCount); + for (let frame = 0; frame < frameCount; frame += 1) { + let sum = 0; + for (let channel = 0; channel < channels; channel += 1) { + sum += interleaved[frame * channels + channel] ?? 0; + } + mono[frame] = Math.round(sum / channels); + } + + return { + sampleRate, + samples: mono, + }; +} diff --git a/packages/app/src/utils/thinking-tone.native-pcm.ts b/packages/app/src/utils/thinking-tone.native-pcm.ts new file mode 100644 index 000000000..0b705b4b4 --- /dev/null +++ b/packages/app/src/utils/thinking-tone.native-pcm.ts @@ -0,0 +1,2 @@ +export const THINKING_TONE_NATIVE_PCM_BASE64 = "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MAAvAC4AKwAoACUAIQAdABgAFAAOAAkABQABAP7/+f/1//L/8P/t/+v/6v/o/+j/6P/p/+r/7P/t/+//8f/z//b/+P/6//z//v8AAAAAAAACAAIAAgACAAIAAgACAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQD//wEA/v8GAKgAOwKVA14EvgMGBPEEHwfBCd8KwwotCb4HMAjDDEsSwBaHFnIS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6//q/+j/6P/o/+n/6v/s/+3/7//x//P/9v/4//r//P/+/wAAAAAAAAIAAgACAAIAAgACAAIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +export const THINKING_TONE_NATIVE_PCM_DURATION_MS = 2980.000; diff --git a/packages/app/src/utils/thinking-tone.test.ts b/packages/app/src/utils/thinking-tone.test.ts new file mode 100644 index 000000000..b1036c2f5 --- /dev/null +++ b/packages/app/src/utils/thinking-tone.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { parsePcm16Wav } from "@/utils/pcm16-wav"; + +interface BuildWavInput { + channels?: number; + sampleRate?: number; + samples: number[]; +} + +function buildPcm16Wav(input: BuildWavInput): ArrayBuffer { + const channels = input.channels ?? 1; + const sampleRate = input.sampleRate ?? 24000; + const bytesPerSample = 2; + const dataSize = input.samples.length * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + function writeAscii(offset: number, value: string): void { + for (let i = 0; i < value.length; i += 1) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + } + + writeAscii(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeAscii(8, "WAVE"); + writeAscii(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channels * bytesPerSample, true); + view.setUint16(32, channels * bytesPerSample, true); + view.setUint16(34, 16, true); + writeAscii(36, "data"); + view.setUint32(40, dataSize, true); + + input.samples.forEach((sample, index) => { + view.setInt16(44 + index * bytesPerSample, sample, true); + }); + + return buffer; +} + +describe("parsePcm16Wav", () => { + it("parses mono PCM16 wav data", () => { + const buffer = buildPcm16Wav({ + sampleRate: 16000, + samples: [100, -200, 300], + }); + + const parsed = parsePcm16Wav(buffer); + + expect(parsed).not.toBeNull(); + expect(parsed?.sampleRate).toBe(16000); + expect(Array.from(parsed?.samples ?? [])).toEqual([100, -200, 300]); + }); + + it("downmixes multichannel PCM16 wav data to mono", () => { + const buffer = buildPcm16Wav({ + channels: 2, + samples: [1000, -1000, 3000, 1000], + }); + + const parsed = parsePcm16Wav(buffer); + + expect(parsed).not.toBeNull(); + expect(Array.from(parsed?.samples ?? [])).toEqual([0, 2000]); + }); + + it("rejects non-wav payloads", () => { + const buffer = new TextEncoder().encode("not-a-wav").buffer; + + expect(parsePcm16Wav(buffer)).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/thinking-tone.ts b/packages/app/src/utils/thinking-tone.ts new file mode 100644 index 000000000..8b1efe378 --- /dev/null +++ b/packages/app/src/utils/thinking-tone.ts @@ -0,0 +1,33 @@ +import { Asset } from "expo-asset"; +import { File } from "expo-file-system"; +import { Platform } from "react-native"; +export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav"; + +export const THINKING_TONE_REPEAT_GAP_MS = 350; + +let thinkingToneArrayBufferPromise: Promise | null = null; + +async function readThinkingToneArrayBuffer(): Promise { + const toneModule = require("../../assets/audio/thinking-tone.wav"); + const asset = Asset.fromModule(toneModule); + + if (Platform.OS === "web") { + const response = await fetch(asset.uri); + if (!response.ok) { + throw new Error(`Failed to fetch thinking tone asset: ${response.status}`); + } + return await response.arrayBuffer(); + } + + const resolvedAsset = asset.localUri ? asset : await asset.downloadAsync(); + const fileUri = resolvedAsset.localUri ?? resolvedAsset.uri; + const file = new File(fileUri); + return await file.arrayBuffer(); +} + +export async function loadThinkingToneArrayBuffer(): Promise { + if (!thinkingToneArrayBufferPromise) { + thinkingToneArrayBufferPromise = readThinkingToneArrayBuffer(); + } + return await thinkingToneArrayBufferPromise; +} diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index e7e08f47e..3854da586 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -140,4 +140,38 @@ describe("tool-call-display", () => { expect(display.errorText).toBe('{\n "message": "boom"\n}'); }); + + it("shows terminal interaction with only the fixed label when no command is available", () => { + const display = buildToolCallDisplayModel({ + name: "terminal", + status: "completed", + error: null, + detail: { + type: "plain_text", + icon: "square_terminal", + }, + }); + + expect(display).toEqual({ + displayName: "Interacted with terminal", + }); + }); + + it("shows terminal interaction command as the summary when available", () => { + const display = buildToolCallDisplayModel({ + name: "terminal", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "npm run test", + icon: "square_terminal", + }, + }); + + expect(display).toEqual({ + displayName: "Interacted with terminal", + summary: "npm run test", + }); + }); }); diff --git a/packages/app/src/voice/audio-engine-types.ts b/packages/app/src/voice/audio-engine-types.ts new file mode 100644 index 000000000..209821781 --- /dev/null +++ b/packages/app/src/voice/audio-engine-types.ts @@ -0,0 +1,29 @@ +export interface AudioEngineCallbacks { + onCaptureData(pcm: Uint8Array): void; + onVolumeLevel(level: number): void; + onError?(error: Error): void; +} + +export interface AudioPlaybackSource { + arrayBuffer(): Promise; + size: number; + type: string; +} + +export interface AudioEngine { + initialize(): Promise; + destroy(): Promise; + + startCapture(): Promise; + stopCapture(): Promise; + toggleMute(): boolean; + isMuted(): boolean; + + play(audio: AudioPlaybackSource): Promise; + stop(): void; + clearQueue(): void; + isPlaying(): boolean; + + playLooping(pcm: Uint8Array, gapMs: number): void; + stopLooping(): void; +} diff --git a/packages/app/src/voice/audio-engine.d.ts b/packages/app/src/voice/audio-engine.d.ts new file mode 100644 index 000000000..f1f181c0d --- /dev/null +++ b/packages/app/src/voice/audio-engine.d.ts @@ -0,0 +1 @@ +export * from "./audio-engine.native"; diff --git a/packages/app/src/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts new file mode 100644 index 000000000..7a87c719d --- /dev/null +++ b/packages/app/src/voice/audio-engine.native.ts @@ -0,0 +1,640 @@ +import { + addExpoTwoWayAudioEventListener, + getMicrophonePermissionsAsync, + initialize, + playPCMData, + requestMicrophonePermissionsAsync, + resumePlayback, + stopPlayback, + tearDown, + toggleRecording, +} from "@boudra/expo-two-way-audio"; +import { + THINKING_TONE_REPEAT_GAP_MS, +} from "@/utils/thinking-tone"; +import { + THINKING_TONE_NATIVE_PCM_BASE64, + THINKING_TONE_NATIVE_PCM_DURATION_MS, +} from "@/utils/thinking-tone.native-pcm"; +import type { + AudioEngine, + AudioEngineCallbacks, + AudioPlaybackSource, +} from "@/voice/audio-engine-types"; +import { Buffer } from "buffer"; + +interface QueuedAudio { + audio: AudioPlaybackSource; + resolve: (duration: number) => void; + reject: (error: Error) => void; +} + +interface CuePcm { + pcm16k: Uint8Array; + durationMs: number; +} + +interface StreamDebugState { + count: number; + totalValue: number; + maxValue: number; + startedAtMs: number; + lastAtMs: number; + maxGapMs: number; + nextLogAtMs: number; +} + +interface AudioEngineTraceOptions { + traceLabel?: string; +} + +let nextAudioEngineInstanceId = 1; + +function getTraceStack(): string | undefined { + const stack = new Error().stack; + if (!stack) { + return undefined; + } + return stack + .split("\n") + .slice(2, 7) + .map((line) => line.trim()) + .join(" | "); +} + +function resamplePcm16( + pcm: Uint8Array, + fromRate: number, + toRate: number +): Uint8Array { + if (fromRate === toRate) { + return pcm; + } + + const inputSamples = Math.floor(pcm.length / 2); + const outputSamples = Math.floor((inputSamples * toRate) / fromRate); + const output = new Uint8Array(outputSamples * 2); + const ratio = fromRate / toRate; + + const readInt16 = (sampleIndex: number): number => { + const offset = sampleIndex * 2; + if (offset + 1 >= pcm.length) { + return 0; + } + const lo = pcm[offset]!; + const hi = pcm[offset + 1]!; + let value = (hi << 8) | lo; + if (value & 0x8000) { + value -= 0x10000; + } + return value; + }; + + const writeInt16 = (sampleIndex: number, value: number): void => { + const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); + const offset = sampleIndex * 2; + output[offset] = clamped & 0xff; + output[offset + 1] = (clamped >> 8) & 0xff; + }; + + for (let i = 0; i < outputSamples; i += 1) { + const sourceIndex = i * ratio; + const i0 = Math.floor(sourceIndex); + const frac = sourceIndex - i0; + const s0 = readInt16(i0); + const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); + writeInt16(i, s0 + (s1 - s0) * frac); + } + + return output; +} + +function parsePcmSampleRate(mimeType: string): number | null { + const match = /rate=(\d+)/i.exec(mimeType); + if (!match) { + return null; + } + const rate = Number(match[1]); + return Number.isFinite(rate) && rate > 0 ? rate : null; +} + +function logVoiceNative(event: string, details?: Record): void { + if (details) { + console.log(`[VoiceNative] ${event}`, details); + return; + } + console.log(`[VoiceNative] ${event}`); +} + +function createStreamDebugState(nowMs: number): StreamDebugState { + return { + count: 0, + totalValue: 0, + maxValue: 0, + startedAtMs: nowMs, + lastAtMs: nowMs, + maxGapMs: 0, + nextLogAtMs: nowMs + 1000, + }; +} + +export function createAudioEngine( + callbacks: AudioEngineCallbacks, + options?: AudioEngineTraceOptions +): AudioEngine { + const engineId = nextAudioEngineInstanceId++; + const traceLabel = options?.traceLabel ?? "unknown"; + const traceStack = getTraceStack(); + const refs: { + initialized: boolean; + captureActive: boolean; + muted: boolean; + queue: QueuedAudio[]; + processingQueue: boolean; + playbackTimeout: ReturnType | null; + activePlayback: { + resolve: (duration: number) => void; + reject: (error: Error) => void; + settled: boolean; + } | null; + looping: { + active: boolean; + token: number; + timeout: ReturnType | null; + }; + thinkingTone: CuePcm | null; + captureDebug: StreamDebugState | null; + volumeDebug: StreamDebugState | null; + destroyed: boolean; + } = { + initialized: false, + captureActive: false, + muted: false, + queue: [], + processingQueue: false, + playbackTimeout: null, + activePlayback: null, + looping: { + active: false, + token: 0, + timeout: null, + }, + thinkingTone: null, + captureDebug: null, + volumeDebug: null, + destroyed: false, + }; + + logVoiceNative("engine_created", { + engineId, + traceLabel, + stack: traceStack, + }); + + function resetCaptureDebug(): void { + refs.captureDebug = null; + } + + function resetVolumeDebug(): void { + refs.volumeDebug = null; + } + + function recordCaptureChunk(byteLength: number): void { + const nowMs = Date.now(); + const debug = refs.captureDebug ?? createStreamDebugState(nowMs); + const gapMs = debug.count === 0 ? 0 : nowMs - debug.lastAtMs; + debug.count += 1; + debug.totalValue += byteLength; + debug.maxValue = Math.max(debug.maxValue, byteLength); + debug.maxGapMs = Math.max(debug.maxGapMs, gapMs); + debug.lastAtMs = nowMs; + + if (nowMs >= debug.nextLogAtMs) { + const elapsedMs = Math.max(1, nowMs - debug.startedAtMs); + logVoiceNative("capture_summary", { + chunks: debug.count, + totalBytes: debug.totalValue, + averageChunkBytes: Math.round(debug.totalValue / debug.count), + maxChunkBytes: debug.maxValue, + chunksPerSecond: Number(((debug.count * 1000) / elapsedMs).toFixed(1)), + maxGapMs: debug.maxGapMs, + muted: refs.muted, + }); + refs.captureDebug = createStreamDebugState(nowMs); + return; + } + + refs.captureDebug = debug; + } + + function recordVolumeLevel(level: number): void { + const nowMs = Date.now(); + const debug = refs.volumeDebug ?? createStreamDebugState(nowMs); + const gapMs = debug.count === 0 ? 0 : nowMs - debug.lastAtMs; + debug.count += 1; + debug.totalValue += level; + debug.maxValue = Math.max(debug.maxValue, level); + debug.maxGapMs = Math.max(debug.maxGapMs, gapMs); + debug.lastAtMs = nowMs; + + if (nowMs >= debug.nextLogAtMs) { + const elapsedMs = Math.max(1, nowMs - debug.startedAtMs); + logVoiceNative("volume_summary", { + samples: debug.count, + averageLevel: Number((debug.totalValue / debug.count).toFixed(3)), + peakLevel: Number(debug.maxValue.toFixed(3)), + samplesPerSecond: Number(((debug.count * 1000) / elapsedMs).toFixed(1)), + maxGapMs: debug.maxGapMs, + muted: refs.muted, + }); + refs.volumeDebug = createStreamDebugState(nowMs); + return; + } + + refs.volumeDebug = debug; + } + + const microphoneSubscription = addExpoTwoWayAudioEventListener( + "onMicrophoneData", + (event) => { + console.log("[VoiceNative] onMicrophoneData", { + engineId, + traceLabel, + captureActive: refs.captureActive, + muted: refs.muted, + bytes: event.data.byteLength, + }); + if (!refs.captureActive || refs.muted) { + return; + } + recordCaptureChunk(event.data.byteLength); + callbacks.onCaptureData(event.data); + } + ); + logVoiceNative("listener_added", { + engineId, + traceLabel, + event: "onMicrophoneData", + }); + + const volumeSubscription = addExpoTwoWayAudioEventListener( + "onInputVolumeLevelData", + (event) => { + console.log("[VoiceNative] onInputVolumeLevelData", { + engineId, + traceLabel, + captureActive: refs.captureActive, + muted: refs.muted, + level: event.data, + }); + if (!refs.captureActive) { + return; + } + const level = refs.muted ? 0 : event.data; + recordVolumeLevel(level); + callbacks.onVolumeLevel(level); + } + ); + logVoiceNative("listener_added", { + engineId, + traceLabel, + event: "onInputVolumeLevelData", + }); + + async function ensureInitialized(): Promise { + if (refs.initialized) { + return; + } + logVoiceNative("initialize_start"); + await initialize(); + refs.initialized = true; + logVoiceNative("initialize_complete"); + } + + async function ensureMicrophonePermission(): Promise { + let permission = await getMicrophonePermissionsAsync().catch(() => null); + if (!permission?.granted) { + permission = await requestMicrophonePermissionsAsync().catch(() => null); + } + if (!permission?.granted) { + throw new Error( + "Microphone permission is required to capture audio. Please enable microphone access in system settings." + ); + } + } + + async function ensureThinkingTone(): Promise { + if (refs.thinkingTone) { + return refs.thinkingTone; + } + const pcm16k = Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64"); + const durationMs = THINKING_TONE_NATIVE_PCM_DURATION_MS; + refs.thinkingTone = { pcm16k, durationMs }; + logVoiceNative("thinking_tone_loaded", { + bytes: pcm16k.byteLength, + durationMs, + }); + return refs.thinkingTone; + } + + function clearPlaybackTimeout(): void { + if (refs.playbackTimeout) { + clearTimeout(refs.playbackTimeout); + refs.playbackTimeout = null; + } + } + + async function playAudio(audio: AudioPlaybackSource): Promise { + await ensureInitialized(); + resumePlayback(); + + return await new Promise(async (resolve, reject) => { + refs.activePlayback = { resolve, reject, settled: false }; + + try { + const arrayBuffer = await audio.arrayBuffer(); + const pcm = new Uint8Array(arrayBuffer); + const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000; + const pcm16k = resamplePcm16(pcm, inputRate, 16000); + const durationSec = pcm16k.length / 2 / 16000; + logVoiceNative("playback_start", { + inputBytes: pcm.byteLength, + outputBytes: pcm16k.byteLength, + inputRate, + durationMs: Math.round(durationSec * 1000), + queueLength: refs.queue.length, + }); + + playPCMData(pcm16k); + clearPlaybackTimeout(); + refs.playbackTimeout = setTimeout(() => { + clearPlaybackTimeout(); + const active = refs.activePlayback; + if (!active || active.settled) { + return; + } + active.settled = true; + refs.activePlayback = null; + logVoiceNative("playback_complete", { + durationMs: Math.round(durationSec * 1000), + }); + resolve(durationSec); + }, durationSec * 1000); + } catch (error) { + clearPlaybackTimeout(); + const active = refs.activePlayback; + if (active && !active.settled) { + active.settled = true; + refs.activePlayback = null; + logVoiceNative("playback_failed", { + error: error instanceof Error ? error.message : String(error), + }); + reject(error instanceof Error ? error : new Error(String(error))); + } + } + }); + } + + async function processQueue(): Promise { + if (refs.processingQueue || refs.queue.length === 0) { + return; + } + + refs.processingQueue = true; + logVoiceNative("queue_drain_start", { + queueLength: refs.queue.length, + }); + while (refs.queue.length > 0) { + const item = refs.queue.shift()!; + try { + const duration = await playAudio(item.audio); + item.resolve(duration); + } catch (error) { + item.reject(error instanceof Error ? error : new Error(String(error))); + } + } + refs.processingQueue = false; + logVoiceNative("queue_drain_complete"); + } + + function stopLooping(): void { + if (refs.looping.active) { + logVoiceNative("thinking_tone_stop"); + } + refs.looping.active = false; + refs.looping.token += 1; + if (refs.looping.timeout) { + clearTimeout(refs.looping.timeout); + refs.looping.timeout = null; + } + stopPlayback(); + } + + return { + async initialize() { + await ensureInitialized(); + await ensureThinkingTone(); + }, + + async destroy() { + if (refs.destroyed) { + logVoiceNative("engine_destroy_skipped", { + engineId, + traceLabel, + }); + return; + } + refs.destroyed = true; + logVoiceNative("engine_destroy_start", { + engineId, + traceLabel, + }); + stopLooping(); + this.stop(); + this.clearQueue(); + if (refs.captureActive) { + toggleRecording(false); + refs.captureActive = false; + } + clearPlaybackTimeout(); + refs.muted = false; + resetCaptureDebug(); + resetVolumeDebug(); + callbacks.onVolumeLevel(0); + if (refs.initialized) { + tearDown(); + refs.initialized = false; + } + microphoneSubscription.remove(); + logVoiceNative("listener_removed", { + engineId, + traceLabel, + event: "onMicrophoneData", + }); + volumeSubscription.remove(); + logVoiceNative("listener_removed", { + engineId, + traceLabel, + event: "onInputVolumeLevelData", + }); + logVoiceNative("engine_destroy_complete", { + engineId, + traceLabel, + }); + }, + + async startCapture() { + if (refs.captureActive) { + return; + } + + try { + logVoiceNative("capture_start_requested"); + logVoiceNative("capture_trace", { + engineId, + traceLabel, + }); + await ensureMicrophonePermission(); + await ensureInitialized(); + toggleRecording(true); + refs.captureActive = true; + resetCaptureDebug(); + resetVolumeDebug(); + logVoiceNative("capture_started"); + } catch (error) { + const wrapped = error instanceof Error ? error : new Error(String(error)); + logVoiceNative("capture_start_failed", { + error: wrapped.message, + }); + callbacks.onError?.(wrapped); + throw wrapped; + } + }, + + async stopCapture() { + if (refs.captureActive) { + toggleRecording(false); + } + refs.captureActive = false; + refs.muted = false; + resetCaptureDebug(); + resetVolumeDebug(); + logVoiceNative("capture_stopped"); + logVoiceNative("capture_trace", { + engineId, + traceLabel, + }); + callbacks.onVolumeLevel(0); + }, + + toggleMute() { + refs.muted = !refs.muted; + logVoiceNative("mute_toggled", { + muted: refs.muted, + }); + if (refs.muted) { + callbacks.onVolumeLevel(0); + } + return refs.muted; + }, + + isMuted() { + return refs.muted; + }, + + async play(audio: AudioPlaybackSource) { + return await new Promise((resolve, reject) => { + refs.queue.push({ audio, resolve, reject }); + logVoiceNative("queue_enqueue", { + queueLength: refs.queue.length, + blobBytes: audio.size, + mimeType: audio.type || null, + }); + if (!refs.processingQueue) { + void processQueue(); + } + }); + }, + + stop() { + if (refs.activePlayback) { + logVoiceNative("playback_stopped"); + } + stopPlayback(); + clearPlaybackTimeout(); + const active = refs.activePlayback; + refs.activePlayback = null; + if (active && !active.settled) { + active.settled = true; + active.reject(new Error("Playback stopped")); + } + }, + + clearQueue() { + if (refs.queue.length > 0) { + logVoiceNative("queue_cleared", { + queueLength: refs.queue.length, + }); + } + while (refs.queue.length > 0) { + refs.queue.shift()!.reject(new Error("Playback stopped")); + } + refs.processingQueue = false; + }, + + isPlaying() { + return refs.activePlayback !== null; + }, + + playLooping(audio, gapMs) { + if (refs.looping.active) { + return; + } + + refs.looping.active = true; + const token = refs.looping.token + 1; + refs.looping.token = token; + + void (async () => { + try { + await ensureInitialized(); + const cue = + audio.byteLength > 0 + ? { + pcm16k: audio, + durationMs: (audio.byteLength / 2 / 16000) * 1000, + } + : await ensureThinkingTone(); + logVoiceNative("thinking_tone_start", { + bytes: cue.pcm16k.byteLength, + durationMs: Math.round(cue.durationMs), + gapMs: gapMs || THINKING_TONE_REPEAT_GAP_MS, + }); + + const loop = () => { + if (!refs.looping.active || refs.looping.token !== token) { + return; + } + resumePlayback(); + playPCMData(cue.pcm16k); + logVoiceNative("thinking_tone_tick", { + durationMs: Math.round(cue.durationMs), + }); + refs.looping.timeout = setTimeout( + loop, + cue.durationMs + (gapMs || THINKING_TONE_REPEAT_GAP_MS) + ); + }; + + loop(); + } catch (error) { + callbacks.onError?.( + error instanceof Error ? error : new Error(String(error)) + ); + } + })(); + }, + + stopLooping, + }; +} diff --git a/packages/app/src/voice/audio-engine.web.ts b/packages/app/src/voice/audio-engine.web.ts new file mode 100644 index 000000000..8b6f6c2c8 --- /dev/null +++ b/packages/app/src/voice/audio-engine.web.ts @@ -0,0 +1,548 @@ +import { getTauri } from "@/utils/tauri"; +import { + loadThinkingToneArrayBuffer, + THINKING_TONE_REPEAT_GAP_MS, +} from "@/utils/thinking-tone"; +import type { + AudioEngine, + AudioEngineCallbacks, + AudioPlaybackSource, +} from "@/voice/audio-engine-types"; + +interface QueuedAudio { + audio: AudioPlaybackSource; + resolve: (duration: number) => void; + reject: (error: Error) => void; +} + +function getAudioContextCtor(): (typeof AudioContext) | null { + if (typeof window === "undefined") { + return null; + } + const browserWindow = window as typeof window & { + webkitAudioContext?: typeof AudioContext; + }; + return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null; +} + +function floatToInt16(sample: number): number { + const clamped = Math.max(-1, Math.min(1, sample)); + return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); +} + +function resampleToPcm16( + input: Float32Array, + inputRate: number, + outputRate: number +): Uint8Array { + if (input.length === 0) { + return new Uint8Array(0); + } + + const ratio = inputRate / outputRate; + const outputLength = Math.max(1, Math.round(input.length / ratio)); + const output = new Int16Array(outputLength); + for (let i = 0; i < outputLength; i += 1) { + const sourceIndex = i * ratio; + const i0 = Math.floor(sourceIndex); + const i1 = Math.min(input.length - 1, i0 + 1); + const frac = sourceIndex - i0; + const sample = input[i0]! * (1 - frac) + input[i1]! * frac; + output[i] = floatToInt16(sample); + } + + return new Uint8Array(output.buffer, output.byteOffset, output.byteLength); +} + +function parsePcmSampleRate(mimeType: string): number | null { + const match = /rate=(\d+)/i.exec(mimeType); + if (!match) { + return null; + } + const rate = Number(match[1]); + return Number.isFinite(rate) && rate > 0 ? rate : null; +} + +function pcm16LeToAudioBuffer( + context: AudioContext, + bytes: Uint8Array, + sampleRate: number +): AudioBuffer { + const sampleCount = Math.floor(bytes.length / 2); + const audioBuffer = context.createBuffer(1, sampleCount, sampleRate); + const channel = audioBuffer.getChannelData(0); + for (let i = 0; i < sampleCount; i += 1) { + const lo = bytes[i * 2]!; + const hi = bytes[i * 2 + 1]!; + let value = (hi << 8) | lo; + if (value & 0x8000) { + value -= 0x10000; + } + channel[i] = value / 0x8000; + } + return audioBuffer; +} + +async function decodeAudioData( + context: AudioContext, + buffer: ArrayBuffer +): Promise { + const maybePromise = context.decodeAudioData(buffer.slice(0)); + if (maybePromise && typeof (maybePromise as Promise).then === "function") { + return maybePromise as Promise; + } + return await new Promise((resolve, reject) => { + context.decodeAudioData(buffer.slice(0), resolve, reject); + }); +} + +export function createAudioEngine( + callbacks: AudioEngineCallbacks, + _options?: { traceLabel?: string } +): AudioEngine { + const refs: { + playbackContext: AudioContext | null; + captureContext: AudioContext | null; + stream: MediaStream | null; + source: MediaStreamAudioSourceNode | null; + processor: ScriptProcessorNode | null; + gain: GainNode | null; + started: boolean; + muted: boolean; + queue: QueuedAudio[]; + processingQueue: boolean; + activePlayback: { + source: AudioBufferSourceNode; + resolve: (duration: number) => void; + reject: (error: Error) => void; + settled: boolean; + } | null; + looping: { + token: number; + source: AudioBufferSourceNode | null; + gain: GainNode | null; + timeout: ReturnType | null; + }; + thinkingTone: { + bytes: Uint8Array | null; + buffer: AudioBuffer | null; + }; + } = { + playbackContext: null, + captureContext: null, + stream: null, + source: null, + processor: null, + gain: null, + started: false, + muted: false, + queue: [], + processingQueue: false, + activePlayback: null, + looping: { + token: 0, + source: null, + gain: null, + timeout: null, + }, + thinkingTone: { + bytes: null, + buffer: null, + }, + }; + + async function ensurePlaybackContext(): Promise { + if (refs.playbackContext) { + if (refs.playbackContext.state === "suspended") { + await refs.playbackContext.resume().catch(() => undefined); + } + return refs.playbackContext; + } + + const AudioContextCtor = getAudioContextCtor(); + if (!AudioContextCtor) { + throw new Error("AudioContext unavailable"); + } + + const context = new AudioContextCtor(); + if (context.state === "suspended") { + await context.resume().catch(() => undefined); + } + refs.playbackContext = context; + return context; + } + + async function ensureCaptureContext(): Promise { + if (refs.captureContext) { + if (refs.captureContext.state === "suspended") { + await refs.captureContext.resume().catch(() => undefined); + } + return refs.captureContext; + } + + const AudioContextCtor = getAudioContextCtor(); + if (!AudioContextCtor) { + throw new Error("AudioContext unavailable"); + } + + const context = new AudioContextCtor(); + if (context.state === "suspended") { + await context.resume().catch(() => undefined); + } + refs.captureContext = context; + return context; + } + + async function ensureThinkingToneBytes(): Promise { + if (refs.thinkingTone.bytes) { + return refs.thinkingTone.bytes; + } + const wav = await loadThinkingToneArrayBuffer(); + refs.thinkingTone.bytes = new Uint8Array(wav); + return refs.thinkingTone.bytes; + } + + async function ensureThinkingToneBuffer(): Promise { + if (refs.thinkingTone.buffer) { + return refs.thinkingTone.buffer; + } + const context = await ensurePlaybackContext(); + const wav = await loadThinkingToneArrayBuffer(); + const audioBuffer = await decodeAudioData(context, wav); + refs.thinkingTone.buffer = audioBuffer; + return audioBuffer; + } + + function stopLooping(): void { + refs.looping.token += 1; + if (refs.looping.timeout) { + clearTimeout(refs.looping.timeout); + refs.looping.timeout = null; + } + if (refs.looping.source) { + try { + refs.looping.source.onended = null; + refs.looping.source.stop(); + } catch { + // Ignore best-effort stop errors. + } + refs.looping.source.disconnect(); + refs.looping.source = null; + } + if (refs.looping.gain) { + refs.looping.gain.disconnect(); + refs.looping.gain = null; + } + } + + async function playAudio(audio: AudioPlaybackSource): Promise { + const context = await ensurePlaybackContext(); + const arrayBuffer = await audio.arrayBuffer(); + const type = (audio.type || "").toLowerCase(); + const audioBuffer = type.startsWith("audio/pcm") + ? pcm16LeToAudioBuffer( + context, + new Uint8Array(arrayBuffer), + parsePcmSampleRate(type) ?? 24000 + ) + : await decodeAudioData(context, arrayBuffer); + + const durationSec = audioBuffer.duration; + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(context.destination); + + return await new Promise((resolve, reject) => { + refs.activePlayback = { source, resolve, reject, settled: false }; + + const settle = (fn: () => void) => { + const active = refs.activePlayback; + if (!active || active.source !== source || active.settled) { + return; + } + active.settled = true; + refs.activePlayback = null; + fn(); + }; + + source.onended = () => { + settle(() => resolve(durationSec)); + }; + + try { + source.start(); + } catch (error) { + settle(() => + reject(error instanceof Error ? error : new Error(String(error))) + ); + } + }); + } + + async function processQueue(): Promise { + if (refs.processingQueue || refs.queue.length === 0) { + return; + } + + refs.processingQueue = true; + while (refs.queue.length > 0) { + const item = refs.queue.shift()!; + try { + const duration = await playAudio(item.audio); + item.resolve(duration); + } catch (error) { + item.reject(error instanceof Error ? error : new Error(String(error))); + } + } + refs.processingQueue = false; + } + + async function stopCapture(): Promise { + refs.started = false; + + try { + refs.processor?.disconnect(); + refs.source?.disconnect(); + refs.gain?.disconnect(); + } catch { + // Ignore best-effort teardown errors. + } + + if (refs.stream) { + for (const track of refs.stream.getTracks()) { + try { + track.stop(); + } catch { + // Ignore best-effort teardown errors. + } + } + } + + refs.stream = null; + refs.source = null; + refs.processor = null; + refs.gain = null; + refs.muted = false; + callbacks.onVolumeLevel(0); + + const captureContext = refs.captureContext; + refs.captureContext = null; + if (captureContext && captureContext.state !== "closed") { + await captureContext.close().catch(() => undefined); + } + } + + return { + async initialize() { + await ensurePlaybackContext(); + await Promise.all([ensureThinkingToneBytes(), ensureThinkingToneBuffer()]); + }, + + async destroy() { + stopLooping(); + this.stop(); + this.clearQueue(); + await stopCapture(); + + const playbackContext = refs.playbackContext; + refs.playbackContext = null; + refs.thinkingTone.buffer = null; + if (playbackContext && playbackContext.state !== "closed") { + await playbackContext.close().catch(() => undefined); + } + }, + + async startCapture() { + if (refs.started) { + return; + } + + const missingNavigator = + typeof navigator === "undefined" || + !navigator.mediaDevices || + typeof navigator.mediaDevices.getUserMedia !== "function"; + const secureContext = + typeof window !== "undefined" && typeof window.isSecureContext === "boolean" + ? window.isSecureContext + : true; + const currentOrigin = + typeof window !== "undefined" && window.location + ? window.location.origin + : "unknown"; + const isTauri = getTauri() !== null; + + if (missingNavigator) { + throw new Error("Microphone capture is not supported in this environment"); + } + if (!secureContext && !isTauri) { + throw new Error( + `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` + ); + } + + try { + const context = await ensureCaptureContext(); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + }); + const source = context.createMediaStreamSource(stream); + const processor = context.createScriptProcessor(4096, 1, 1); + const gain = context.createGain(); + gain.gain.value = 0; + + processor.onaudioprocess = (event) => { + if (!refs.started) { + return; + } + + const input = event.inputBuffer.getChannelData(0); + let sumSquares = 0; + for (let i = 0; i < input.length; i += 1) { + const sample = input[i]!; + sumSquares += sample * sample; + } + const rms = Math.sqrt(sumSquares / Math.max(1, input.length)); + const normalized = Math.min(1, Math.max(0, rms * 2)); + callbacks.onVolumeLevel(normalized); + + if (refs.muted) { + return; + } + + callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000)); + }; + + source.connect(processor); + processor.connect(gain); + gain.connect(context.destination); + + refs.started = true; + refs.stream = stream; + refs.source = source; + refs.processor = processor; + refs.gain = gain; + } catch (error) { + await stopCapture(); + const wrapped = error instanceof Error ? error : new Error(String(error)); + callbacks.onError?.(wrapped); + throw wrapped; + } + }, + + async stopCapture() { + await stopCapture(); + }, + + toggleMute() { + refs.muted = !refs.muted; + if (refs.muted) { + callbacks.onVolumeLevel(0); + } + return refs.muted; + }, + + isMuted() { + return refs.muted; + }, + + async play(audio: AudioPlaybackSource) { + return await new Promise((resolve, reject) => { + refs.queue.push({ audio, resolve, reject }); + if (!refs.processingQueue) { + void processQueue(); + } + }); + }, + + stop() { + if (refs.activePlayback) { + const active = refs.activePlayback; + refs.activePlayback = null; + try { + active.source.onended = null; + active.source.stop(); + } catch { + // Ignore best-effort stop errors. + } + if (!active.settled) { + active.settled = true; + active.reject(new Error("Playback stopped")); + } + } + }, + + clearQueue() { + while (refs.queue.length > 0) { + refs.queue.shift()!.reject(new Error("Playback stopped")); + } + refs.processingQueue = false; + }, + + isPlaying() { + return refs.activePlayback !== null; + }, + + playLooping(audio, gapMs) { + if (refs.looping.source || refs.looping.timeout) { + return; + } + + const token = refs.looping.token + 1; + refs.looping.token = token; + + void (async () => { + try { + const context = await ensurePlaybackContext(); + const audioBuffer = + audio.byteLength > 0 + ? pcm16LeToAudioBuffer(context, audio, 16000) + : await ensureThinkingToneBuffer(); + if (refs.looping.token !== token) { + return; + } + + const gain = context.createGain(); + gain.gain.value = 1; + gain.connect(context.destination); + refs.looping.gain = gain; + + const playOnce = () => { + if (refs.looping.token !== token) { + return; + } + const source = context.createBufferSource(); + source.buffer = audioBuffer; + source.connect(gain); + refs.looping.source = source; + source.onended = () => { + if (refs.looping.source === source) { + refs.looping.source = null; + } + if (refs.looping.token !== token) { + return; + } + refs.looping.timeout = setTimeout( + playOnce, + gapMs || THINKING_TONE_REPEAT_GAP_MS + ); + }; + source.start(); + }; + + playOnce(); + } catch (error) { + callbacks.onError?.( + error instanceof Error ? error : new Error(String(error)) + ); + } + })(); + }, + + stopLooping, + }; +} diff --git a/packages/app/src/voice/speech-segmenter.ts b/packages/app/src/voice/speech-segmenter.ts index 6beae1343..a3315a4df 100644 --- a/packages/app/src/voice/speech-segmenter.ts +++ b/packages/app/src/voice/speech-segmenter.ts @@ -139,6 +139,11 @@ export class SpeechSegmenter { } flush(isLast: boolean): void { + console.log("[SpeechSegmenter] flush", { + isLast, + audioBufferLength: this.audioBuffer.length, + bufferedBytes: this.bufferedBytes, + }); if (this.audioBuffer.length === 0) { this.bufferedBytes = 0; return; @@ -157,6 +162,13 @@ export class SpeechSegmenter { * In non-continuous mode, chunk buffering is gated by the VAD state. */ pushPcmChunk(chunk: Uint8Array): void { + console.log("[SpeechSegmenter] pushPcmChunk", { + bytes: chunk.length, + speechDetectionStartMs: this.speechDetectionStartMs, + isSpeaking: this.isSpeaking, + speechConfirmed: this.speechConfirmed, + bufferedBytes: this.bufferedBytes, + }); if (chunk.length === 0) { return; } @@ -186,6 +198,16 @@ export class SpeechSegmenter { * Called with a normalized volume level (0..1). Drives VAD transitions. */ pushVolumeLevel(volume: number, nowMs: number): void { + console.log("[SpeechSegmenter] pushVolumeLevel", { + volume, + nowMs, + speechDetectionStartMs: this.speechDetectionStartMs, + isSpeaking: this.isSpeaking, + isDetecting: this.isDetecting, + speechConfirmed: this.speechConfirmed, + silenceStartMs: this.silenceStartMs, + confirmedDropStartMs: this.confirmedDropStartMs, + }); if (this.config.enableContinuousStreaming) { return; } diff --git a/packages/app/src/voice/voice-runtime.test.ts b/packages/app/src/voice/voice-runtime.test.ts new file mode 100644 index 000000000..d30ba1248 --- /dev/null +++ b/packages/app/src/voice/voice-runtime.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonServerInfo } from "@/stores/session-store"; +import type { AudioEngine } from "@/voice/audio-engine-types"; +import { + createVoiceRuntime, + type VoiceRuntime, + type VoiceSessionAdapter, +} from "@/voice/voice-runtime"; +import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; + +function createAudioEngineMock(): AudioEngine { + return { + initialize: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + startCapture: vi.fn().mockResolvedValue(undefined), + stopCapture: vi.fn().mockResolvedValue(undefined), + toggleMute: vi.fn().mockReturnValue(true), + isMuted: vi.fn().mockReturnValue(false), + play: vi.fn().mockResolvedValue(0.1), + stop: vi.fn(), + clearQueue: vi.fn(), + isPlaying: vi.fn().mockReturnValue(false), + playLooping: vi.fn(), + stopLooping: vi.fn(), + }; +} + +function createSessionAdapter(serverId = "server-1"): VoiceSessionAdapter { + return { + serverId, + setVoiceMode: vi.fn().mockResolvedValue(undefined), + sendVoiceAudioChunk: vi.fn().mockResolvedValue(undefined), + abortRequest: vi.fn().mockResolvedValue(undefined), + setAssistantAudioPlaying: vi.fn(), + }; +} + +function createServerInfo(): DaemonServerInfo { + return { + serverId: "server-1", + hostname: "host", + version: "1.0.0", + capabilities: { + voice: { + dictation: { enabled: true, reason: "" }, + voice: { enabled: true, reason: "" }, + }, + }, + }; +} + +function createRuntime(options?: { + engine?: AudioEngine; + getServerInfo?: (serverId: string) => DaemonServerInfo | null; +}) { + const engine = options?.engine ?? createAudioEngineMock(); + const runtime = createVoiceRuntime({ + engine, + getServerInfo: options?.getServerInfo ?? (() => createServerInfo()), + activateKeepAwake: vi.fn().mockResolvedValue(undefined), + deactivateKeepAwake: vi.fn().mockResolvedValue(undefined), + }); + + return { runtime, engine }; +} + +describe("voice runtime", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts voice when adapter is ready", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + + expect(engine.initialize).toHaveBeenCalled(); + expect(adapter.setVoiceMode).toHaveBeenCalledWith(true, "agent-1"); + expect(engine.startCapture).toHaveBeenCalled(); + expect(runtime.getSnapshot()).toMatchObject({ + phase: "listening", + isVoiceMode: true, + activeServerId: "server-1", + activeAgentId: "agent-1", + }); + }); + + it("transitions from capturing to submitting to waiting on the final chunk", async () => { + const adapter = createSessionAdapter(); + const { runtime } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.handleCaptureVolume(0.4); + await vi.advanceTimersByTimeAsync( + REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs + 10 + ); + runtime.handleCaptureVolume(0.4); + runtime.handleCapturePcm(new Uint8Array(32000)); + expect(runtime.getSnapshot().phase).toBe("capturing"); + + runtime.handleCapturePcm(new Uint8Array(4000)); + runtime.handleCaptureVolume(0); + await vi.advanceTimersByTimeAsync( + REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs + 10 + ); + runtime.handleCaptureVolume(0); + await vi.advanceTimersByTimeAsync( + REALTIME_VOICE_VAD_CONFIG.silenceDurationMs + 10 + ); + runtime.handleCaptureVolume(0); + await Promise.resolve(); + + expect(adapter.sendVoiceAudioChunk).toHaveBeenLastCalledWith( + expect.any(String), + "audio/pcm;rate=16000;bits=16", + true + ); + expect(runtime.getSnapshot().phase).toBe("waiting"); + }); + + it("moves from waiting to playing on the first assistant audio", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.onAssistantAudioStarted("server-1"); + + expect(runtime.getSnapshot().phase).toBe("playing"); + expect(engine.stopLooping).toHaveBeenCalled(); + expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(true); + }); + + it("returns to waiting after assistant playback when the turn is still active", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + runtime.onAssistantAudioStarted("server-1"); + runtime.onAssistantAudioFinished("server-1"); + + expect(runtime.getSnapshot().phase).toBe("waiting"); + expect(engine.playLooping).toHaveBeenCalled(); + }); + + it("returns to listening after assistant playback once the turn is complete", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + runtime.onAssistantAudioStarted("server-1"); + runtime.onTurnEvent("server-1", "agent-1", "turn_completed"); + runtime.onAssistantAudioFinished("server-1"); + + expect(runtime.getSnapshot().phase).toBe("listening"); + expect(engine.playLooping).not.toHaveBeenCalled(); + }); + + it("interrupts the active turn on barge-in after the grace period", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + runtime.onAssistantAudioStarted("server-1"); + + runtime.handleCaptureVolume(0.5); + await vi.advanceTimersByTimeAsync( + REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs + 10 + ); + await vi.advanceTimersByTimeAsync( + REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs + ); + + expect(adapter.abortRequest).toHaveBeenCalled(); + expect(engine.stop).toHaveBeenCalled(); + expect(runtime.getSnapshot().phase).toBe("capturing"); + }); + + it("authoritatively stops and suppresses later voice audio", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + await runtime.stopVoice(); + + expect(adapter.setVoiceMode).toHaveBeenLastCalledWith(false); + expect(engine.stopCapture).toHaveBeenCalled(); + expect(runtime.getSnapshot().phase).toBe("disabled"); + expect(runtime.shouldPlayVoiceAudio("server-1")).toBe(false); + }); + + it("returns an explicit not-ready error when the adapter is missing", async () => { + const { runtime } = createRuntime(); + await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow( + "Voice runtime is not ready for host server-1" + ); + }); + + it("resyncs voice mode after connection recovers", async () => { + const adapter = createSessionAdapter(); + const { runtime } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + vi.mocked(adapter.setVoiceMode).mockClear(); + + runtime.updateSessionConnection("server-1", false); + runtime.updateSessionConnection("server-1", true); + await Promise.resolve(); + + expect(adapter.setVoiceMode).toHaveBeenCalledWith(true, "agent-1"); + }); + + it("does not emit when the snapshot is unchanged", async () => { + const adapter = createSessionAdapter(); + const { runtime } = createRuntime(); + runtime.registerSession(adapter); + await runtime.startVoice("server-1", "agent-1"); + + const listener = vi.fn(); + const unsubscribe = runtime.subscribe(listener); + + runtime.handleCaptureVolume(0); + runtime.handleCaptureVolume(0); + + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); +}); diff --git a/packages/app/src/voice/voice-runtime.ts b/packages/app/src/voice/voice-runtime.ts new file mode 100644 index 000000000..d012231b7 --- /dev/null +++ b/packages/app/src/voice/voice-runtime.ts @@ -0,0 +1,859 @@ +import type { AgentStreamEventPayload } from "@server/shared/messages"; +import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; +import type { DaemonServerInfo } from "@/stores/session-store"; +import type { AudioEngine } from "@/voice/audio-engine-types"; +import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; +import { SpeechSegmenter } from "@/voice/speech-segmenter"; + +const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16"; +const KEEP_AWAKE_TAG = "paseo:voice"; +const THINKING_TONE_REPEAT_GAP_MS = 350; +const DISPLAY_VOLUME_PUBLISH_INTERVAL_MS = 120; +const DISPLAY_VOLUME_CHANGE_EPSILON = 0.02; +const DISPLAY_VOLUME_ATTACK = 0.35; +const DISPLAY_VOLUME_RELEASE = 0.18; + +type TurnEventType = Extract< + AgentStreamEventPayload["type"], + "turn_started" | "turn_completed" | "turn_failed" | "turn_canceled" +>; + +export type VoiceRuntimePhase = + | "disabled" + | "starting" + | "listening" + | "capturing" + | "submitting" + | "waiting" + | "playing" + | "stopping"; + +export interface VoiceRuntimeSnapshot { + phase: VoiceRuntimePhase; + isVoiceMode: boolean; + isVoiceSwitching: boolean; + isMuted: boolean; + activeServerId: string | null; + activeAgentId: string | null; +} + +export interface VoiceRuntimeTelemetrySnapshot { + volume: number; + isDetecting: boolean; + isSpeaking: boolean; + segmentDuration: number; +} + +export interface VoiceSessionAdapter { + serverId: string; + setVoiceMode(enabled: boolean, agentId?: string): Promise; + sendVoiceAudioChunk( + audioData: string, + mimeType: string, + isLast: boolean + ): Promise; + abortRequest(): Promise; + setAssistantAudioPlaying(isPlaying: boolean): void; +} + +export interface VoiceRuntimeDeps { + engine: AudioEngine; + getServerInfo(serverId: string): DaemonServerInfo | null; + activateKeepAwake(tag: string): Promise; + deactivateKeepAwake(tag: string): Promise; +} + +interface RuntimeSessionState { + adapter: VoiceSessionAdapter; + connected: boolean; +} + +interface RuntimeState { + snapshot: VoiceRuntimeSnapshot; + telemetry: VoiceRuntimeTelemetrySnapshot; + turnInProgress: boolean; + transportReady: boolean; + generation: number; + speechInterruptTimer: ReturnType | null; + speechInterruptSent: boolean; + segmentDurationTimer: ReturnType | null; + lastDisplayVolumePublishMs: number; +} + +const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = { + phase: "disabled", + isVoiceMode: false, + isVoiceSwitching: false, + isMuted: false, + activeServerId: null, + activeAgentId: null, +}; + +const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = { + volume: 0, + isDetecting: false, + isSpeaking: false, + segmentDuration: 0, +}; + +function logVoiceRuntime( + event: string, + details?: Record +): void { + if (details) { + console.log(`[VoiceRuntime] ${event}`, details); + return; + } + console.log(`[VoiceRuntime] ${event}`); +} + +function snapshotsEqual( + left: VoiceRuntimeSnapshot, + right: VoiceRuntimeSnapshot +): boolean { + return ( + left.phase === right.phase && + left.isVoiceMode === right.isVoiceMode && + left.isVoiceSwitching === right.isVoiceSwitching && + left.isMuted === right.isMuted && + left.activeServerId === right.activeServerId && + left.activeAgentId === right.activeAgentId + ); +} + +function telemetryEqual( + left: VoiceRuntimeTelemetrySnapshot, + right: VoiceRuntimeTelemetrySnapshot +): boolean { + return ( + left.volume === right.volume && + left.isDetecting === right.isDetecting && + left.isSpeaking === right.isSpeaking && + left.segmentDuration === right.segmentDuration + ); +} + +export interface VoiceRuntime { + subscribe(listener: () => void): () => void; + getSnapshot(): VoiceRuntimeSnapshot; + subscribeTelemetry(listener: () => void): () => void; + getTelemetrySnapshot(): VoiceRuntimeTelemetrySnapshot; + registerSession(adapter: VoiceSessionAdapter): () => void; + updateSessionConnection(serverId: string, connected: boolean): void; + handleCapturePcm(chunk: Uint8Array): void; + handleCaptureVolume(level: number): void; + startVoice(serverId: string, agentId: string): Promise; + stopVoice(): Promise; + destroy(): Promise; + toggleMute(): void; + isVoiceModeForAgent(serverId: string, agentId: string): boolean; + shouldPlayVoiceAudio(serverId: string): boolean; + onAssistantAudioStarted(serverId: string): void; + onAssistantAudioFinished(serverId: string): void; + onTranscriptionResult(serverId: string, text: string): void; + onTurnEvent(serverId: string, agentId: string, eventType: TurnEventType): void; +} + +export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { + const listeners = new Set<() => void>(); + const telemetryListeners = new Set<() => void>(); + const sessions = new Map(); + const state: RuntimeState = { + snapshot: INITIAL_SNAPSHOT, + telemetry: INITIAL_TELEMETRY, + turnInProgress: false, + transportReady: false, + generation: 0, + speechInterruptTimer: null, + speechInterruptSent: false, + segmentDurationTimer: null, + lastDisplayVolumePublishMs: 0, + }; + + function emit(): void { + for (const listener of listeners) { + listener(); + } + } + + function emitTelemetry(): void { + for (const listener of telemetryListeners) { + listener(); + } + } + + function patchSnapshot( + patch: + | Partial + | ((previous: VoiceRuntimeSnapshot) => VoiceRuntimeSnapshot) + ): void { + const next = + typeof patch === "function" + ? patch(state.snapshot) + : { ...state.snapshot, ...patch }; + if (snapshotsEqual(next, state.snapshot)) { + return; + } + const previous = state.snapshot; + state.snapshot = next; + logVoiceRuntime("snapshot_changed", { + previousPhase: previous.phase, + phase: next.phase, + isVoiceMode: next.isVoiceMode, + isVoiceSwitching: next.isVoiceSwitching, + isMuted: next.isMuted, + activeServerId: next.activeServerId, + activeAgentId: next.activeAgentId, + }); + emit(); + } + + function patchTelemetry( + patch: + | Partial + | (( + previous: VoiceRuntimeTelemetrySnapshot + ) => VoiceRuntimeTelemetrySnapshot) + ): void { + const next = + typeof patch === "function" + ? patch(state.telemetry) + : { ...state.telemetry, ...patch }; + if (telemetryEqual(next, state.telemetry)) { + return; + } + state.telemetry = next; + emitTelemetry(); + } + + function getActiveSession(): RuntimeSessionState | null { + if (!state.snapshot.activeServerId) { + return null; + } + return sessions.get(state.snapshot.activeServerId) ?? null; + } + + function clearSpeechInterruptTimer(): void { + if (state.speechInterruptTimer) { + clearTimeout(state.speechInterruptTimer); + state.speechInterruptTimer = null; + } + } + + function clearSegmentDurationTimer(): void { + if (state.segmentDurationTimer) { + clearInterval(state.segmentDurationTimer); + state.segmentDurationTimer = null; + } + } + + function reconcileSegmentDurationTimer( + segmenter: SpeechSegmenter + ): void { + if (!state.telemetry.isDetecting && !state.telemetry.isSpeaking) { + clearSegmentDurationTimer(); + patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 })); + return; + } + + if (state.segmentDurationTimer) { + return; + } + + state.segmentDurationTimer = setInterval(() => { + const startedAt = segmenter.getSpeechDetectionStartMs(); + patchTelemetry((prev) => ({ + ...prev, + segmentDuration: startedAt ? Date.now() - startedAt : 0, + })); + }, 100); + } + + function canPlayCue(): boolean { + return ( + state.snapshot.isVoiceMode && + state.snapshot.phase === "waiting" && + !state.telemetry.isDetecting && + !state.telemetry.isSpeaking + ); + } + + function stopCue(): void { + deps.engine.stopLooping(); + } + + function resetSegmenter(segmenter: SpeechSegmenter): void { + segmenter.reset(); + clearSpeechInterruptTimer(); + clearSegmentDurationTimer(); + patchTelemetry({ ...INITIAL_TELEMETRY }); + } + + function reconcileCue(): void { + if (!canPlayCue()) { + stopCue(); + return; + } + logVoiceRuntime("thinking_tone_requested", { + phase: state.snapshot.phase, + isDetecting: state.telemetry.isDetecting, + isSpeaking: state.telemetry.isSpeaking, + }); + deps.engine.playLooping(new Uint8Array(0), THINKING_TONE_REPEAT_GAP_MS); + } + + async function interruptActiveTurn(): Promise { + const activeSession = getActiveSession(); + if (!activeSession) { + return; + } + + stopCue(); + deps.engine.stop(); + deps.engine.clearQueue(); + activeSession.adapter.setAssistantAudioPlaying(false); + patchSnapshot((prev) => ({ ...prev, phase: "capturing" })); + + try { + await activeSession.adapter.abortRequest(); + } catch (error) { + console.error("[VoiceRuntime] Failed to abort active turn:", error); + } + } + + const segmenter = new SpeechSegmenter( + { + enableContinuousStreaming: false, + volumeThreshold: REALTIME_VOICE_VAD_CONFIG.volumeThreshold, + confirmedDropGracePeriodMs: + REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs, + silenceDurationMs: REALTIME_VOICE_VAD_CONFIG.silenceDurationMs, + speechConfirmationMs: REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs, + detectionGracePeriodMs: REALTIME_VOICE_VAD_CONFIG.detectionGracePeriodMs, + }, + { + onAudioSegment: ({ audioData, isLast }) => { + const activeSession = getActiveSession(); + if (!activeSession || !state.transportReady || !state.snapshot.isVoiceMode) { + return; + } + + const generation = state.generation; + patchSnapshot((prev) => ({ + ...prev, + phase: isLast ? "submitting" : "capturing", + })); + if (isLast) { + state.turnInProgress = true; + } + logVoiceRuntime("audio_segment_ready", { + isLast, + base64Bytes: audioData.length, + phase: state.snapshot.phase, + serverId: activeSession.adapter.serverId, + }); + + void activeSession.adapter + .sendVoiceAudioChunk(audioData, PCM_MIME_TYPE, isLast) + .then(() => { + if ( + !isLast || + generation !== state.generation || + !state.snapshot.isVoiceMode || + state.snapshot.activeServerId !== activeSession.adapter.serverId || + state.snapshot.phase !== "submitting" + ) { + return; + } + logVoiceRuntime("audio_segment_sent", { + isLast, + serverId: activeSession.adapter.serverId, + }); + patchSnapshot((prev) => ({ ...prev, phase: "waiting" })); + reconcileCue(); + }) + .catch((error) => { + console.error("[VoiceRuntime] Failed to send audio segment:", error); + }); + }, + onSpeechStart: () => { + logVoiceRuntime("speech_started"); + stopCue(); + }, + onSpeechEnd: () => { + logVoiceRuntime("speech_ended"); + clearSpeechInterruptTimer(); + state.speechInterruptSent = false; + reconcileCue(); + }, + onDetectingChange: (isDetecting) => { + const previous = state.telemetry; + patchTelemetry((prev) => ({ ...prev, isDetecting })); + if (previous.isDetecting !== isDetecting) { + logVoiceRuntime("detecting_changed", { + isDetecting, + volume: Number(state.telemetry.volume.toFixed(3)), + }); + } + if (!previous.isDetecting && isDetecting) { + stopCue(); + } + reconcileSegmentDurationTimer(segmenter); + reconcileCue(); + }, + onSpeakingChange: (isSpeaking) => { + const previous = state.telemetry; + patchTelemetry((prev) => ({ ...prev, isSpeaking })); + if (previous.isSpeaking !== isSpeaking) { + logVoiceRuntime("speaking_changed", { + isSpeaking, + phase: state.snapshot.phase, + volume: Number(state.telemetry.volume.toFixed(3)), + }); + } + + if (!previous.isSpeaking && isSpeaking) { + stopCue(); + if ( + state.snapshot.phase === "waiting" || + state.snapshot.phase === "playing" + ) { + clearSpeechInterruptTimer(); + state.speechInterruptTimer = setTimeout(() => { + state.speechInterruptTimer = null; + if ( + state.speechInterruptSent || + !state.snapshot.isVoiceMode || + !state.telemetry.isSpeaking + ) { + return; + } + state.speechInterruptSent = true; + logVoiceRuntime("barge_in_interrupt_triggered", { + phase: state.snapshot.phase, + }); + void interruptActiveTurn(); + }, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs); + logVoiceRuntime("barge_in_interrupt_scheduled", { + gracePeriodMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs, + phase: state.snapshot.phase, + }); + } + } + + if (!isSpeaking) { + clearSpeechInterruptTimer(); + state.speechInterruptSent = false; + } + + reconcileSegmentDurationTimer(segmenter); + reconcileCue(); + }, + } + ); + + function resetToDisabledState(): void { + state.transportReady = false; + state.turnInProgress = false; + state.speechInterruptSent = false; + state.lastDisplayVolumePublishMs = 0; + resetSegmenter(segmenter); + patchSnapshot({ ...INITIAL_SNAPSHOT }); + } + + function publishDisplayVolume(level: number, nowMs: number): void { + const previousVolume = state.telemetry.volume; + const smoothing = + level >= previousVolume ? DISPLAY_VOLUME_ATTACK : DISPLAY_VOLUME_RELEASE; + const nextVolume = Math.max( + 0, + Math.min(1, previousVolume + (level - previousVolume) * smoothing) + ); + const enoughTimeElapsed = + nowMs - state.lastDisplayVolumePublishMs >= + DISPLAY_VOLUME_PUBLISH_INTERVAL_MS; + const enoughChange = + Math.abs(nextVolume - previousVolume) >= DISPLAY_VOLUME_CHANGE_EPSILON; + + if (!enoughTimeElapsed && !enoughChange) { + return; + } + + state.lastDisplayVolumePublishMs = nowMs; + patchTelemetry((prev) => ({ + ...prev, + volume: Number(nextVolume.toFixed(3)), + })); + } + + async function performLocalStop(): Promise { + stopCue(); + deps.engine.stop(); + deps.engine.clearQueue(); + await deps.engine.stopCapture().catch(() => undefined); + await deps.deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); + getActiveSession()?.adapter.setAssistantAudioPlaying(false); + resetToDisabledState(); + } + + async function resyncVoiceMode(serverId: string): Promise { + if ( + !state.snapshot.isVoiceMode || + state.snapshot.activeServerId !== serverId || + !state.snapshot.activeAgentId + ) { + return; + } + + const activeSession = getActiveSession(); + if (!activeSession || !activeSession.connected) { + return; + } + + patchSnapshot((prev) => ({ ...prev, isVoiceSwitching: true })); + try { + await activeSession.adapter.setVoiceMode(true, state.snapshot.activeAgentId); + state.transportReady = true; + } finally { + patchSnapshot((prev) => ({ ...prev, isVoiceSwitching: false })); + } + } + + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getSnapshot() { + return state.snapshot; + }, + + subscribeTelemetry(listener) { + telemetryListeners.add(listener); + return () => { + telemetryListeners.delete(listener); + }; + }, + + getTelemetrySnapshot() { + return state.telemetry; + }, + + registerSession(adapter) { + logVoiceRuntime("session_registered", { + serverId: adapter.serverId, + }); + sessions.set(adapter.serverId, { + adapter, + connected: true, + }); + + return () => { + const activeServerId = state.snapshot.activeServerId; + sessions.delete(adapter.serverId); + if (activeServerId === adapter.serverId) { + void performLocalStop(); + } + }; + }, + + updateSessionConnection(serverId, connected) { + const session = sessions.get(serverId); + if (!session) { + return; + } + session.connected = connected; + logVoiceRuntime("session_connection_changed", { + serverId, + connected, + }); + if (state.snapshot.activeServerId !== serverId) { + return; + } + if (!connected) { + state.transportReady = false; + return; + } + void resyncVoiceMode(serverId); + }, + + handleCapturePcm(chunk) { + console.log("[VoiceRuntime] handleCapturePcm", { + bytes: chunk.byteLength, + isVoiceMode: state.snapshot.isVoiceMode, + isMuted: state.snapshot.isMuted, + phase: state.snapshot.phase, + }); + if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) { + return; + } + segmenter.pushPcmChunk(chunk); + }, + + handleCaptureVolume(level) { + const nowMs = Date.now(); + console.log("[VoiceRuntime] handleCaptureVolume", { + level, + isVoiceMode: state.snapshot.isVoiceMode, + isMuted: state.snapshot.isMuted, + phase: state.snapshot.phase, + }); + const displayLevel = state.snapshot.isMuted ? 0 : level; + publishDisplayVolume(displayLevel, nowMs); + if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) { + return; + } + segmenter.pushVolumeLevel(level, nowMs); + }, + + async startVoice(serverId, agentId) { + logVoiceRuntime("start_requested", { + serverId, + agentId, + }); + const session = sessions.get(serverId); + if (!session) { + throw new Error(`Voice runtime is not ready for host ${serverId}`); + } + if (!session.connected) { + throw new Error(`Host ${serverId} is not connected`); + } + + const serverInfo = deps.getServerInfo(serverId); + const unavailableMessage = resolveVoiceUnavailableMessage({ + serverInfo, + mode: "voice", + }); + if (unavailableMessage) { + throw new Error(unavailableMessage); + } + + const previousServerId = state.snapshot.activeServerId; + const previousAgentId = state.snapshot.activeAgentId; + const generation = state.generation + 1; + state.generation = generation; + state.transportReady = false; + patchSnapshot((prev) => ({ + ...prev, + isVoiceSwitching: true, + phase: "starting", + activeServerId: serverId, + activeAgentId: agentId, + })); + + try { + if ( + state.snapshot.isVoiceMode && + previousServerId && + (previousServerId !== serverId || previousAgentId !== agentId) + ) { + const previousSession = sessions.get(previousServerId); + if (previousSession) { + previousSession.adapter.setAssistantAudioPlaying(false); + await previousSession.adapter.setVoiceMode(false); + } + } + + await deps.activateKeepAwake(KEEP_AWAKE_TAG).catch((error) => { + console.warn("[VoiceRuntime] Failed to activate keep-awake:", error); + }); + + await deps.engine.initialize(); + await session.adapter.setVoiceMode(true, agentId); + await deps.engine.startCapture(); + if (state.generation !== generation) { + return; + } + + state.transportReady = true; + state.turnInProgress = false; + resetSegmenter(segmenter); + patchSnapshot((prev) => ({ + ...prev, + isVoiceMode: true, + isVoiceSwitching: false, + phase: "listening", + isMuted: deps.engine.isMuted(), + })); + logVoiceRuntime("start_completed", { + serverId, + agentId, + }); + } catch (error) { + logVoiceRuntime("start_failed", { + serverId, + agentId, + error: error instanceof Error ? error.message : String(error), + }); + await performLocalStop(); + throw error; + } + }, + + async stopVoice() { + logVoiceRuntime("stop_requested", { + activeServerId: state.snapshot.activeServerId, + activeAgentId: state.snapshot.activeAgentId, + }); + const activeSession = getActiveSession(); + const generation = state.generation + 1; + state.generation = generation; + patchSnapshot((prev) => ({ + ...prev, + isVoiceSwitching: true, + phase: "stopping", + })); + + try { + state.transportReady = false; + stopCue(); + deps.engine.stop(); + deps.engine.clearQueue(); + activeSession?.adapter.setAssistantAudioPlaying(false); + if (activeSession) { + await activeSession.adapter.setVoiceMode(false); + } + await deps.engine.stopCapture(); + await deps.deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined); + } finally { + if (state.generation === generation) { + resetToDisabledState(); + } + logVoiceRuntime("stop_completed"); + } + }, + + async destroy() { + await this.stopVoice().catch(() => undefined); + await deps.engine.destroy(); + listeners.clear(); + telemetryListeners.clear(); + sessions.clear(); + }, + + toggleMute() { + const nextMuted = deps.engine.toggleMute(); + logVoiceRuntime("mute_toggled", { + muted: nextMuted, + }); + if (nextMuted) { + resetSegmenter(segmenter); + patchSnapshot((prev) => ({ + ...prev, + isMuted: true, + })); + reconcileCue(); + return; + } + + patchSnapshot((prev) => ({ ...prev, isMuted: false })); + }, + + isVoiceModeForAgent(serverId, agentId) { + return ( + state.snapshot.isVoiceMode && + state.snapshot.activeServerId === serverId && + state.snapshot.activeAgentId === agentId + ); + }, + + shouldPlayVoiceAudio(serverId) { + return ( + state.snapshot.isVoiceMode && + state.snapshot.activeServerId === serverId && + state.snapshot.phase !== "stopping" && + state.snapshot.phase !== "disabled" && + !state.telemetry.isDetecting && + !state.telemetry.isSpeaking + ); + }, + + onAssistantAudioStarted(serverId) { + if ( + !state.snapshot.isVoiceMode || + state.snapshot.activeServerId !== serverId + ) { + return; + } + logVoiceRuntime("assistant_audio_started", { + serverId, + }); + stopCue(); + getActiveSession()?.adapter.setAssistantAudioPlaying(true); + patchSnapshot((prev) => ({ ...prev, phase: "playing" })); + }, + + onAssistantAudioFinished(serverId) { + if (state.snapshot.activeServerId !== serverId) { + return; + } + + logVoiceRuntime("assistant_audio_finished", { + serverId, + turnInProgress: state.turnInProgress, + }); + getActiveSession()?.adapter.setAssistantAudioPlaying(false); + if (!state.snapshot.isVoiceMode) { + return; + } + + if (state.turnInProgress) { + patchSnapshot((prev) => ({ ...prev, phase: "waiting" })); + reconcileCue(); + return; + } + + patchSnapshot((prev) => ({ ...prev, phase: "listening" })); + reconcileCue(); + }, + + onTranscriptionResult(serverId, text) { + if ( + serverId !== state.snapshot.activeServerId || + !state.snapshot.isVoiceMode + ) { + return; + } + + if (text.trim()) { + return; + } + + logVoiceRuntime("empty_transcription_result", { + serverId, + }); + state.turnInProgress = false; + patchSnapshot((prev) => ({ ...prev, phase: "listening" })); + stopCue(); + }, + + onTurnEvent(serverId, agentId, eventType) { + if ( + !state.snapshot.isVoiceMode || + state.snapshot.activeServerId !== serverId || + state.snapshot.activeAgentId !== agentId + ) { + return; + } + logVoiceRuntime("turn_event", { + serverId, + agentId, + eventType, + }); + + if (eventType === "turn_started") { + state.turnInProgress = true; + return; + } + + state.turnInProgress = false; + if (state.snapshot.phase !== "playing") { + patchSnapshot((prev) => ({ ...prev, phase: "listening" })); + } + stopCue(); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index f38988fae..7451861ea 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -44,6 +44,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; +import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; @@ -1054,6 +1055,32 @@ function mapCodexPatchNotificationToToolCall(params: { return params.running ? toRunningToolCall(mapped) : mapped; } +function mapCodexTerminalInteractionToToolCall(params: { + processId?: string | null; + fallbackCallId?: string | null; + command?: string | null; +}): ToolCallTimelineItem { + const processId = nonEmptyString(params.processId ?? undefined); + const callId = + processId + ? `terminal-session-${processId}` + : nonEmptyString(params.fallbackCallId ?? undefined) ?? "terminal-interaction"; + const label = nonEmptyString(params.command ?? undefined); + return { + type: "tool_call", + callId, + name: "terminal", + status: "completed", + error: null, + detail: { + type: "plain_text", + ...(label ? { label } : {}), + icon: "square_terminal", + }, + ...(processId ? { metadata: { processId } } : {}), + }; +} + function threadItemToTimeline( item: any, options?: { includeUserMessage?: boolean; cwd?: string | null } @@ -1278,6 +1305,23 @@ const CodexEventExecCommandOutputDeltaNotificationSchema = z.object({ .passthrough(), }).passthrough(); +const CodexEventTerminalInteractionNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("terminal_interaction"), + call_id: z.string().optional(), + process_id: z.union([z.string(), z.number()]).optional(), + stdin: z.string().optional(), + }) + .passthrough(), +}).passthrough(); + +const ItemCommandExecutionTerminalInteractionNotificationSchema = z.object({ + itemId: z.string().optional(), + processId: z.union([z.string(), z.number()]).optional(), + stdin: z.string().optional(), +}).passthrough(); + const CodexEventPatchApplyBeginNotificationSchema = z.object({ msg: z .object({ @@ -1358,6 +1402,13 @@ type ParsedCodexNotification = stream: string | null; chunk: string | null; } + | { + kind: "terminal_interaction"; + source: "item" | "codex_event"; + callId: string | null; + processId: string | null; + stdin: string | null; + } | { kind: "patch_apply_started"; callId: string | null; @@ -1544,6 +1595,45 @@ const CodexNotificationSchema = z.union([ }).transform( ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ), + z.object({ + method: z.literal("codex/event/terminal_interaction"), + params: CodexEventTerminalInteractionNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "terminal_interaction", + source: "codex_event", + callId: params.msg.call_id ?? null, + processId: + typeof params.msg.process_id === "number" + ? String(params.msg.process_id) + : params.msg.process_id ?? null, + stdin: params.msg.stdin ?? null, + }) + ), + z.object({ method: z.literal("codex/event/terminal_interaction"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("item/commandExecution/terminalInteraction"), + params: ItemCommandExecutionTerminalInteractionNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "terminal_interaction", + source: "item", + callId: params.itemId ?? null, + processId: + typeof params.processId === "number" + ? String(params.processId) + : params.processId ?? null, + stdin: params.stdin ?? null, + }) + ), + z.object({ + method: z.literal("item/commandExecution/terminalInteraction"), + params: z.unknown(), + }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), z.object({ method: z.literal("codex/event/patch_apply_begin"), params: CodexEventPatchApplyBeginNotificationSchema, @@ -1758,6 +1848,8 @@ class CodexAppServerAgentSession implements AgentSession { private pendingReasoning = new Map(); private pendingCommandOutputDeltas = new Map(); private pendingFileChangeOutputDeltas = new Map(); + private terminalCommandByProcessId = new Map(); + private emittedTerminalInteractionKeys = new Set(); private emittedExecCommandStartedCallIds = new Set(); private emittedExecCommandCompletedCallIds = new Set(); private emittedItemStartedIds = new Set(); @@ -2667,11 +2759,13 @@ class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas, parsed.callId ); + const resolvedOutput = parsed.output ?? bufferedOutput; + this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput); const timelineItem = mapCodexExecNotificationToToolCall({ callId: parsed.callId, command: parsed.command, cwd: parsed.cwd ?? this.config.cwd ?? null, - output: parsed.output ?? bufferedOutput, + output: resolvedOutput, exitCode: parsed.exitCode, success: parsed.success, stderr: parsed.stderr, @@ -2684,6 +2778,25 @@ class CodexAppServerAgentSession implements AgentSession { return; } + if (parsed.kind === "terminal_interaction") { + const interactionKey = [ + parsed.processId ?? "", + parsed.stdin ?? "", + ].join("\u0000"); + if (!this.shouldEmitTerminalInteractionKey(interactionKey)) { + return; + } + const timelineItem = mapCodexTerminalInteractionToToolCall({ + processId: parsed.processId, + fallbackCallId: parsed.callId, + command: + (parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ?? + null, + }); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + return; + } + if (parsed.kind === "patch_apply_started") { if (parsed.callId) { this.pendingFileChangeOutputDeltas.delete(parsed.callId); @@ -2876,6 +2989,33 @@ class CodexAppServerAgentSession implements AgentSession { return buffered.join(""); } + private rememberTerminalProcessForCommand(command: unknown, output: string | null): void { + const normalizedCommand = normalizeCodexCommandValue(command); + if (!normalizedCommand) { + return; + } + const displayCommand = + typeof normalizedCommand === "string" + ? normalizedCommand + : normalizedCommand.join(" ").trim(); + if (!displayCommand) { + return; + } + const processId = extractCodexTerminalSessionId(output ?? undefined); + if (!processId) { + return; + } + this.terminalCommandByProcessId.set(processId, displayCommand); + } + + private shouldEmitTerminalInteractionKey(key: string): boolean { + if (this.emittedTerminalInteractionKeys.has(key)) { + return false; + } + this.emittedTerminalInteractionKeys.add(key); + return true; + } + private warnOnIncompleteEditToolCall( item: ToolCallTimelineItem, source: string, diff --git a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts index b7fdbf821..d4df80253 100644 --- a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts +++ b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts @@ -86,7 +86,7 @@ describe("codex rollout parsing", () => { }); }); - test("skips write_stdin function calls (polling)", async () => { + test("maps write_stdin polling calls into terminal interaction tool calls", async () => { const rolloutPath = join(tmpDir, "rollout.jsonl"); const lines = [ JSON.stringify({ @@ -99,6 +99,16 @@ describe("codex rollout parsing", () => { call_id: "call_real", }, }), + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.785Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_real", + output: + "Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nProcess running with session ID 7144", + }, + }), JSON.stringify({ timestamp: "2026-01-22T07:28:16.497Z", type: "response_item", @@ -116,8 +126,54 @@ describe("codex rollout parsing", () => { const timeline = await parseRolloutFile(rolloutPath); const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(1); - expect(toolCalls[0].name).toBe("Bash"); + expect(toolCalls.length).toBe(2); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + }); + expect(toolCalls[1]).toMatchObject({ + type: "tool_call", + name: "terminal", + callId: "terminal-session-7144", + detail: { + type: "plain_text", + icon: "square_terminal", + }, + metadata: { + processId: "7144", + }, + }); + }); + + test("falls back to generic terminal interaction label when command cannot be resolved", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:28:16.497Z", + type: "response_item", + payload: { + type: "function_call", + name: "write_stdin", + arguments: + '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}', + call_id: "call_polling", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "terminal", + callId: "terminal-session-7144", + detail: { + type: "plain_text", + icon: "square_terminal", + }, + }); }); }); diff --git a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts index 0051528a6..a0b2aaca0 100644 --- a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts +++ b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts @@ -7,6 +7,7 @@ import type { Logger } from "pino"; import type { AgentTimelineItem } from "../agent-sdk-types.js"; import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js"; +import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; const MAX_ROLLOUT_SEARCH_DEPTH = 4; @@ -272,6 +273,74 @@ function parseJsonLikeValue(value: unknown): unknown { return value; } +function readTerminalSessionId(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value === "string") { + return nonEmptyString(value.trim()); + } + return undefined; +} + +function mapCodexTerminalInteractionToToolCall(params: { + processId?: string | undefined; + fallbackCallId?: string | undefined; + command?: string | undefined; +}): Extract { + const processId = nonEmptyString(params.processId); + const callId = processId + ? `terminal-session-${processId}` + : nonEmptyString(params.fallbackCallId) ?? "terminal-interaction"; + const label = nonEmptyString(params.command); + return { + type: "tool_call", + callId, + name: "terminal", + status: "completed", + error: null, + detail: { + type: "plain_text", + ...(label ? { label } : {}), + icon: "square_terminal", + }, + metadata: processId ? { processId } : undefined, + }; +} + +function buildTerminalCommandBySessionId( + parsedRecords: ParsedRolloutRecord[] +): Map { + const outputsByCallId = parsedRecords + .filter((record): record is Extract => record.kind === "output") + .reduce((map, record) => map.set(record.callId, record.output), new Map()); + + const commandsBySessionId = new Map(); + for (const record of parsedRecords) { + if (record.kind !== "call" || record.name === "write_stdin") { + continue; + } + + const mapped = mapCodexRolloutToolCall({ + callId: record.callId ?? null, + name: record.name, + input: record.input ?? null, + output: record.callId ? outputsByCallId.get(record.callId) ?? null : null, + }); + if (!mapped || mapped.detail.type !== "shell") { + continue; + } + + const sessionId = extractCodexTerminalSessionId(mapped.detail.output); + if (!sessionId) { + continue; + } + commandsBySessionId.set(sessionId, mapped.detail.command); + } + + return commandsBySessionId; +} + function readOutputPayloadValue(payload: Record): unknown { if (payload.output !== undefined) { return parseJsonLikeValue(payload.output); @@ -319,15 +388,12 @@ const RolloutResponseRecordSchema = z rawName === "exec_command" || rawName === "shell" ? FunctionCallInputNormalizationSchema.parse(parsedArguments) : { name: rawName, input: parsedArguments }; - const skip = rawName === "write_stdin"; - return skip - ? { kind: "ignore" } - : { - kind: "call", - name: normalized.name, - callId: payload.call_id, - input: normalized.input, - }; + return { + kind: "call", + name: normalized.name, + callId: payload.call_id, + input: normalized.input, + }; }), z .union([ @@ -498,12 +564,29 @@ export async function parseRolloutFile( const outputsByCallId = parsedRecords .filter((record): record is Extract => record.kind === "output") .reduce((map, record) => map.set(record.callId, record.output), new Map()); + const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords); const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] => record.kind === "timeline" ? [record.item] : record.kind === "call" ? (() => { + if (record.name === "write_stdin") { + const input = + record.input && typeof record.input === "object" + ? (record.input as { session_id?: unknown; sessionId?: unknown }) + : null; + const sessionId = + readTerminalSessionId(input?.session_id) ?? + readTerminalSessionId(input?.sessionId); + return [ + mapCodexTerminalInteractionToToolCall({ + processId: sessionId, + fallbackCallId: record.callId, + command: sessionId ? terminalCommandsBySessionId.get(sessionId) : undefined, + }), + ]; + } const mapped = mapCodexRolloutToolCall({ callId: record.callId ?? null, name: record.name, diff --git a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts index d203225e8..6810aa04a 100644 --- a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts +++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts @@ -72,6 +72,16 @@ export function extractCodexShellOutput(value: string | undefined): string | und return nonEmptyString(lines.slice(firstBodyLineIndex).join("\n")); } +export function extractCodexTerminalSessionId(value: string | undefined): string | undefined { + const text = nonEmptyString(value); + if (!text) { + return undefined; + } + + const match = text.match(/process running with session id\s+([A-Za-z0-9._:-]+)/i); + return nonEmptyString(match?.[1]); +} + export function flattenReadContent( value: string | Chunk | Chunk[] | undefined ): string | undefined { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9fd79539d..3f1399e14 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1255,6 +1255,132 @@ export class Session { }) } + private buildPersistedProjectRecord(input: { + workspaceId: string + placement: ProjectPlacementPayload + createdAt: string + updatedAt: string + }): PersistedProjectRecord { + return createPersistedProjectRecord({ + projectId: input.placement.projectKey, + rootPath: deriveProjectRootPath({ + cwd: input.workspaceId, + checkout: input.placement.checkout, + }), + kind: deriveProjectKind(input.placement.checkout), + displayName: input.placement.projectName, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + archivedAt: null, + }) + } + + private buildPersistedWorkspaceRecord(input: { + workspaceId: string + placement: ProjectPlacementPayload + createdAt: string + updatedAt: string + }): PersistedWorkspaceRecord { + return createPersistedWorkspaceRecord({ + workspaceId: input.workspaceId, + projectId: input.placement.projectKey, + cwd: input.workspaceId, + kind: deriveWorkspaceKind(input.placement.checkout), + displayName: deriveWorkspaceDisplayName({ + cwd: input.workspaceId, + checkout: input.placement.checkout, + }), + createdAt: input.createdAt, + updatedAt: input.updatedAt, + archivedAt: null, + }) + } + + private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise { + const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === projectId && !workspace.archivedAt + ) + if (siblingWorkspaces.length === 0) { + await this.projectRegistry.archive(projectId, archivedAt) + } + } + + private async reconcileWorkspaceRecord(workspaceId: string): Promise<{ + workspace: PersistedWorkspaceRecord + changed: boolean + }> { + const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId) + const existing = await this.workspaceRegistry.get(normalizedWorkspaceId) + const placement = await this.buildProjectPlacement(normalizedWorkspaceId) + const now = new Date().toISOString() + const nextProjectCreatedAt = existing?.createdAt ?? now + const nextWorkspaceCreatedAt = existing?.createdAt ?? now + const currentProjectRecord = await this.projectRegistry.get(placement.projectKey) + const nextProjectRecord = this.buildPersistedProjectRecord({ + workspaceId: normalizedWorkspaceId, + placement, + createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt, + updatedAt: now, + }) + const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ + workspaceId: normalizedWorkspaceId, + placement, + createdAt: nextWorkspaceCreatedAt, + updatedAt: now, + }) + + const needsWorkspaceUpdate = + !existing || + existing.archivedAt || + existing.projectId !== nextWorkspaceRecord.projectId || + existing.kind !== nextWorkspaceRecord.kind || + existing.displayName !== nextWorkspaceRecord.displayName + + const needsProjectUpdate = + !currentProjectRecord || + currentProjectRecord.archivedAt || + currentProjectRecord.rootPath !== nextProjectRecord.rootPath || + currentProjectRecord.kind !== nextProjectRecord.kind || + currentProjectRecord.displayName !== nextProjectRecord.displayName + + if (!needsWorkspaceUpdate && !needsProjectUpdate) { + return { + workspace: existing!, + changed: false, + } + } + + await this.projectRegistry.upsert(nextProjectRecord) + await this.workspaceRegistry.upsert(nextWorkspaceRecord) + + if ( + existing && + !existing.archivedAt && + existing.projectId !== nextWorkspaceRecord.projectId + ) { + await this.archiveProjectRecordIfEmpty(existing.projectId, now) + } + + return { + workspace: nextWorkspaceRecord, + changed: true, + } + } + + private async reconcileActiveWorkspaceRecords(): Promise> { + const changedWorkspaceIds = new Set() + const activeWorkspaces = (await this.workspaceRegistry.list()).filter((workspace) => !workspace.archivedAt) + + for (const workspace of activeWorkspaces) { + const result = await this.reconcileWorkspaceRecord(workspace.workspaceId) + if (result.changed) { + changedWorkspaceIds.add(result.workspace.workspaceId) + } + } + + return changedWorkspaceIds + } + private async forwardAgentUpdate(agent: ManagedAgent): Promise { try { await this.ensureWorkspaceRegistered(agent.cwd) @@ -5275,7 +5401,7 @@ export class Session { } } - private async listWorkspaceDescriptors(): Promise { + private async listWorkspaceDescriptorsSnapshot(): Promise { const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([ this.listAgentPayloads(), this.workspaceRegistry.list(), @@ -5318,6 +5444,11 @@ export class Session { return Array.from(descriptorsByWorkspaceId.values()) } + private async listWorkspaceDescriptors(): Promise { + await this.reconcileActiveWorkspaceRecords() + return this.listWorkspaceDescriptorsSnapshot() + } + private normalizeFetchWorkspacesSort( sort: FetchWorkspacesRequestSort[] | undefined ): FetchWorkspacesRequestSort[] { @@ -5597,43 +5728,7 @@ export class Session { private async ensureWorkspaceRegistered(cwd: string): Promise { const workspaceId = normalizePersistedWorkspaceId(cwd) - const existing = await this.workspaceRegistry.get(workspaceId) - if (existing && !existing.archivedAt) { - return existing - } - - const placement = await this.buildProjectPlacement(workspaceId) - const now = new Date().toISOString() - const projectExisting = await this.projectRegistry.get(placement.projectKey) - const projectRecord: PersistedProjectRecord = createPersistedProjectRecord({ - projectId: placement.projectKey, - rootPath: deriveProjectRootPath({ - cwd: workspaceId, - checkout: placement.checkout, - }), - kind: deriveProjectKind(placement.checkout), - displayName: placement.projectName, - createdAt: projectExisting?.createdAt ?? now, - updatedAt: now, - archivedAt: null, - }) - await this.projectRegistry.upsert(projectRecord) - - const workspaceRecord = createPersistedWorkspaceRecord({ - workspaceId, - projectId: placement.projectKey, - cwd: workspaceId, - kind: deriveWorkspaceKind(placement.checkout), - displayName: deriveWorkspaceDisplayName({ - cwd: workspaceId, - checkout: placement.checkout, - }), - createdAt: existing?.createdAt ?? now, - updatedAt: now, - archivedAt: null, - }) - await this.workspaceRegistry.upsert(workspaceRecord) - return workspaceRecord + return (await this.reconcileWorkspaceRecord(workspaceId)).workspace } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { @@ -5660,28 +5755,26 @@ export class Session { } const workspaceId = normalizePersistedWorkspaceId(cwd) - const all = await this.listWorkspaceDescriptors() - const workspace = all.find((entry) => entry.id === workspaceId) - if (!workspace) { - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: 'remove', - id: workspaceId, - }) - return - } + const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords() + const all = await this.listWorkspaceDescriptorsSnapshot() + const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)) + const workspaceIdsToEmit = new Set([workspaceId, ...changedWorkspaceIds]) - if (!this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) { - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: 'remove', - id: workspaceId, - }) - return - } + for (const nextWorkspaceId of workspaceIdsToEmit) { + const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId) + if (!workspace || !this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) { + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'remove', + id: nextWorkspaceId, + }) + continue + } - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: 'upsert', - workspace, - }) + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'upsert', + workspace, + }) + } } private async emitWorkspaceUpdatesForCwds(cwds: Iterable): Promise { @@ -5689,7 +5782,8 @@ export class Session { return } - const uniqueWorkspaceCwds = new Set() + const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords() + const uniqueWorkspaceCwds = new Set(changedWorkspaceIds) for (const cwd of cwds) { const normalized = normalizePersistedWorkspaceId(cwd) if (!normalized) { @@ -5698,8 +5792,24 @@ export class Session { uniqueWorkspaceCwds.add(normalized) } - for (const workspaceCwd of uniqueWorkspaceCwds) { - await this.emitWorkspaceUpdateForCwd(workspaceCwd) + const subscription = this.workspaceUpdatesSubscription + const all = await this.listWorkspaceDescriptorsSnapshot() + const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)) + + for (const workspaceId of uniqueWorkspaceCwds) { + const workspace = descriptorsByWorkspaceId.get(workspaceId) + if (!workspace || !this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) { + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'remove', + id: workspaceId, + }) + continue + } + + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: 'upsert', + workspace, + }) } } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 5bbaa9dd9..740d55383 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -284,8 +284,9 @@ describe('workspace aggregation', () => { isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), } + session.reconcileActiveWorkspaceRecords = async () => new Set() - session.listWorkspaceDescriptors = async () => [ + session.listWorkspaceDescriptorsSnapshot = async () => [ { id: '/tmp/repo', projectId: '/tmp/repo', @@ -300,7 +301,7 @@ describe('workspace aggregation', () => { ] await session.emitWorkspaceUpdateForCwd('/tmp/repo') - session.listWorkspaceDescriptors = async () => [ + session.listWorkspaceDescriptorsSnapshot = async () => [ { id: '/tmp/repo', projectId: '/tmp/repo', @@ -335,16 +336,40 @@ describe('workspace aggregation', () => { }) test('workspace update fanout for multiple cwd values is deduplicated', async () => { + const emitted: Array<{ type: string; payload: unknown }> = [] const session = createSessionForWorkspaceTests() as any + session.emit = (message: any) => emitted.push(message) session.workspaceUpdatesSubscription = { subscriptionId: 'sub-dedupe', filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), } - - const emitWorkspaceUpdateForCwd = vi.fn(async () => {}) - session.emitWorkspaceUpdateForCwd = emitWorkspaceUpdateForCwd + session.reconcileActiveWorkspaceRecords = async () => new Set() + session.listWorkspaceDescriptorsSnapshot = async () => [ + { + id: '/tmp/repo', + projectId: '/tmp/repo', + projectDisplayName: 'repo', + projectRootPath: '/tmp/repo', + projectKind: 'non_git', + workspaceKind: 'directory', + name: 'repo', + status: 'done', + activityAt: null, + }, + { + id: '/tmp/repo/sub', + projectId: '/tmp/repo', + projectDisplayName: 'repo', + projectRootPath: '/tmp/repo', + projectKind: 'non_git', + workspaceKind: 'directory', + name: 'sub', + status: 'done', + activityAt: null, + }, + ] await session.emitWorkspaceUpdatesForCwds([ '/tmp/repo', @@ -354,9 +379,12 @@ describe('workspace aggregation', () => { '/tmp/repo/sub/', ]) - expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledTimes(2) - expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(1, '/tmp/repo') - expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(2, '/tmp/repo/sub') + const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[] + expect(workspaceUpdates).toHaveLength(2) + expect(workspaceUpdates.map((message) => message.payload.workspace.id)).toEqual([ + '/tmp/repo', + '/tmp/repo/sub', + ]) }) test('open_project_request registers a workspace before any agent exists', async () => { @@ -434,4 +462,180 @@ describe('workspace aggregation', () => { const response = emitted.find((message) => message.type === 'archive_workspace_response') as any expect(response?.payload.error).toBeNull() }) + + test('opening a new worktree reconciles older local workspaces into the remote project', async () => { + const emitted: Array<{ type: string; payload: unknown }> = [] + const session = createSessionForWorkspaceTests() as any + const projects = new Map>() + const workspaces = new Map>() + + const mainWorkspaceId = '/tmp/inkwell' + const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a' + const localProjectId = mainWorkspaceId + const remoteProjectId = 'remote:github.com/zimakki/inkwell' + + projects.set( + localProjectId, + createPersistedProjectRecord({ + projectId: localProjectId, + rootPath: mainWorkspaceId, + kind: 'git', + displayName: 'inkwell', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }) + ) + workspaces.set( + mainWorkspaceId, + createPersistedWorkspaceRecord({ + workspaceId: mainWorkspaceId, + projectId: localProjectId, + cwd: mainWorkspaceId, + kind: 'local_checkout', + displayName: 'main', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }) + ) + + session.emit = (message: any) => emitted.push(message) + session.workspaceUpdatesSubscription = { + subscriptionId: 'sub-reconcile', + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + } + session.listAgentPayloads = async () => [] + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null + session.projectRegistry.list = async () => Array.from(projects.values()) + session.projectRegistry.upsert = async (record: ReturnType) => { + projects.set(record.projectId, record) + } + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const existing = projects.get(projectId) + if (!existing) return + projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }) + } + session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null + session.workspaceRegistry.list = async () => Array.from(workspaces.values()) + session.workspaceRegistry.upsert = async ( + record: ReturnType + ) => { + workspaces.set(record.workspaceId, record) + } + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: remoteProjectId, + projectName: 'zimakki/inkwell', + checkout: { + cwd, + isGit: true, + currentBranch: cwd === mainWorkspaceId ? 'main' : 'feature-a', + remoteUrl: 'https://github.com/zimakki/inkwell.git', + isPaseoOwnedWorktree: cwd !== mainWorkspaceId, + mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, + }, + }) + + await session.handleMessage({ + type: 'open_project_request', + cwd: worktreeWorkspaceId, + requestId: 'req-open-worktree', + }) + + expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId) + expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId) + expect(projects.get(localProjectId)?.archivedAt).toBeTruthy() + + const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[] + expect(workspaceUpdates).toHaveLength(2) + expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([ + mainWorkspaceId, + worktreeWorkspaceId, + ]) + expect( + workspaceUpdates.every((message) => message.payload.workspace.projectId === remoteProjectId) + ).toBe(true) + }) + + test('fetch_workspaces_request reconciles remote URL changes for existing workspaces', async () => { + const session = createSessionForWorkspaceTests() as any + const projects = new Map>() + const workspaces = new Map>() + + const mainWorkspaceId = '/tmp/inkwell' + const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a' + const oldProjectId = 'remote:github.com/old-owner/inkwell' + const newProjectId = 'remote:github.com/new-owner/inkwell' + + projects.set( + oldProjectId, + createPersistedProjectRecord({ + projectId: oldProjectId, + rootPath: mainWorkspaceId, + kind: 'git', + displayName: 'old-owner/inkwell', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }) + ) + + for (const [workspaceId, displayName] of [ + [mainWorkspaceId, 'main'], + [worktreeWorkspaceId, 'feature-a'], + ] as const) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: oldProjectId, + cwd: workspaceId, + kind: workspaceId === mainWorkspaceId ? 'local_checkout' : 'worktree', + displayName, + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }) + ) + } + + session.listAgentPayloads = async () => [] + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null + session.projectRegistry.list = async () => Array.from(projects.values()) + session.projectRegistry.upsert = async (record: ReturnType) => { + projects.set(record.projectId, record) + } + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const existing = projects.get(projectId) + if (!existing) return + projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }) + } + session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null + session.workspaceRegistry.list = async () => Array.from(workspaces.values()) + session.workspaceRegistry.upsert = async ( + record: ReturnType + ) => { + workspaces.set(record.workspaceId, record) + } + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: newProjectId, + projectName: 'new-owner/inkwell', + checkout: { + cwd, + isGit: true, + currentBranch: cwd === mainWorkspaceId ? 'main' : 'feature-a', + remoteUrl: 'https://github.com/new-owner/inkwell.git', + isPaseoOwnedWorktree: cwd !== mainWorkspaceId, + mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, + }, + }) + + const result = await session.listFetchWorkspacesEntries({ + type: 'fetch_workspaces_request', + requestId: 'req-fetch-reconcile', + }) + + expect(result.entries.map((entry: any) => entry.projectId)).toEqual([newProjectId, newProjectId]) + expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId) + expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId) + expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy() + }) }) diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index c1f887d0b..de5839691 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -106,4 +106,38 @@ describe("shared tool-call display mapping", () => { expect(display.errorText).toBe('{\n "message": "boom"\n}'); }); + + it("labels terminal interaction rows without a summary when no command is available", () => { + const display = buildToolCallDisplayModel({ + name: "terminal", + status: "completed", + error: null, + detail: { + type: "plain_text", + icon: "square_terminal", + }, + }); + + expect(display).toEqual({ + displayName: "Interacted with terminal", + }); + }); + + it("uses the command as terminal interaction summary when available", () => { + const display = buildToolCallDisplayModel({ + name: "terminal", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "npm run test", + icon: "square_terminal", + }, + }); + + expect(display).toEqual({ + displayName: "Interacted with terminal", + summary: "npm run test", + }); + }); }); diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index ea24da17a..c9dc88fdb 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -103,6 +103,12 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall summary = isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined; } else if (lowerName === "thinking" && input.detail.type === "unknown") { displayName = "Thinking"; + } else if (lowerName === "terminal") { + displayName = "Interacted with terminal"; + summary = + input.detail.type === "plain_text" + ? readString(input.detail.label) + : undefined; } const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;