Compare commits

...

8 Commits

Author SHA1 Message Date
Alex P 7ec583ed6a refactor(audio): centralize config and remove debug logs
- Move hardcoded constants to centralized config system
- Remove verbose debug logging statements
- Clean up unused code and improve error handling
2025-08-25 16:49:48 +00:00
Alex P d1c192bf8b feat(audio): add real-time USB audio config updates and validation
- Add audio device change listener in UI to update USB config
- Implement audio configuration validation before enabling USB audio
- Broadcast audio device change events for all state changes
2025-08-25 14:21:49 +00:00
Alex P c89d678963 refactor(audio): remove unused context from audio input manager
Simplify AudioInputIPCManager by removing unused context and cancellation logic. The context was not providing any meaningful functionality.

fix(ui): handle audio device changes with proper sync

Add delayed microphone state synchronization when audio devices change to prevent race conditions during USB audio reconfiguration.
2025-08-25 13:53:29 +00:00
Alex P 6f02870c90 fix(audio): improve audio device state management
Add proper cleanup for both audio input manager and output supervisor when disabling audio
Ensure complete shutdown of audio processes before USB reconfiguration
2025-08-25 13:19:29 +00:00
Alex P 1a0377bbdf Improvement: automatically resume audio when the audio usb gadget is re-enabled from settings 2025-08-25 12:36:04 +00:00
Alex P f24443e072 Improvement: automatically resume audio when the audio usb gadget is re-enabled from settings 2025-08-25 11:05:42 +00:00
Alex P 2afe2ca539 Fix: USB Gadgets updates 2025-08-25 10:41:53 +00:00
Alex P bc53523fbb Fix: USB Gadgets update 2025-08-25 09:19:03 +00:00
28 changed files with 785 additions and 219 deletions

View File

@ -10,7 +10,10 @@ var (
ErrAudioAlreadyRunning = errors.New("audio already running") ErrAudioAlreadyRunning = errors.New("audio already running")
) )
const MaxAudioFrameSize = 1500 // MaxAudioFrameSize is now retrieved from centralized config
func GetMaxAudioFrameSize() int {
return GetConfig().MaxAudioFrameSize
}
// AudioQuality represents different audio quality presets // AudioQuality represents different audio quality presets
type AudioQuality int type AudioQuality int
@ -45,14 +48,14 @@ var (
currentConfig = AudioConfig{ currentConfig = AudioConfig{
Quality: AudioQualityMedium, Quality: AudioQualityMedium,
Bitrate: 64, Bitrate: 64,
SampleRate: 48000, SampleRate: GetConfig().SampleRate,
Channels: 2, Channels: GetConfig().Channels,
FrameSize: 20 * time.Millisecond, FrameSize: 20 * time.Millisecond,
} }
currentMicrophoneConfig = AudioConfig{ currentMicrophoneConfig = AudioConfig{
Quality: AudioQualityMedium, Quality: AudioQualityMedium,
Bitrate: 32, Bitrate: 32,
SampleRate: 48000, SampleRate: GetConfig().SampleRate,
Channels: 1, Channels: 1,
FrameSize: 20 * time.Millisecond, FrameSize: 20 * time.Millisecond,
} }
@ -77,12 +80,12 @@ var qualityPresets = map[AudioQuality]struct {
}, },
AudioQualityHigh: { AudioQualityHigh: {
outputBitrate: 128, inputBitrate: 64, outputBitrate: 128, inputBitrate: 64,
sampleRate: 48000, channels: 2, sampleRate: GetConfig().SampleRate, channels: GetConfig().Channels,
frameSize: 20 * time.Millisecond, frameSize: 20 * time.Millisecond,
}, },
AudioQualityUltra: { AudioQualityUltra: {
outputBitrate: 192, inputBitrate: 96, outputBitrate: 192, inputBitrate: 96,
sampleRate: 48000, channels: 2, sampleRate: GetConfig().SampleRate, channels: GetConfig().Channels,
frameSize: 10 * time.Millisecond, frameSize: 10 * time.Millisecond,
}, },
} }

View File

@ -2,28 +2,14 @@ package audio
import "time" import "time"
// MonitoringConfig contains configuration constants for audio monitoring // GetMetricsUpdateInterval returns the current metrics update interval from centralized config
type MonitoringConfig struct {
// MetricsUpdateInterval defines how often metrics are collected and broadcast
MetricsUpdateInterval time.Duration
}
// DefaultMonitoringConfig returns the default monitoring configuration
func DefaultMonitoringConfig() MonitoringConfig {
return MonitoringConfig{
MetricsUpdateInterval: 1000 * time.Millisecond, // 1 second interval
}
}
// Global monitoring configuration instance
var monitoringConfig = DefaultMonitoringConfig()
// GetMetricsUpdateInterval returns the current metrics update interval
func GetMetricsUpdateInterval() time.Duration { func GetMetricsUpdateInterval() time.Duration {
return monitoringConfig.MetricsUpdateInterval return GetConfig().MetricsUpdateInterval
} }
// SetMetricsUpdateInterval sets the metrics update interval // SetMetricsUpdateInterval sets the metrics update interval in centralized config
func SetMetricsUpdateInterval(interval time.Duration) { func SetMetricsUpdateInterval(interval time.Duration) {
monitoringConfig.MetricsUpdateInterval = interval config := GetConfig()
config.MetricsUpdateInterval = interval
UpdateConfig(config)
} }

View File

@ -0,0 +1,150 @@
package audio
import "time"
// AudioConfigConstants centralizes all hardcoded values used across audio components
type AudioConfigConstants struct {
// Audio Quality Presets
MaxAudioFrameSize int
// Opus Encoding Parameters
OpusBitrate int
OpusComplexity int
OpusVBR int
OpusVBRConstraint int
OpusDTX int
// Audio Parameters
SampleRate int
Channels int
FrameSize int
MaxPacketSize int
// Process Management
MaxRestartAttempts int
RestartWindow time.Duration
RestartDelay time.Duration
MaxRestartDelay time.Duration
// Buffer Management
PreallocSize int
MaxPoolSize int
MessagePoolSize int
OptimalSocketBuffer int
MaxSocketBuffer int
MinSocketBuffer int
// IPC Configuration
MagicNumber uint32
MaxFrameSize int
WriteTimeout time.Duration
MaxDroppedFrames int
HeaderSize int
// Monitoring and Metrics
MetricsUpdateInterval time.Duration
EMAAlpha float64
WarmupSamples int
LogThrottleInterval time.Duration
MetricsChannelBuffer int
// Performance Tuning
CPUFactor float64
MemoryFactor float64
LatencyFactor float64
InputSizeThreshold int
OutputSizeThreshold int
TargetLevel float64
// Priority Scheduling
AudioHighPriority int
AudioMediumPriority int
AudioLowPriority int
NormalPriority int
NiceValue int
// Error Handling
MaxConsecutiveErrors int
MaxRetryAttempts int
}
// DefaultAudioConfig returns the default configuration constants
func DefaultAudioConfig() *AudioConfigConstants {
return &AudioConfigConstants{
// Audio Quality Presets
MaxAudioFrameSize: 4096,
// Opus Encoding Parameters
OpusBitrate: 128000,
OpusComplexity: 10,
OpusVBR: 1,
OpusVBRConstraint: 0,
OpusDTX: 0,
// Audio Parameters
SampleRate: 48000,
Channels: 2,
FrameSize: 960,
MaxPacketSize: 4000,
// Process Management
MaxRestartAttempts: 5,
RestartWindow: 5 * time.Minute,
RestartDelay: 2 * time.Second,
MaxRestartDelay: 30 * time.Second,
// Buffer Management
PreallocSize: 1024 * 1024, // 1MB
MaxPoolSize: 100,
MessagePoolSize: 100,
OptimalSocketBuffer: 262144, // 256KB
MaxSocketBuffer: 1048576, // 1MB
MinSocketBuffer: 8192, // 8KB
// IPC Configuration
MagicNumber: 0xDEADBEEF,
MaxFrameSize: 4096,
WriteTimeout: 5 * time.Second,
MaxDroppedFrames: 10,
HeaderSize: 8,
// Monitoring and Metrics
MetricsUpdateInterval: 1000 * time.Millisecond,
EMAAlpha: 0.1,
WarmupSamples: 10,
LogThrottleInterval: 5 * time.Second,
MetricsChannelBuffer: 100,
// Performance Tuning
CPUFactor: 0.7,
MemoryFactor: 0.8,
LatencyFactor: 0.9,
InputSizeThreshold: 1024,
OutputSizeThreshold: 2048,
TargetLevel: 0.5,
// Priority Scheduling
AudioHighPriority: -10,
AudioMediumPriority: -5,
AudioLowPriority: 0,
NormalPriority: 0,
NiceValue: -10,
// Error Handling
MaxConsecutiveErrors: 5,
MaxRetryAttempts: 3,
}
}
// Global configuration instance
var audioConfigInstance = DefaultAudioConfig()
// UpdateConfig allows runtime configuration updates
func UpdateConfig(newConfig *AudioConfigConstants) {
audioConfigInstance = newConfig
}
// GetConfig returns the current configuration
func GetConfig() *AudioConfigConstants {
return audioConfigInstance
}

