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