mirror of https://github.com/jetkvm/kvm.git
Compare commits
8 Commits
44a35aa5c2
...
7ec583ed6a
Author | SHA1 | Date |
---|---|---|
|
7ec583ed6a | |
|
d1c192bf8b | |
|
c89d678963 | |
|
6f02870c90 | |
|
1a0377bbdf | |
|
f24443e072 | |
|
2afe2ca539 | |
|
bc53523fbb |
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
178
jsonrpc.go
178
jsonrpc.go
|
@ -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
15
main.go
|
@ -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")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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) => {
|
||||||
|
|
Loading…
Reference in New Issue