View File

@ -23,6 +23,7 @@ const (
AudioEventMicrophoneMetrics AudioEventType = "microphone-metrics-update" AudioEventMicrophoneMetrics AudioEventType = "microphone-metrics-update"
AudioEventProcessMetrics AudioEventType = "audio-process-metrics" AudioEventProcessMetrics AudioEventType = "audio-process-metrics"
AudioEventMicProcessMetrics AudioEventType = "microphone-process-metrics" AudioEventMicProcessMetrics AudioEventType = "microphone-process-metrics"
AudioEventDeviceChanged AudioEventType = "audio-device-changed"
) )
// AudioEvent represents a WebSocket audio event // AudioEvent represents a WebSocket audio event
@ -73,6 +74,12 @@ type ProcessMetricsData struct {
ProcessName string `json:"process_name"` 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 // AudioEventSubscriber represents a WebSocket connection subscribed to audio events
type AudioEventSubscriber struct { type AudioEventSubscriber struct {
conn *websocket.Conn conn *websocket.Conn
@ -164,6 +171,15 @@ func (aeb *AudioEventBroadcaster) BroadcastMicrophoneStateChanged(running, sessi
aeb.broadcast(event) 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 // sendInitialState sends current audio state to a new subscriber
func (aeb *AudioEventBroadcaster) sendInitialState(connectionID string) { func (aeb *AudioEventBroadcaster) sendInitialState(connectionID string) {
aeb.mutex.RLock() aeb.mutex.RLock()
@ -204,18 +220,6 @@ func (aeb *AudioEventBroadcaster) sendInitialState(connectionID string) {
aeb.sendCurrentMetrics(subscriber) aeb.sendCurrentMetrics(subscriber)
} }
// convertAudioMetricsToEventData converts internal audio metrics to AudioMetricsData for events
func convertAudioMetricsToEventData(metrics AudioMetrics) AudioMetricsData {
return AudioMetricsData{
FramesReceived: metrics.FramesReceived,
FramesDropped: metrics.FramesDropped,
BytesProcessed: metrics.BytesProcessed,
LastFrameTime: metrics.LastFrameTime.Format("2006-01-02T15:04:05.000Z"),
ConnectionDrops: metrics.ConnectionDrops,
AverageLatency: metrics.AverageLatency.String(),
}
}
// convertAudioMetricsToEventDataWithLatencyMs converts internal audio metrics to AudioMetricsData with millisecond latency formatting // convertAudioMetricsToEventDataWithLatencyMs converts internal audio metrics to AudioMetricsData with millisecond latency formatting
func convertAudioMetricsToEventDataWithLatencyMs(metrics AudioMetrics) AudioMetricsData { func convertAudioMetricsToEventDataWithLatencyMs(metrics AudioMetrics) AudioMetricsData {
return AudioMetricsData{ return AudioMetricsData{
@ -228,18 +232,6 @@ func convertAudioMetricsToEventDataWithLatencyMs(metrics AudioMetrics) AudioMetr
} }
} }
// convertAudioInputMetricsToEventData converts internal audio input metrics to MicrophoneMetricsData for events
func convertAudioInputMetricsToEventData(metrics AudioInputMetrics) MicrophoneMetricsData {
return MicrophoneMetricsData{
FramesSent: metrics.FramesSent,
FramesDropped: metrics.FramesDropped,
BytesProcessed: metrics.BytesProcessed,
LastFrameTime: metrics.LastFrameTime.Format("2006-01-02T15:04:05.000Z"),
ConnectionDrops: metrics.ConnectionDrops,
AverageLatency: metrics.AverageLatency.String(),
}
}
// convertAudioInputMetricsToEventDataWithLatencyMs converts internal audio input metrics to MicrophoneMetricsData with millisecond latency formatting // convertAudioInputMetricsToEventDataWithLatencyMs converts internal audio input metrics to MicrophoneMetricsData with millisecond latency formatting
func convertAudioInputMetricsToEventDataWithLatencyMs(metrics AudioInputMetrics) MicrophoneMetricsData { func convertAudioInputMetricsToEventDataWithLatencyMs(metrics AudioInputMetrics) MicrophoneMetricsData {
return MicrophoneMetricsData{ return MicrophoneMetricsData{
@ -342,7 +334,7 @@ func (aeb *AudioEventBroadcaster) getMicrophoneProcessMetrics() ProcessMetricsDa
func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubscriber) { func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubscriber) {
// Send audio metrics // Send audio metrics
audioMetrics := GetAudioMetrics() audioMetrics := GetAudioMetrics()
audioMetricsEvent := createAudioEvent(AudioEventMetricsUpdate, convertAudioMetricsToEventData(audioMetrics)) audioMetricsEvent := createAudioEvent(AudioEventMetricsUpdate, convertAudioMetricsToEventDataWithLatencyMs(audioMetrics))
aeb.sendToSubscriber(subscriber, audioMetricsEvent) aeb.sendToSubscriber(subscriber, audioMetricsEvent)
// Send audio process metrics // Send audio process metrics
@ -358,7 +350,7 @@ func (aeb *AudioEventBroadcaster) sendCurrentMetrics(subscriber *AudioEventSubsc
if sessionProvider.IsSessionActive() { if sessionProvider.IsSessionActive() {
if inputManager := sessionProvider.GetAudioInputManager(); inputManager != nil { if inputManager := sessionProvider.GetAudioInputManager(); inputManager != nil {
micMetrics := inputManager.GetMetrics() micMetrics := inputManager.GetMetrics()
micMetricsEvent := createAudioEvent(AudioEventMicrophoneMetrics, convertAudioInputMetricsToEventData(micMetrics)) micMetricsEvent := createAudioEvent(AudioEventMicrophoneMetrics, convertAudioInputMetricsToEventDataWithLatencyMs(micMetrics))
aeb.sendToSubscriber(subscriber, micMetricsEvent) aeb.sendToSubscriber(subscriber, micMetricsEvent)
} }
} }

View File

@ -1,7 +1,6 @@
package audio package audio
import ( import (
"context"
"sync/atomic" "sync/atomic"
"time" "time"
@ -16,18 +15,13 @@ type AudioInputIPCManager struct {
supervisor *AudioInputSupervisor supervisor *AudioInputSupervisor
logger zerolog.Logger logger zerolog.Logger
running int32 running int32
ctx context.Context
cancel context.CancelFunc
} }
// NewAudioInputIPCManager creates a new IPC-based audio input manager // NewAudioInputIPCManager creates a new IPC-based audio input manager
func NewAudioInputIPCManager() *AudioInputIPCManager { func NewAudioInputIPCManager() *AudioInputIPCManager {
ctx, cancel := context.WithCancel(context.Background())
return &AudioInputIPCManager{ return &AudioInputIPCManager{
supervisor: NewAudioInputSupervisor(), supervisor: NewAudioInputSupervisor(),
logger: logging.GetDefaultLogger().With().Str("component", "audio-input-ipc").Logger(), logger: logging.GetDefaultLogger().With().Str("component", "audio-input-ipc").Logger(),
ctx: ctx,
cancel: cancel,
} }
} }
@ -52,14 +46,8 @@ func (aim *AudioInputIPCManager) Start() error {
FrameSize: 960, FrameSize: 960,
} }
// Wait with timeout for subprocess readiness // Wait for subprocess readiness
select { time.Sleep(200 * time.Millisecond)
case <-time.After(200 * time.Millisecond):
case <-aim.ctx.Done():
aim.supervisor.Stop()
atomic.StoreInt32(&aim.running, 0)
return aim.ctx.Err()
}
err = aim.supervisor.SendConfig(config) err = aim.supervisor.SendConfig(config)
if err != nil { if err != nil {
@ -77,7 +65,6 @@ func (aim *AudioInputIPCManager) Stop() {
} }
aim.logger.Info().Msg("Stopping IPC-based audio input system") aim.logger.Info().Msg("Stopping IPC-based audio input system")
aim.cancel()
aim.supervisor.Stop() aim.supervisor.Stop()
aim.logger.Info().Msg("IPC-based audio input system stopped") aim.logger.Info().Msg("IPC-based audio input system stopped")
} }

View File

@ -58,7 +58,7 @@ func NewOutputStreamer() (*OutputStreamer, error) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
return &OutputStreamer{ return &OutputStreamer{
client: client, client: client,
bufferPool: NewAudioBufferPool(MaxAudioFrameSize), // Use existing buffer pool bufferPool: NewAudioBufferPool(GetMaxAudioFrameSize()), // Use existing buffer pool
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
batchSize: initialBatchSize, // Use adaptive batch size batchSize: initialBatchSize, // Use adaptive batch size
@ -319,7 +319,7 @@ func StartAudioOutputStreaming(send func([]byte)) error {
}() }()
getOutputStreamingLogger().Info().Msg("Audio output streaming started") getOutputStreamingLogger().Info().Msg("Audio output streaming started")
buffer := make([]byte, MaxAudioFrameSize) buffer := make([]byte, GetMaxAudioFrameSize())
for { for {
select { select {

View File

@ -3,6 +3,7 @@ package audio
import ( import (
"context" "context"
"fmt" "fmt"
"reflect"
"sync" "sync"
"time" "time"
@ -168,15 +169,22 @@ func (r *AudioRelay) relayLoop() {
// forwardToWebRTC forwards a frame to the WebRTC audio track // forwardToWebRTC forwards a frame to the WebRTC audio track
func (r *AudioRelay) forwardToWebRTC(frame []byte) error { func (r *AudioRelay) forwardToWebRTC(frame []byte) error {
r.mutex.RLock() r.mutex.RLock()
defer r.mutex.RUnlock()
audioTrack := r.audioTrack audioTrack := r.audioTrack
config := r.config config := r.config
muted := r.muted muted := r.muted
r.mutex.RUnlock()
// Comprehensive nil check for audioTrack to prevent panic
if audioTrack == nil { if audioTrack == nil {
return nil // No audio track available return nil // No audio track available
} }
// Check if interface contains nil pointer using reflection
if reflect.ValueOf(audioTrack).IsNil() {
return nil // Audio track interface contains nil pointer
}
// Prepare sample data // Prepare sample data
var sampleData []byte var sampleData []byte
if muted { if muted {
@ -186,7 +194,7 @@ func (r *AudioRelay) forwardToWebRTC(frame []byte) error {
sampleData = frame sampleData = frame
} }
// Write sample to WebRTC track // Write sample to WebRTC track while holding the read lock
return audioTrack.WriteSample(media.Sample{ return audioTrack.WriteSample(media.Sample{
Data: sampleData, Data: sampleData,
Duration: config.FrameSize, Duration: config.FrameSize,

View File

@ -17,16 +17,22 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
const ( // Restart configuration is now retrieved from centralized config
// Maximum number of restart attempts within the restart window func getMaxRestartAttempts() int {
maxRestartAttempts = 5 return GetConfig().MaxRestartAttempts
// Time window for counting restart attempts }
restartWindow = 5 * time.Minute
// Delay between restart attempts func getRestartWindow() time.Duration {
restartDelay = 2 * time.Second return GetConfig().RestartWindow
// Maximum restart delay (exponential backoff) }
maxRestartDelay = 30 * time.Second
) func getRestartDelay() time.Duration {
return GetConfig().RestartDelay
}
func getMaxRestartDelay() time.Duration {
return GetConfig().MaxRestartDelay
}
// AudioServerSupervisor manages the audio server subprocess lifecycle // AudioServerSupervisor manages the audio server subprocess lifecycle
type AudioServerSupervisor struct { type AudioServerSupervisor struct {
@ -95,6 +101,14 @@ func (s *AudioServerSupervisor) Start() error {
s.logger.Info().Msg("starting audio server supervisor") s.logger.Info().Msg("starting audio server supervisor")
// Recreate channels in case they were closed by a previous Stop() call
s.mutex.Lock()
s.processDone = make(chan struct{})
s.stopChan = make(chan struct{})
// Recreate context as well since it might have been cancelled
s.ctx, s.cancel = context.WithCancel(context.Background())
s.mutex.Unlock()
// Start the supervision loop // Start the supervision loop
go s.supervisionLoop() go s.supervisionLoop()
@ -387,13 +401,13 @@ func (s *AudioServerSupervisor) shouldRestart() bool {
now := time.Now() now := time.Now()
var recentAttempts []time.Time var recentAttempts []time.Time
for _, attempt := range s.restartAttempts { for _, attempt := range s.restartAttempts {
if now.Sub(attempt) < restartWindow { if now.Sub(attempt) < getRestartWindow() {
recentAttempts = append(recentAttempts, attempt) recentAttempts = append(recentAttempts, attempt)
} }
} }
s.restartAttempts = recentAttempts s.restartAttempts = recentAttempts
return len(s.restartAttempts) < maxRestartAttempts return len(s.restartAttempts) < getMaxRestartAttempts()
} }
// recordRestartAttempt records a restart attempt // recordRestartAttempt records a restart attempt
@ -412,17 +426,17 @@ func (s *AudioServerSupervisor) calculateRestartDelay() time.Duration {
// Exponential backoff based on recent restart attempts // Exponential backoff based on recent restart attempts
attempts := len(s.restartAttempts) attempts := len(s.restartAttempts)
if attempts == 0 { if attempts == 0 {
return restartDelay return getRestartDelay()
} }
// Calculate exponential backoff: 2^attempts * base delay // Calculate exponential backoff: 2^attempts * base delay
delay := restartDelay delay := getRestartDelay()
for i := 0; i < attempts && delay < maxRestartDelay; i++ { for i := 0; i < attempts && delay < getMaxRestartDelay(); i++ {
delay *= 2 delay *= 2
} }
if delay > maxRestartDelay { if delay > getMaxRestartDelay() {
delay = maxRestartDelay delay = getMaxRestartDelay()
} }
return delay return delay

View File

@ -262,7 +262,7 @@ type ZeroCopyFramePoolStats struct {
} }
var ( var (
globalZeroCopyPool = NewZeroCopyFramePool(MaxAudioFrameSize) globalZeroCopyPool = NewZeroCopyFramePool(GetMaxAudioFrameSize())
) )
// GetZeroCopyFrame gets a frame from the global pool // GetZeroCopyFrame gets a frame from the global pool
@ -284,16 +284,17 @@ func PutZeroCopyFrame(frame *ZeroCopyAudioFrame) {
func ZeroCopyAudioReadEncode() (*ZeroCopyAudioFrame, error) { func ZeroCopyAudioReadEncode() (*ZeroCopyAudioFrame, error) {
frame := GetZeroCopyFrame() frame := GetZeroCopyFrame()
maxFrameSize := GetMaxAudioFrameSize()
// Ensure frame has enough capacity // Ensure frame has enough capacity
if frame.Capacity() < MaxAudioFrameSize { if frame.Capacity() < maxFrameSize {
// Reallocate if needed // Reallocate if needed
frame.data = make([]byte, MaxAudioFrameSize) frame.data = make([]byte, maxFrameSize)
frame.capacity = MaxAudioFrameSize frame.capacity = maxFrameSize
frame.pooled = false frame.pooled = false
} }
// Use unsafe pointer for direct CGO call // Use unsafe pointer for direct CGO call
n, err := CGOAudioReadEncode(frame.data[:MaxAudioFrameSize]) n, err := CGOAudioReadEncode(frame.data[:maxFrameSize])
if err != nil { if err != nil {
PutZeroCopyFrame(frame) PutZeroCopyFrame(frame)
return nil, err return nil, err

View File

@ -1,7 +1,9 @@
package usbgadget package usbgadget
import ( import (
"context"
"fmt" "fmt"
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag" "github.com/sourcegraph/tf-dag/dag"
@ -114,7 +116,20 @@ func (c *ChangeSetResolver) resolveChanges(initial bool) error {
} }
func (c *ChangeSetResolver) applyChanges() error { func (c *ChangeSetResolver) applyChanges() error {
return c.applyChangesWithTimeout(45 * time.Second)
}
func (c *ChangeSetResolver) applyChangesWithTimeout(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, change := range c.resolvedChanges { for _, change := range c.resolvedChanges {
select {
case <-ctx.Done():
return fmt.Errorf("USB gadget reconfiguration timed out after %v: %w", timeout, ctx.Err())
default:
}
change.ResetActionResolution() change.ResetActionResolution()
action := change.Action() action := change.Action()
actionStr := FileChangeResolvedActionString[action] actionStr := FileChangeResolvedActionString[action]
@ -126,7 +141,7 @@ func (c *ChangeSetResolver) applyChanges() error {
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change") l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change) err := c.applyChangeWithTimeout(ctx, change)
if err != nil { if err != nil {
if change.IgnoreErrors { if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error") c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
@ -139,6 +154,20 @@ func (c *ChangeSetResolver) applyChanges() error {
return nil return nil
} }
func (c *ChangeSetResolver) applyChangeWithTimeout(ctx context.Context, change *FileChange) error {
done := make(chan error, 1)
go func() {
done <- c.changeset.applyChange(change)
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("change application timed out for %s: %w", change.String(), ctx.Err())
}
}
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) { func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
localChanges := c.changeset.Changes localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange) changesMap := make(map[string]*FileChange)

View File

@ -213,11 +213,17 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig() u.loadGadgetConfig()
// Close HID files before reconfiguration to prevent "file already closed" errors
u.CloseHidFiles()
err := u.configureUsbGadget(true) err := u.configureUsbGadget(true)
if err != nil { if err != nil {
return u.logError("unable to update gadget config", err) return u.logError("unable to update gadget config", err)
} }
// Reopen HID files after reconfiguration
u.PreOpenHidFiles()
return nil return nil
} }

View File

@ -1,10 +1,12 @@
package usbgadget package usbgadget
import ( import (
"context"
"fmt" "fmt"
"path" "path"
"path/filepath" "path/filepath"
"sort" "sort"
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -52,22 +54,50 @@ func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
} }
func (u *UsbGadget) WithTransaction(fn func() error) error { func (u *UsbGadget) WithTransaction(fn func() error) error {
u.txLock.Lock() return u.WithTransactionTimeout(fn, 60*time.Second)
defer u.txLock.Unlock() }
err := u.newUsbGadgetTransaction(false) // WithTransactionTimeout executes a USB gadget transaction with a specified timeout
if err != nil { // to prevent indefinite blocking during USB reconfiguration operations
u.log.Error().Err(err).Msg("failed to create transaction") func (u *UsbGadget) WithTransactionTimeout(fn func() error, timeout time.Duration) error {
return err // Create a context with timeout for the entire transaction
} ctx, cancel := context.WithTimeout(context.Background(), timeout)
if err := fn(); err != nil { defer cancel()
u.log.Error().Err(err).Msg("transaction failed")
return err
}
result := u.tx.Commit()
u.tx = nil
return result // Channel to signal when the transaction is complete
done := make(chan error, 1)
// Execute the transaction in a goroutine
go func() {
u.txLock.Lock()
defer u.txLock.Unlock()
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
done <- err
return
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
done <- err
return
}
result := u.tx.Commit()
u.tx = nil
done <- result
}()
// Wait for either completion or timeout
select {
case err := <-done:
return err
case <-ctx.Done():
u.log.Error().Dur("timeout", timeout).Msg("USB gadget transaction timed out")
return fmt.Errorf("USB gadget transaction timed out after %v: %w", timeout, ctx.Err())
}
} }
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string { func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {

View File

@ -1,10 +1,12 @@
package usbgadget package usbgadget
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"path" "path"
"strings" "strings"
"time"
) )
func getUdcs() []string { func getUdcs() []string {
@ -26,17 +28,44 @@ func getUdcs() []string {
} }
func rebindUsb(udc string, ignoreUnbindError bool) error { func rebindUsb(udc string, ignoreUnbindError bool) error {
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(udc), 0644) return rebindUsbWithTimeout(udc, ignoreUnbindError, 10*time.Second)
}
func rebindUsbWithTimeout(udc string, ignoreUnbindError bool, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Unbind with timeout
err := writeFileWithTimeout(ctx, path.Join(dwc3Path, "unbind"), []byte(udc), 0644)
if err != nil && !ignoreUnbindError { if err != nil && !ignoreUnbindError {
return err return fmt.Errorf("failed to unbind UDC: %w", err)
} }
err = os.WriteFile(path.Join(dwc3Path, "bind"), []byte(udc), 0644)
// Small delay to allow unbind to complete
time.Sleep(100 * time.Millisecond)
// Bind with timeout
err = writeFileWithTimeout(ctx, path.Join(dwc3Path, "bind"), []byte(udc), 0644)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to bind UDC: %w", err)
} }
return nil return nil
} }
func writeFileWithTimeout(ctx context.Context, filename string, data []byte, perm os.FileMode) error {
done := make(chan error, 1)
go func() {
done <- os.WriteFile(filename, data, perm)
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("write operation timed out: %w", ctx.Err())
}
}
func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error { func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error {
u.log.Info().Str("udc", u.udc).Msg("rebinding USB gadget to UDC") u.log.Info().Str("udc", u.udc).Msg("rebinding USB gadget to UDC")
return rebindUsb(u.udc, ignoreUnbindError) return rebindUsb(u.udc, ignoreUnbindError)

View File

@ -95,8 +95,41 @@ func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *
return newUsbGadget(name, defaultGadgetConfig, enabledDevices, config, logger) return newUsbGadget(name, defaultGadgetConfig, enabledDevices, config, logger)
} }
// CloseHidFiles closes all open HID files
func (u *UsbGadget) CloseHidFiles() {
u.log.Debug().Msg("closing HID files")
// Close keyboard HID file
if u.keyboardHidFile != nil {
if err := u.keyboardHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close keyboard HID file")
}
u.keyboardHidFile = nil
}
// Close absolute mouse HID file
if u.absMouseHidFile != nil {
if err := u.absMouseHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close absolute mouse HID file")
}
u.absMouseHidFile = nil
}
// Close relative mouse HID file
if u.relMouseHidFile != nil {
if err := u.relMouseHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close relative mouse HID file")
}
u.relMouseHidFile = nil
}
}
// PreOpenHidFiles opens all HID files to reduce input latency // PreOpenHidFiles opens all HID files to reduce input latency
func (u *UsbGadget) PreOpenHidFiles() { func (u *UsbGadget) PreOpenHidFiles() {
// Add a small delay to allow USB gadget reconfiguration to complete
// This prevents "no such device or address" errors when trying to open HID files
time.Sleep(100 * time.Millisecond)
if u.enabledDevices.Keyboard { if u.enabledDevices.Keyboard {
if err := u.openKeyboardHidFile(); err != nil { if err := u.openKeyboardHidFile(); err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file") u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file")

View File

@ -15,6 +15,7 @@ import (
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/audio"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
) )
@ -907,10 +908,121 @@ func updateUsbRelatedConfig() error {
return nil return nil
} }
// validateAudioConfiguration checks if audio functionality can be enabled
func validateAudioConfiguration(enabled bool) error {
if !enabled {
return nil // Disabling audio is always allowed
}
// Check if audio supervisor is available
if audioSupervisor == nil {
return fmt.Errorf("audio supervisor not initialized - audio functionality not available")
}
// Check if ALSA devices are available by attempting to list them
// This is a basic check to ensure the system has audio capabilities
if _, err := os.Stat("/proc/asound/cards"); os.IsNotExist(err) {
return fmt.Errorf("no ALSA sound cards detected - audio hardware not available")
}
// Check if USB gadget audio function is supported
if _, err := os.Stat("/sys/kernel/config/usb_gadget"); os.IsNotExist(err) {
return fmt.Errorf("USB gadget configfs not available - cannot enable USB audio")
}
return nil
}
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error { func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
// Validate audio configuration before proceeding
if err := validateAudioConfiguration(usbDevices.Audio); err != nil {
logger.Warn().Err(err).Msg("audio configuration validation failed")
return fmt.Errorf("audio validation failed: %w", err)
}
// Check if audio state is changing
previousAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
newAudioEnabled := usbDevices.Audio
// Handle audio process management if state is changing
if previousAudioEnabled != newAudioEnabled {
if !newAudioEnabled {
// Stop audio processes when audio is disabled
logger.Info().Msg("stopping audio processes due to audio device being disabled")
// Stop audio input manager if active
if currentSession != nil && currentSession.AudioInputManager != nil && currentSession.AudioInputManager.IsRunning() {
logger.Info().Msg("stopping audio input manager")
currentSession.AudioInputManager.Stop()
// Wait for audio input to fully stop
for i := 0; i < 50; i++ { // Wait up to 5 seconds
if !currentSession.AudioInputManager.IsRunning() {
break
}
time.Sleep(100 * time.Millisecond)
}
logger.Info().Msg("audio input manager stopped")
}
// Stop audio output supervisor
if audioSupervisor != nil && audioSupervisor.IsRunning() {
logger.Info().Msg("stopping audio output supervisor")
if err := audioSupervisor.Stop(); err != nil {
logger.Error().Err(err).Msg("failed to stop audio supervisor")
}
// Wait for audio processes to fully stop before proceeding
for i := 0; i < 50; i++ { // Wait up to 5 seconds
if !audioSupervisor.IsRunning() {
break
}
time.Sleep(100 * time.Millisecond)
}
logger.Info().Msg("audio output supervisor stopped")
}
logger.Info().Msg("audio processes stopped, proceeding with USB gadget reconfiguration")
} else if newAudioEnabled && audioSupervisor != nil && !audioSupervisor.IsRunning() {
// Start audio processes when audio is enabled (after USB reconfiguration)
logger.Info().Msg("audio will be started after USB gadget reconfiguration")
}
}
config.UsbDevices = &usbDevices config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices) gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
// Apply USB gadget configuration changes
err := updateUsbRelatedConfig()
if err != nil {
return err
}
// Start audio processes after successful USB reconfiguration if needed
if previousAudioEnabled != newAudioEnabled && newAudioEnabled && audioSupervisor != nil {
// Ensure supervisor is fully stopped before starting
for i := 0; i < 50; i++ { // Wait up to 5 seconds
if !audioSupervisor.IsRunning() {
break
}
time.Sleep(100 * time.Millisecond)
}
logger.Info().Msg("starting audio processes after USB gadget reconfiguration")
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")
}
} else if previousAudioEnabled != newAudioEnabled {
// Broadcast audio device change event for disabling audio
broadcaster := audio.GetAudioEventBroadcaster()
broadcaster.BroadcastAudioDeviceChanged(newAudioEnabled, "usb_reconfiguration")
logger.Info().Bool("enabled", newAudioEnabled).Msg("broadcasted audio device change event after USB reconfiguration")
}
return nil
} }
func rpcSetUsbDeviceState(device string, enabled bool) error { func rpcSetUsbDeviceState(device string, enabled bool) error {
@ -923,6 +1035,70 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
config.UsbDevices.Keyboard = enabled config.UsbDevices.Keyboard = enabled
case "massStorage": case "massStorage":
config.UsbDevices.MassStorage = enabled config.UsbDevices.MassStorage = enabled
case "audio":
// Validate audio configuration before proceeding
if err := validateAudioConfiguration(enabled); err != nil {
logger.Warn().Err(err).Msg("audio device state validation failed")
return fmt.Errorf("audio validation failed: %w", err)
}
// Handle audio process management
if !enabled {
// Stop audio processes when audio is disabled
logger.Info().Msg("stopping audio processes due to audio device being disabled")
// Stop audio input manager if active
if currentSession != nil && currentSession.AudioInputManager != nil && currentSession.AudioInputManager.IsRunning() {
logger.Info().Msg("stopping audio input manager")
currentSession.AudioInputManager.Stop()
// Wait for audio input to fully stop
for i := 0; i < 50; i++ { // Wait up to 5 seconds
if !currentSession.AudioInputManager.IsRunning() {
break
}
time.Sleep(100 * time.Millisecond)
}
logger.Info().Msg("audio input manager stopped")
}
// Stop audio output supervisor
if audioSupervisor != nil && audioSupervisor.IsRunning() {
logger.Info().Msg("stopping audio output supervisor")
if err := audioSupervisor.Stop(); err != nil {
logger.Error().Err(err).Msg("failed to stop audio supervisor")
}
// Wait for audio processes to fully stop
for i := 0; i < 50; i++ { // Wait up to 5 seconds
if !audioSupervisor.IsRunning() {
break
}
time.Sleep(100 * time.Millisecond)
}
logger.Info().Msg("audio output supervisor stopped")
}
} else if enabled && audioSupervisor != nil {
// Ensure supervisor is fully stopped before starting
for i := 0; i < 50; i++ { // Wait up to 5 seconds
if !audioSupervisor.IsRunning() {
break
}
time.Sleep(100 * time.Millisecond)
}
// Start audio processes when audio is enabled
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")
}
// Always broadcast the audio device change event regardless of enable/disable
broadcaster := audio.GetAudioEventBroadcaster()
broadcaster.BroadcastAudioDeviceChanged(enabled, "device_state_changed")
logger.Info().Bool("enabled", enabled).Msg("broadcasted audio device state change event")
}
config.UsbDevices.Audio = enabled
default: default:
return fmt.Errorf("invalid device: %s", device) return fmt.Errorf("invalid device: %s", device)
} }

15
main.go
View File

@ -11,6 +11,7 @@ import (
"github.com/gwatts/rootcerts" "github.com/gwatts/rootcerts"
"github.com/jetkvm/kvm/internal/audio" "github.com/jetkvm/kvm/internal/audio"
"github.com/pion/webrtc/v4"
) )
var ( var (
@ -46,9 +47,17 @@ func startAudioSubprocess() error {
func(pid int) { func(pid int) {
logger.Info().Int("pid", pid).Msg("audio server process started") logger.Info().Int("pid", pid).Msg("audio server process started")
// Start audio relay system for main process without a track initially // Start audio relay system for main process
// The track will be updated when a WebRTC session is created // If there's an active WebRTC session, use its audio track
if err := audio.StartAudioRelay(nil); err != nil { var audioTrack *webrtc.TrackLocalStaticSample
if currentSession != nil && currentSession.AudioTrack != nil {
audioTrack = currentSession.AudioTrack
logger.Info().Msg("restarting audio relay with existing WebRTC audio track")
} else {
logger.Info().Msg("starting audio relay without WebRTC track (will be updated when session is created)")
}
if err := audio.StartAudioRelay(audioTrack); err != nil {
logger.Error().Err(err).Msg("failed to start audio relay") logger.Error().Err(err).Msg("failed to start audio relay")
} }
}, },

View File

@ -21,6 +21,7 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import AudioControlPopover from "@/components/popovers/AudioControlPopover"; import AudioControlPopover from "@/components/popovers/AudioControlPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useAudioEvents } from "@/hooks/useAudioEvents"; import { useAudioEvents } from "@/hooks/useAudioEvents";
import { useUsbDeviceConfig } from "@/hooks/useUsbDeviceConfig";
// Type for microphone error // Type for microphone error
@ -87,6 +88,10 @@ export default function Actionbar({
// Use WebSocket data exclusively - no polling fallback // Use WebSocket data exclusively - no polling fallback
const isMuted = audioMuted ?? false; // Default to false if WebSocket data not available yet const isMuted = audioMuted ?? false; // Default to false if WebSocket data not available yet
// Get USB device configuration to check if audio is enabled
const { usbDeviceConfig } = useUsbDeviceConfig();
const isAudioEnabledInUsb = usbDeviceConfig?.audio ?? true; // Default to true while loading
return ( return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900"> <Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
@ -316,25 +321,32 @@ export default function Actionbar({
/> />
</div> </div>
<Popover> <Popover>
<PopoverButton as={Fragment}> <PopoverButton as={Fragment} disabled={!isAudioEnabledInUsb}>
<Button <div title={!isAudioEnabledInUsb ? "Audio needs to be enabled in USB device settings" : undefined}>
size="XS" <Button
theme="light" size="XS"
text="Audio" theme="light"
LeadingIcon={({ className }) => ( text="Audio"
<div className="flex items-center"> disabled={!isAudioEnabledInUsb}
{isMuted ? ( LeadingIcon={({ className }) => (
<MdVolumeOff className={cx(className, "text-red-500")} /> <div className="flex items-center">
) : ( {!isAudioEnabledInUsb ? (
<MdVolumeUp className={cx(className, "text-green-500")} /> <MdVolumeOff className={cx(className, "text-gray-400")} />
)} ) : isMuted ? (
<MdGraphicEq className={cx(className, "ml-1 text-blue-500")} /> <MdVolumeOff className={cx(className, "text-red-500")} />
</div> ) : (
)} <MdVolumeUp className={cx(className, "text-green-500")} />
onClick={() => { )}
setDisableFocusTrap(true); <MdGraphicEq className={cx(className, "ml-1", !isAudioEnabledInUsb ? "text-gray-400" : "text-blue-500")} />
}} </div>
/> )}
onClick={() => {
if (isAudioEnabledInUsb) {
setDisableFocusTrap(true);
}
}}
/>
</div>
</PopoverButton> </PopoverButton>
<PopoverPanel <PopoverPanel
anchor="bottom end" anchor="bottom end"

View File

@ -122,8 +122,8 @@ export default function AudioMetricsDashboard() {
const response = await api.GET('/system/memory'); const response = await api.GET('/system/memory');
const data = await response.json(); const data = await response.json();
setSystemMemoryMB(data.total_memory_mb); setSystemMemoryMB(data.total_memory_mb);
} catch (error) { } catch {
console.warn('Failed to fetch system memory, using default:', error); // Failed to fetch system memory, using default
} }
}; };
fetchSystemMemory(); fetchSystemMemory();
@ -260,8 +260,8 @@ export default function AudioMetricsDashboard() {
const micConfigData = await micConfigResp.json(); const micConfigData = await micConfigResp.json();
setMicrophoneConfig(micConfigData.current); setMicrophoneConfig(micConfigData.current);
} }
} catch (micConfigError) { } catch {
console.debug("Microphone config not available:", micConfigError); // Microphone config not available
} }
} catch (error) { } catch (error) {
console.error("Failed to load audio config:", error); console.error("Failed to load audio config:", error);
@ -321,8 +321,8 @@ export default function AudioMetricsDashboard() {
}); });
} }
} }
} catch (audioProcessError) { } catch {
console.debug("Audio process metrics not available:", audioProcessError); // Audio process metrics not available
} }
// Load microphone metrics // Load microphone metrics
@ -332,9 +332,9 @@ export default function AudioMetricsDashboard() {
const micData = await micResp.json(); const micData = await micResp.json();
setFallbackMicrophoneMetrics(micData); setFallbackMicrophoneMetrics(micData);
} }
} catch (micError) { } catch {
// Microphone metrics might not be available, that's okay // Microphone metrics might not be available, that's okay
console.debug("Microphone metrics not available:", micError); // Microphone metrics not available
} }
// Load microphone process metrics // Load microphone process metrics
@ -374,8 +374,8 @@ export default function AudioMetricsDashboard() {
return newMap; return newMap;
}); });
} }
} catch (micProcessError) { } catch {
console.debug("Microphone process metrics not available:", micProcessError); // Microphone process metrics not available
} }
} catch (error) { } catch (error) {
console.error("Failed to load audio data:", error); console.error("Failed to load audio data:", error);

View File

@ -32,9 +32,8 @@ export default function InfoBar() {
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); rpcDataChannel.onclose = () => { /* RPC data channel closed */ };
rpcDataChannel.onerror = e => rpcDataChannel.onerror = () => { /* Error on RPC data channel */ };
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const keyboardLedState = useHidStore(state => state.keyboardLedState); const keyboardLedState = useHidStore(state => state.keyboardLedState);

View File

@ -22,6 +22,7 @@ export interface UsbDeviceConfig {
absolute_mouse: boolean; absolute_mouse: boolean;
relative_mouse: boolean; relative_mouse: boolean;
mass_storage: boolean; mass_storage: boolean;
audio: boolean;
} }
const defaultUsbDeviceConfig: UsbDeviceConfig = { const defaultUsbDeviceConfig: UsbDeviceConfig = {
@ -29,17 +30,30 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
absolute_mouse: true, absolute_mouse: true,
relative_mouse: true, relative_mouse: true,
mass_storage: true, mass_storage: true,
audio: true,
}; };
const usbPresets = [ const usbPresets = [
{ {
label: "Keyboard, Mouse and Mass Storage", label: "Keyboard, Mouse, Mass Storage and Audio",
value: "default", value: "default",
config: { config: {
keyboard: true, keyboard: true,
absolute_mouse: true, absolute_mouse: true,
relative_mouse: true, relative_mouse: true,
mass_storage: true, mass_storage: true,
audio: true,
},
},
{
label: "Keyboard, Mouse and Mass Storage",
value: "no_audio",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
audio: false,
}, },
}, },
{ {
@ -50,6 +64,7 @@ const usbPresets = [
absolute_mouse: false, absolute_mouse: false,
relative_mouse: false, relative_mouse: false,
mass_storage: false, mass_storage: false,
audio: false,
}, },
}, },
{ {
@ -217,6 +232,17 @@ export function UsbDeviceSetting() {
/> />
</SettingsItem> </SettingsItem>
</div> </div>
<div className="space-y-4">
<SettingsItem
title="Enable Audio Input/Output"
description="Enable USB audio input and output devices"
>
<Checkbox
checked={usbDeviceConfig.audio}
onChange={onUsbConfigItemChange("audio")}
/>
</SettingsItem>
</div>
</div> </div>
<div className="mt-6 flex gap-x-2"> <div className="mt-6 flex gap-x-2">
<Button <Button

View File

@ -155,8 +155,8 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
} }
setConfigsLoaded(true); setConfigsLoaded(true);
} catch (error) { } catch {
console.error("Failed to load audio configurations:", error); // Failed to load audio configurations
} }
}; };
@ -165,11 +165,11 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
try { try {
const resp = await api.POST("/audio/mute", { muted: !isMuted }); const resp = await api.POST("/audio/mute", { muted: !isMuted });
if (!resp.ok) { if (!resp.ok) {
console.error("Failed to toggle mute:", resp.statusText); // Failed to toggle mute
} }
// WebSocket will handle the state update automatically // WebSocket will handle the state update automatically
} catch (error) { } catch {
console.error("Failed to toggle mute:", error); // Failed to toggle mute
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -183,8 +183,8 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
const data = await resp.json(); const data = await resp.json();
setCurrentConfig(data.config); setCurrentConfig(data.config);
} }
} catch (error) { } catch {
console.error("Failed to change audio quality:", error); // Failed to change audio quality
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -197,8 +197,8 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
const data = await resp.json(); const data = await resp.json();
setCurrentMicrophoneConfig(data.config); setCurrentMicrophoneConfig(data.config);
} }
} catch (error) { } catch {
console.error("Failed to change microphone quality:", error); // Failed to change microphone quality
} }
}; };
@ -217,8 +217,8 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
if (!result.success && result.error) { if (!result.success && result.error) {
notifications.error(result.error.message); notifications.error(result.error.message);
} }
} catch (error) { } catch {
console.error("Failed to toggle microphone:", error); // Failed to toggle microphone
notifications.error("An unexpected error occurred"); notifications.error("An unexpected error occurred");
} }
}; };
@ -238,8 +238,8 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
if (!result.success && result.error) { if (!result.success && result.error) {
notifications.error(result.error.message); notifications.error(result.error.message);
} }
} catch (error) { } catch {
console.error("Failed to toggle microphone mute:", error); // Failed to toggle microphone mute
notifications.error("Failed to toggle microphone mute"); notifications.error("Failed to toggle microphone mute");
} }
}; };
@ -258,8 +258,8 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
if (!result.success && result.error) { if (!result.success && result.error) {
notifications.error(result.error.message); notifications.error(result.error.message);
} }
} catch (error) { } catch {
console.error("Failed to change microphone device:", error); // Failed to change microphone device
notifications.error("Failed to change microphone device"); notifications.error("Failed to change microphone device");
} }
} }
@ -273,11 +273,11 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
if (videoElement && 'setSinkId' in videoElement) { if (videoElement && 'setSinkId' in videoElement) {
try { try {
await (videoElement as HTMLVideoElement & { setSinkId: (deviceId: string) => Promise<void> }).setSinkId(deviceId); await (videoElement as HTMLVideoElement & { setSinkId: (deviceId: string) => Promise<void> }).setSinkId(deviceId);
} catch (error: unknown) { } catch {
console.error('Failed to change audio output device:', error); // Failed to change audio output device
} }
} else { } else {
console.warn('setSinkId not supported or video element not found'); // setSinkId not supported or video element not found
} }
}; };

