Files
praxis/client/src/useVoiceSession.ts
T
Praxis CI 7b1b296430 feat(P01-02-05,P01-02-06): React client + latency readout
client/ — React + Vite + TypeScript scaffolded with the Pipecat client SDK
(@pipecat-ai/client-js) and SmallWebRTCTransport
(@pipecat-ai/small-webrtc-transport). useVoiceSession.ts hook manages mic
permission, WebRTC connect, audio playback, live transcript, and a latency
readout (captures the e2e_latency_ms metric the server emits). App.tsx is a
minimal one-page session UI: disclaimer, Start/End buttons, status badge,
latency readout (within/over 600ms budget), live transcript. vite.config.ts
proxies /pipecat + /health to the Python server (port 8789). npm run
typecheck + npm run build pass.

server/latency.py — LatencyObserver (a Pipecat FrameProcessor) timestamps
transcript-ready, LLM-first-token, TTS-first-audio, and playback-start per
turn, computes ASR→TTS-first-audio (the v0.1 latency target), and logs it
to console with a within/over-budget verdict. Wired into the pipeline
between STT/LLM/TTS so it observes without altering the frame stream. 5
unit tests pass (LatencyRecord e2e math + observer construction +
reset_turn). Full server suite: 18 passed.

---ci---
phase: 1
milestone: v0.1
plan: 02
task: 02-05,02-06
status: execute
persona: frontend-engineer,backend-engineer
requirements:
  covered: [REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-NFR-LAT-01]
---/ci---
2026-08-01 13:08:42 +00:00

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 }
}