import { useEffect, useState } from "react"; import { MdVolumeOff, MdVolumeUp, MdGraphicEq, MdMic, MdMicOff, MdRefresh } from "react-icons/md"; import { LuActivity, LuSettings, LuSignal } from "react-icons/lu"; import { Button } from "@components/Button"; import { AudioLevelMeter } from "@components/AudioLevelMeter"; import { cx } from "@/cva.config"; import { useUiStore } from "@/hooks/stores"; import { useAudioDevices } from "@/hooks/useAudioDevices"; import { useAudioLevel } from "@/hooks/useAudioLevel"; import { useAudioEvents } from "@/hooks/useAudioEvents"; import api from "@/api"; import notifications from "@/notifications"; // Type for microphone error interface MicrophoneError { type: 'permission' | 'device' | 'network' | 'unknown'; message: string; } // Type for microphone hook return value interface MicrophoneHookReturn { isMicrophoneActive: boolean; isMicrophoneMuted: boolean; microphoneStream: MediaStream | null; startMicrophone: (deviceId?: string) => Promise<{ success: boolean; error?: MicrophoneError }>; stopMicrophone: () => Promise<{ success: boolean; error?: MicrophoneError }>; toggleMicrophoneMute: () => Promise<{ success: boolean; error?: MicrophoneError }>; syncMicrophoneState: () => Promise; // Loading states isStarting: boolean; isStopping: boolean; isToggling: boolean; } interface AudioConfig { Quality: number; Bitrate: number; SampleRate: number; Channels: number; FrameSize: string; } interface AudioMetrics { frames_received: number; frames_dropped: number; bytes_processed: number; last_frame_time: string; connection_drops: number; average_latency: string; } interface MicrophoneMetrics { frames_sent: number; frames_dropped: number; bytes_processed: number; last_frame_time: string; connection_drops: number; average_latency: string; } const qualityLabels = { 0: "Low (32kbps)", 1: "Medium (64kbps)", 2: "High (128kbps)", 3: "Ultra (256kbps)" }; interface AudioControlPopoverProps { microphone: MicrophoneHookReturn; } export default function AudioControlPopover({ microphone }: AudioControlPopoverProps) { const [currentConfig, setCurrentConfig] = useState(null); const [currentMicrophoneConfig, setCurrentMicrophoneConfig] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [isLoading, setIsLoading] = useState(false); // Add cooldown to prevent rapid clicking const [lastClickTime, setLastClickTime] = useState(0); const CLICK_COOLDOWN = 500; // 500ms cooldown between clicks // Use WebSocket-based audio events for real-time updates const { audioMuted, audioMetrics, microphoneMetrics, isConnected: wsConnected } = useAudioEvents(); // Fallback state for when WebSocket is not connected const [fallbackMuted, setFallbackMuted] = useState(false); const [fallbackMetrics, setFallbackMetrics] = useState(null); const [fallbackMicMetrics, setFallbackMicMetrics] = useState(null); const [fallbackConnected, setFallbackConnected] = useState(false); // Microphone state from props const { isMicrophoneActive, isMicrophoneMuted, microphoneStream, startMicrophone, stopMicrophone, toggleMicrophoneMute, syncMicrophoneState, // Loading states isStarting, isStopping, isToggling, } = microphone; // Use WebSocket data when available, fallback to polling data otherwise const isMuted = wsConnected && audioMuted !== null ? audioMuted : fallbackMuted; const metrics = wsConnected && audioMetrics !== null ? audioMetrics : fallbackMetrics; const micMetrics = wsConnected && microphoneMetrics !== null ? microphoneMetrics : fallbackMicMetrics; const isConnected = wsConnected ? wsConnected : fallbackConnected; // Audio level monitoring const { audioLevel, isAnalyzing } = useAudioLevel(microphoneStream); // Audio devices const { audioInputDevices, audioOutputDevices, selectedInputDevice, selectedOutputDevice, setSelectedInputDevice, setSelectedOutputDevice, isLoading: devicesLoading, error: devicesError, refreshDevices } = useAudioDevices(); const { toggleSidebarView } = useUiStore(); // Load initial configurations once (these don't change frequently) useEffect(() => { loadAudioConfigurations(); }, []); // Load initial audio state and set up fallback polling when WebSocket is not connected useEffect(() => { if (!wsConnected) { loadAudioState(); // Only load metrics as fallback when WebSocket is disconnected loadAudioMetrics(); loadMicrophoneMetrics(); // Set up metrics refresh interval for fallback only const metricsInterval = setInterval(() => { loadAudioMetrics(); loadMicrophoneMetrics(); }, 2000); return () => clearInterval(metricsInterval); } // Always sync microphone state syncMicrophoneState(); }, [wsConnected, syncMicrophoneState]); const loadAudioConfigurations = async () => { try { // Load quality config const qualityResp = await api.GET("/audio/quality"); if (qualityResp.ok) { const qualityData = await qualityResp.json(); setCurrentConfig(qualityData.current); } // Load microphone quality config const micQualityResp = await api.GET("/microphone/quality"); if (micQualityResp.ok) { const micQualityData = await micQualityResp.json(); setCurrentMicrophoneConfig(micQualityData.current); } } catch (error) { console.error("Failed to load audio configurations:", error); } }; const loadAudioState = async () => { try { // Load mute state only (configurations are loaded separately) const muteResp = await api.GET("/audio/mute"); if (muteResp.ok) { const muteData = await muteResp.json(); setFallbackMuted(!!muteData.muted); } } catch (error) { console.error("Failed to load audio state:", error); } }; const loadAudioMetrics = async () => { try { const resp = await api.GET("/audio/metrics"); if (resp.ok) { const data = await resp.json(); setFallbackMetrics(data); // Consider connected if API call succeeds, regardless of frame count setFallbackConnected(true); } else { setFallbackConnected(false); } } catch (error) { console.error("Failed to load audio metrics:", error); setFallbackConnected(false); } }; const loadMicrophoneMetrics = async () => { try { const resp = await api.GET("/microphone/metrics"); if (resp.ok) { const data = await resp.json(); setFallbackMicMetrics(data); } } catch (error) { console.error("Failed to load microphone metrics:", error); } }; const handleToggleMute = async () => { setIsLoading(true); try { const resp = await api.POST("/audio/mute", { muted: !isMuted }); if (resp.ok) { // WebSocket will handle the state update, but update fallback for immediate feedback if (!wsConnected) { setFallbackMuted(!isMuted); } } } catch (error) { console.error("Failed to toggle mute:", error); } finally { setIsLoading(false); } }; const handleQualityChange = async (quality: number) => { setIsLoading(true); try { const resp = await api.POST("/audio/quality", { quality }); if (resp.ok) { const data = await resp.json(); setCurrentConfig(data.config); } } catch (error) { console.error("Failed to change audio quality:", error); } finally { setIsLoading(false); } }; const handleMicrophoneQualityChange = async (quality: number) => { try { const resp = await api.POST("/microphone/quality", { quality }); if (resp.ok) { const data = await resp.json(); setCurrentMicrophoneConfig(data.config); } } catch (error) { console.error("Failed to change microphone quality:", error); } }; const handleToggleMicrophone = async () => { const now = Date.now(); // Prevent rapid clicking - if any operation is in progress or within cooldown, ignore the click if (isStarting || isStopping || isToggling || (now - lastClickTime < CLICK_COOLDOWN)) { console.log("Microphone operation already in progress or within cooldown, ignoring click"); return; } setLastClickTime(now); try { const result = isMicrophoneActive ? await stopMicrophone() : await startMicrophone(selectedInputDevice); if (!result.success && result.error) { notifications.error(result.error.message); } } catch (error) { console.error("Failed to toggle microphone:", error); notifications.error("An unexpected error occurred"); } }; const handleToggleMicrophoneMute = async () => { const now = Date.now(); // Prevent rapid clicking - if any operation is in progress or within cooldown, ignore the click if (isStarting || isStopping || isToggling || (now - lastClickTime < CLICK_COOLDOWN)) { console.log("Microphone operation already in progress or within cooldown, ignoring mute toggle"); return; } setLastClickTime(now); try { const result = await toggleMicrophoneMute(); if (!result.success && result.error) { notifications.error(result.error.message); } } catch (error) { console.error("Failed to toggle microphone mute:", error); notifications.error("Failed to toggle microphone mute"); } }; // Handle microphone device change const handleMicrophoneDeviceChange = async (deviceId: string) => { setSelectedInputDevice(deviceId); // If microphone is currently active, restart it with the new device if (isMicrophoneActive) { try { // Stop current microphone await stopMicrophone(); // Start with new device const result = await startMicrophone(deviceId); if (!result.success && result.error) { notifications.error(result.error.message); } } catch (error) { console.error("Failed to change microphone device:", error); notifications.error("Failed to change microphone device"); } } }; const handleAudioOutputDeviceChange = async (deviceId: string) => { setSelectedOutputDevice(deviceId); // Find the video element and set the audio output device const videoElement = document.querySelector('video'); if (videoElement && 'setSinkId' in videoElement) { try { await (videoElement as HTMLVideoElement & { setSinkId: (deviceId: string) => Promise }).setSinkId(deviceId); console.log('Audio output device changed to:', deviceId); } catch (error: unknown) { console.error('Failed to change audio output device:', error); } } else { console.warn('setSinkId not supported or video element not found'); } }; const formatBytes = (bytes: number) => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; const formatNumber = (num: number) => { return new Intl.NumberFormat().format(num); }; return (
{/* Header */}

