'use client'; import { Pause, Play, Volume2, VolumeX } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useAuth } from '@/lib/auth'; import logger from '@/lib/logger'; interface LiveAudioPlayerProps { testSessionId: number; sessionStatus: 'pending' | 'running' | 'completed' | 'failed'; autoStart?: boolean; } type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; type AudioRole = 'mixed' | 'actor' | 'adversary'; export function LiveAudioPlayer({ testSessionId, sessionStatus, autoStart = false }: LiveAudioPlayerProps) { const [connectionStatus, setConnectionStatus] = useState('disconnected'); const [audioRole, setAudioRole] = useState(() => { // Load saved preference from localStorage if (typeof window !== 'undefined') { const saved = localStorage.getItem('looptalk-audio-role'); return (saved as AudioRole) || 'mixed'; } return 'mixed'; }); const [isPlaying, setIsPlaying] = useState(false); const [volume, setVolume] = useState(0.8); const [bufferedDuration, setBufferedDuration] = useState(0); const [audioLevel, setAudioLevel] = useState(0); const wsRef = useRef(null); const audioContextRef = useRef(null); const gainNodeRef = useRef(null); const analyserRef = useRef(null); const audioQueueRef = useRef([]); const nextStartTimeRef = useRef(0); const animationFrameRef = useRef(undefined); const isConnectingRef = useRef(false); const { user, getAccessToken } = useAuth(); // Auto-start streaming when session starts useEffect(() => { if (sessionStatus === 'running' && autoStart && !isPlaying) { setIsPlaying(true); } }, [sessionStatus, autoStart, isPlaying]); // Save audio role preference useEffect(() => { if (typeof window !== 'undefined') { localStorage.setItem('looptalk-audio-role', audioRole); } }, [audioRole]); // Audio level monitoring const monitorAudioLevel = useCallback(() => { if (!analyserRef.current) return; const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount); analyserRef.current.getByteFrequencyData(dataArray); // Calculate average level const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length; setAudioLevel(average / 255); // Normalize to 0-1 animationFrameRef.current = requestAnimationFrame(monitorAudioLevel); }, []); const connectWebSocket = useCallback(async () => { // Check if already connected or connecting if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { logger.debug('WebSocket already connected or connecting, skipping'); return; } // Prevent multiple concurrent connection attempts if (isConnectingRef.current) { logger.debug('Already attempting to connect, skipping'); return; } isConnectingRef.current = true; try { setConnectionStatus('connecting'); if (!user) return; // Get auth token const accessToken = await getAccessToken(); const httpBase = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin; const baseUrl = httpBase.replace(/^http/, 'ws'); const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; // Create AudioContext with gain control and analyser if (!audioContextRef.current) { audioContextRef.current = new AudioContext(); gainNodeRef.current = audioContextRef.current.createGain(); analyserRef.current = audioContextRef.current.createAnalyser(); analyserRef.current.fftSize = 256; // Connect gain -> analyser -> destination gainNodeRef.current.connect(analyserRef.current); analyserRef.current.connect(audioContextRef.current.destination); // Set initial volume gainNodeRef.current.gain.value = volume; } ws.onopen = () => { setConnectionStatus('connected'); logger.info('Audio stream connected'); monitorAudioLevel(); }; ws.onmessage = async (event) => { try { const data = JSON.parse(event.data); if (data.type === 'audio' && data.audio) { // Decode base64 audio data const audioBytes = Uint8Array.from(atob(data.audio), c => c.charCodeAt(0)); // Create audio buffer from PCM data const samplesPerChannel = audioBytes.length / (data.num_channels * 2); const audioBuffer = audioContextRef.current!.createBuffer( data.num_channels, samplesPerChannel, data.sample_rate ); // Convert PCM to float samples const dataView = new DataView(audioBytes.buffer); for (let channel = 0; channel < data.num_channels; channel++) { const channelData = audioBuffer.getChannelData(channel); for (let i = 0; i < samplesPerChannel; i++) { const sampleIndex = i * data.num_channels + channel; const sample = dataView.getInt16(sampleIndex * 2, true) / 32768.0; channelData[i] = sample; } } // Schedule audio buffer playback const source = audioContextRef.current!.createBufferSource(); source.buffer = audioBuffer; source.connect(gainNodeRef.current!); // Schedule seamless playback const currentTime = audioContextRef.current!.currentTime; if (nextStartTimeRef.current < currentTime) { nextStartTimeRef.current = currentTime; } source.start(nextStartTimeRef.current); nextStartTimeRef.current += audioBuffer.duration; // Track scheduled sources audioQueueRef.current.push(source); source.onended = () => { const index = audioQueueRef.current.indexOf(source); if (index > -1) { audioQueueRef.current.splice(index, 1); } }; setBufferedDuration(nextStartTimeRef.current - currentTime); } } catch (error) { logger.error('Error processing audio data:', error); } }; ws.onerror = (error) => { logger.error('WebSocket error:', error); setConnectionStatus('error'); }; ws.onclose = (event) => { setConnectionStatus('disconnected'); logger.info('Audio stream disconnected', { code: event.code, reason: event.reason }); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; } catch (error) { logger.error('Error connecting to audio stream:', error); setConnectionStatus('error'); } finally { isConnectingRef.current = false; } }, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel]); // Removed connectionStatus to avoid loops const disconnect = useCallback(() => { if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } // Stop all scheduled audio audioQueueRef.current.forEach(source => { try { source.stop(); } catch { // Ignore if already stopped } }); audioQueueRef.current = []; nextStartTimeRef.current = 0; setBufferedDuration(0); setAudioLevel(0); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }, []); // Handle play/pause useEffect(() => { if (isPlaying && sessionStatus === 'running') { connectWebSocket(); } else { disconnect(); } return () => { disconnect(); }; }, [isPlaying, sessionStatus, connectWebSocket, disconnect]); // Include stable callbacks // Handle audio role changes useEffect(() => { // Use ref to check connection state to avoid dependency issues if (isPlaying && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { logger.info('Audio role changed, reconnecting with new role:', audioRole); // Reconnect with new role disconnect(); // Set a flag to prevent double connections const timer = setTimeout(() => { if (isPlaying) { connectWebSocket(); } }, 500); return () => clearTimeout(timer); } }, [audioRole, isPlaying, connectWebSocket, disconnect]); // Include all dependencies // Update volume useEffect(() => { if (gainNodeRef.current) { gainNodeRef.current.gain.value = volume; } }, [volume]); const getStatusColor = () => { switch (connectionStatus) { case 'connected': return 'bg-green-500'; case 'connecting': return 'bg-yellow-500'; case 'error': return 'bg-red-500'; default: return 'bg-gray-500'; } }; return (
Live Audio Stream
{connectionStatus}
{/* Play/Pause Controls */}
{/* Audio Role Selector */}
{(['mixed', 'actor', 'adversary'] as const).map((role) => ( ))}
{/* Volume Control */}
setVolume(parseFloat(e.target.value))} min="0" max="1" step="0.01" className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" />
{/* Audio Level Meter */}
Audio Level
{/* Status Info */}
{connectionStatus === 'connected' && ( <>Streaming... (buffered: {bufferedDuration.toFixed(1)}s) )} {connectionStatus === 'connecting' && 'Connecting to audio stream...'} {connectionStatus === 'error' && 'Failed to connect to audio stream'} {connectionStatus === 'disconnected' && sessionStatus === 'running' && 'Click play to start streaming'} {sessionStatus === 'pending' && 'Waiting for session to start...'} {sessionStatus === 'completed' && 'Session completed'} {sessionStatus === 'failed' && 'Session failed'}
); }