dograh/ui/src/components/looptalk/LiveAudioPlayer.tsx
Abhishek 50a59164e7
fix: ssl error when using self signed certificate (#238)
fix: ssl error when using self signed certificate with remote deployment
2026-04-14 18:58:27 +05:30

370 lines
15 KiB
TypeScript

'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<ConnectionStatus>('disconnected');
const [audioRole, setAudioRole] = useState<AudioRole>(() => {
// 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<WebSocket | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioQueueRef = useRef<AudioBufferSourceNode[]>([]);
const nextStartTimeRef = useRef(0);
const animationFrameRef = useRef<number | undefined>(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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Volume2 className="h-5 w-5" />
Live Audio Stream
</CardTitle>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<Badge variant={connectionStatus === 'connected' ? 'default' : 'secondary'}>
{connectionStatus}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Play/Pause Controls */}
<div className="flex items-center gap-4">
<Button
onClick={() => setIsPlaying(!isPlaying)}
disabled={sessionStatus !== 'running'}
size="sm"
variant={isPlaying ? 'default' : 'outline'}
>
{isPlaying ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Play
</>
)}
</Button>
{/* Audio Role Selector */}
<div className="flex gap-1">
{(['mixed', 'actor', 'adversary'] as const).map((role) => (
<Button
key={role}
size="sm"
variant={audioRole === role ? 'default' : 'outline'}
onClick={() => setAudioRole(role)}
className="capitalize"
>
{role}
</Button>
))}
</div>
</div>
{/* Volume Control */}
<div className="flex items-center gap-4">
<VolumeX className="h-4 w-4 text-gray-500" />
<input
type="range"
value={volume}
onChange={(e) => 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"
/>
<Volume2 className="h-4 w-4 text-gray-500" />
</div>
{/* Audio Level Meter */}
<div className="space-y-2">
<div className="text-sm text-gray-500">Audio Level</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-100"
style={{ width: `${audioLevel * 100}%` }}
/>
</div>
</div>
{/* Status Info */}
<div className="text-sm text-gray-500">
{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'}
</div>
</CardContent>
</Card>
);
}