| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| import { useVoiceController } from './voice/useVoiceController'; |
| import { useCallSession } from './call/useCallSession'; |
| import Waveform from './phone/primitives/Waveform'; |
| import Aura from './phone/primitives/Aura'; |
| import { createStreamingTts } from './call/streamTts'; |
| import { clog, speakOwned, isCallFullDuplexEnabled } from './call/log'; |
| import { useBargeInDetector } from './call/bargeIn'; |
| |
| |
| |
| |
| |
| 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', |
| }; |
| function hpCallStateColor(state) { |
| 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) { |
| 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) { |
| if (state === 'listening' || state === 'thinking') |
| return 'listening'; |
| if (state === 'speaking') |
| return 'speaking'; |
| if (state === 'muted') |
| return 'muted'; |
| return 'idle'; |
| } |
| function hpCallStateLabel(state, personaName) { |
| 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 ''; |
| } |
| } |
| const IconBase = ({ 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 = (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 = (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 = (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 = (p) => (<IconBase {...p}> |
| <path d="M4 5h16v11H9l-5 4z"/> |
| </IconBase>); |
| const IconBack = (p) => (<IconBase {...p} strokeWidth={1.9}> |
| <path d="M15 5l-7 7 7 7"/> |
| </IconBase>); |
| const ControlBtn = ({ 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 = ({ 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>); |
| }; |
| const CallModal = ({ 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 default function CallOverlay(props) { |
| 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, }) { |
| const initial = skipDialing ? 'connecting' : 'dialing'; |
| const [state, setState] = useState(initial); |
| const [muted, setMuted] = useState(false); |
| const [durationSec, setDurationSec] = useState(0); |
| |
| |
| |
| useEffect(() => { |
| const timers = []; |
| 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.AudioContext || |
| window.webkitAudioContext; |
| if (!AC) |
| return; |
| let ctx = 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]; |
| const oscs = []; |
| 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.AudioContext || |
| window.webkitAudioContext; |
| if (!AC) |
| return; |
| let ctx = 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, startAt, durSec) => { |
| 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) => { 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', |
| |
| |
| |
| |
| 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) => { |
| clog({ |
| e: 'turn', |
| action: 'user_out', |
| route: useBackendRef.current ? 'ws' : 'chat_rest', |
| }); |
| if (useBackendRef.current) { |
| sessionRef.current.sendTranscript(text); |
| } |
| else { |
| onSendTextRef.current?.(text); |
| } |
| }); |
| const turnLockRef = useRef('idle'); |
| const turnUnlockTimerRef = useRef(null); |
| const fullDuplexEnabledRef = useRef(isCallFullDuplexEnabled()); |
| const setTurnLock = useCallback((next, trigger) => { |
| 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) => { |
| 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) => { |
| 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) => 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; |
| 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(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(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>); |
| } |
| |
| |
| |
| |
| |
|
|