diff --git a/packages/app/assets/audio/thinking-tone.wav b/packages/app/assets/audio/thinking-tone.wav new file mode 100644 index 000000000..aed18cce6 Binary files /dev/null and b/packages/app/assets/audio/thinking-tone.wav differ diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index aecf39d3e..a300f6c59 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -442,6 +442,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return ( + {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;