/** * Praxis voice session hook — wraps the Pipecat client + SmallWebRTCTransport. * * Connects to the server's POST /pipecat/webrtc endpoint, manages mic permission, * audio playback, live transcript, and a latency readout (ASR→TTS-first-audio). * * v0.1 SLICE-02: minimal start/speak/reply loop. SLICE-05 expands to the full * start → live → debrief session flow. */ import { useCallback, useEffect, useRef, useState } from 'react' import { PipecatClient, type PipecatClientOptions } from '@pipecat-ai/client-js' import { SmallWebRTCTransport } from '@pipecat-ai/small-webrtc-transport' export type SessionState = 'idle' | 'connecting' | 'connected' | 'error' export interface TranscriptEntry { role: 'user' | 'assistant' text: string ts: number } export interface LatencyReading { /** ms from bot-ready to first assistant audio (approx ASR→TTS first audio). */ e2eMs: number | null label: string } export interface UseVoiceSessionResult { state: SessionState error: string | null transcripts: TranscriptEntry[] latency: LatencyReading | null start: () => Promise stop: () => Promise } const SERVER_OFFER_URL = '/pipecat/webrtc' export function useVoiceSession(): UseVoiceSessionResult { const [state, setState] = useState('idle') const [error, setError] = useState(null) const [transcripts, setTranscripts] = useState([]) const [latency, setLatency] = useState(null) const clientRef = useRef(null) const readyAtRef = useRef(null) const stop = useCallback(async () => { const c = clientRef.current if (c) { try { await c.disconnect() } catch { /* ignore */ } clientRef.current = null } setState('idle') readyAtRef.current = null }, []) const start = useCallback(async () => { setError(null) setState('connecting') try { const transport = new SmallWebRTCTransport({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], offerUrlTemplate: SERVER_OFFER_URL, }) const options: PipecatClientOptions = { transport, enableMic: true, callbacks: { 'bot-transport-ready': () => { readyAtRef.current = performance.now() }, 'bot-ready': () => { setState('connected') readyAtRef.current = performance.now() }, 'user-connected': () => { readyAtRef.current = performance.now() }, // Latency: capture the metrics frame the server emits (TASK-02-06). metric: (m: { name?: string; value?: number }) => { if (m?.name === 'e2e_latency_ms' && typeof m.value === 'number') { setLatency({ e2eMs: m.value, label: 'ASR→TTS first audio' }) } }, // Transcript (optional display). 'bot-transcription': (data: { text?: string }) => { const text = data?.text if (text) { setTranscripts((prev) => [ ...prev, { role: 'assistant', text, ts: Date.now() }, ]) } }, 'user-transcription': (data: { text?: string }) => { const text = data?.text if (text) { setTranscripts((prev) => [ ...prev, { role: 'user', text, ts: Date.now() }, ]) } }, } as any, } const client = new PipecatClient(options) clientRef.current = client // initDevices triggers mic permission; connect() opens the WebRTC session. await client.initDevices() await client.connect() } catch (e: any) { setError(e?.message ?? String(e)) setState('error') } }, []) useEffect(() => { return () => { void stop() } }, [stop]) return { state, error, transcripts, latency, start, stop } }