View File

@ -845,7 +845,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
const { sendFn } = get(); const { sendFn } = get();
if (!sendFn) { if (!sendFn) {
console.warn("JSON-RPC send function not available."); // console.warn("JSON-RPC send function not available.");
return; return;
} }
@ -855,7 +855,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => { sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
if (response.error) { if (response.error) {
console.error("Error loading macros:", response.error); // console.error("Error loading macros:", response.error);
reject(new Error(response.error.message)); reject(new Error(response.error.message));
return; return;
} }
@ -879,8 +879,8 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
resolve(); resolve();
}); });
}); });
} catch (error) { } catch {
console.error("Failed to load macros:", error); // console.error("Failed to load macros:", _error);
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
@ -889,20 +889,20 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
saveMacros: async (macros: KeySequence[]) => { saveMacros: async (macros: KeySequence[]) => {
const { sendFn } = get(); const { sendFn } = get();
if (!sendFn) { if (!sendFn) {
console.warn("JSON-RPC send function not available."); // console.warn("JSON-RPC send function not available.");
throw new Error("JSON-RPC send function not available"); throw new Error("JSON-RPC send function not available");
} }
if (macros.length > MAX_TOTAL_MACROS) { if (macros.length > MAX_TOTAL_MACROS) {
console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`); // console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`); throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`);
} }
for (const macro of macros) { for (const macro of macros) {
if (macro.steps.length > MAX_STEPS_PER_MACRO) { if (macro.steps.length > MAX_STEPS_PER_MACRO) {
console.error( // console.error(
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`, // `Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
); // );
throw new Error( throw new Error(
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`, `Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
); );
@ -911,9 +911,9 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
for (let i = 0; i < macro.steps.length; i++) { for (let i = 0; i < macro.steps.length; i++) {
const step = macro.steps[i]; const step = macro.steps[i];
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
console.error( // console.error(
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`, // `Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
); // );
throw new Error( throw new Error(
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`, `Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
); );
@ -940,7 +940,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
}); });
if (response.error) { if (response.error) {
console.error("Error saving macros:", response.error); // console.error("Error saving macros:", response.error);
const errorMessage = const errorMessage =
typeof response.error.data === "string" typeof response.error.data === "string"
? response.error.data ? response.error.data
@ -950,9 +950,6 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
// Only update the store if the request was successful // Only update the store if the request was successful
set({ macros: macrosWithSortOrder }); set({ macros: macrosWithSortOrder });
} catch (error) {
console.error("Failed to save macros:", error);
throw error;
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }

View File

@ -63,10 +63,7 @@ export function useAudioDevices(): UseAudioDevicesReturn {
setAudioInputDevices(inputDevices); setAudioInputDevices(inputDevices);
setAudioOutputDevices(outputDevices); setAudioOutputDevices(outputDevices);
console.log('Audio devices enumerated:', { // Audio devices enumerated
inputs: inputDevices.length,
outputs: outputDevices.length
});
} catch (err) { } catch (err) {
console.error('Failed to enumerate audio devices:', err); console.error('Failed to enumerate audio devices:', err);
@ -79,7 +76,7 @@ export function useAudioDevices(): UseAudioDevicesReturn {
// Listen for device changes // Listen for device changes
useEffect(() => { useEffect(() => {
const handleDeviceChange = () => { const handleDeviceChange = () => {
console.log('Audio devices changed, refreshing...'); // Audio devices changed, refreshing
refreshDevices(); refreshDevices();
}; };

View File

@ -8,7 +8,8 @@ export type AudioEventType =
| 'microphone-state-changed' | 'microphone-state-changed'
| 'microphone-metrics-update' | 'microphone-metrics-update'
| 'audio-process-metrics' | 'audio-process-metrics'
| 'microphone-process-metrics'; | 'microphone-process-metrics'
| 'audio-device-changed';
// Audio event data interfaces // Audio event data interfaces
export interface AudioMuteData { export interface AudioMuteData {
@ -48,10 +49,15 @@ export interface ProcessMetricsData {
process_name: string; process_name: string;
} }
export interface AudioDeviceChangedData {
enabled: boolean;
reason: string;
}
// Audio event structure // Audio event structure
export interface AudioEvent { export interface AudioEvent {
type: AudioEventType; type: AudioEventType;
data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData | ProcessMetricsData; data: AudioMuteData | AudioMetricsData | MicrophoneStateData | MicrophoneMetricsData | ProcessMetricsData | AudioDeviceChangedData;
} }
// Hook return type // Hook return type
@ -72,6 +78,9 @@ export interface UseAudioEventsReturn {
audioProcessMetrics: ProcessMetricsData | null; audioProcessMetrics: ProcessMetricsData | null;
microphoneProcessMetrics: ProcessMetricsData | null; microphoneProcessMetrics: ProcessMetricsData | null;
// Device change events
onAudioDeviceChanged?: (data: AudioDeviceChangedData) => void;
// Manual subscription control // Manual subscription control
subscribe: () => void; subscribe: () => void;
unsubscribe: () => void; unsubscribe: () => void;
@ -84,7 +93,7 @@ const globalSubscriptionState = {
connectionId: null as string | null connectionId: null as string | null
}; };
export function useAudioEvents(): UseAudioEventsReturn { export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedData) => void): UseAudioEventsReturn {
// State for audio data // State for audio data
const [audioMuted, setAudioMuted] = useState<boolean | null>(null); const [audioMuted, setAudioMuted] = useState<boolean | null>(null);
const [audioMetrics, setAudioMetrics] = useState<AudioMetricsData | null>(null); const [audioMetrics, setAudioMetrics] = useState<AudioMetricsData | null>(null);
@ -115,13 +124,13 @@ export function useAudioEvents(): UseAudioEventsReturn {
reconnectInterval: 3000, reconnectInterval: 3000,
share: true, // Share the WebSocket connection across multiple hooks share: true, // Share the WebSocket connection across multiple hooks
onOpen: () => { onOpen: () => {
console.log('[AudioEvents] WebSocket connected'); // WebSocket connected
// Reset global state on new connection // Reset global state on new connection
globalSubscriptionState.isSubscribed = false; globalSubscriptionState.isSubscribed = false;
globalSubscriptionState.connectionId = Math.random().toString(36); globalSubscriptionState.connectionId = Math.random().toString(36);
}, },
onClose: () => { onClose: () => {
console.log('[AudioEvents] WebSocket disconnected'); // WebSocket disconnected
// Reset global state on disconnect // Reset global state on disconnect
globalSubscriptionState.isSubscribed = false; globalSubscriptionState.isSubscribed = false;
globalSubscriptionState.subscriberCount = 0; globalSubscriptionState.subscriberCount = 0;
@ -151,7 +160,7 @@ export function useAudioEvents(): UseAudioEventsReturn {
sendMessage(JSON.stringify(subscribeMessage)); sendMessage(JSON.stringify(subscribeMessage));
globalSubscriptionState.isSubscribed = true; globalSubscriptionState.isSubscribed = true;
console.log('[AudioEvents] Subscribed to audio events'); // Subscribed to audio events
} }
}, 100); // 100ms delay to debounce subscription attempts }, 100); // 100ms delay to debounce subscription attempts
} }
@ -188,11 +197,11 @@ export function useAudioEvents(): UseAudioEventsReturn {
sendMessage(JSON.stringify(unsubscribeMessage)); sendMessage(JSON.stringify(unsubscribeMessage));
globalSubscriptionState.isSubscribed = false; globalSubscriptionState.isSubscribed = false;
globalSubscriptionState.subscriberCount = 0; globalSubscriptionState.subscriberCount = 0;
console.log('[AudioEvents] Sent unsubscribe message to backend'); // Sent unsubscribe message to backend
} }
} }
console.log('[AudioEvents] Component unsubscribed from audio events'); // Component unsubscribed from audio events
}, [readyState, isLocallySubscribed, sendMessage]); }, [readyState, isLocallySubscribed, sendMessage]);
// Handle incoming messages // Handle incoming messages
@ -209,7 +218,7 @@ export function useAudioEvents(): UseAudioEventsReturn {
case 'audio-mute-changed': { case 'audio-mute-changed': {
const muteData = audioEvent.data as AudioMuteData; const muteData = audioEvent.data as AudioMuteData;
setAudioMuted(muteData.muted); setAudioMuted(muteData.muted);
console.log('[AudioEvents] Audio mute changed:', muteData.muted); // Audio mute changed
break; break;
} }
@ -222,7 +231,7 @@ export function useAudioEvents(): UseAudioEventsReturn {
case 'microphone-state-changed': { case 'microphone-state-changed': {
const micStateData = audioEvent.data as MicrophoneStateData; const micStateData = audioEvent.data as MicrophoneStateData;
setMicrophoneState(micStateData); setMicrophoneState(micStateData);
console.log('[AudioEvents] Microphone state changed:', micStateData); // Microphone state changed
break; break;
} }
@ -244,6 +253,15 @@ export function useAudioEvents(): UseAudioEventsReturn {
break; break;
} }
case 'audio-device-changed': {
const deviceChangedData = audioEvent.data as AudioDeviceChangedData;
// Audio device changed
if (onAudioDeviceChanged) {
onAudioDeviceChanged(deviceChangedData);
}
break;
}
default: default:
// Ignore other message types (WebRTC signaling, etc.) // Ignore other message types (WebRTC signaling, etc.)
break; break;
@ -256,7 +274,7 @@ export function useAudioEvents(): UseAudioEventsReturn {
} }
} }
} }
}, [lastMessage]); }, [lastMessage, onAudioDeviceChanged]);
// Auto-subscribe when connected // Auto-subscribe when connected
useEffect(() => { useEffect(() => {
@ -309,6 +327,9 @@ export function useAudioEvents(): UseAudioEventsReturn {
audioProcessMetrics, audioProcessMetrics,
microphoneProcessMetrics, microphoneProcessMetrics,
// Device change events
onAudioDeviceChanged,
// Manual subscription control // Manual subscription control
subscribe, subscribe,
unsubscribe, unsubscribe,

View File

@ -104,8 +104,8 @@ export const useAudioLevel = (
// Use setInterval instead of requestAnimationFrame for more predictable timing // Use setInterval instead of requestAnimationFrame for more predictable timing
intervalRef.current = window.setInterval(updateLevel, updateInterval); intervalRef.current = window.setInterval(updateLevel, updateInterval);
} catch (error) { } catch {
console.error('Failed to create audio level analyzer:', error); // Audio level analyzer creation failed - silently handle
setIsAnalyzing(false); setIsAnalyzing(false);
setAudioLevel(0); setAudioLevel(0);
} }

View File

@ -57,19 +57,14 @@ export function useMicrophone() {
// Cleanup function to stop microphone stream // Cleanup function to stop microphone stream
const stopMicrophoneStream = useCallback(async () => { const stopMicrophoneStream = useCallback(async () => {
console.log("stopMicrophoneStream called - cleaning up stream"); // Cleaning up microphone stream
console.trace("stopMicrophoneStream call stack");
if (microphoneStreamRef.current) { if (microphoneStreamRef.current) {
console.log("Stopping microphone stream:", microphoneStreamRef.current.id);
microphoneStreamRef.current.getTracks().forEach(track => { microphoneStreamRef.current.getTracks().forEach(track => {
track.stop(); track.stop();
}); });
microphoneStreamRef.current = null; microphoneStreamRef.current = null;
setMicrophoneStream(null); setMicrophoneStream(null);
console.log("Microphone stream cleared from ref and store");
} else {
console.log("No microphone stream to stop");
} }
if (microphoneSender && peerConnection) { if (microphoneSender && peerConnection) {
@ -217,17 +212,7 @@ export function useMicrophone() {
audio: audioConstraints audio: audioConstraints
}); });
console.log("Microphone stream created successfully:", { // Microphone stream created successfully
streamId: stream.id,
audioTracks: stream.getAudioTracks().length,
videoTracks: stream.getVideoTracks().length,
audioTrackDetails: stream.getAudioTracks().map(track => ({
id: track.id,
label: track.label,
enabled: track.enabled,
readyState: track.readyState
}))
});
// Store the stream in both ref and store // Store the stream in both ref and store
microphoneStreamRef.current = stream; microphoneStreamRef.current = stream;
@ -471,7 +456,7 @@ export function useMicrophone() {
setIsStarting(false); setIsStarting(false);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Failed to start microphone:", error); // Failed to start microphone
let micError: MicrophoneError; let micError: MicrophoneError;
if (error instanceof Error) { if (error instanceof Error) {

View File

@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from "react";
import { JsonRpcResponse, useJsonRpc } from "./useJsonRpc";
import { useAudioEvents } from "./useAudioEvents";
export interface UsbDeviceConfig {
keyboard: boolean;
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
audio: boolean;
}
export function useUsbDeviceConfig() {
const { send } = useJsonRpc();
const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchUsbDeviceConfig = useCallback(() => {
setLoading(true);
setError(null);
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
setLoading(false);
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
setError(resp.error.data || "Unknown error");
setUsbDeviceConfig(null);
} else {
const config = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(config);
setError(null);
}
});
}, [send]);
// Listen for audio device changes to update USB config in real-time
const handleAudioDeviceChanged = useCallback(() => {
// Audio device changed, refetching USB config
fetchUsbDeviceConfig();
}, [fetchUsbDeviceConfig]);
// Subscribe to audio events for real-time updates
useAudioEvents(handleAudioDeviceChanged);
useEffect(() => {
fetchUsbDeviceConfig();
}, [fetchUsbDeviceConfig]);
return {
usbDeviceConfig,
loading,
error,
refetch: fetchUsbDeviceConfig,
};
}

View File

@ -34,6 +34,7 @@ import {
VideoState, VideoState,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { useMicrophone } from "@/hooks/useMicrophone"; import { useMicrophone } from "@/hooks/useMicrophone";
import { useAudioEvents } from "@/hooks/useAudioEvents";
import WebRTCVideo from "@components/WebRTCVideo"; import WebRTCVideo from "@components/WebRTCVideo";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
@ -145,6 +146,8 @@ export default function KvmIdRoute() {
// Microphone hook - moved here to prevent unmounting when popover closes // Microphone hook - moved here to prevent unmounting when popover closes
const microphoneHook = useMicrophone(); const microphoneHook = useMicrophone();
// Extract syncMicrophoneState to avoid dependency issues
const { syncMicrophoneState } = microphoneHook;
const isLegacySignalingEnabled = useRef(false); const isLegacySignalingEnabled = useRef(false);
@ -655,6 +658,21 @@ export default function KvmIdRoute() {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const { send } = useJsonRpc(onJsonRpcRequest); const { send } = useJsonRpc(onJsonRpcRequest);
// Handle audio device changes to sync microphone state
const handleAudioDeviceChanged = useCallback((data: { enabled: boolean; reason: string }) => {
console.log('[AudioDeviceChanged] Audio device changed:', data);
// Sync microphone state when audio device configuration changes
// This ensures the microphone state is properly synchronized after USB audio reconfiguration
if (syncMicrophoneState) {
setTimeout(() => {
syncMicrophoneState();
}, 500); // Small delay to ensure backend state is settled
}
}, [syncMicrophoneState]);
// Use audio events hook with device change handler
useAudioEvents(handleAudioDeviceChanged);
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
send("getVideoState", {}, (resp: JsonRpcResponse) => { send("getVideoState", {}, (resp: JsonRpcResponse) => {