| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| import { useVoiceController, type VoiceState } from './voice/useVoiceController' |
| import { useCallSession } from './call/useCallSession' |
| import Waveform, { type WaveformMode } from './phone/primitives/Waveform' |
| import Aura from './phone/primitives/Aura' |
| import { createStreamingTts, type StreamingTts } from './call/streamTts' |
| import { clog, speakOwned, isCallFullDuplexEnabled } from './call/log' |
| import { useBargeInDetector } from './call/bargeIn' |
|
|
| export type CallState = |
| | 'dialing' |
| | 'connecting' |
| | 'listening' |
| | 'thinking' |
| | 'speaking' |
| | 'muted' |
| | 'ended' |
|
|
| |
| |
| |
| |
| |
| const DIAL_MS = 2200 |
| const CONNECT_MS = 500 |
| const END_FADE_MS = 220 |
|
|
| |
|
|
| const HP_CALL = { |
| font: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif', |
| fontTabular: 'ui-monospace, SFMono-Regular, "Roboto Mono", Menlo, monospace', |
| backdrop: 'rgba(5, 5, 6, 0.72)', |
| surface: '#0b0b0c', |
| surface2: '#121214', |
| border: 'rgba(255, 255, 255, 0.08)', |
| text: 'rgba(255, 255, 255, 0.92)', |
| text2: 'rgba(255, 255, 255, 0.55)', |
| text3: 'rgba(255, 255, 255, 0.35)', |
| accent: '#22d3ee', |
| stateListening: '#22d3ee', |
| stateThinking: '#a78bfa', |
| stateSpeaking: '#10b981', |
| stateError: '#f87171', |
| end: '#ef4444', |
| } as const |
|
|
| function hpCallStateColor(state: CallState): string { |
| switch (state) { |
| case 'listening': return HP_CALL.stateListening |
| case 'thinking': return HP_CALL.stateThinking |
| case 'speaking': return HP_CALL.stateSpeaking |
| case 'dialing': |
| case 'connecting': return HP_CALL.accent |
| case 'muted': |
| case 'ended': return HP_CALL.text3 |
| default: return HP_CALL.text2 |
| } |
| } |
|
|
| |
| |
| |
| function mapVoiceState(s: VoiceState): CallState | null { |
| switch (s) { |
| case 'LISTENING': return 'listening' |
| case 'THINKING': return 'thinking' |
| case 'SPEAKING': return 'speaking' |
| case 'IDLE': return 'listening' |
| case 'OFF': return null |
| default: return null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function waveformModeFromCallState(state: CallState): WaveformMode { |
| if (state === 'listening' || state === 'thinking') return 'listening' |
| if (state === 'speaking') return 'speaking' |
| if (state === 'muted') return 'muted' |
| return 'idle' |
| } |
|
|
| function hpCallStateLabel(state: CallState, personaName: string): string { |
| switch (state) { |
| case 'dialing': return `calling ${personaName.toLowerCase()}β¦` |
| case 'connecting': return 'connectingβ¦' |
| case 'listening': return 'listening' |
| case 'thinking': return 'thinking' |
| case 'speaking': return 'speaking' |
| case 'muted': return 'microphone off' |
| case 'ended': return 'call ended' |
| default: return '' |
| } |
| } |
|
|
| |
|
|
| type IconProps = { size?: number; color?: string } |
| const IconBase: React.FC<IconProps & { strokeWidth?: number; children: React.ReactNode }> = ({ |
| size = 20, color = 'currentColor', strokeWidth = 1.75, children, |
| }) => ( |
| <svg width={size} height={size} viewBox="0 0 24 24" fill="none" |
| stroke={color} strokeWidth={strokeWidth} |
| strokeLinecap="round" strokeLinejoin="round"> |
| {children} |
| </svg> |
| ) |
| const IconPhoneEnd: React.FC<IconProps> = (p) => ( |
| <IconBase {...p} strokeWidth={1.9}> |
| <path d="M4 14c5-5 11-5 16 0l-2 2-3-1v-2a9 9 0 0 0-6 0v2l-3 1-2-2z" transform="rotate(135 12 12)" /> |
| </IconBase> |
| ) |
| const IconMic: React.FC<IconProps> = (p) => ( |
| <IconBase {...p}> |
| <rect x="9" y="3" width="6" height="12" rx="3" /> |
| <path d="M5 11a7 7 0 0 0 14 0M12 18v3" /> |
| </IconBase> |
| ) |
| const IconMicOff: React.FC<IconProps> = (p) => ( |
| <IconBase {...p}> |
| <path d="M9 9V6a3 3 0 0 1 6 0v5m0 4a3 3 0 0 1-6 0" /> |
| <path d="M5 11a7 7 0 0 0 11.5 5.3M19 11a7 7 0 0 1-.4 2.3M12 18v3" /> |
| <path d="M3 3l18 18" /> |
| </IconBase> |
| ) |
| const IconChat: React.FC<IconProps> = (p) => ( |
| <IconBase {...p}> |
| <path d="M4 5h16v11H9l-5 4z" /> |
| </IconBase> |
| ) |
| const IconBack: React.FC<IconProps> = (p) => ( |
| <IconBase {...p} strokeWidth={1.9}> |
| <path d="M15 5l-7 7 7 7" /> |
| </IconBase> |
| ) |
|
|
| |
|
|
| type Tone = 'neutral' | 'danger' | 'start' | 'accent' |
| const ControlBtn: React.FC<{ |
| size?: number |
| tone?: Tone |
| active?: boolean |
| disabled?: boolean |
| label?: string |
| ariaLabel?: string |
| onClick?: () => void |
| children: React.ReactNode |
| }> = ({ |
| size = 48, tone = 'neutral', active = false, disabled, label, ariaLabel, onClick, children, |
| }) => { |
| const bg = |
| tone === 'danger' ? HP_CALL.end : |
| tone === 'start' ? HP_CALL.stateSpeaking : |
| tone === 'accent' ? HP_CALL.accent : |
| active ? 'rgba(255,255,255,0.92)' : HP_CALL.surface2 |
| const fg = |
| tone === 'danger' || tone === 'start' ? '#ffffff' : |
| tone === 'accent' ? '#052c33' : |
| active ? '#0b0b0c' : HP_CALL.text |
| const glow = |
| tone === 'danger' ? 'rgba(239, 68, 68, 0.45)' : |
| tone === 'start' ? 'rgba(16, 185, 129, 0.45)' : |
| tone === 'accent' ? 'rgba(34, 211, 238, 0.4)' : |
| 'rgba(0, 0, 0, 0.3)' |
| const border = tone === 'neutral' && !active ? `1px solid ${HP_CALL.border}` : 'none' |
|
|
| return ( |
| <div style={{ |
| display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, |
| opacity: disabled ? 0.45 : 1, |
| }}> |
| <button |
| type="button" |
| onClick={onClick} |
| disabled={disabled} |
| aria-label={ariaLabel || label || ''} |
| style={{ |
| width: size, height: size, borderRadius: '50%', |
| background: bg, border, color: fg, padding: 0, |
| display: 'flex', alignItems: 'center', justifyContent: 'center', |
| cursor: disabled ? 'not-allowed' : 'pointer', |
| boxShadow: |
| tone === 'neutral' && !active |
| ? 'inset 0 1px 0 rgba(255,255,255,0.04)' |
| : `0 8px 22px ${glow}, inset 0 1px 0 rgba(255,255,255,0.12)`, |
| transition: 'transform 80ms ease, background 120ms ease, box-shadow 120ms ease', |
| }} |
| > |
| {children} |
| </button> |
| {label ? ( |
| <span style={{ |
| fontFamily: HP_CALL.font, fontSize: 11, fontWeight: 500, letterSpacing: 0.2, |
| color: HP_CALL.text3, textTransform: 'lowercase', |
| }}>{label}</span> |
| ) : null} |
| </div> |
| ) |
| } |
|
|
| |
|
|
| const CallAvatar: React.FC<{ |
| size?: number |
| state: CallState |
| imageUrl?: string | null |
| accentColor?: string | null |
| }> = ({ size = 156, state, imageUrl = null, accentColor = null }) => { |
| const stateColor = hpCallStateColor(state) |
| const breathes = |
| state === 'listening' || |
| state === 'connecting' || |
| state === 'speaking' || |
| state === 'dialing' |
| const showPulseRings = state === 'dialing' |
| const haloOuter = size + 28 |
| |
| |
| |
|
|
| return ( |
| <div style={{ |
| position: 'relative', width: haloOuter, height: haloOuter, |
| display: 'flex', alignItems: 'center', justifyContent: 'center', |
| }}> |
| {/* Expanding pulse rings β only while dialing. Three rings on |
| staggered delays so the modal reads as "ringing", not just |
| "spinning up". */} |
| {showPulseRings && [0, 1, 2].map(i => ( |
| <div key={i} aria-hidden="true" style={{ |
| position: 'absolute', inset: 0, borderRadius: '50%', |
| border: `1.5px solid ${stateColor}`, |
| // Split to longhand so per-ring animationDelay doesn't race |
| // the shorthand reset β React warns when both are set |
| // (mixing shorthand + longhand produces inconsistent results |
| // across browsers). |
| animationName: 'hp-call-pulse-ring', |
| animationDuration: '1600ms', |
| animationTimingFunction: 'ease-out', |
| animationIterationCount: 'infinite', |
| animationDelay: `${i * 420}ms`, |
| opacity: 0, |
| }} /> |
| ))} |
| <div style={{ |
| position: 'absolute', inset: 0, borderRadius: '50%', |
| background: `radial-gradient(circle, ${stateColor}55 0%, transparent 68%)`, |
| filter: 'blur(14px)', |
| opacity: breathes ? 0.8 : 0.35, |
| animation: breathes ? 'hp-halo-breathe 2s ease-in-out infinite' : 'none', |
| }} /> |
| <div style={{ |
| position: 'absolute', width: size + 10, height: size + 10, |
| borderRadius: '50%', |
| border: `2px solid ${stateColor}`, |
| opacity: state === 'ended' || state === 'muted' ? 0.25 : 0.92, |
| }} /> |
| <div |
| style={{ |
| // ``state === 'ended'`` filter stays on this wrapper so |
| // the Aura primitive stays state-agnostic while the |
| // overlay still gets a visible "call's over" cue. |
| filter: state === 'ended' |
| ? 'grayscale(0.6) brightness(0.7)' |
| : 'none', |
| borderRadius: '50%', |
| }} |
| > |
| <Aura |
| seed={imageUrl || accentColor || 'homepilot'} |
| size={size} |
| photoUrl={imageUrl} |
| // Hue-drift polish is the only thing the Aura primitive |
| // animates internally. We disable it here β the overlay |
| // already owns the halo breath + dialing rings, and the |
| // combined motion was too busy on the call surface. |
| animated={false} |
| /> |
| </div> |
| </div> |
| ) |
| } |
|
|
| |
| |
| |
| |
|
|
| |
|
|
| interface CallModalProps { |
| state: CallState |
| personaName: string |
| imageUrl?: string | null |
| accentColor?: string | null |
| durationSec: number |
| onEnd: () => void |
| onToggleMute: () => void |
| onMinimize: () => void |
| onSwitchToChat?: () => void |
| |
| intensityRef?: React.MutableRefObject<number> |
| } |
|
|
| const CallModal: React.FC<CallModalProps> = ({ |
| state, personaName, imageUrl = null, accentColor = null, |
| durationSec, onEnd, onToggleMute, onMinimize, onSwitchToChat, |
| intensityRef, |
| }) => { |
| const stateColor = hpCallStateColor(state) |
| const stateLabel = hpCallStateLabel(state, personaName) |
| const mm = Math.floor(durationSec / 60).toString().padStart(2, '0') |
| const ss = (durationSec % 60).toString().padStart(2, '0') |
| const preConnect = state === 'dialing' || state === 'connecting' |
| const timer = preConnect ? 'β:β' : `${mm}:${ss}` |
|
|
| return ( |
| <div style={{ |
| width: 'min(420px, 92vw)', |
| padding: '18px 22px 26px', |
| borderRadius: 24, |
| background: HP_CALL.surface, |
| border: `1px solid ${HP_CALL.border}`, |
| boxShadow: |
| '0 30px 80px rgba(0,0,0,0.55), 0 0 0 1px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.04)', |
| position: 'relative', overflow: 'hidden', |
| fontFamily: HP_CALL.font, color: HP_CALL.text, |
| animation: 'hp-call-in 180ms ease-out', |
| }}> |
| <div style={{ |
| position: 'absolute', inset: '-40% -10% auto -10%', height: '60%', |
| background: `radial-gradient(ellipse at 50% 100%, ${stateColor}26 0%, transparent 65%)`, |
| pointerEvents: 'none', opacity: state === 'muted' ? 0.1 : 0.35, |
| }} /> |
| |
| {/* Header row */} |
| <div style={{ |
| position: 'relative', zIndex: 2, |
| display: 'flex', alignItems: 'center', |
| height: 32, marginBottom: 10, |
| }}> |
| <button |
| type="button" |
| onClick={onMinimize} |
| aria-label="Minimize call" |
| style={{ |
| width: 32, height: 32, borderRadius: 10, padding: 0, |
| background: 'transparent', border: 'none', cursor: 'pointer', |
| color: HP_CALL.text2, |
| display: 'flex', alignItems: 'center', justifyContent: 'center', |
| }} |
| > |
| <IconBack size={18} color={HP_CALL.text2} /> |
| </button> |
| <div style={{ |
| position: 'absolute', left: 40, right: 40, textAlign: 'center', |
| fontSize: 16, fontWeight: 600, letterSpacing: -0.1, |
| color: HP_CALL.text, |
| whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', |
| pointerEvents: 'none', |
| }}>{personaName}</div> |
| </div> |
| |
| {/* Timer */} |
| <div style={{ |
| position: 'relative', zIndex: 2, textAlign: 'center', |
| fontSize: 13, color: HP_CALL.text2, |
| fontFamily: HP_CALL.fontTabular, |
| fontVariantNumeric: 'tabular-nums', |
| marginBottom: 18, |
| }}>{timer}</div> |
| |
| {/* Avatar + halo */} |
| <div style={{ |
| position: 'relative', zIndex: 2, |
| display: 'flex', justifyContent: 'center', marginBottom: 18, |
| }}> |
| <CallAvatar size={156} state={state} imageUrl={imageUrl} accentColor={accentColor} /> |
| </div> |
| |
| {/* State label + waveform/dots */} |
| <div style={{ |
| position: 'relative', zIndex: 2, |
| display: 'flex', flexDirection: 'column', alignItems: 'center', |
| gap: 10, marginBottom: 22, minHeight: 52, |
| }}> |
| {preConnect ? ( |
| <div style={{ display: 'flex', gap: 5 }}> |
| {[0, 1, 2].map(i => ( |
| <div key={i} style={{ |
| width: 5, height: 5, borderRadius: '50%', |
| background: HP_CALL.text, |
| opacity: 0.35 + i * 0.2, |
| // Longhand β keeps per-dot animationDelay from being |
| // reset by the shorthand during React rerenders. |
| animationName: 'hp-dot-pulse', |
| animationDuration: state === 'dialing' ? '1.2s' : '0.8s', |
| animationTimingFunction: 'ease-in-out', |
| animationIterationCount: 'infinite', |
| animationDelay: `${i * 0.12}s`, |
| }} /> |
| ))} |
| </div> |
| ) : ( |
| <Waveform |
| bars={26} |
| height={24} |
| mode={waveformModeFromCallState(state)} |
| seed={personaName} |
| intensityRef={intensityRef} |
| /> |
| )} |
| <div style={{ |
| fontSize: 14, fontWeight: 500, letterSpacing: 0.3, |
| color: stateColor, textTransform: 'lowercase', |
| }}>{stateLabel}</div> |
| </div> |
| |
| {/* Three-button dock */} |
| <div style={{ |
| position: 'relative', zIndex: 2, |
| display: 'flex', justifyContent: 'center', alignItems: 'center', |
| gap: 28, |
| }}> |
| <ControlBtn |
| size={56} |
| active={state === 'muted'} |
| ariaLabel={state === 'muted' ? 'Unmute microphone' : 'Mute microphone'} |
| onClick={onToggleMute} |
| disabled={state === 'dialing' || state === 'connecting' || state === 'ended'} |
| > |
| {state === 'muted' |
| ? <IconMicOff size={22} color="#0b0b0c" /> |
| : <IconMic size={22} />} |
| </ControlBtn> |
| |
| <ControlBtn |
| size={72} tone="danger" ariaLabel="End call" |
| onClick={onEnd} |
| > |
| <IconPhoneEnd size={26} color="#ffffff" /> |
| </ControlBtn> |
| |
| <ControlBtn |
| size={56} ariaLabel="Switch to text chat" |
| onClick={onSwitchToChat} |
| disabled={!onSwitchToChat || state === 'dialing' || state === 'connecting' || state === 'ended'} |
| > |
| <IconChat size={22} /> |
| </ControlBtn> |
| </div> |
| </div> |
| ) |
| } |
|
|
| |
|
|
| export interface CallOverlayProps { |
| open: boolean |
| onClose: () => void |
| |
| personaName?: string |
| |
| avatarUrl?: string | null |
| |
| accentColor?: string | null |
| |
| onMinimize?: () => void |
| |
| onSwitchToChat?: () => void |
| |
| |
| skipDialing?: boolean |
| |
| |
| onEnded?: (durationSec: number) => void |
| |
| |
| |
| |
| onSendText?: (text: string) => void |
| |
| |
| |
| isAssistantThinking?: boolean |
| |
| |
| |
| |
| backend?: { |
| backendUrl: string |
| authToken: string | null |
| conversationId?: string | null |
| personaId?: string | null |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| messages?: ReadonlyArray<{ |
| id: string |
| role: 'user' | 'assistant' | string |
| text: string |
| callMemory?: unknown |
| }> |
| } |
|
|
| |
| |
| |
| |
| |
| export default function CallOverlay(props: CallOverlayProps) { |
| if (!props.open) return null |
| return <CallOverlayInner {...props} /> |
| } |
|
|
| function CallOverlayInner({ |
| onClose, |
| personaName = 'Assistant', |
| avatarUrl = null, |
| accentColor = null, |
| onMinimize, |
| onSwitchToChat, |
| skipDialing = false, |
| onEnded, |
| onSendText, |
| backend, |
| messages, |
| }: CallOverlayProps) { |
| const initial: CallState = skipDialing ? 'connecting' : 'dialing' |
| const [state, setState] = useState<CallState>(initial) |
| const [muted, setMuted] = useState(false) |
| const [durationSec, setDurationSec] = useState(0) |
|
|
| |
| |
| |
| useEffect(() => { |
| const timers: number[] = [] |
| if (!skipDialing) { |
| timers.push(window.setTimeout(() => { |
| setState(s => (s === 'dialing' ? 'connecting' : s)) |
| }, DIAL_MS)) |
| } |
| timers.push(window.setTimeout(() => { |
| setState(s => |
| (s === 'connecting' || s === 'dialing') ? 'listening' : s |
| ) |
| }, (skipDialing ? 0 : DIAL_MS) + CONNECT_MS)) |
|
|
| return () => { timers.forEach(t => window.clearTimeout(t)) } |
| }, [skipDialing]) |
|
|
| |
| useEffect(() => { |
| if (state === 'dialing' || state === 'connecting' || state === 'ended') return |
| const iv = window.setInterval(() => setDurationSec((n) => n + 1), 1000) |
| return () => window.clearInterval(iv) |
| }, [state]) |
|
|
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (state !== 'dialing') return |
| const AC = |
| (window as unknown as { AudioContext?: typeof AudioContext }).AudioContext || |
| (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext |
| if (!AC) return |
| let ctx: AudioContext | null = null |
| try { ctx = new AC() } catch { return } |
|
|
| const start = ctx.currentTime + 0.03 |
| const durationSec = 2.0 |
| const peakGain = 0.07 |
| |
|
|
| |
| |
| const freqs = [440, 480] as const |
| const oscs: OscillatorNode[] = [] |
| const gain = ctx.createGain() |
| gain.gain.setValueAtTime(0, start) |
| gain.gain.linearRampToValueAtTime(peakGain, start + 0.08) |
| gain.gain.setValueAtTime(peakGain, start + durationSec - 0.2) |
| gain.gain.linearRampToValueAtTime(0, start + durationSec) |
| gain.connect(ctx.destination) |
|
|
| for (const f of freqs) { |
| const o = ctx.createOscillator() |
| o.type = 'sine' |
| o.frequency.setValueAtTime(f, start) |
| o.connect(gain) |
| o.start(start) |
| o.stop(start + durationSec + 0.05) |
| oscs.push(o) |
| } |
|
|
| return () => { |
| for (const o of oscs) { try { o.stop() } catch { } } |
| try { ctx?.close() } catch { } |
| } |
| }, [state]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (state !== 'ended') return |
| if (muted) return |
| const AC = |
| (window as unknown as { AudioContext?: typeof AudioContext }).AudioContext || |
| (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext |
| if (!AC) return |
| let ctx: AudioContext | null = null |
| try { ctx = new AC() } catch { return } |
|
|
| const t0 = ctx.currentTime + 0.02 |
| const peakGain = 0.15 |
| const attack = 0.02 |
| const release = 0.08 |
|
|
| |
| const t1Start = t0 |
| const t1Dur = 0.18 |
| |
| const t2Start = t0 + 0.13 |
| const t2Dur = 0.22 |
|
|
| const makeTone = (freq: number, startAt: number, durSec: number) => { |
| const osc = ctx!.createOscillator() |
| osc.type = 'sine' |
| osc.frequency.setValueAtTime(freq, startAt) |
| const gain = ctx!.createGain() |
| gain.gain.setValueAtTime(0, startAt) |
| gain.gain.linearRampToValueAtTime(peakGain, startAt + attack) |
| gain.gain.setValueAtTime(peakGain, startAt + durSec - release) |
| gain.gain.linearRampToValueAtTime(0, startAt + durSec) |
| osc.connect(gain).connect(ctx!.destination) |
| osc.start(startAt) |
| osc.stop(startAt + durSec + 0.05) |
| return osc |
| } |
|
|
| const oscs = [ |
| makeTone(660, t1Start, t1Dur), |
| makeTone(440, t2Start, t2Dur), |
| ] |
| const totalDuration = (t2Start - t0) + t2Dur + 0.05 |
|
|
| |
| |
| |
| const closeTimer = window.setTimeout(() => { |
| try { ctx?.close() } catch { } |
| }, Math.ceil(totalDuration * 1000) + 50) |
|
|
| return () => { |
| window.clearTimeout(closeTimer) |
| for (const o of oscs) { try { o.stop() } catch { } } |
| try { ctx?.close() } catch { } |
| } |
| }, [state, muted]) |
|
|
| const handleEnd = useCallback(() => { |
| setState('ended') |
| |
| |
| if (useBackendRef.current) { |
| try { sessionRef.current.end() } catch { } |
| } |
| |
| |
| |
| |
| const endedWith = durationSec |
| window.setTimeout(() => { |
| if (onEnded) onEnded(endedWith) |
| onClose() |
| }, END_FADE_MS) |
| }, [onClose, onEnded, durationSec]) |
|
|
| |
| useEffect(() => { |
| const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') handleEnd() } |
| window.addEventListener('keydown', onKey) |
| return () => window.removeEventListener('keydown', onKey) |
| }, [handleEnd]) |
|
|
| |
| |
| |
| |
| const session = useCallSession({ |
| enabled: !!backend, |
| backendUrl: backend?.backendUrl ?? '', |
| authToken: backend?.authToken ?? null, |
| request: useMemo(() => ({ |
| conversation_id: backend?.conversationId ?? null, |
| persona_id: backend?.personaId ?? null, |
| entry_mode: 'call' as const, |
| |
| |
| |
| |
| device_info: { |
| tz: Intl.DateTimeFormat().resolvedOptions().timeZone, |
| platform: navigator.platform, |
| streaming: true, |
| barge_in: true, |
| }, |
| }), [backend?.conversationId, backend?.personaId]), |
| }) |
|
|
| const useBackend = |
| !!backend && |
| session.status !== 'unavailable' && |
| session.status !== 'error' |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [openerPlaying, setOpenerPlaying] = useState(false) |
| |
| |
| |
| const connected = |
| state !== 'dialing' && state !== 'connecting' && state !== 'ended' |
| const openerDoneRef = useRef(false) |
| useEffect(() => { |
| if (session.status === 'creating') { |
| openerDoneRef.current = false |
| setOpenerPlaying(false) |
| } |
| }, [session.status]) |
|
|
| |
| |
| |
| |
| |
| const onSendTextRef = useRef(onSendText) |
| useEffect(() => { onSendTextRef.current = onSendText }, [onSendText]) |
| const sessionRef = useRef(session) |
| useEffect(() => { sessionRef.current = session }, [session]) |
| const useBackendRef = useRef(useBackend) |
| useEffect(() => { useBackendRef.current = useBackend }, [useBackend]) |
|
|
| const voice = useVoiceController((text: string) => { |
| clog({ |
| e: 'turn', |
| action: 'user_out', |
| route: useBackendRef.current ? 'ws' : 'chat_rest', |
| }) |
| if (useBackendRef.current) { |
| sessionRef.current.sendTranscript(text) |
| } else { |
| onSendTextRef.current?.(text) |
| } |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type TurnLock = 'ai' | 'user' | 'idle' |
| const turnLockRef = useRef<TurnLock>('idle') |
| const turnUnlockTimerRef = useRef<number | null>(null) |
| const fullDuplexEnabledRef = useRef<boolean>(isCallFullDuplexEnabled()) |
|
|
| const setTurnLock = useCallback((next: TurnLock, trigger: string) => { |
| if (!fullDuplexEnabledRef.current) return |
| const prev = turnLockRef.current |
| if (prev === next) return |
| turnLockRef.current = next |
| voice.setListeningSuppressed(next === 'ai', `turn_lock:${trigger}`) |
| clog({ e: 'state', from: prev, to: next, trigger }) |
| }, [voice]) |
|
|
| const releaseAiTurnWithMargin = useCallback((trigger: string) => { |
| if (turnUnlockTimerRef.current) { |
| window.clearTimeout(turnUnlockTimerRef.current) |
| turnUnlockTimerRef.current = null |
| } |
| turnUnlockTimerRef.current = window.setTimeout(() => { |
| setTurnLock('idle', `${trigger}:margin_release`) |
| turnUnlockTimerRef.current = null |
| }, 300) |
| }, [setTurnLock]) |
|
|
| useEffect(() => { |
| return () => { |
| if (turnUnlockTimerRef.current) { |
| window.clearTimeout(turnUnlockTimerRef.current) |
| turnUnlockTimerRef.current = null |
| } |
| } |
| }, []) |
|
|
| const speakText = useCallback((text: string) => { |
| if (!text || !text.trim()) return |
| setTurnLock('ai', 'speak:start') |
| const isOpener = !openerDoneRef.current |
| if (isOpener) setOpenerPlaying(true) |
| const markDone = () => { |
| openerDoneRef.current = true |
| setOpenerPlaying(false) |
| |
| |
| |
| |
| releaseAiTurnWithMargin('speak:end') |
| } |
| |
| |
| |
| |
| const estimateSpeechMs = (t: string) => |
| Math.max(1500, Math.min(8000, t.length * 80)) |
| try { |
| |
| |
| |
| |
| |
| |
| if (fullDuplexEnabledRef.current) { |
| const spoke = speakOwned('overlay', text, { |
| onEnd: markDone, |
| onError: () => markDone(), |
| }) |
| if (spoke) { |
| if (isOpener) window.setTimeout(markDone, estimateSpeechMs(text)) |
| return |
| } |
| } else { |
| const w = window as unknown as { |
| SpeechService?: { speak?: (t: string) => void } |
| } |
| if (w.SpeechService?.speak) { |
| w.SpeechService.speak(text) |
| if (isOpener) window.setTimeout(markDone, estimateSpeechMs(text)) |
| return |
| } |
| } |
| if ('speechSynthesis' in window) { |
| const utt = new SpeechSynthesisUtterance(text) |
| if (isOpener) { |
| utt.onend = markDone |
| utt.onerror = markDone |
| window.setTimeout(markDone, estimateSpeechMs(text)) |
| } |
| window.speechSynthesis.speak(utt) |
| return |
| } |
| if (isOpener) markDone() |
| } catch { |
| if (isOpener) markDone() |
| } |
| }, [setTurnLock, releaseAiTurnWithMargin]) |
|
|
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (!useBackend) return |
| const unsub = session.onAssistantTranscript((p) => { |
| clog({ e: 'turn', action: 'assistant_in', route: 'ws' }) |
| speakText(p.text) |
| }) |
| return unsub |
| }, [useBackend, session, speakText]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| const fallbackOpeners = useMemo( |
| () => [ |
| 'Hello?', |
| 'Yes?', |
| `Hi, this is ${personaName}.`, |
| `${personaName} speaking.`, |
| `Hey β ${personaName}.`, |
| `Hi, ${personaName} here.`, |
| ], |
| [personaName], |
| ) |
| const fallbackOpenerFiredRef = useRef(false) |
| useEffect(() => { |
| if (useBackend) return |
| if (!connected) return |
| if (fallbackOpenerFiredRef.current) return |
| fallbackOpenerFiredRef.current = true |
| const g = fallbackOpeners[ |
| Math.floor(Math.random() * fallbackOpeners.length) |
| ] |
| speakText(g) |
| }, [useBackend, connected, fallbackOpeners, speakText]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| const ttsRef = useRef<StreamingTts | null>(null) |
| useEffect(() => { |
| if (!useBackend || !session.streamingNegotiated) { |
| ttsRef.current = null |
| return |
| } |
| const tts = createStreamingTts() |
| ttsRef.current = tts |
| return () => { |
| try { tts.stop() } catch { } |
| ttsRef.current = null |
| } |
| }, [useBackend, session.streamingNegotiated]) |
|
|
| |
| |
| const currentTurnIdRef = useRef<string | null>(null) |
|
|
| |
| |
| |
| useEffect(() => { |
| if (!useBackend || !session.streamingNegotiated) return |
| return session.onAssistantPartial((p) => { |
| currentTurnIdRef.current = p.turn_id |
| ttsRef.current?.appendDelta(p.delta) |
| }) |
| }, [useBackend, session]) |
|
|
| |
| useEffect(() => { |
| if (!useBackend || !session.streamingNegotiated) return |
| return session.onAssistantTurnEnd((p) => { |
| if (p.reason === 'cancelled' || p.reason === 'error') { |
| ttsRef.current?.stop() |
| } else { |
| ttsRef.current?.flush() |
| } |
| if (currentTurnIdRef.current === p.turn_id) { |
| currentTurnIdRef.current = null |
| } |
| }) |
| }, [useBackend, session]) |
|
|
| |
| |
| useEffect(() => { |
| if (!useBackend || !session.streamingNegotiated) return |
| return session.onAssistantCancel((_p) => { |
| ttsRef.current?.stop() |
| }) |
| }, [useBackend, session]) |
|
|
| |
| |
| |
| |
| const voiceLevelRef = useRef(0) |
| useEffect(() => { |
| voiceLevelRef.current = voice.audioLevel |
| }, [voice.audioLevel]) |
|
|
| |
| |
| const bargeInEnabled = |
| useBackend && |
| session.bargeInNegotiated && |
| (ttsRef.current?.isSpeaking ?? false) |
|
|
| useBargeInDetector({ |
| audioLevelRef: voiceLevelRef, |
| enabled: bargeInEnabled, |
| onBargeIn: () => { |
| const tid = currentTurnIdRef.current |
| clog({ e: 'turn', action: 'barge_in', route: 'ws' }) |
| ttsRef.current?.stop() |
| if (tid) session.sendBargeIn(tid) |
| }, |
| }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const greetedRef = useRef(false) |
| useEffect(() => { |
| if (!useBackend) return |
| if (session.callState !== 'live') return |
| if (greetedRef.current) return |
| greetedRef.current = true |
| |
| |
| |
| session.sendTranscript('[phone-call-open]') |
| }, [useBackend, session]) |
|
|
| |
| useEffect(() => { |
| if (session.status === 'creating') greetedRef.current = false |
| }, [session.status]) |
|
|
| |
| |
| |
| useEffect(() => { |
| if (!useBackend) return |
| const unsub1 = session.onAssistantBackchannel((p) => { |
| try { window.speechSynthesis?.speak?.(new SpeechSynthesisUtterance(p.token)) } |
| catch { } |
| }) |
| const unsub2 = session.onAssistantFiller((p) => { |
| try { window.speechSynthesis?.speak?.(new SpeechSynthesisUtterance(p.token)) } |
| catch { } |
| }) |
| return () => { unsub1(); unsub2() } |
| }, [useBackend, session]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const voiceHandleRef = useRef(voice) |
| useEffect(() => { voiceHandleRef.current = voice }, [voice]) |
| const setTurnLockRef = useRef(setTurnLock) |
| useEffect(() => { setTurnLockRef.current = setTurnLock }, [setTurnLock]) |
|
|
| useEffect(() => { |
| if (!connected) return |
| if (openerPlaying) return |
| setTurnLockRef.current('idle', 'connected_open_mic') |
| voiceHandleRef.current.setHandsFree(true) |
| }, [connected, openerPlaying]) |
|
|
| |
| |
| |
| |
| useEffect(() => { |
| return () => { |
| try { voiceHandleRef.current.setHandsFree(false) } catch { } |
| try { setTurnLockRef.current('idle', 'cleanup') } catch { } |
| } |
| }, []) |
|
|
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (!connected) return |
| if (muted) return |
| const mapped = mapVoiceState(voice.state) |
| if (!mapped) return |
| setState(prev => { |
| if (prev === 'ended' || prev === 'muted') return prev |
| return mapped |
| }) |
| }, [voice.state, connected, muted]) |
|
|
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (!fullDuplexEnabledRef.current) return |
| if (!connected) return |
| if (muted) return |
| if (voice.state === 'LISTENING' && turnLockRef.current === 'idle') { |
| clog({ e: 'vad', action: 'speech_start', state: voice.state }) |
| setTurnLock('user', 'voice_listening') |
| } else if (voice.state === 'THINKING' && turnLockRef.current === 'user') { |
| clog({ e: 'vad', action: 'speech_end', state: voice.state }) |
| setTurnLock('idle', 'voice_thinking') |
| } |
| }, [voice.state, connected, muted, setTurnLock]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (!openerPlaying) return |
| setState(prev => |
| (prev === 'ended' || prev === 'muted') ? prev : 'speaking', |
| ) |
| }, [openerPlaying]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const intensityRef = useRef(0.05) |
| const voiceRef = useRef(voice) |
| useEffect(() => { voiceRef.current = voice }, [voice]) |
|
|
| useEffect(() => { |
| let raf = 0 |
| const start = performance.now() |
|
|
| if (state === 'listening' || state === 'muted') { |
| const tick = () => { |
| |
| |
| intensityRef.current = muted ? 0 : voiceRef.current.audioLevel |
| raf = requestAnimationFrame(tick) |
| } |
| raf = requestAnimationFrame(tick) |
| return () => cancelAnimationFrame(raf) |
| } |
|
|
| if (state === 'speaking') { |
| const tick = () => { |
| const t = (performance.now() - start) / 1000 |
| |
| |
| |
| |
| const v = |
| 0.45 + |
| Math.sin(t * 2.3) * 0.22 + |
| Math.sin(t * 5.7 + 1.3) * 0.16 + |
| Math.sin(t * 11.1 + 2.7) * 0.09 + |
| (Math.random() - 0.5) * 0.07 |
| intensityRef.current = Math.max(0, Math.min(1, v)) |
| raf = requestAnimationFrame(tick) |
| } |
| raf = requestAnimationFrame(tick) |
| return () => cancelAnimationFrame(raf) |
| } |
|
|
| if (state === 'thinking') { |
| const tick = () => { |
| const t = (performance.now() - start) / 1000 |
| intensityRef.current = 0.18 + Math.sin(t * 1.1) * 0.06 |
| raf = requestAnimationFrame(tick) |
| } |
| raf = requestAnimationFrame(tick) |
| return () => cancelAnimationFrame(raf) |
| } |
|
|
| |
| intensityRef.current = 0.05 |
| }, [state, muted]) |
|
|
| const toggleMute = useCallback(() => { |
| setMuted((m) => { |
| const next = !m |
| if (next) { |
| voice.setHandsFree(false) |
| setState('muted') |
| } else { |
| voice.setHandsFree(true) |
| setState('listening') |
| } |
| if (useBackendRef.current) { |
| try { sessionRef.current.sendUiState({ muted: next }) } catch { } |
| } |
| return next |
| }) |
| }, [voice]) |
|
|
| const handleMinimize = useCallback(() => { |
| if (onMinimize) onMinimize() |
| else onClose() |
| }, [onMinimize, onClose]) |
|
|
| return ( |
| <div |
| role="dialog" |
| aria-modal="true" |
| aria-label={`Call with ${personaName}`} |
| className="fixed inset-0 z-[100] flex items-center justify-center" |
| > |
| <div |
| className="absolute inset-0" |
| style={{ |
| background: HP_CALL.backdrop, |
| backdropFilter: 'blur(20px)', |
| WebkitBackdropFilter: 'blur(20px)', |
| transition: 'opacity 200ms ease', |
| opacity: state === 'ended' ? 0 : 1, |
| }} |
| /> |
| <div className="relative z-10"> |
| <CallModal |
| state={state} |
| personaName={personaName} |
| imageUrl={avatarUrl} |
| accentColor={accentColor} |
| durationSec={durationSec} |
| onEnd={handleEnd} |
| onToggleMute={toggleMute} |
| onMinimize={handleMinimize} |
| onSwitchToChat={onSwitchToChat} |
| intensityRef={intensityRef} |
| /> |
| </div> |
| |
| <style>{` |
| @keyframes hp-call-in { |
| from { opacity: 0; transform: scale(0.95); } |
| to { opacity: 1; transform: scale(1); } |
| } |
| @keyframes hp-halo-breathe { |
| 0%, 100% { opacity: 0.30; transform: scale(1); } |
| 50% { opacity: 0.85; transform: scale(1.06); } |
| } |
| @keyframes hp-dot-pulse { |
| 0%, 100% { transform: translateY(0); opacity: 0.35; } |
| 50% { transform: translateY(-3px); opacity: 1; } |
| } |
| @keyframes hp-call-pulse-ring { |
| 0% { transform: scale(1); opacity: 0.65; } |
| 80% { opacity: 0; } |
| 100% { transform: scale(1.55); opacity: 0; } |
| } |
| @keyframes hp-call-toast-in { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| `}</style> |
| </div> |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
|
|