Download frontend/src/ui/CallOverlay.jsx from ruslanmv/HomePilot: direct link, hf CLI and curl.
- Browser
- Download file 51.1 kB
-
https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/CallOverlay.jsx
- Command line
-
hf download hf://spaces/ruslanmv/HomePilot/frontend/src/ui/CallOverlay.jsx
-
curl -L -o CallOverlay.jsx https://huggingface.co/spaces/ruslanmv/HomePilot/resolve/main/frontend/src/ui/CallOverlay.jsx
51.1 kB
| /** | |
| * CallOverlay.tsx β TSX port of the frontend/src/ui/phone/ design set. | |
| * | |
| * Maps 1:1 onto the design-only mockups in `phone/`: | |
| * tokens.jsx β HP_CALL + hpCallStateColor/Label (inlined below) | |
| * icons.jsx β <Icon* /> components | |
| * controls.jsx β <ControlBtn /> | |
| * avatar.jsx β <CallAvatar /> | |
| * waveform.jsx β <CallWaveform /> | |
| * call-modal.jsx β <CallModal /> + the ModalPresentation wrapper | |
| * | |
| * Layered as a distinct "call mode" on top of the chat β it is NOT | |
| * the same thing as Voice mode: | |
| * π€ Voice = input method inside the chat | |
| * π Call = a separate immersive session (this component) | |
| * | |
| * States (mirrors the designer's set): | |
| * connecting Β· listening Β· thinking Β· speaking Β· muted | |
| * (ended is intentionally short-lived β the overlay fades out.) | |
| * | |
| * This pass wires the visual shell. Real STT/TTS plumbing from | |
| * useVoiceController maps `listening/thinking/speaking` later. | |
| */ | |
| 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'; | |
| // ββ Staged lifecycle timings. Numbers chosen so the user *feels* the | |
| // handshake instead of getting a hard screen flip: dial (ringing) β | |
| // connecting β listening (live). Dial is long enough for ~one full | |
| // PSTN ringback cadence (2 s on / 4 s off in the US) so the user | |
| // actually hears the phone "ring" before pickup. | |
| const DIAL_MS = 2200; // π "callingβ¦" β pulse-ring phase + one PSTN ring | |
| const CONNECT_MS = 500; // π΅ "connectingβ¦" β dots-pulse phase | |
| const END_FADE_MS = 220; // π΄ end button β modal fade-out β onClose | |
| // ββ Design tokens (ported from phone/tokens.jsx) ββββββββββββββββββ | |
| 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; | |
| } | |
| } | |
| // Map the voice-controller's internal machine onto our UI machine. | |
| // Returns null for states we don't want to reflect (OFF β overlay is | |
| // open so "off" is not a meaningful UI frame; render last mapped). | |
| 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; | |
| } | |
| } | |
| // Map the overlay's richer CallState onto the primitive Waveform's | |
| // four-valued mode enum. The primitive doesn't know about dialing / | |
| // connecting / thinking β those all fold to 'idle' (low ambient | |
| // amplitude, faint colour); only the three audible states drive | |
| // distinct waveform modes. | |
| 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>); | |
| }; | |
| // ββ CallAvatar (ported from phone/avatar.jsx) βββββββββββββββββββββ | |
| 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; | |
| // seed + hue derivation now live inside the Aura primitive | |
| // (hashHue + moodOffset). This component only owns the halo + | |
| // ring decorations that depend on CallState. | |
| 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>); | |
| }; | |
| // Outer shell β renders nothing when closed so the voice hook inside | |
| // CallOverlayInner only mounts during an active call. This matters: the | |
| // voice controller requests mic permission + starts the VAD as soon as | |
| // it mounts. Keeping it gated behind `open` means the mic is only | |
| // touched while the user is in a call. | |
| 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); | |
| // Staged lifecycle on mount: | |
| // dialing (DIAL_MS) β connecting (CONNECT_MS) β listening | |
| // (or: connecting β listening when skipDialing). | |
| 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]); | |
| // Live timer during active call (dialing + connecting don't count). | |
| useEffect(() => { | |
| if (state === 'dialing' || state === 'connecting' || state === 'ended') | |
| return; | |
| const iv = window.setInterval(() => setDurationSec((n) => n + 1), 1000); | |
| return () => window.clearInterval(iv); | |
| }, [state]); | |
| // Ringback tone β PSTN-standard two-frequency synthesis via | |
| // WebAudio. US telephony uses 440 Hz + 480 Hz played together, 2 s | |
| // on / 4 s off. We play one full on-phase (2 s) during dialing so | |
| // the user hears a real phone-style ring before pickup. Nothing | |
| // plays during connecting or live β a phone that keeps ringing | |
| // after pickup is a broken phone. | |
| 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; // "on" portion of the 2-on-4-off cadence | |
| const peakGain = 0.07; // loud enough to be heard, soft enough to sit | |
| // under the persona's TTS once it lands | |
| // 440 Hz + 480 Hz fundamentals β the exact CCITT-spec recipe for | |
| // "ringing signal, North American" (Precise Tone Plan). | |
| 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 { /* ignore */ } | |
| } | |
| try { | |
| ctx?.close(); | |
| } | |
| catch { /* ignore */ } | |
| }; | |
| }, [state]); | |
| // End-call chime β plays once on ``dialing/connecting/listening/β¦ | |
| // β ended`` transition. Matches the WhatsApp / FaceTime / Slack | |
| // Huddle convention: two-tone descending sine (perfect fifth down, | |
| // 660 β 440 Hz), short (~400 ms), quiet (peak gain 0.15), soft | |
| // attack + exponential-ish release so there's no click. The second | |
| // tone overlaps the first by 50 ms for smoothness. Fires inside | |
| // the END_FADE_MS window so the audio "goodbye" lands with the | |
| // visual fade β matches every industry example. | |
| // | |
| // Same WebAudio pattern as the ringback above so behaviour across | |
| // browsers is identical. Muted state suppresses the tone β the | |
| // user already signalled "quiet please". | |
| 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; // 20 ms β no click | |
| const release = 0.08; // 80 ms β gentle tail | |
| // Tone 1 β 660 Hz for 180 ms | |
| const t1Start = t0; | |
| const t1Dur = 0.18; | |
| // Tone 2 β 440 Hz, overlaps tone 1 by 50 ms, runs 220 ms | |
| 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; | |
| // Close the context after the tones finish so we don't leave a | |
| // live audio node dangling. The overlay unmounts ~END_FADE_MS | |
| // after state='ended' anyway, but being explicit is cheap. | |
| const closeTimer = window.setTimeout(() => { | |
| try { | |
| ctx?.close(); | |
| } | |
| catch { /* ignore */ } | |
| }, Math.ceil(totalDuration * 1000) + 50); | |
| return () => { | |
| window.clearTimeout(closeTimer); | |
| for (const o of oscs) { | |
| try { | |
| o.stop(); | |
| } | |
| catch { /* ignore */ } | |
| } | |
| try { | |
| ctx?.close(); | |
| } | |
| catch { /* ignore */ } | |
| }; | |
| }, [state, muted]); | |
| const handleEnd = useCallback(() => { | |
| setState('ended'); | |
| // If the backend session is live, send a graceful call.control | |
| // 'end' envelope so the server-side ledger closes cleanly. | |
| if (useBackendRef.current) { | |
| try { | |
| sessionRef.current.end(); | |
| } | |
| catch { /* ignore */ } | |
| } | |
| // Capture the live-phase duration NOW β durationSec is frozen at | |
| // the last tick because the interval effect tears down on | |
| // state==='ended'. Close after a short fade so the backdrop + | |
| // modal get a chance to animate out. | |
| const endedWith = durationSec; | |
| window.setTimeout(() => { | |
| if (onEnded) | |
| onEnded(endedWith); | |
| onClose(); | |
| }, END_FADE_MS); | |
| }, [onClose, onEnded, durationSec]); | |
| // Esc ends. | |
| useEffect(() => { | |
| const onKey = (e) => { if (e.key === 'Escape') | |
| handleEnd(); }; | |
| window.addEventListener('keydown', onKey); | |
| return () => window.removeEventListener('keydown', onKey); | |
| }, [handleEnd]); | |
| // ββ Backend voice_call session (preferred transport) βββββββββββ | |
| // When a `backend` prop is supplied we attempt the dedicated | |
| // voice_call WebSocket. If the backend responds 404/501 we stay | |
| // in 'unavailable' and the chat-REST fallback takes over below. | |
| 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', | |
| // Declare Phase 2/3 client capability. Server replies with | |
| // its own capability advertisement; effective mode is the | |
| // intersection (useCallSession reads capabilities from the | |
| // session-create response). | |
| 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'; | |
| // ββ "Listen first" gate β opener-TTS is playing βββββββββββββββ | |
| // When the backend emits the curated opening greeting as a | |
| // transcript.final right after call.state live, the client's | |
| // microphone must stay CLOSED until the greeting has finished | |
| // speaking. Otherwise the user's natural "hello?" mid-greeting | |
| // triggers a barge-in and cuts the persona off β the opposite | |
| // of the "AI answers first, user listens" product spec. | |
| // | |
| // openerDoneRef is a one-shot β once the first assistant | |
| // utterance has finished, subsequent utterances (responses to | |
| // user turns) do NOT relock the mic; the user can barge-in on | |
| // those freely, which is the correct UX. | |
| const [openerPlaying, setOpenerPlaying] = useState(false); | |
| // ``connected`` is a pure derivation from ``state``; declared early | |
| // so effects that fire before the setHandsFree gate (fallback opener | |
| // below) can reference it without a forward-ref. | |
| const connected = state !== 'dialing' && state !== 'connecting' && state !== 'ended'; | |
| const openerDoneRef = useRef(false); | |
| useEffect(() => { | |
| if (session.status === 'creating') { | |
| openerDoneRef.current = false; | |
| setOpenerPlaying(false); | |
| } | |
| }, [session.status]); | |
| // ββ Real voice pipeline ββββββββββββββββββββββββββββββββββββββββ | |
| // STT capture β preferred transport. If the backend session is | |
| // live we route transcripts through the voice_call WS; otherwise | |
| // we fall back to onSendText (regular chat REST) so the overlay | |
| // still works when VOICE_CALL_ENABLED is off server-side. | |
| 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); | |
| // 300 ms margin before we flip the lock back to 'idle'. Echo | |
| // tail can linger after TTS says "ended"; releasing immediately | |
| // would let the mic re-open while our own voice is still in | |
| // the air. | |
| releaseAiTurnWithMargin('speak:end'); | |
| }; | |
| // Length-based estimate for the SpeechService path (the shim | |
| // doesn't expose onend). ~12 chars/sec; floor 1.5 s so "Yes?" | |
| // still gives the user a beat; ceiling 8 s so a runaway | |
| // greeting can't lock the mic indefinitely. | |
| const estimateSpeechMs = (t) => Math.max(1500, Math.min(8000, t.length * 80)); | |
| try { | |
| // Prefer the owned TTS path when full-duplex is on. speakOwned | |
| // handles both the window.SpeechService route AND the | |
| // cross-source dedupe (see call/log.ts). Returns true iff this | |
| // caller owns the utterance; false means another source | |
| // (App-level TTS) was mid-speak β we fall through to the legacy | |
| // speechSynthesis path unchanged so the overlay isn't silent. | |
| 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]); | |
| // WS path β speak every assistant transcript.final (the opening | |
| // greeting from ws.py, and in unary mode every reply). In | |
| // streaming mode, per-token partials go through streamTts below | |
| // and THIS path fires only for transcript.final emitted outside | |
| // a streamed turn β e.g. the opener itself. | |
| 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]); | |
| // ββ Fallback mode (VOICE_CALL_ENABLED=false on backend) βββββββ | |
| // When the backend voice_call route isn't mounted, the session | |
| // POST 404s, useBackend stays false, and the STT pipeline routes | |
| // through the regular chat-REST path (onSendText above). That | |
| // works for text round-tripping β but without the hooks below, | |
| // the assistant's replies land in chatMessages and are never | |
| // spoken, so the user hears nothing. | |
| // | |
| // These two effects close that gap so the overlay is functional | |
| // even with the WS disabled: | |
| // (a) a synthetic opener fires when the call connects, so the | |
| // AI still "answers first" | |
| // (b) every new assistant message arriving in ``messages`` | |
| // during the call is spoken via the same TTS path the WS | |
| // reply path uses | |
| // (a) Fallback opener β once per session, when the line connects | |
| // and we're running fallback mode, pick a short greeting and | |
| // speak it. The greeting is marked as opener via speakText's | |
| // first-utterance heuristic, so the mic gate stays closed | |
| // exactly like the WS path. | |
| 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]); | |
| // (b) Ongoing assistant replies in fallback mode are now spoken | |
| // by the App-level TTS effect (App.tsx β gated on callOpen). That | |
| // pipeline already handles stripMarkdownForSpeech + per-message | |
| // dedupe via lastSpokenMessageIdRef, so we don't duplicate it | |
| // here. The fallback OPENER above stays β it's synthesized in | |
| // the overlay and never enters chatMessages, so the App-level | |
| // effect can't produce it. | |
| // | |
| // ``messages`` prop is still accepted for future use (mid-call | |
| // transcript viewer, etc.) but intentionally unused here β no | |
| // TTS side-effect reads it. | |
| // ββ Phase 2: streaming TTS pipeline βββββββββββββββββββββββββββ | |
| // Holds one streamTts instance per mounted overlay. Stopped on | |
| // unmount so a re-entry creates a fresh engine. | |
| const ttsRef = useRef(null); | |
| useEffect(() => { | |
| if (!useBackend || !session.streamingNegotiated) { | |
| ttsRef.current = null; | |
| return; | |
| } | |
| const tts = createStreamingTts(); | |
| ttsRef.current = tts; | |
| return () => { | |
| try { | |
| tts.stop(); | |
| } | |
| catch { /* ignore */ } | |
| ttsRef.current = null; | |
| }; | |
| }, [useBackend, session.streamingNegotiated]); | |
| // Track the currently-streaming turn_id so the barge-in path can | |
| // address the right turn without racing a new one. | |
| const currentTurnIdRef = useRef(null); | |
| // Partial deltas β streamTts.appendDelta. Each delta triggers | |
| // sentence-boundary flushing inside the TTS engine; callers don't | |
| // see the buffering. | |
| useEffect(() => { | |
| if (!useBackend || !session.streamingNegotiated) | |
| return; | |
| return session.onAssistantPartial((p) => { | |
| currentTurnIdRef.current = p.turn_id; | |
| ttsRef.current?.appendDelta(p.delta); | |
| }); | |
| }, [useBackend, session]); | |
| // Turn end β flush residual buffer + clear the active turn id. | |
| 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]); | |
| // Server-initiated cancel (barge-in ack) β stop TTS immediately. | |
| // Must land inside ~50 ms of receipt (Β§ 5.3 contract). | |
| useEffect(() => { | |
| if (!useBackend || !session.streamingNegotiated) | |
| return; | |
| return session.onAssistantCancel((_p) => { | |
| ttsRef.current?.stop(); | |
| }); | |
| }, [useBackend, session]); | |
| // ββ Phase 3: barge-in VAD tap βββββββββββββββββββββββββββββββββ | |
| // Audio level mirror β useVoiceController returns fresh values | |
| // every render; we stash the latest into a ref so the rAF loop | |
| // inside useBargeInDetector always sees the current value. | |
| const voiceLevelRef = useRef(0); | |
| useEffect(() => { | |
| voiceLevelRef.current = voice.audioLevel; | |
| }, [voice.audioLevel]); | |
| // Re-evaluated each render; the detector's rAF loop reads its own | |
| // refs so identity changes don't thrash. | |
| 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); | |
| }, | |
| }); | |
| // Call-center-style auto-greeting β the moment the backend session | |
| // goes live, prompt the persona to answer first (the way a real | |
| // person picks up the phone). We send a short synthetic transcript | |
| // the backend's persona_call phase machine will treat as turn 0 of | |
| // the opening sequence β it responds with a contextual "hello?" / | |
| // "hey, what's up?" in the persona's own voice. | |
| // | |
| // This is a one-shot per call session. `greetedRef` guards against | |
| // reconnect storms triggering a second "hello". | |
| const greetedRef = useRef(false); | |
| useEffect(() => { | |
| if (!useBackend) | |
| return; | |
| if (session.callState !== 'live') | |
| return; | |
| if (greetedRef.current) | |
| return; | |
| greetedRef.current = true; | |
| // One very short open-channel ping. persona_call reads this as the | |
| // caller-initiated summons in the Schegloff opening structure and | |
| // the persona answers. | |
| session.sendTranscript('[phone-call-open]'); | |
| }, [useBackend, session]); | |
| // Reset the greet guard when a fresh session is (re)created. | |
| useEffect(() => { | |
| if (session.status === 'creating') | |
| greetedRef.current = false; | |
| }, [session.status]); | |
| // Backchannel + filler events are short, low-volume TTS clips. | |
| // We route them through the same speak path; they're short enough | |
| // (one token) that interrupting a reply mid-stream is fine. | |
| useEffect(() => { | |
| if (!useBackend) | |
| return; | |
| const unsub1 = session.onAssistantBackchannel((p) => { | |
| try { | |
| window.speechSynthesis?.speak?.(new SpeechSynthesisUtterance(p.token)); | |
| } | |
| catch { /* ignore */ } | |
| }); | |
| const unsub2 = session.onAssistantFiller((p) => { | |
| try { | |
| window.speechSynthesis?.speak?.(new SpeechSynthesisUtterance(p.token)); | |
| } | |
| catch { /* ignore */ } | |
| }); | |
| return () => { unsub1(); unsub2(); }; | |
| }, [useBackend, session]); | |
| // Enter hands-free once the line is "connected" AND the opener | |
| // has finished speaking. The second gate is the "listen first" | |
| // rule β the AI speaks its greeting and the user hears it out | |
| // before the mic opens; otherwise a natural early "hello?" trips | |
| // the barge-in detector and cuts the persona off mid-greeting. | |
| // ``connected`` is declared earlier (near state). | |
| // | |
| // This effect intentionally has NO cleanup that toggles handsFree β | |
| // the original cleanup raced against `openerPlaying` transitions | |
| // and re-render-driven re-runs (voice + setTurnLock change identity | |
| // on every render), which flipped handsFree OFF mid-call and left | |
| // state on OFF right when the user started speaking. The STT path | |
| // then dropped the VAD speech_start because state !== IDLE. Tear- | |
| // down lives in the dedicated unmount effect below. | |
| 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]); | |
| // Unmount-only cleanup. Guarantees the mic is released and the | |
| // turn-lock is reset when the overlay actually tears down (call | |
| // ended, user navigated away), without coupling that teardown to | |
| // the normal-effect re-run cycle above. | |
| useEffect(() => { | |
| return () => { | |
| try { | |
| voiceHandleRef.current.setHandsFree(false); | |
| } | |
| catch { /* ignore */ } | |
| try { | |
| setTurnLockRef.current('idle', 'cleanup'); | |
| } | |
| catch { /* ignore */ } | |
| }; | |
| }, []); | |
| // Mirror the voice-controller's machine onto our UI state so the | |
| // halo + waveform + label actually reflect what's happening: | |
| // LISTENING β 'listening' | |
| // THINKING β 'thinking' | |
| // SPEAKING β 'speaking' | |
| // IDLE β 'listening' (ready, nothing to say yet) | |
| // OFF / muted β preserved | |
| 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]); | |
| // Mirror the voice-controller's LISTENING / THINKING onto the | |
| // turn-lock 'user' slot. The AI owns 'ai' (see speakText); VAD | |
| // drives 'user' only when the AI isn't already speaking. This is | |
| // the industry-standard "who has the floor" gate β with it, the | |
| // AI's TTS bleed through the speaker can't trip a false user turn | |
| // (the root cause of the "AI says yes, hears itself, stops" bug). | |
| 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]); | |
| // While the opener TTS is playing, the UI label must read | |
| // 'speaking' β the persona is literally talking. Default state | |
| // transitions would land on 'listening' (post-connect default) | |
| // which is a lie: audio is going OUT of the speaker, not coming | |
| // IN to the mic. The mic is still closed (see the openerPlaying | |
| // gate on setHandsFree above), so voice.state stays OFF during | |
| // this window and the mirror effect above doesn't override us. | |
| useEffect(() => { | |
| if (!openerPlaying) | |
| return; | |
| setState(prev => (prev === 'ended' || prev === 'muted') ? prev : 'speaking'); | |
| }, [openerPlaying]); | |
| // ββ Live waveform intensity (0..1) βββββββββββββββββββββββββββββ | |
| // One rAF loop per state-group writes to a ref that CallWaveform | |
| // reads directly (no React re-renders). React only sees the state | |
| // label; the bars themselves are driven straight from the DOM. | |
| // | |
| // listening / muted β voice.audioLevel (real mic RMS) | |
| // speaking β synthesised speech envelope β browser TTS | |
| // audio isn't routable through an AnalyserNode, | |
| // so we generate a natural-sounding envelope | |
| // (sum of 3 sines + a small noise floor). The | |
| // visual cadence matches syllable rate + the | |
| // amplitude range matches what mic analysers | |
| // produce on voiced speech, so the persona's | |
| // bars and the user's bars "look like the | |
| // same kind of thing." | |
| // thinking β slow 0.15..0.25 drift β "processing" | |
| // dialing/connecting/ended β 0.05 floor β visible but quiet | |
| 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 = () => { | |
| // Real mic level. useVoiceController already smooths with | |
| // an EMA internally; we pass it through unmodified. | |
| 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; | |
| // Three sines at the frequencies you'd see in actual | |
| // voiced speech analysis: ~2 Hz syllable rate, ~5 Hz | |
| // mid-band, ~10 Hz high-band flutter. A small noise floor | |
| // breaks the periodicity so bars don't pulse in lockstep. | |
| 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); | |
| } | |
| // dialing / connecting / ended β static low floor. | |
| 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 { /* ignore */ } | |
| } | |
| 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>); | |
| } | |
| // ββ Call-ended toast removed ββββββββββββββββββββββββββββββββββββββ | |
| // The former CallEndedToast has been retired. The inline | |
| // CallEventRow (rendered directly in the chat stream) now carries | |
| // the post-call record + Resume affordance, so a floating toast | |
| // would be redundant. Kept this comment as a reviewer breadcrumb. | |