diff --git a/internal/audio/events.go b/internal/audio/events.go index b0c2638..d6fa2ac 100644 --- a/internal/audio/events.go +++ b/internal/audio/events.go @@ -23,6 +23,7 @@ const ( AudioEventMicrophoneMetrics AudioEventType = "microphone-metrics-update" AudioEventProcessMetrics AudioEventType = "audio-process-metrics" AudioEventMicProcessMetrics AudioEventType = "microphone-process-metrics" + AudioEventDeviceChanged AudioEventType = "audio-device-changed" ) // AudioEvent represents a WebSocket audio event @@ -73,6 +74,12 @@ type ProcessMetricsData struct { ProcessName string `json:"process_name"` } +// AudioDeviceChangedData represents audio device configuration change data +type AudioDeviceChangedData struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason"` +} + // AudioEventSubscriber represents a WebSocket connection subscribed to audio events type AudioEventSubscriber struct { conn *websocket.Conn @@ -164,6 +171,15 @@ func (aeb *AudioEventBroadcaster) BroadcastMicrophoneStateChanged(running, sessi aeb.broadcast(event) } +// BroadcastAudioDeviceChanged broadcasts audio device configuration changes +func (aeb *AudioEventBroadcaster) BroadcastAudioDeviceChanged(enabled bool, reason string) { + event := createAudioEvent(AudioEventDeviceChanged, AudioDeviceChangedData{ + Enabled: enabled, + Reason: reason, + }) + aeb.broadcast(event) +} + // sendInitialState sends current audio state to a new subscriber func (aeb *AudioEventBroadcaster) sendInitialState(connectionID string) { aeb.mutex.RLock() diff --git a/jsonrpc.go b/jsonrpc.go index d592f2f..945a7a8 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -15,6 +15,7 @@ import ( "github.com/pion/webrtc/v4" "go.bug.st/serial" + "github.com/jetkvm/kvm/internal/audio" "github.com/jetkvm/kvm/internal/usbgadget" ) @@ -956,6 +957,11 @@ func rpcSetUsbDevices(usbDevices usbgadget.Devices) error { if err := audioSupervisor.Start(); err != nil { logger.Error().Err(err).Msg("failed to start audio supervisor") // Don't return error here as USB reconfiguration was successful + } else { + // Broadcast audio device change event to notify WebRTC session + broadcaster := audio.GetAudioEventBroadcaster() + broadcaster.BroadcastAudioDeviceChanged(true, "usb_reconfiguration") + logger.Info().Msg("broadcasted audio device change event after USB reconfiguration") } } @@ -999,6 +1005,11 @@ func rpcSetUsbDeviceState(device string, enabled bool) error { logger.Info().Msg("starting audio processes due to audio device being enabled") if err := audioSupervisor.Start(); err != nil { logger.Error().Err(err).Msg("failed to start audio supervisor") + } else { + // Broadcast audio device change event to notify WebRTC session + broadcaster := audio.GetAudioEventBroadcaster() + broadcaster.BroadcastAudioDeviceChanged(true, "device_enabled") + logger.Info().Msg("broadcasted audio device change event after enabling audio device") } } config.UsbDevices.Audio = enabled diff --git a/ui/src/hooks/useAudioEvents.ts b/ui/src/hooks/useAudioEvents.ts index c61ca1c..8436529 100644 --- a/ui/src/hooks/useAudioEvents.ts +++ b/ui/src/hooks/useAudioEvents.ts @@ -8,7 +8,8 @@ export type AudioEventType = | 'microphone-state-changed' | 'microphone-metrics-update' | 'audio-process-metrics' - | 'microphone-process-metrics'; + | 'microphone-process-metrics' + | 'audio-device-changed'; // Audio event data interfaces export interface AudioMuteData { @@ -48,10 +49,15 @@ export interface ProcessMetricsData { process_name: string; } +export interface AudioDeviceChangedData { + enabled: boolean; + reason: string; +} + // Audio event structure export interface AudioEvent { type: AudioEventType; - data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData | ProcessMetricsData; + data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData | ProcessMetricsData | AudioDeviceChangedData; } // Hook return type @@ -72,6 +78,9 @@ export interface UseAudioEventsReturn { audioProcessMetrics: ProcessMetricsData | null; microphoneProcessMetrics: ProcessMetricsData | null; + // Device change events + onAudioDeviceChanged?: (data: AudioDeviceChangedData) => void; + // Manual subscription control subscribe: () => void; unsubscribe: () => void; @@ -84,7 +93,7 @@ const globalSubscriptionState = { connectionId: null as string | null }; -export function useAudioEvents(): UseAudioEventsReturn { +export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedData) => void): UseAudioEventsReturn { // State for audio data const [audioMuted, setAudioMuted] = useState(null); const [audioMetrics, setAudioMetrics] = useState(null); @@ -244,6 +253,15 @@ export function useAudioEvents(): UseAudioEventsReturn { break; } + case 'audio-device-changed': { + const deviceChangedData = audioEvent.data as AudioDeviceChangedData; + console.log('[AudioEvents] Audio device changed:', deviceChangedData); + if (onAudioDeviceChanged) { + onAudioDeviceChanged(deviceChangedData); + } + break; + } + default: // Ignore other message types (WebRTC signaling, etc.) break; @@ -256,7 +274,7 @@ export function useAudioEvents(): UseAudioEventsReturn { } } } - }, [lastMessage]); + }, [lastMessage, onAudioDeviceChanged]); // Auto-subscribe when connected useEffect(() => { @@ -309,6 +327,9 @@ export function useAudioEvents(): UseAudioEventsReturn { audioProcessMetrics, microphoneProcessMetrics, + // Device change events + onAudioDeviceChanged, + // Manual subscription control subscribe, unsubscribe, diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index f07bae8..31ed8f0 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -34,6 +34,7 @@ import { VideoState, } from "@/hooks/stores"; import { useMicrophone } from "@/hooks/useMicrophone"; +import { useAudioEvents } from "@/hooks/useAudioEvents"; import WebRTCVideo from "@components/WebRTCVideo"; import { checkAuth, isInCloud, isOnDevice } from "@/main"; import DashboardNavbar from "@components/Header"; @@ -655,6 +656,9 @@ export default function KvmIdRoute() { const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const { send } = useJsonRpc(onJsonRpcRequest); + // Use audio events hook without device change handler to avoid subscription loops + useAudioEvents(); + useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; send("getVideoState", {}, (resp: JsonRpcResponse) => {