fbd6602814
---ci--- phase: 0 milestone: v0.1 status: complete ---/ci---
129 lines
3.9 KiB
TypeScript
129 lines
3.9 KiB
TypeScript
/**
|
|
* 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<void>
|
|
stop: () => Promise<void>
|
|
}
|
|
|
|
const SERVER_OFFER_URL = '/pipecat/webrtc'
|
|
|
|
export function useVoiceSession(): UseVoiceSessionResult {
|
|
const [state, setState] = useState<SessionState>('idle')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [transcripts, setTranscripts] = useState<TranscriptEntry[]>([])
|
|
const [latency, setLatency] = useState<LatencyReading | null>(null)
|
|
const clientRef = useRef<PipecatClient | null>(null)
|
|
const readyAtRef = useRef<number | null>(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 }
|
|
} |