Audio Controls

{isConnected ? "Connected" : "Disconnected"}
{/* Mute Control */}
{isMuted ? ( ) : ( )} {isMuted ? "Muted" : "Unmuted"}
{/* Microphone Control */}
Microphone Input
{isMicrophoneActive ? ( isMicrophoneMuted ? ( ) : ( ) ) : ( )} {!isMicrophoneActive ? "Inactive" : isMicrophoneMuted ? "Muted" : "Active" }
{/* Audio Level Meter */} {isMicrophoneActive && (
{/* Debug information */}
Stream: {microphoneStream ? '✓' : '✗'} Analyzing: {isAnalyzing ? '✓' : '✗'} Active: {isMicrophoneActive ? '✓' : '✗'} Muted: {isMicrophoneMuted ? '✓' : '✗'}
{microphoneStream && (
Tracks: {microphoneStream.getAudioTracks().length} {microphoneStream.getAudioTracks().length > 0 && ( (Enabled: {microphoneStream.getAudioTracks().filter((t: MediaStreamTrack) => t.enabled).length}) )}
)}
)}
{/* Device Selection */}
Audio Devices {devicesLoading && (
)}
{devicesError && (
{devicesError}
)} {/* Microphone Selection */}
{isMicrophoneActive && (

Changing device will restart the microphone

)}
{/* Speaker Selection */}
{/* Microphone Quality Settings */} {isMicrophoneActive && (
Microphone Quality
{Object.entries(qualityLabels).map(([quality, label]) => ( ))}
{currentMicrophoneConfig && (
Sample Rate: {currentMicrophoneConfig.SampleRate}Hz Channels: {currentMicrophoneConfig.Channels} Bitrate: {currentMicrophoneConfig.Bitrate}kbps Frame: {currentMicrophoneConfig.FrameSize}
)}
)} {/* Quality Settings */}
Audio Output Quality
{Object.entries(qualityLabels).map(([quality, label]) => ( ))}
{currentConfig && (
Sample Rate: {currentConfig.SampleRate}Hz Channels: {currentConfig.Channels} Bitrate: {currentConfig.Bitrate}kbps Frame: {currentConfig.FrameSize}
)}
{/* Advanced Controls Toggle */} {/* Advanced Metrics */} {showAdvanced && (
Performance Metrics
{metrics ? ( <>

Audio Output

Frames Received
{formatNumber(metrics.frames_received)}
Frames Dropped
0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" )}> {formatNumber(metrics.frames_dropped)}
Data Processed
{formatBytes(metrics.bytes_processed)}
Connection Drops
0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" )}> {formatNumber(metrics.connection_drops)}
{micMetrics && (

Microphone Input

Frames Sent
{formatNumber(micMetrics.frames_sent)}
Frames Dropped
0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" )}> {formatNumber(micMetrics.frames_dropped)}
Data Processed
{formatBytes(micMetrics.bytes_processed)}
Connection Drops
0 ? "text-red-600 dark:text-red-400" : "text-green-600 dark:text-green-400" )}> {formatNumber(micMetrics.connection_drops)}
)} {metrics.frames_received > 0 && (
Drop Rate
5 ? "text-red-600 dark:text-red-400" : ((metrics.frames_dropped / metrics.frames_received) * 100) > 1 ? "text-yellow-600 dark:text-yellow-400" : "text-green-600 dark:text-green-400" )}> {((metrics.frames_dropped / metrics.frames_received) * 100).toFixed(2)}%
)}
Last updated: {new Date().toLocaleTimeString()}
) : (
Loading metrics...
)}
)} {/* Audio Metrics Dashboard Button */}
); }