mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
370 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|