mirror of https://github.com/jetkvm/kvm.git
Compare commits
5 Commits
fff2d2b791
...
8fb0b9f9c6
Author | SHA1 | Date |
---|---|---|
|
8fb0b9f9c6 | |
|
e8d12bae4b | |
|
6a68e23d12 | |
|
b1f85db7de | |
|
e4ed2b8fad |
|
@ -11,7 +11,27 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdaptiveBufferConfig holds configuration for adaptive buffer sizing
|
// AdaptiveBufferConfig holds configuration for the adaptive buffer sizing algorithm.
|
||||||
|
//
|
||||||
|
// The adaptive buffer system dynamically adjusts audio buffer sizes based on real-time
|
||||||
|
// system conditions to optimize the trade-off between latency and stability. The algorithm
|
||||||
|
// uses multiple factors to make decisions:
|
||||||
|
//
|
||||||
|
// 1. System Load Monitoring:
|
||||||
|
// - CPU usage: High CPU load increases buffer sizes to prevent underruns
|
||||||
|
// - Memory usage: High memory pressure reduces buffer sizes to conserve RAM
|
||||||
|
//
|
||||||
|
// 2. Latency Tracking:
|
||||||
|
// - Target latency: Optimal latency for the current quality setting
|
||||||
|
// - Max latency: Hard limit beyond which buffers are aggressively reduced
|
||||||
|
//
|
||||||
|
// 3. Adaptation Strategy:
|
||||||
|
// - Exponential smoothing: Prevents oscillation and provides stable adjustments
|
||||||
|
// - Discrete steps: Buffer sizes change in fixed increments to avoid instability
|
||||||
|
// - Hysteresis: Different thresholds for increasing vs decreasing buffer sizes
|
||||||
|
//
|
||||||
|
// The algorithm is specifically tuned for embedded ARM systems with limited resources,
|
||||||
|
// prioritizing stability over absolute minimum latency.
|
||||||
type AdaptiveBufferConfig struct {
|
type AdaptiveBufferConfig struct {
|
||||||
// Buffer size limits (in frames)
|
// Buffer size limits (in frames)
|
||||||
MinBufferSize int
|
MinBufferSize int
|
||||||
|
@ -156,6 +176,32 @@ func (abm *AdaptiveBufferManager) adaptationLoop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// adaptBufferSizes analyzes system conditions and adjusts buffer sizes
|
// adaptBufferSizes analyzes system conditions and adjusts buffer sizes
|
||||||
|
// adaptBufferSizes implements the core adaptive buffer sizing algorithm.
|
||||||
|
//
|
||||||
|
// This function uses a multi-factor approach to determine optimal buffer sizes:
|
||||||
|
//
|
||||||
|
// Mathematical Model:
|
||||||
|
// 1. Factor Calculation:
|
||||||
|
//
|
||||||
|
// - CPU Factor: Sigmoid function that increases buffer size under high CPU load
|
||||||
|
//
|
||||||
|
// - Memory Factor: Inverse relationship that decreases buffer size under memory pressure
|
||||||
|
//
|
||||||
|
// - Latency Factor: Exponential decay that aggressively reduces buffers when latency exceeds targets
|
||||||
|
//
|
||||||
|
// 2. Combined Factor:
|
||||||
|
// Combined = (CPU_factor * Memory_factor * Latency_factor)
|
||||||
|
// This multiplicative approach ensures any single critical factor can override others
|
||||||
|
//
|
||||||
|
// 3. Exponential Smoothing:
|
||||||
|
// New_size = Current_size + smoothing_factor * (Target_size - Current_size)
|
||||||
|
// This prevents rapid oscillations and provides stable convergence
|
||||||
|
//
|
||||||
|
// 4. Discrete Quantization:
|
||||||
|
// Final sizes are rounded to frame boundaries and clamped to configured limits
|
||||||
|
//
|
||||||
|
// The algorithm runs periodically and only applies changes when the adaptation interval
|
||||||
|
// has elapsed, preventing excessive adjustments that could destabilize the audio pipeline.
|
||||||
func (abm *AdaptiveBufferManager) adaptBufferSizes() {
|
func (abm *AdaptiveBufferManager) adaptBufferSizes() {
|
||||||
// Collect current system metrics
|
// Collect current system metrics
|
||||||
metrics := abm.processMonitor.GetCurrentMetrics()
|
metrics := abm.processMonitor.GetCurrentMetrics()
|
||||||
|
|
|
@ -45,7 +45,7 @@ func DefaultOptimizerConfig() OptimizerConfig {
|
||||||
CooldownPeriod: GetConfig().CooldownPeriod,
|
CooldownPeriod: GetConfig().CooldownPeriod,
|
||||||
Aggressiveness: GetConfig().OptimizerAggressiveness,
|
Aggressiveness: GetConfig().OptimizerAggressiveness,
|
||||||
RollbackThreshold: GetConfig().RollbackThreshold,
|
RollbackThreshold: GetConfig().RollbackThreshold,
|
||||||
StabilityPeriod: 10 * time.Second,
|
StabilityPeriod: GetConfig().AdaptiveOptimizerStability,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global audio output supervisor instance
|
// Global audio output supervisor instance
|
||||||
globalOutputSupervisor unsafe.Pointer // *AudioServerSupervisor
|
globalOutputSupervisor unsafe.Pointer // *AudioOutputSupervisor
|
||||||
)
|
)
|
||||||
|
|
||||||
// isAudioServerProcess detects if we're running as the audio server subprocess
|
// isAudioServerProcess detects if we're running as the audio server subprocess
|
||||||
|
@ -58,15 +58,15 @@ func StopNonBlockingAudioStreaming() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAudioOutputSupervisor sets the global audio output supervisor
|
// SetAudioOutputSupervisor sets the global audio output supervisor
|
||||||
func SetAudioOutputSupervisor(supervisor *AudioServerSupervisor) {
|
func SetAudioOutputSupervisor(supervisor *AudioOutputSupervisor) {
|
||||||
atomic.StorePointer(&globalOutputSupervisor, unsafe.Pointer(supervisor))
|
atomic.StorePointer(&globalOutputSupervisor, unsafe.Pointer(supervisor))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioOutputSupervisor returns the global audio output supervisor
|
// GetAudioOutputSupervisor returns the global audio output supervisor
|
||||||
func GetAudioOutputSupervisor() *AudioServerSupervisor {
|
func GetAudioOutputSupervisor() *AudioOutputSupervisor {
|
||||||
ptr := atomic.LoadPointer(&globalOutputSupervisor)
|
ptr := atomic.LoadPointer(&globalOutputSupervisor)
|
||||||
if ptr == nil {
|
if ptr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (*AudioServerSupervisor)(ptr)
|
return (*AudioOutputSupervisor)(ptr)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,204 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AtomicCounter provides thread-safe counter operations
|
||||||
|
type AtomicCounter struct {
|
||||||
|
value int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAtomicCounter creates a new atomic counter
|
||||||
|
func NewAtomicCounter() *AtomicCounter {
|
||||||
|
return &AtomicCounter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add atomically adds delta to the counter and returns the new value
|
||||||
|
func (c *AtomicCounter) Add(delta int64) int64 {
|
||||||
|
return atomic.AddInt64(&c.value, delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment atomically increments the counter by 1
|
||||||
|
func (c *AtomicCounter) Increment() int64 {
|
||||||
|
return atomic.AddInt64(&c.value, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load atomically loads the counter value
|
||||||
|
func (c *AtomicCounter) Load() int64 {
|
||||||
|
return atomic.LoadInt64(&c.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store atomically stores a new value
|
||||||
|
func (c *AtomicCounter) Store(value int64) {
|
||||||
|
atomic.StoreInt64(&c.value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset atomically resets the counter to zero
|
||||||
|
func (c *AtomicCounter) Reset() {
|
||||||
|
atomic.StoreInt64(&c.value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap atomically swaps the value and returns the old value
|
||||||
|
func (c *AtomicCounter) Swap(new int64) int64 {
|
||||||
|
return atomic.SwapInt64(&c.value, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameMetrics provides common frame tracking metrics
|
||||||
|
type FrameMetrics struct {
|
||||||
|
Total *AtomicCounter
|
||||||
|
Dropped *AtomicCounter
|
||||||
|
Bytes *AtomicCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFrameMetrics creates a new frame metrics tracker
|
||||||
|
func NewFrameMetrics() *FrameMetrics {
|
||||||
|
return &FrameMetrics{
|
||||||
|
Total: NewAtomicCounter(),
|
||||||
|
Dropped: NewAtomicCounter(),
|
||||||
|
Bytes: NewAtomicCounter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFrame atomically records a successful frame with its size
|
||||||
|
func (fm *FrameMetrics) RecordFrame(size int64) {
|
||||||
|
fm.Total.Increment()
|
||||||
|
fm.Bytes.Add(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordDrop atomically records a dropped frame
|
||||||
|
func (fm *FrameMetrics) RecordDrop() {
|
||||||
|
fm.Dropped.Increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns current metrics values
|
||||||
|
func (fm *FrameMetrics) GetStats() (total, dropped, bytes int64) {
|
||||||
|
return fm.Total.Load(), fm.Dropped.Load(), fm.Bytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset resets all metrics to zero
|
||||||
|
func (fm *FrameMetrics) Reset() {
|
||||||
|
fm.Total.Reset()
|
||||||
|
fm.Dropped.Reset()
|
||||||
|
fm.Bytes.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDropRate calculates the drop rate as a percentage
|
||||||
|
func (fm *FrameMetrics) GetDropRate() float64 {
|
||||||
|
total := fm.Total.Load()
|
||||||
|
if total == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
dropped := fm.Dropped.Load()
|
||||||
|
return float64(dropped) / float64(total) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyTracker provides atomic latency tracking
|
||||||
|
type LatencyTracker struct {
|
||||||
|
current *AtomicCounter
|
||||||
|
min *AtomicCounter
|
||||||
|
max *AtomicCounter
|
||||||
|
average *AtomicCounter
|
||||||
|
samples *AtomicCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLatencyTracker creates a new latency tracker
|
||||||
|
func NewLatencyTracker() *LatencyTracker {
|
||||||
|
lt := &LatencyTracker{
|
||||||
|
current: NewAtomicCounter(),
|
||||||
|
min: NewAtomicCounter(),
|
||||||
|
max: NewAtomicCounter(),
|
||||||
|
average: NewAtomicCounter(),
|
||||||
|
samples: NewAtomicCounter(),
|
||||||
|
}
|
||||||
|
// Initialize min to max value so first measurement sets it properly
|
||||||
|
lt.min.Store(int64(^uint64(0) >> 1)) // Max int64
|
||||||
|
return lt
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLatency atomically records a new latency measurement
|
||||||
|
func (lt *LatencyTracker) RecordLatency(latency time.Duration) {
|
||||||
|
latencyNanos := latency.Nanoseconds()
|
||||||
|
lt.current.Store(latencyNanos)
|
||||||
|
lt.samples.Increment()
|
||||||
|
|
||||||
|
// Update min
|
||||||
|
for {
|
||||||
|
oldMin := lt.min.Load()
|
||||||
|
if latencyNanos >= oldMin {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if atomic.CompareAndSwapInt64(<.min.value, oldMin, latencyNanos) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update max
|
||||||
|
for {
|
||||||
|
oldMax := lt.max.Load()
|
||||||
|
if latencyNanos <= oldMax {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if atomic.CompareAndSwapInt64(<.max.value, oldMax, latencyNanos) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update average using exponential moving average
|
||||||
|
oldAvg := lt.average.Load()
|
||||||
|
newAvg := (oldAvg*7 + latencyNanos) / 8 // 87.5% weight to old average
|
||||||
|
lt.average.Store(newAvg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatencyStats returns current latency statistics
|
||||||
|
func (lt *LatencyTracker) GetLatencyStats() (current, min, max, average time.Duration, samples int64) {
|
||||||
|
return time.Duration(lt.current.Load()),
|
||||||
|
time.Duration(lt.min.Load()),
|
||||||
|
time.Duration(lt.max.Load()),
|
||||||
|
time.Duration(lt.average.Load()),
|
||||||
|
lt.samples.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolMetrics provides common pool performance metrics
|
||||||
|
type PoolMetrics struct {
|
||||||
|
Hits *AtomicCounter
|
||||||
|
Misses *AtomicCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPoolMetrics creates a new pool metrics tracker
|
||||||
|
func NewPoolMetrics() *PoolMetrics {
|
||||||
|
return &PoolMetrics{
|
||||||
|
Hits: NewAtomicCounter(),
|
||||||
|
Misses: NewAtomicCounter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordHit atomically records a pool hit
|
||||||
|
func (pm *PoolMetrics) RecordHit() {
|
||||||
|
pm.Hits.Increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordMiss atomically records a pool miss
|
||||||
|
func (pm *PoolMetrics) RecordMiss() {
|
||||||
|
pm.Misses.Increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHitRate calculates the hit rate as a percentage
|
||||||
|
func (pm *PoolMetrics) GetHitRate() float64 {
|
||||||
|
hits := pm.Hits.Load()
|
||||||
|
misses := pm.Misses.Load()
|
||||||
|
total := hits + misses
|
||||||
|
if total == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(hits) / float64(total) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns hit and miss counts
|
||||||
|
func (pm *PoolMetrics) GetStats() (hits, misses int64, hitRate float64) {
|
||||||
|
hits = pm.Hits.Load()
|
||||||
|
misses = pm.Misses.Load()
|
||||||
|
hitRate = pm.GetHitRate()
|
||||||
|
return
|
||||||
|
}
|
|
@ -40,7 +40,8 @@ func NewAudioBufferPool(bufferSize int) *AudioBufferPool {
|
||||||
preallocSize: preallocSize,
|
preallocSize: preallocSize,
|
||||||
pool: sync.Pool{
|
pool: sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() interface{} {
|
||||||
return make([]byte, 0, bufferSize)
|
buf := make([]byte, 0, bufferSize)
|
||||||
|
return &buf
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,12 +61,15 @@ static volatile int capture_initialized = 0;
|
||||||
static volatile int playback_initializing = 0;
|
static volatile int playback_initializing = 0;
|
||||||
static volatile int playback_initialized = 0;
|
static volatile int playback_initialized = 0;
|
||||||
|
|
||||||
// Safe ALSA device opening with retry logic
|
// Enhanced ALSA device opening with exponential backoff retry logic
|
||||||
static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream_t stream) {
|
static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream_t stream) {
|
||||||
int attempts = 3;
|
int max_attempts = 5; // Increased from 3 to 5
|
||||||
|
int attempt = 0;
|
||||||
int err;
|
int err;
|
||||||
|
int backoff_us = sleep_microseconds; // Start with base sleep time
|
||||||
|
const int max_backoff_us = 500000; // Max 500ms backoff
|
||||||
|
|
||||||
while (attempts-- > 0) {
|
while (attempt < max_attempts) {
|
||||||
err = snd_pcm_open(handle, device, stream, SND_PCM_NONBLOCK);
|
err = snd_pcm_open(handle, device, stream, SND_PCM_NONBLOCK);
|
||||||
if (err >= 0) {
|
if (err >= 0) {
|
||||||
// Switch to blocking mode after successful open
|
// Switch to blocking mode after successful open
|
||||||
|
@ -74,12 +77,26 @@ static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err == -EBUSY && attempts > 0) {
|
attempt++;
|
||||||
// Device busy, wait and retry
|
if (attempt >= max_attempts) break;
|
||||||
usleep(sleep_microseconds); // 50ms
|
|
||||||
continue;
|
// Enhanced error handling with specific retry strategies
|
||||||
|
if (err == -EBUSY || err == -EAGAIN) {
|
||||||
|
// Device busy or temporarily unavailable - retry with backoff
|
||||||
|
usleep(backoff_us);
|
||||||
|
backoff_us = (backoff_us * 2 < max_backoff_us) ? backoff_us * 2 : max_backoff_us;
|
||||||
|
} else if (err == -ENODEV || err == -ENOENT) {
|
||||||
|
// Device not found - longer wait as device might be initializing
|
||||||
|
usleep(backoff_us * 2);
|
||||||
|
backoff_us = (backoff_us * 2 < max_backoff_us) ? backoff_us * 2 : max_backoff_us;
|
||||||
|
} else if (err == -EPERM || err == -EACCES) {
|
||||||
|
// Permission denied - shorter wait, likely persistent issue
|
||||||
|
usleep(backoff_us / 2);
|
||||||
|
} else {
|
||||||
|
// Other errors - standard backoff
|
||||||
|
usleep(backoff_us);
|
||||||
|
backoff_us = (backoff_us * 2 < max_backoff_us) ? backoff_us * 2 : max_backoff_us;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
@ -217,43 +234,114 @@ int jetkvm_audio_init() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and encode one frame with enhanced error handling
|
// jetkvm_audio_read_encode reads one audio frame from ALSA, encodes it with Opus, and handles errors.
|
||||||
|
//
|
||||||
|
// This function implements a robust audio capture pipeline with the following features:
|
||||||
|
// - ALSA PCM capture with automatic device recovery
|
||||||
|
// - Opus encoding with optimized settings for real-time processing
|
||||||
|
// - Progressive error recovery with exponential backoff
|
||||||
|
// - Buffer underrun and device suspension handling
|
||||||
|
//
|
||||||
|
// Error Recovery Strategy:
|
||||||
|
// 1. EPIPE (buffer underrun): Prepare device and retry with progressive delays
|
||||||
|
// 2. ESTRPIPE (device suspended): Resume device with timeout and fallback to prepare
|
||||||
|
// 3. Other errors: Log and attempt recovery up to max_recovery_attempts
|
||||||
|
//
|
||||||
|
// Performance Optimizations:
|
||||||
|
// - Stack-allocated PCM buffer to avoid heap allocations
|
||||||
|
// - Direct memory access for Opus encoding
|
||||||
|
// - Minimal system calls in the hot path
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// opus_buf: Output buffer for encoded Opus data (must be at least max_packet_size bytes)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// >0: Number of bytes written to opus_buf
|
||||||
|
// -1: Initialization error or safety check failure
|
||||||
|
// -2: Unrecoverable ALSA or Opus error after all retry attempts
|
||||||
int jetkvm_audio_read_encode(void *opus_buf) {
|
int jetkvm_audio_read_encode(void *opus_buf) {
|
||||||
short pcm_buffer[1920]; // max 2ch*960
|
short pcm_buffer[1920]; // max 2ch*960
|
||||||
unsigned char *out = (unsigned char*)opus_buf;
|
unsigned char *out = (unsigned char*)opus_buf;
|
||||||
int err = 0;
|
int err = 0;
|
||||||
|
int recovery_attempts = 0;
|
||||||
|
const int max_recovery_attempts = 3;
|
||||||
|
|
||||||
// Safety checks
|
// Safety checks
|
||||||
if (!capture_initialized || !pcm_handle || !encoder || !opus_buf) {
|
if (!capture_initialized || !pcm_handle || !encoder || !opus_buf) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retry_read:
|
||||||
|
;
|
||||||
int pcm_rc = snd_pcm_readi(pcm_handle, pcm_buffer, frame_size);
|
int pcm_rc = snd_pcm_readi(pcm_handle, pcm_buffer, frame_size);
|
||||||
|
|
||||||
// Handle ALSA errors with enhanced recovery
|
// Handle ALSA errors with robust recovery strategies
|
||||||
if (pcm_rc < 0) {
|
if (pcm_rc < 0) {
|
||||||
if (pcm_rc == -EPIPE) {
|
if (pcm_rc == -EPIPE) {
|
||||||
// Buffer underrun - try to recover
|
// Buffer underrun - implement progressive recovery
|
||||||
err = snd_pcm_prepare(pcm_handle);
|
recovery_attempts++;
|
||||||
if (err < 0) return -1;
|
if (recovery_attempts > max_recovery_attempts) {
|
||||||
|
return -1; // Give up after max attempts
|
||||||
|
}
|
||||||
|
|
||||||
pcm_rc = snd_pcm_readi(pcm_handle, pcm_buffer, frame_size);
|
// Try to recover with prepare
|
||||||
if (pcm_rc < 0) return -1;
|
err = snd_pcm_prepare(pcm_handle);
|
||||||
|
if (err < 0) {
|
||||||
|
// If prepare fails, try drop and prepare
|
||||||
|
snd_pcm_drop(pcm_handle);
|
||||||
|
err = snd_pcm_prepare(pcm_handle);
|
||||||
|
if (err < 0) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retry to allow device to stabilize
|
||||||
|
usleep(sleep_microseconds * recovery_attempts);
|
||||||
|
goto retry_read;
|
||||||
} else if (pcm_rc == -EAGAIN) {
|
} else if (pcm_rc == -EAGAIN) {
|
||||||
// No data available - return 0 to indicate no frame
|
// No data available - return 0 to indicate no frame
|
||||||
return 0;
|
return 0;
|
||||||
} else if (pcm_rc == -ESTRPIPE) {
|
} else if (pcm_rc == -ESTRPIPE) {
|
||||||
// Device suspended, try to resume
|
// Device suspended, implement robust resume logic
|
||||||
while ((err = snd_pcm_resume(pcm_handle)) == -EAGAIN) {
|
recovery_attempts++;
|
||||||
usleep(sleep_microseconds); // Use centralized constant
|
if (recovery_attempts > max_recovery_attempts) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resume with timeout
|
||||||
|
int resume_attempts = 0;
|
||||||
|
while ((err = snd_pcm_resume(pcm_handle)) == -EAGAIN && resume_attempts < 10) {
|
||||||
|
usleep(sleep_microseconds);
|
||||||
|
resume_attempts++;
|
||||||
}
|
}
|
||||||
if (err < 0) {
|
if (err < 0) {
|
||||||
|
// Resume failed, try prepare as fallback
|
||||||
err = snd_pcm_prepare(pcm_handle);
|
err = snd_pcm_prepare(pcm_handle);
|
||||||
if (err < 0) return -1;
|
if (err < 0) return -1;
|
||||||
}
|
}
|
||||||
return 0; // Skip this frame
|
// Wait before retry to allow device to stabilize
|
||||||
|
usleep(sleep_microseconds * recovery_attempts);
|
||||||
|
return 0; // Skip this frame but don't fail
|
||||||
|
} else if (pcm_rc == -ENODEV) {
|
||||||
|
// Device disconnected - critical error
|
||||||
|
return -1;
|
||||||
|
} else if (pcm_rc == -EIO) {
|
||||||
|
// I/O error - try recovery once
|
||||||
|
recovery_attempts++;
|
||||||
|
if (recovery_attempts <= max_recovery_attempts) {
|
||||||
|
snd_pcm_drop(pcm_handle);
|
||||||
|
err = snd_pcm_prepare(pcm_handle);
|
||||||
|
if (err >= 0) {
|
||||||
|
usleep(sleep_microseconds);
|
||||||
|
goto retry_read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
} else {
|
} else {
|
||||||
// Other error - return error code
|
// Other errors - limited retry for transient issues
|
||||||
|
recovery_attempts++;
|
||||||
|
if (recovery_attempts <= 1 && (pcm_rc == -EINTR || pcm_rc == -EBUSY)) {
|
||||||
|
usleep(sleep_microseconds / 2);
|
||||||
|
goto retry_read;
|
||||||
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -327,11 +415,38 @@ int jetkvm_audio_playback_init() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode Opus and write PCM with enhanced error handling
|
// jetkvm_audio_decode_write decodes Opus data and writes PCM to ALSA playback device.
|
||||||
|
//
|
||||||
|
// This function implements a robust audio playback pipeline with the following features:
|
||||||
|
// - Opus decoding with packet loss concealment
|
||||||
|
// - ALSA PCM playback with automatic device recovery
|
||||||
|
// - Progressive error recovery with exponential backoff
|
||||||
|
// - Buffer underrun and device suspension handling
|
||||||
|
//
|
||||||
|
// Error Recovery Strategy:
|
||||||
|
// 1. EPIPE (buffer underrun): Prepare device, optionally drop+prepare, retry with delays
|
||||||
|
// 2. ESTRPIPE (device suspended): Resume with timeout, fallback to prepare if needed
|
||||||
|
// 3. Opus decode errors: Attempt packet loss concealment before failing
|
||||||
|
//
|
||||||
|
// Performance Optimizations:
|
||||||
|
// - Stack-allocated PCM buffer to minimize heap allocations
|
||||||
|
// - Bounds checking to prevent buffer overruns
|
||||||
|
// - Direct ALSA device access for minimal latency
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// opus_buf: Input buffer containing Opus-encoded audio data
|
||||||
|
// opus_size: Size of the Opus data in bytes (must be > 0 and <= max_packet_size)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// 0: Success - audio frame decoded and written to playback device
|
||||||
|
// -1: Invalid parameters, initialization error, or bounds check failure
|
||||||
|
// -2: Unrecoverable ALSA or Opus error after all retry attempts
|
||||||
int jetkvm_audio_decode_write(void *opus_buf, int opus_size) {
|
int jetkvm_audio_decode_write(void *opus_buf, int opus_size) {
|
||||||
short pcm_buffer[1920]; // max 2ch*960
|
short pcm_buffer[1920]; // max 2ch*960
|
||||||
unsigned char *in = (unsigned char*)opus_buf;
|
unsigned char *in = (unsigned char*)opus_buf;
|
||||||
int err = 0;
|
int err = 0;
|
||||||
|
int recovery_attempts = 0;
|
||||||
|
const int max_recovery_attempts = 3;
|
||||||
|
|
||||||
// Safety checks
|
// Safety checks
|
||||||
if (!playback_initialized || !pcm_playback_handle || !decoder || !opus_buf || opus_size <= 0) {
|
if (!playback_initialized || !pcm_playback_handle || !decoder || !opus_buf || opus_size <= 0) {
|
||||||
|
@ -343,31 +458,91 @@ int jetkvm_audio_decode_write(void *opus_buf, int opus_size) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode Opus to PCM
|
// Decode Opus to PCM with error handling
|
||||||
int pcm_frames = opus_decode(decoder, in, opus_size, pcm_buffer, frame_size, 0);
|
int pcm_frames = opus_decode(decoder, in, opus_size, pcm_buffer, frame_size, 0);
|
||||||
if (pcm_frames < 0) return -1;
|
if (pcm_frames < 0) {
|
||||||
|
// Try packet loss concealment on decode error
|
||||||
|
pcm_frames = opus_decode(decoder, NULL, 0, pcm_buffer, frame_size, 0);
|
||||||
|
if (pcm_frames < 0) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
// Write PCM to playback device with enhanced recovery
|
retry_write:
|
||||||
|
;
|
||||||
|
// Write PCM to playback device with robust recovery
|
||||||
int pcm_rc = snd_pcm_writei(pcm_playback_handle, pcm_buffer, pcm_frames);
|
int pcm_rc = snd_pcm_writei(pcm_playback_handle, pcm_buffer, pcm_frames);
|
||||||
if (pcm_rc < 0) {
|
if (pcm_rc < 0) {
|
||||||
if (pcm_rc == -EPIPE) {
|
if (pcm_rc == -EPIPE) {
|
||||||
// Buffer underrun - try to recover
|
// Buffer underrun - implement progressive recovery
|
||||||
err = snd_pcm_prepare(pcm_playback_handle);
|
recovery_attempts++;
|
||||||
if (err < 0) return -2;
|
if (recovery_attempts > max_recovery_attempts) {
|
||||||
|
return -2;
|
||||||
pcm_rc = snd_pcm_writei(pcm_playback_handle, pcm_buffer, pcm_frames);
|
|
||||||
} else if (pcm_rc == -ESTRPIPE) {
|
|
||||||
// Device suspended, try to resume
|
|
||||||
while ((err = snd_pcm_resume(pcm_playback_handle)) == -EAGAIN) {
|
|
||||||
usleep(sleep_microseconds); // Use centralized constant
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to recover with prepare
|
||||||
|
err = snd_pcm_prepare(pcm_playback_handle);
|
||||||
if (err < 0) {
|
if (err < 0) {
|
||||||
|
// If prepare fails, try drop and prepare
|
||||||
|
snd_pcm_drop(pcm_playback_handle);
|
||||||
err = snd_pcm_prepare(pcm_playback_handle);
|
err = snd_pcm_prepare(pcm_playback_handle);
|
||||||
if (err < 0) return -2;
|
if (err < 0) return -2;
|
||||||
}
|
}
|
||||||
return 0; // Skip this frame
|
|
||||||
|
// Wait before retry to allow device to stabilize
|
||||||
|
usleep(sleep_microseconds * recovery_attempts);
|
||||||
|
goto retry_write;
|
||||||
|
} else if (pcm_rc == -ESTRPIPE) {
|
||||||
|
// Device suspended, implement robust resume logic
|
||||||
|
recovery_attempts++;
|
||||||
|
if (recovery_attempts > max_recovery_attempts) {
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resume with timeout
|
||||||
|
int resume_attempts = 0;
|
||||||
|
while ((err = snd_pcm_resume(pcm_playback_handle)) == -EAGAIN && resume_attempts < 10) {
|
||||||
|
usleep(sleep_microseconds);
|
||||||
|
resume_attempts++;
|
||||||
|
}
|
||||||
|
if (err < 0) {
|
||||||
|
// Resume failed, try prepare as fallback
|
||||||
|
err = snd_pcm_prepare(pcm_playback_handle);
|
||||||
|
if (err < 0) return -2;
|
||||||
|
}
|
||||||
|
// Wait before retry to allow device to stabilize
|
||||||
|
usleep(sleep_microseconds * recovery_attempts);
|
||||||
|
return 0; // Skip this frame but don't fail
|
||||||
|
} else if (pcm_rc == -ENODEV) {
|
||||||
|
// Device disconnected - critical error
|
||||||
|
return -2;
|
||||||
|
} else if (pcm_rc == -EIO) {
|
||||||
|
// I/O error - try recovery once
|
||||||
|
recovery_attempts++;
|
||||||
|
if (recovery_attempts <= max_recovery_attempts) {
|
||||||
|
snd_pcm_drop(pcm_playback_handle);
|
||||||
|
err = snd_pcm_prepare(pcm_playback_handle);
|
||||||
|
if (err >= 0) {
|
||||||
|
usleep(sleep_microseconds);
|
||||||
|
goto retry_write;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -2;
|
||||||
|
} else if (pcm_rc == -EAGAIN) {
|
||||||
|
// Device not ready - brief wait and retry
|
||||||
|
recovery_attempts++;
|
||||||
|
if (recovery_attempts <= max_recovery_attempts) {
|
||||||
|
usleep(sleep_microseconds / 4);
|
||||||
|
goto retry_write;
|
||||||
|
}
|
||||||
|
return -2;
|
||||||
|
} else {
|
||||||
|
// Other errors - limited retry for transient issues
|
||||||
|
recovery_attempts++;
|
||||||
|
if (recovery_attempts <= 1 && (pcm_rc == -EINTR || pcm_rc == -EBUSY)) {
|
||||||
|
usleep(sleep_microseconds / 2);
|
||||||
|
goto retry_write;
|
||||||
|
}
|
||||||
|
return -2;
|
||||||
}
|
}
|
||||||
if (pcm_rc < 0) return -2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return pcm_frames;
|
return pcm_frames;
|
||||||
|
|
|
@ -881,6 +881,12 @@ type AudioConfigConstants struct {
|
||||||
// Default 5s provides responsive input monitoring.
|
// Default 5s provides responsive input monitoring.
|
||||||
InputSupervisorTimeout time.Duration // 5s
|
InputSupervisorTimeout time.Duration // 5s
|
||||||
|
|
||||||
|
// OutputSupervisorTimeout defines timeout for output supervisor operations.
|
||||||
|
// Used in: supervisor.go for output process monitoring
|
||||||
|
// Impact: Shorter timeouts improve output responsiveness but may cause false timeouts.
|
||||||
|
// Default 5s provides responsive output monitoring.
|
||||||
|
OutputSupervisorTimeout time.Duration // 5s
|
||||||
|
|
||||||
// ShortTimeout defines brief timeout for time-critical operations.
|
// ShortTimeout defines brief timeout for time-critical operations.
|
||||||
// Used in: Real-time audio processing for minimal timeout scenarios
|
// Used in: Real-time audio processing for minimal timeout scenarios
|
||||||
// Impact: Very short timeouts ensure responsiveness but may cause premature failures.
|
// Impact: Very short timeouts ensure responsiveness but may cause premature failures.
|
||||||
|
@ -1382,6 +1388,201 @@ type AudioConfigConstants struct {
|
||||||
// Impact: Controls scaling factor for memory influence on buffer sizing.
|
// Impact: Controls scaling factor for memory influence on buffer sizing.
|
||||||
// Default 100 provides standard percentage scaling for memory calculations.
|
// Default 100 provides standard percentage scaling for memory calculations.
|
||||||
AdaptiveBufferMemoryMultiplier int
|
AdaptiveBufferMemoryMultiplier int
|
||||||
|
|
||||||
|
// Socket Names - Configuration for IPC socket file names
|
||||||
|
// Used in: IPC communication for audio input/output
|
||||||
|
// Impact: Controls socket file naming and IPC connection endpoints
|
||||||
|
|
||||||
|
// InputSocketName defines the socket file name for audio input IPC.
|
||||||
|
// Used in: input_ipc.go for microphone input communication
|
||||||
|
// Impact: Must be unique to prevent conflicts with other audio sockets.
|
||||||
|
// Default "audio_input.sock" provides clear identification for input socket.
|
||||||
|
InputSocketName string
|
||||||
|
|
||||||
|
// OutputSocketName defines the socket file name for audio output IPC.
|
||||||
|
// Used in: ipc.go for audio output communication
|
||||||
|
// Impact: Must be unique to prevent conflicts with other audio sockets.
|
||||||
|
// Default "audio_output.sock" provides clear identification for output socket.
|
||||||
|
OutputSocketName string
|
||||||
|
|
||||||
|
// Component Names - Standardized component identifiers for logging
|
||||||
|
// Used in: Logging and monitoring throughout audio system
|
||||||
|
// Impact: Provides consistent component identification across logs
|
||||||
|
|
||||||
|
// AudioInputComponentName defines component name for audio input logging.
|
||||||
|
// Used in: input_ipc.go and related input processing components
|
||||||
|
// Impact: Ensures consistent logging identification for input components.
|
||||||
|
// Default "audio-input" provides clear component identification.
|
||||||
|
AudioInputComponentName string
|
||||||
|
|
||||||
|
// AudioOutputComponentName defines component name for audio output logging.
|
||||||
|
// Used in: ipc.go and related output processing components
|
||||||
|
// Impact: Ensures consistent logging identification for output components.
|
||||||
|
// Default "audio-output" provides clear component identification.
|
||||||
|
AudioOutputComponentName string
|
||||||
|
|
||||||
|
// AudioServerComponentName defines component name for audio server logging.
|
||||||
|
// Used in: supervisor.go and server management components
|
||||||
|
// Impact: Ensures consistent logging identification for server components.
|
||||||
|
// Default "audio-server" provides clear component identification.
|
||||||
|
AudioServerComponentName string
|
||||||
|
|
||||||
|
// AudioRelayComponentName defines component name for audio relay logging.
|
||||||
|
// Used in: relay.go for audio relay operations
|
||||||
|
// Impact: Ensures consistent logging identification for relay components.
|
||||||
|
// Default "audio-relay" provides clear component identification.
|
||||||
|
AudioRelayComponentName string
|
||||||
|
|
||||||
|
// AudioEventsComponentName defines component name for audio events logging.
|
||||||
|
// Used in: events.go for event broadcasting operations
|
||||||
|
// Impact: Ensures consistent logging identification for event components.
|
||||||
|
// Default "audio-events" provides clear component identification.
|
||||||
|
AudioEventsComponentName string
|
||||||
|
|
||||||
|
// Test Configuration - Constants for testing scenarios
|
||||||
|
// Used in: Test files for consistent test configuration
|
||||||
|
// Impact: Provides standardized test parameters and timeouts
|
||||||
|
|
||||||
|
// TestSocketTimeout defines timeout for test socket operations.
|
||||||
|
// Used in: integration_test.go for test socket communication
|
||||||
|
// Impact: Prevents test hangs while allowing sufficient time for operations.
|
||||||
|
// Default 100ms provides quick test execution with adequate timeout.
|
||||||
|
TestSocketTimeout time.Duration
|
||||||
|
|
||||||
|
// TestBufferSize defines buffer size for test operations.
|
||||||
|
// Used in: test_utils.go for test buffer allocation
|
||||||
|
// Impact: Provides adequate buffer space for test scenarios.
|
||||||
|
// Default 4096 bytes matches production buffer sizes for realistic testing.
|
||||||
|
TestBufferSize int
|
||||||
|
|
||||||
|
// TestRetryDelay defines delay between test retry attempts.
|
||||||
|
// Used in: Test files for retry logic in test scenarios
|
||||||
|
// Impact: Provides reasonable delay for test retry operations.
|
||||||
|
// Default 200ms allows sufficient time for test state changes.
|
||||||
|
TestRetryDelay time.Duration
|
||||||
|
|
||||||
|
// Latency Histogram Configuration - Constants for latency tracking
|
||||||
|
// Used in: granular_metrics.go for latency distribution analysis
|
||||||
|
// Impact: Controls granularity and accuracy of latency measurements
|
||||||
|
|
||||||
|
// LatencyHistogramMaxSamples defines maximum samples for latency tracking.
|
||||||
|
// Used in: granular_metrics.go for latency histogram management
|
||||||
|
// Impact: Controls memory usage and accuracy of latency statistics.
|
||||||
|
// Default 1000 samples provides good statistical accuracy with reasonable memory usage.
|
||||||
|
LatencyHistogramMaxSamples int
|
||||||
|
|
||||||
|
// LatencyPercentile50 defines 50th percentile calculation factor.
|
||||||
|
// Used in: granular_metrics.go for median latency calculation
|
||||||
|
// Impact: Must be 50 for accurate median calculation.
|
||||||
|
// Default 50 provides standard median percentile calculation.
|
||||||
|
LatencyPercentile50 int
|
||||||
|
|
||||||
|
// LatencyPercentile95 defines 95th percentile calculation factor.
|
||||||
|
// Used in: granular_metrics.go for high-percentile latency calculation
|
||||||
|
// Impact: Must be 95 for accurate 95th percentile calculation.
|
||||||
|
// Default 95 provides standard high-percentile calculation.
|
||||||
|
LatencyPercentile95 int
|
||||||
|
|
||||||
|
// LatencyPercentile99 defines 99th percentile calculation factor.
|
||||||
|
// Used in: granular_metrics.go for extreme latency calculation
|
||||||
|
// Impact: Must be 99 for accurate 99th percentile calculation.
|
||||||
|
// Default 99 provides standard extreme percentile calculation.
|
||||||
|
LatencyPercentile99 int
|
||||||
|
|
||||||
|
// BufferPoolMaxOperations defines maximum operations to track for efficiency.
|
||||||
|
// Used in: granular_metrics.go for buffer pool efficiency tracking
|
||||||
|
// Impact: Controls memory usage and accuracy of efficiency statistics.
|
||||||
|
// Default 1000 operations provides good balance of accuracy and memory usage.
|
||||||
|
BufferPoolMaxOperations int
|
||||||
|
|
||||||
|
// HitRateCalculationBase defines base value for hit rate percentage calculation.
|
||||||
|
// Used in: granular_metrics.go for hit rate percentage calculation
|
||||||
|
// Impact: Must be 100 for accurate percentage calculation.
|
||||||
|
// Default 100 provides standard percentage calculation base.
|
||||||
|
HitRateCalculationBase float64
|
||||||
|
|
||||||
|
// Validation Constants - Configuration for input validation
|
||||||
|
// Used in: validation.go for parameter validation
|
||||||
|
// Impact: Controls validation thresholds and limits
|
||||||
|
|
||||||
|
// MaxLatency defines maximum allowed latency for audio processing.
|
||||||
|
// Used in: validation.go for latency validation
|
||||||
|
// Impact: Controls maximum acceptable latency before optimization triggers.
|
||||||
|
// Default 200ms provides reasonable upper bound for real-time audio.
|
||||||
|
MaxLatency time.Duration
|
||||||
|
|
||||||
|
// MinMetricsUpdateInterval defines minimum allowed metrics update interval.
|
||||||
|
// Used in: validation.go for metrics interval validation
|
||||||
|
// Impact: Prevents excessive metrics updates that could impact performance.
|
||||||
|
// Default 100ms provides reasonable minimum update frequency.
|
||||||
|
MinMetricsUpdateInterval time.Duration
|
||||||
|
|
||||||
|
// MaxMetricsUpdateInterval defines maximum allowed metrics update interval.
|
||||||
|
// Used in: validation.go for metrics interval validation
|
||||||
|
// Impact: Ensures metrics are updated frequently enough for monitoring.
|
||||||
|
// Default 30s provides reasonable maximum update interval.
|
||||||
|
MaxMetricsUpdateInterval time.Duration
|
||||||
|
|
||||||
|
// MinSampleRate defines minimum allowed audio sample rate.
|
||||||
|
// Used in: validation.go for sample rate validation
|
||||||
|
// Impact: Ensures sample rate is sufficient for audio quality.
|
||||||
|
// Default 8000Hz provides minimum for voice communication.
|
||||||
|
MinSampleRate int
|
||||||
|
|
||||||
|
// MaxSampleRate defines maximum allowed audio sample rate.
|
||||||
|
// Used in: validation.go for sample rate validation
|
||||||
|
// Impact: Prevents excessive sample rates that could impact performance.
|
||||||
|
// Default 192000Hz provides upper bound for high-quality audio.
|
||||||
|
MaxSampleRate int
|
||||||
|
|
||||||
|
// MaxChannels defines maximum allowed audio channels.
|
||||||
|
// Used in: validation.go for channel count validation
|
||||||
|
// Impact: Prevents excessive channel counts that could impact performance.
|
||||||
|
// Default 8 channels provides reasonable upper bound for multi-channel audio.
|
||||||
|
MaxChannels int
|
||||||
|
|
||||||
|
// Device Health Monitoring Configuration
|
||||||
|
// Used in: device_health.go for proactive device monitoring and recovery
|
||||||
|
// Impact: Controls health check frequency and recovery thresholds
|
||||||
|
|
||||||
|
// HealthCheckIntervalMS defines interval between device health checks in milliseconds.
|
||||||
|
// Used in: DeviceHealthMonitor for periodic health assessment
|
||||||
|
// Impact: Lower values provide faster detection but increase CPU usage.
|
||||||
|
// Default 5000ms (5s) provides good balance between responsiveness and overhead.
|
||||||
|
HealthCheckIntervalMS int
|
||||||
|
|
||||||
|
// HealthRecoveryThreshold defines number of consecutive successful operations
|
||||||
|
// required to mark a device as healthy after being unhealthy.
|
||||||
|
// Used in: DeviceHealthMonitor for recovery state management
|
||||||
|
// Impact: Higher values prevent premature recovery declarations.
|
||||||
|
// Default 3 consecutive successes ensures stable recovery.
|
||||||
|
HealthRecoveryThreshold int
|
||||||
|
|
||||||
|
// HealthLatencyThresholdMS defines maximum acceptable latency in milliseconds
|
||||||
|
// before considering a device unhealthy.
|
||||||
|
// Used in: DeviceHealthMonitor for latency-based health assessment
|
||||||
|
// Impact: Lower values trigger recovery sooner but may cause false positives.
|
||||||
|
// Default 100ms provides reasonable threshold for real-time audio.
|
||||||
|
HealthLatencyThresholdMS int
|
||||||
|
|
||||||
|
// HealthErrorRateLimit defines maximum error rate (0.0-1.0) before
|
||||||
|
// considering a device unhealthy.
|
||||||
|
// Used in: DeviceHealthMonitor for error rate assessment
|
||||||
|
// Impact: Lower values trigger recovery sooner for error-prone devices.
|
||||||
|
// Default 0.1 (10%) allows some transient errors while detecting problems.
|
||||||
|
HealthErrorRateLimit float64
|
||||||
|
|
||||||
|
// Latency Histogram Bucket Configuration
|
||||||
|
// Used in: LatencyHistogram for granular latency measurement buckets
|
||||||
|
// Impact: Defines the boundaries for latency distribution analysis
|
||||||
|
LatencyBucket10ms time.Duration // 10ms latency bucket
|
||||||
|
LatencyBucket25ms time.Duration // 25ms latency bucket
|
||||||
|
LatencyBucket50ms time.Duration // 50ms latency bucket
|
||||||
|
LatencyBucket100ms time.Duration // 100ms latency bucket
|
||||||
|
LatencyBucket250ms time.Duration // 250ms latency bucket
|
||||||
|
LatencyBucket500ms time.Duration // 500ms latency bucket
|
||||||
|
LatencyBucket1s time.Duration // 1s latency bucket
|
||||||
|
LatencyBucket2s time.Duration // 2s latency bucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAudioConfig returns the default configuration constants
|
// DefaultAudioConfig returns the default configuration constants
|
||||||
|
@ -2204,6 +2405,12 @@ func DefaultAudioConfig() *AudioConfigConstants {
|
||||||
// Default 5s (shorter than general supervisor) for faster input recovery
|
// Default 5s (shorter than general supervisor) for faster input recovery
|
||||||
InputSupervisorTimeout: 5 * time.Second,
|
InputSupervisorTimeout: 5 * time.Second,
|
||||||
|
|
||||||
|
// OutputSupervisorTimeout defines timeout for output supervisor operations.
|
||||||
|
// Used in: Output process monitoring, speaker supervision
|
||||||
|
// Impact: Controls responsiveness of output failure detection
|
||||||
|
// Default 5s (shorter than general supervisor) for faster output recovery
|
||||||
|
OutputSupervisorTimeout: 5 * time.Second,
|
||||||
|
|
||||||
// ShortTimeout defines brief timeout for quick operations (5ms).
|
// ShortTimeout defines brief timeout for quick operations (5ms).
|
||||||
// Used in: Lock acquisition, quick IPC operations, immediate responses
|
// Used in: Lock acquisition, quick IPC operations, immediate responses
|
||||||
// Impact: Critical for maintaining real-time performance
|
// Impact: Critical for maintaining real-time performance
|
||||||
|
@ -2365,6 +2572,56 @@ func DefaultAudioConfig() *AudioConfigConstants {
|
||||||
// Adaptive Buffer Constants
|
// Adaptive Buffer Constants
|
||||||
AdaptiveBufferCPUMultiplier: 100, // 100 multiplier for CPU percentage
|
AdaptiveBufferCPUMultiplier: 100, // 100 multiplier for CPU percentage
|
||||||
AdaptiveBufferMemoryMultiplier: 100, // 100 multiplier for memory percentage
|
AdaptiveBufferMemoryMultiplier: 100, // 100 multiplier for memory percentage
|
||||||
|
|
||||||
|
// Socket Names
|
||||||
|
InputSocketName: "audio_input.sock", // Socket name for audio input IPC
|
||||||
|
OutputSocketName: "audio_output.sock", // Socket name for audio output IPC
|
||||||
|
|
||||||
|
// Component Names
|
||||||
|
AudioInputComponentName: "audio-input", // Component name for input logging
|
||||||
|
AudioOutputComponentName: "audio-output", // Component name for output logging
|
||||||
|
AudioServerComponentName: "audio-server", // Component name for server logging
|
||||||
|
AudioRelayComponentName: "audio-relay", // Component name for relay logging
|
||||||
|
AudioEventsComponentName: "audio-events", // Component name for events logging
|
||||||
|
|
||||||
|
// Test Configuration
|
||||||
|
TestSocketTimeout: 100 * time.Millisecond, // 100ms timeout for test socket operations
|
||||||
|
TestBufferSize: 4096, // 4096 bytes buffer size for test operations
|
||||||
|
TestRetryDelay: 200 * time.Millisecond, // 200ms delay between test retry attempts
|
||||||
|
|
||||||
|
// Latency Histogram Configuration
|
||||||
|
LatencyHistogramMaxSamples: 1000, // 1000 samples for latency tracking
|
||||||
|
LatencyPercentile50: 50, // 50th percentile calculation factor
|
||||||
|
LatencyPercentile95: 95, // 95th percentile calculation factor
|
||||||
|
LatencyPercentile99: 99, // 99th percentile calculation factor
|
||||||
|
|
||||||
|
// Buffer Pool Efficiency Constants
|
||||||
|
BufferPoolMaxOperations: 1000, // 1000 operations for efficiency tracking
|
||||||
|
HitRateCalculationBase: 100.0, // 100.0 base for hit rate percentage calculation
|
||||||
|
|
||||||
|
// Validation Constants
|
||||||
|
MaxLatency: 500 * time.Millisecond, // 500ms maximum allowed latency
|
||||||
|
MinMetricsUpdateInterval: 100 * time.Millisecond, // 100ms minimum metrics update interval
|
||||||
|
MaxMetricsUpdateInterval: 10 * time.Second, // 10s maximum metrics update interval
|
||||||
|
MinSampleRate: 8000, // 8kHz minimum sample rate
|
||||||
|
MaxSampleRate: 48000, // 48kHz maximum sample rate
|
||||||
|
MaxChannels: 8, // 8 maximum audio channels
|
||||||
|
|
||||||
|
// Device Health Monitoring Configuration
|
||||||
|
HealthCheckIntervalMS: 5000, // 5000ms (5s) health check interval
|
||||||
|
HealthRecoveryThreshold: 3, // 3 consecutive successes for recovery
|
||||||
|
HealthLatencyThresholdMS: 100, // 100ms latency threshold for health
|
||||||
|
HealthErrorRateLimit: 0.1, // 10% error rate limit for health
|
||||||
|
|
||||||
|
// Latency Histogram Bucket Configuration
|
||||||
|
LatencyBucket10ms: 10 * time.Millisecond, // 10ms latency bucket
|
||||||
|
LatencyBucket25ms: 25 * time.Millisecond, // 25ms latency bucket
|
||||||
|
LatencyBucket50ms: 50 * time.Millisecond, // 50ms latency bucket
|
||||||
|
LatencyBucket100ms: 100 * time.Millisecond, // 100ms latency bucket
|
||||||
|
LatencyBucket250ms: 250 * time.Millisecond, // 250ms latency bucket
|
||||||
|
LatencyBucket500ms: 500 * time.Millisecond, // 500ms latency bucket
|
||||||
|
LatencyBucket1s: 1 * time.Second, // 1s latency bucket
|
||||||
|
LatencyBucket2s: 2 * time.Second, // 2s latency bucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,514 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceHealthStatus represents the health status of an audio device
|
||||||
|
type DeviceHealthStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeviceHealthUnknown DeviceHealthStatus = iota
|
||||||
|
DeviceHealthHealthy
|
||||||
|
DeviceHealthDegraded
|
||||||
|
DeviceHealthFailing
|
||||||
|
DeviceHealthCritical
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s DeviceHealthStatus) String() string {
|
||||||
|
switch s {
|
||||||
|
case DeviceHealthHealthy:
|
||||||
|
return "healthy"
|
||||||
|
case DeviceHealthDegraded:
|
||||||
|
return "degraded"
|
||||||
|
case DeviceHealthFailing:
|
||||||
|
return "failing"
|
||||||
|
case DeviceHealthCritical:
|
||||||
|
return "critical"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceHealthMetrics tracks health-related metrics for audio devices
|
||||||
|
type DeviceHealthMetrics struct {
|
||||||
|
// Error tracking
|
||||||
|
ConsecutiveErrors int64 `json:"consecutive_errors"`
|
||||||
|
TotalErrors int64 `json:"total_errors"`
|
||||||
|
LastErrorTime time.Time `json:"last_error_time"`
|
||||||
|
ErrorRate float64 `json:"error_rate"` // errors per minute
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
AverageLatency time.Duration `json:"average_latency"`
|
||||||
|
MaxLatency time.Duration `json:"max_latency"`
|
||||||
|
LatencySpikes int64 `json:"latency_spikes"`
|
||||||
|
Underruns int64 `json:"underruns"`
|
||||||
|
Overruns int64 `json:"overruns"`
|
||||||
|
|
||||||
|
// Device availability
|
||||||
|
LastSuccessfulOp time.Time `json:"last_successful_op"`
|
||||||
|
DeviceDisconnects int64 `json:"device_disconnects"`
|
||||||
|
RecoveryAttempts int64 `json:"recovery_attempts"`
|
||||||
|
SuccessfulRecoveries int64 `json:"successful_recoveries"`
|
||||||
|
|
||||||
|
// Health assessment
|
||||||
|
CurrentStatus DeviceHealthStatus `json:"current_status"`
|
||||||
|
StatusLastChanged time.Time `json:"status_last_changed"`
|
||||||
|
HealthScore float64 `json:"health_score"` // 0.0 to 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceHealthMonitor monitors the health of audio devices and triggers recovery
|
||||||
|
type DeviceHealthMonitor struct {
|
||||||
|
// Atomic fields first for ARM32 alignment
|
||||||
|
running int32
|
||||||
|
monitoringEnabled int32
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
checkInterval time.Duration
|
||||||
|
recoveryThreshold int
|
||||||
|
latencyThreshold time.Duration
|
||||||
|
errorRateLimit float64 // max errors per minute
|
||||||
|
|
||||||
|
// State tracking
|
||||||
|
captureMetrics *DeviceHealthMetrics
|
||||||
|
playbackMetrics *DeviceHealthMetrics
|
||||||
|
mutex sync.RWMutex
|
||||||
|
|
||||||
|
// Control channels
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
stopChan chan struct{}
|
||||||
|
doneChan chan struct{}
|
||||||
|
|
||||||
|
// Recovery callbacks
|
||||||
|
recoveryCallbacks map[string]func() error
|
||||||
|
callbackMutex sync.RWMutex
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
logger zerolog.Logger
|
||||||
|
config *AudioConfigConstants
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeviceHealthMonitor creates a new device health monitor
|
||||||
|
func NewDeviceHealthMonitor() *DeviceHealthMonitor {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
config := GetConfig()
|
||||||
|
|
||||||
|
return &DeviceHealthMonitor{
|
||||||
|
checkInterval: time.Duration(config.HealthCheckIntervalMS) * time.Millisecond,
|
||||||
|
recoveryThreshold: config.HealthRecoveryThreshold,
|
||||||
|
latencyThreshold: time.Duration(config.HealthLatencyThresholdMS) * time.Millisecond,
|
||||||
|
errorRateLimit: config.HealthErrorRateLimit,
|
||||||
|
captureMetrics: &DeviceHealthMetrics{
|
||||||
|
CurrentStatus: DeviceHealthUnknown,
|
||||||
|
HealthScore: 1.0,
|
||||||
|
},
|
||||||
|
playbackMetrics: &DeviceHealthMetrics{
|
||||||
|
CurrentStatus: DeviceHealthUnknown,
|
||||||
|
HealthScore: 1.0,
|
||||||
|
},
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
doneChan: make(chan struct{}),
|
||||||
|
recoveryCallbacks: make(map[string]func() error),
|
||||||
|
logger: logging.GetDefaultLogger().With().Str("component", "device-health-monitor").Logger(),
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins health monitoring
|
||||||
|
func (dhm *DeviceHealthMonitor) Start() error {
|
||||||
|
if !atomic.CompareAndSwapInt32(&dhm.running, 0, 1) {
|
||||||
|
return fmt.Errorf("device health monitor already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.logger.Info().Msg("starting device health monitor")
|
||||||
|
atomic.StoreInt32(&dhm.monitoringEnabled, 1)
|
||||||
|
|
||||||
|
go dhm.monitoringLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops health monitoring
|
||||||
|
func (dhm *DeviceHealthMonitor) Stop() {
|
||||||
|
if !atomic.CompareAndSwapInt32(&dhm.running, 1, 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.logger.Info().Msg("stopping device health monitor")
|
||||||
|
atomic.StoreInt32(&dhm.monitoringEnabled, 0)
|
||||||
|
|
||||||
|
close(dhm.stopChan)
|
||||||
|
dhm.cancel()
|
||||||
|
|
||||||
|
// Wait for monitoring loop to finish
|
||||||
|
select {
|
||||||
|
case <-dhm.doneChan:
|
||||||
|
dhm.logger.Info().Msg("device health monitor stopped")
|
||||||
|
case <-time.After(time.Duration(dhm.config.SupervisorTimeout)):
|
||||||
|
dhm.logger.Warn().Msg("device health monitor stop timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRecoveryCallback registers a recovery function for a specific component
|
||||||
|
func (dhm *DeviceHealthMonitor) RegisterRecoveryCallback(component string, callback func() error) {
|
||||||
|
dhm.callbackMutex.Lock()
|
||||||
|
defer dhm.callbackMutex.Unlock()
|
||||||
|
dhm.recoveryCallbacks[component] = callback
|
||||||
|
dhm.logger.Info().Str("component", component).Msg("registered recovery callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordError records an error for health tracking
|
||||||
|
func (dhm *DeviceHealthMonitor) RecordError(deviceType string, err error) {
|
||||||
|
if atomic.LoadInt32(&dhm.monitoringEnabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.mutex.Lock()
|
||||||
|
defer dhm.mutex.Unlock()
|
||||||
|
|
||||||
|
var metrics *DeviceHealthMetrics
|
||||||
|
switch deviceType {
|
||||||
|
case "capture":
|
||||||
|
metrics = dhm.captureMetrics
|
||||||
|
case "playback":
|
||||||
|
metrics = dhm.playbackMetrics
|
||||||
|
default:
|
||||||
|
dhm.logger.Warn().Str("device_type", deviceType).Msg("unknown device type for error recording")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&metrics.ConsecutiveErrors, 1)
|
||||||
|
atomic.AddInt64(&metrics.TotalErrors, 1)
|
||||||
|
metrics.LastErrorTime = time.Now()
|
||||||
|
|
||||||
|
// Update error rate (errors per minute)
|
||||||
|
if !metrics.LastErrorTime.IsZero() {
|
||||||
|
timeSinceFirst := time.Since(metrics.LastErrorTime)
|
||||||
|
if timeSinceFirst > 0 {
|
||||||
|
metrics.ErrorRate = float64(metrics.TotalErrors) / timeSinceFirst.Minutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.logger.Debug().
|
||||||
|
Str("device_type", deviceType).
|
||||||
|
Err(err).
|
||||||
|
Int64("consecutive_errors", metrics.ConsecutiveErrors).
|
||||||
|
Float64("error_rate", metrics.ErrorRate).
|
||||||
|
Msg("recorded device error")
|
||||||
|
|
||||||
|
// Trigger immediate health assessment
|
||||||
|
dhm.assessDeviceHealth(deviceType, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful operation
|
||||||
|
func (dhm *DeviceHealthMonitor) RecordSuccess(deviceType string) {
|
||||||
|
if atomic.LoadInt32(&dhm.monitoringEnabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.mutex.Lock()
|
||||||
|
defer dhm.mutex.Unlock()
|
||||||
|
|
||||||
|
var metrics *DeviceHealthMetrics
|
||||||
|
switch deviceType {
|
||||||
|
case "capture":
|
||||||
|
metrics = dhm.captureMetrics
|
||||||
|
case "playback":
|
||||||
|
metrics = dhm.playbackMetrics
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset consecutive errors on success
|
||||||
|
atomic.StoreInt64(&metrics.ConsecutiveErrors, 0)
|
||||||
|
metrics.LastSuccessfulOp = time.Now()
|
||||||
|
|
||||||
|
// Improve health score gradually
|
||||||
|
if metrics.HealthScore < 1.0 {
|
||||||
|
metrics.HealthScore = min(1.0, metrics.HealthScore+0.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLatency records operation latency for health assessment
|
||||||
|
func (dhm *DeviceHealthMonitor) RecordLatency(deviceType string, latency time.Duration) {
|
||||||
|
if atomic.LoadInt32(&dhm.monitoringEnabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.mutex.Lock()
|
||||||
|
defer dhm.mutex.Unlock()
|
||||||
|
|
||||||
|
var metrics *DeviceHealthMetrics
|
||||||
|
switch deviceType {
|
||||||
|
case "capture":
|
||||||
|
metrics = dhm.captureMetrics
|
||||||
|
case "playback":
|
||||||
|
metrics = dhm.playbackMetrics
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update latency metrics
|
||||||
|
if metrics.AverageLatency == 0 {
|
||||||
|
metrics.AverageLatency = latency
|
||||||
|
} else {
|
||||||
|
// Exponential moving average
|
||||||
|
metrics.AverageLatency = time.Duration(float64(metrics.AverageLatency)*0.9 + float64(latency)*0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if latency > metrics.MaxLatency {
|
||||||
|
metrics.MaxLatency = latency
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track latency spikes
|
||||||
|
if latency > dhm.latencyThreshold {
|
||||||
|
atomic.AddInt64(&metrics.LatencySpikes, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordUnderrun records an audio underrun event
|
||||||
|
func (dhm *DeviceHealthMonitor) RecordUnderrun(deviceType string) {
|
||||||
|
if atomic.LoadInt32(&dhm.monitoringEnabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.mutex.Lock()
|
||||||
|
defer dhm.mutex.Unlock()
|
||||||
|
|
||||||
|
var metrics *DeviceHealthMetrics
|
||||||
|
switch deviceType {
|
||||||
|
case "capture":
|
||||||
|
metrics = dhm.captureMetrics
|
||||||
|
case "playback":
|
||||||
|
metrics = dhm.playbackMetrics
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&metrics.Underruns, 1)
|
||||||
|
dhm.logger.Debug().Str("device_type", deviceType).Msg("recorded audio underrun")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOverrun records an audio overrun event
|
||||||
|
func (dhm *DeviceHealthMonitor) RecordOverrun(deviceType string) {
|
||||||
|
if atomic.LoadInt32(&dhm.monitoringEnabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dhm.mutex.Lock()
|
||||||
|
defer dhm.mutex.Unlock()
|
||||||
|
|
||||||
|
var metrics *DeviceHealthMetrics
|
||||||
|
switch deviceType {
|
||||||
|
case "capture":
|
||||||
|
metrics = dhm.captureMetrics
|
||||||
|
case "playback":
|
||||||
|
metrics = dhm.playbackMetrics
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddInt64(&metrics.Overruns, 1)
|
||||||
|
dhm.logger.Debug().Str("device_type", deviceType).Msg("recorded audio overrun")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthMetrics returns current health metrics
|
||||||
|
func (dhm *DeviceHealthMonitor) GetHealthMetrics() (capture, playback DeviceHealthMetrics) {
|
||||||
|
dhm.mutex.RLock()
|
||||||
|
defer dhm.mutex.RUnlock()
|
||||||
|
return *dhm.captureMetrics, *dhm.playbackMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitoringLoop runs the main health monitoring loop
|
||||||
|
func (dhm *DeviceHealthMonitor) monitoringLoop() {
|
||||||
|
defer close(dhm.doneChan)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(dhm.checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-dhm.stopChan:
|
||||||
|
return
|
||||||
|
case <-dhm.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
dhm.performHealthCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// performHealthCheck performs a comprehensive health check
|
||||||
|
func (dhm *DeviceHealthMonitor) performHealthCheck() {
|
||||||
|
dhm.mutex.Lock()
|
||||||
|
defer dhm.mutex.Unlock()
|
||||||
|
|
||||||
|
// Assess health for both devices
|
||||||
|
dhm.assessDeviceHealth("capture", dhm.captureMetrics)
|
||||||
|
dhm.assessDeviceHealth("playback", dhm.playbackMetrics)
|
||||||
|
|
||||||
|
// Check if recovery is needed
|
||||||
|
dhm.checkRecoveryNeeded("capture", dhm.captureMetrics)
|
||||||
|
dhm.checkRecoveryNeeded("playback", dhm.playbackMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assessDeviceHealth assesses the health status of a device
|
||||||
|
func (dhm *DeviceHealthMonitor) assessDeviceHealth(deviceType string, metrics *DeviceHealthMetrics) {
|
||||||
|
previousStatus := metrics.CurrentStatus
|
||||||
|
newStatus := dhm.calculateHealthStatus(metrics)
|
||||||
|
|
||||||
|
if newStatus != previousStatus {
|
||||||
|
metrics.CurrentStatus = newStatus
|
||||||
|
metrics.StatusLastChanged = time.Now()
|
||||||
|
dhm.logger.Info().
|
||||||
|
Str("device_type", deviceType).
|
||||||
|
Str("previous_status", previousStatus.String()).
|
||||||
|
Str("new_status", newStatus.String()).
|
||||||
|
Float64("health_score", metrics.HealthScore).
|
||||||
|
Msg("device health status changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update health score
|
||||||
|
metrics.HealthScore = dhm.calculateHealthScore(metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateHealthStatus determines health status based on metrics
|
||||||
|
func (dhm *DeviceHealthMonitor) calculateHealthStatus(metrics *DeviceHealthMetrics) DeviceHealthStatus {
|
||||||
|
consecutiveErrors := atomic.LoadInt64(&metrics.ConsecutiveErrors)
|
||||||
|
totalErrors := atomic.LoadInt64(&metrics.TotalErrors)
|
||||||
|
|
||||||
|
// Critical: Too many consecutive errors or device disconnected recently
|
||||||
|
if consecutiveErrors >= int64(dhm.recoveryThreshold) {
|
||||||
|
return DeviceHealthCritical
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical: No successful operations in a long time
|
||||||
|
if !metrics.LastSuccessfulOp.IsZero() && time.Since(metrics.LastSuccessfulOp) > time.Duration(dhm.config.SupervisorTimeout) {
|
||||||
|
return DeviceHealthCritical
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failing: High error rate or frequent latency spikes
|
||||||
|
if metrics.ErrorRate > dhm.errorRateLimit || atomic.LoadInt64(&metrics.LatencySpikes) > int64(dhm.config.MaxDroppedFrames) {
|
||||||
|
return DeviceHealthFailing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degraded: Some errors or performance issues
|
||||||
|
if consecutiveErrors > 0 || totalErrors > int64(dhm.config.MaxDroppedFrames/2) || metrics.AverageLatency > dhm.latencyThreshold {
|
||||||
|
return DeviceHealthDegraded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthy: No significant issues
|
||||||
|
return DeviceHealthHealthy
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateHealthScore calculates a numeric health score (0.0 to 1.0)
|
||||||
|
func (dhm *DeviceHealthMonitor) calculateHealthScore(metrics *DeviceHealthMetrics) float64 {
|
||||||
|
score := 1.0
|
||||||
|
|
||||||
|
// Penalize consecutive errors
|
||||||
|
consecutiveErrors := atomic.LoadInt64(&metrics.ConsecutiveErrors)
|
||||||
|
if consecutiveErrors > 0 {
|
||||||
|
score -= float64(consecutiveErrors) * 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalize high error rate
|
||||||
|
if metrics.ErrorRate > 0 {
|
||||||
|
score -= min(0.5, metrics.ErrorRate/dhm.errorRateLimit*0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalize high latency
|
||||||
|
if metrics.AverageLatency > dhm.latencyThreshold {
|
||||||
|
excess := float64(metrics.AverageLatency-dhm.latencyThreshold) / float64(dhm.latencyThreshold)
|
||||||
|
score -= min(0.3, excess*0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalize underruns/overruns
|
||||||
|
underruns := atomic.LoadInt64(&metrics.Underruns)
|
||||||
|
overruns := atomic.LoadInt64(&metrics.Overruns)
|
||||||
|
if underruns+overruns > 0 {
|
||||||
|
score -= min(0.2, float64(underruns+overruns)*0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0.0, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRecoveryNeeded checks if recovery is needed and triggers it
|
||||||
|
func (dhm *DeviceHealthMonitor) checkRecoveryNeeded(deviceType string, metrics *DeviceHealthMetrics) {
|
||||||
|
if metrics.CurrentStatus == DeviceHealthCritical {
|
||||||
|
dhm.triggerRecovery(deviceType, metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerRecovery triggers recovery for a device
|
||||||
|
func (dhm *DeviceHealthMonitor) triggerRecovery(deviceType string, metrics *DeviceHealthMetrics) {
|
||||||
|
atomic.AddInt64(&metrics.RecoveryAttempts, 1)
|
||||||
|
|
||||||
|
dhm.logger.Warn().
|
||||||
|
Str("device_type", deviceType).
|
||||||
|
Str("status", metrics.CurrentStatus.String()).
|
||||||
|
Int64("consecutive_errors", atomic.LoadInt64(&metrics.ConsecutiveErrors)).
|
||||||
|
Float64("error_rate", metrics.ErrorRate).
|
||||||
|
Msg("triggering device recovery")
|
||||||
|
|
||||||
|
// Try registered recovery callbacks
|
||||||
|
dhm.callbackMutex.RLock()
|
||||||
|
defer dhm.callbackMutex.RUnlock()
|
||||||
|
|
||||||
|
for component, callback := range dhm.recoveryCallbacks {
|
||||||
|
if callback != nil {
|
||||||
|
go func(comp string, cb func() error) {
|
||||||
|
if err := cb(); err != nil {
|
||||||
|
dhm.logger.Error().
|
||||||
|
Str("component", comp).
|
||||||
|
Str("device_type", deviceType).
|
||||||
|
Err(err).
|
||||||
|
Msg("recovery callback failed")
|
||||||
|
} else {
|
||||||
|
atomic.AddInt64(&metrics.SuccessfulRecoveries, 1)
|
||||||
|
dhm.logger.Info().
|
||||||
|
Str("component", comp).
|
||||||
|
Str("device_type", deviceType).
|
||||||
|
Msg("recovery callback succeeded")
|
||||||
|
}
|
||||||
|
}(component, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global device health monitor instance
|
||||||
|
var (
|
||||||
|
globalDeviceHealthMonitor *DeviceHealthMonitor
|
||||||
|
deviceHealthOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDeviceHealthMonitor returns the global device health monitor
|
||||||
|
func GetDeviceHealthMonitor() *DeviceHealthMonitor {
|
||||||
|
deviceHealthOnce.Do(func() {
|
||||||
|
globalDeviceHealthMonitor = NewDeviceHealthMonitor()
|
||||||
|
})
|
||||||
|
return globalDeviceHealthMonitor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for min/max
|
||||||
|
func min(a, b float64) float64 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
|
@ -111,7 +111,7 @@ func initializeBroadcaster() {
|
||||||
go audioEventBroadcaster.startMetricsBroadcasting()
|
go audioEventBroadcaster.startMetricsBroadcasting()
|
||||||
|
|
||||||
// Start granular metrics logging with same interval as metrics broadcasting
|
// Start granular metrics logging with same interval as metrics broadcasting
|
||||||
StartGranularMetricsLogging(GetMetricsUpdateInterval())
|
// StartGranularMetricsLogging(GetMetricsUpdateInterval()) // Disabled to reduce log pollution
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitializeAudioEventBroadcaster initializes the global audio event broadcaster
|
// InitializeAudioEventBroadcaster initializes the global audio event broadcaster
|
||||||
|
|
|
@ -93,18 +93,18 @@ type BufferPoolEfficiencyTracker struct {
|
||||||
|
|
||||||
// NewLatencyHistogram creates a new latency histogram with predefined buckets
|
// NewLatencyHistogram creates a new latency histogram with predefined buckets
|
||||||
func NewLatencyHistogram(maxSamples int, logger zerolog.Logger) *LatencyHistogram {
|
func NewLatencyHistogram(maxSamples int, logger zerolog.Logger) *LatencyHistogram {
|
||||||
// Define latency buckets: 1ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s+
|
// Define latency buckets using configuration constants
|
||||||
buckets := []int64{
|
buckets := []int64{
|
||||||
int64(1 * time.Millisecond),
|
int64(1 * time.Millisecond),
|
||||||
int64(5 * time.Millisecond),
|
int64(5 * time.Millisecond),
|
||||||
int64(10 * time.Millisecond),
|
int64(GetConfig().LatencyBucket10ms),
|
||||||
int64(25 * time.Millisecond),
|
int64(GetConfig().LatencyBucket25ms),
|
||||||
int64(50 * time.Millisecond),
|
int64(GetConfig().LatencyBucket50ms),
|
||||||
int64(100 * time.Millisecond),
|
int64(GetConfig().LatencyBucket100ms),
|
||||||
int64(250 * time.Millisecond),
|
int64(GetConfig().LatencyBucket250ms),
|
||||||
int64(500 * time.Millisecond),
|
int64(GetConfig().LatencyBucket500ms),
|
||||||
int64(1 * time.Second),
|
int64(GetConfig().LatencyBucket1s),
|
||||||
int64(2 * time.Second),
|
int64(GetConfig().LatencyBucket2s),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LatencyHistogram{
|
return &LatencyHistogram{
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -10,10 +11,10 @@ import (
|
||||||
|
|
||||||
// AudioInputMetrics holds metrics for microphone input
|
// AudioInputMetrics holds metrics for microphone input
|
||||||
type AudioInputMetrics struct {
|
type AudioInputMetrics struct {
|
||||||
FramesSent int64
|
FramesSent int64 // Total frames sent
|
||||||
FramesDropped int64
|
FramesDropped int64 // Total frames dropped
|
||||||
BytesProcessed int64
|
BytesProcessed int64 // Total bytes processed
|
||||||
ConnectionDrops int64
|
ConnectionDrops int64 // Connection drops
|
||||||
AverageLatency time.Duration // time.Duration is int64
|
AverageLatency time.Duration // time.Duration is int64
|
||||||
LastFrameTime time.Time
|
LastFrameTime time.Time
|
||||||
}
|
}
|
||||||
|
@ -31,26 +32,30 @@ type AudioInputManager struct {
|
||||||
func NewAudioInputManager() *AudioInputManager {
|
func NewAudioInputManager() *AudioInputManager {
|
||||||
return &AudioInputManager{
|
return &AudioInputManager{
|
||||||
ipcManager: NewAudioInputIPCManager(),
|
ipcManager: NewAudioInputIPCManager(),
|
||||||
logger: logging.GetDefaultLogger().With().Str("component", "audio-input").Logger(),
|
logger: logging.GetDefaultLogger().With().Str("component", AudioInputManagerComponent).Logger(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins processing microphone input
|
// Start begins processing microphone input
|
||||||
func (aim *AudioInputManager) Start() error {
|
func (aim *AudioInputManager) Start() error {
|
||||||
if !atomic.CompareAndSwapInt32(&aim.running, 0, 1) {
|
if !atomic.CompareAndSwapInt32(&aim.running, 0, 1) {
|
||||||
return nil // Already running
|
return fmt.Errorf("audio input manager is already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
aim.logger.Info().Msg("Starting audio input manager")
|
aim.logger.Info().Str("component", AudioInputManagerComponent).Msg("starting component")
|
||||||
|
|
||||||
// Start the IPC-based audio input
|
// Start the IPC-based audio input
|
||||||
err := aim.ipcManager.Start()
|
err := aim.ipcManager.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aim.logger.Error().Err(err).Msg("Failed to start IPC audio input")
|
aim.logger.Error().Err(err).Str("component", AudioInputManagerComponent).Msg("failed to start component")
|
||||||
|
// Ensure proper cleanup on error
|
||||||
atomic.StoreInt32(&aim.running, 0)
|
atomic.StoreInt32(&aim.running, 0)
|
||||||
|
// Reset metrics on failed start
|
||||||
|
aim.resetMetrics()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aim.logger.Info().Str("component", AudioInputManagerComponent).Msg("component started successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,12 +65,20 @@ func (aim *AudioInputManager) Stop() {
|
||||||
return // Already stopped
|
return // Already stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
aim.logger.Info().Msg("Stopping audio input manager")
|
aim.logger.Info().Str("component", AudioInputManagerComponent).Msg("stopping component")
|
||||||
|
|
||||||
// Stop the IPC-based audio input
|
// Stop the IPC-based audio input
|
||||||
aim.ipcManager.Stop()
|
aim.ipcManager.Stop()
|
||||||
|
|
||||||
aim.logger.Info().Msg("Audio input manager stopped")
|
aim.logger.Info().Str("component", AudioInputManagerComponent).Msg("component stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetMetrics resets all metrics to zero
|
||||||
|
func (aim *AudioInputManager) resetMetrics() {
|
||||||
|
atomic.StoreInt64(&aim.metrics.FramesSent, 0)
|
||||||
|
atomic.StoreInt64(&aim.metrics.FramesDropped, 0)
|
||||||
|
atomic.StoreInt64(&aim.metrics.BytesProcessed, 0)
|
||||||
|
atomic.StoreInt64(&aim.metrics.ConnectionDrops, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteOpusFrame writes an Opus frame to the audio input system with latency tracking
|
// WriteOpusFrame writes an Opus frame to the audio input system with latency tracking
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
@ -14,12 +13,12 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/logging"
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
inputMagicNumber uint32 = GetConfig().InputMagicNumber // "JKMI" (JetKVM Microphone Input)
|
inputMagicNumber uint32 = GetConfig().InputMagicNumber // "JKMI" (JetKVM Microphone Input)
|
||||||
inputSocketName = "audio_input.sock"
|
inputSocketName = "audio_input.sock"
|
||||||
writeTimeout = GetConfig().WriteTimeout // Non-blocking write timeout
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -51,6 +50,27 @@ type InputIPCMessage struct {
|
||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Implement IPCMessage interface
|
||||||
|
func (msg *InputIPCMessage) GetMagic() uint32 {
|
||||||
|
return msg.Magic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *InputIPCMessage) GetType() uint8 {
|
||||||
|
return uint8(msg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *InputIPCMessage) GetLength() uint32 {
|
||||||
|
return msg.Length
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *InputIPCMessage) GetTimestamp() int64 {
|
||||||
|
return msg.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *InputIPCMessage) GetData() []byte {
|
||||||
|
return msg.Data
|
||||||
|
}
|
||||||
|
|
||||||
// OptimizedIPCMessage represents an optimized message with pre-allocated buffers
|
// OptimizedIPCMessage represents an optimized message with pre-allocated buffers
|
||||||
type OptimizedIPCMessage struct {
|
type OptimizedIPCMessage struct {
|
||||||
header [headerSize]byte // Pre-allocated header buffer
|
header [headerSize]byte // Pre-allocated header buffer
|
||||||
|
@ -80,16 +100,15 @@ var globalMessagePool = &MessagePool{
|
||||||
|
|
||||||
var messagePoolInitOnce sync.Once
|
var messagePoolInitOnce sync.Once
|
||||||
|
|
||||||
// initializeMessagePool initializes the message pool with pre-allocated messages
|
// initializeMessagePool initializes the global message pool with pre-allocated messages
|
||||||
func initializeMessagePool() {
|
func initializeMessagePool() {
|
||||||
messagePoolInitOnce.Do(func() {
|
messagePoolInitOnce.Do(func() {
|
||||||
// Pre-allocate 30% of pool size for immediate availability
|
preallocSize := messagePoolSize / 4 // 25% pre-allocated for immediate use
|
||||||
preallocSize := messagePoolSize * GetConfig().InputPreallocPercentage / 100
|
|
||||||
globalMessagePool.preallocSize = preallocSize
|
globalMessagePool.preallocSize = preallocSize
|
||||||
globalMessagePool.maxPoolSize = messagePoolSize * GetConfig().PoolGrowthMultiplier // Allow growth up to 2x
|
globalMessagePool.maxPoolSize = messagePoolSize * GetConfig().PoolGrowthMultiplier // Allow growth up to 2x
|
||||||
globalMessagePool.preallocated = make([]*OptimizedIPCMessage, 0, preallocSize)
|
globalMessagePool.preallocated = make([]*OptimizedIPCMessage, 0, preallocSize)
|
||||||
|
|
||||||
// Pre-allocate messages to reduce initial allocation overhead
|
// Pre-allocate messages for immediate use
|
||||||
for i := 0; i < preallocSize; i++ {
|
for i := 0; i < preallocSize; i++ {
|
||||||
msg := &OptimizedIPCMessage{
|
msg := &OptimizedIPCMessage{
|
||||||
data: make([]byte, 0, maxFrameSize),
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
@ -97,7 +116,7 @@ func initializeMessagePool() {
|
||||||
globalMessagePool.preallocated = append(globalMessagePool.preallocated, msg)
|
globalMessagePool.preallocated = append(globalMessagePool.preallocated, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill the channel pool with remaining messages
|
// Fill the channel with remaining messages
|
||||||
for i := preallocSize; i < messagePoolSize; i++ {
|
for i := preallocSize; i < messagePoolSize; i++ {
|
||||||
globalMessagePool.pool <- &OptimizedIPCMessage{
|
globalMessagePool.pool <- &OptimizedIPCMessage{
|
||||||
data: make([]byte, 0, maxFrameSize),
|
data: make([]byte, 0, maxFrameSize),
|
||||||
|
@ -167,7 +186,7 @@ type InputIPCConfig struct {
|
||||||
|
|
||||||
// AudioInputServer handles IPC communication for audio input processing
|
// AudioInputServer handles IPC communication for audio input processing
|
||||||
type AudioInputServer struct {
|
type AudioInputServer struct {
|
||||||
// Atomic fields must be first for proper alignment on ARM
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
bufferSize int64 // Current buffer size (atomic)
|
bufferSize int64 // Current buffer size (atomic)
|
||||||
processingTime int64 // Average processing time in nanoseconds (atomic)
|
processingTime int64 // Average processing time in nanoseconds (atomic)
|
||||||
droppedFrames int64 // Dropped frames counter (atomic)
|
droppedFrames int64 // Dropped frames counter (atomic)
|
||||||
|
@ -227,6 +246,11 @@ func (ais *AudioInputServer) Start() error {
|
||||||
|
|
||||||
ais.running = true
|
ais.running = true
|
||||||
|
|
||||||
|
// Reset counters on start
|
||||||
|
atomic.StoreInt64(&ais.totalFrames, 0)
|
||||||
|
atomic.StoreInt64(&ais.droppedFrames, 0)
|
||||||
|
atomic.StoreInt64(&ais.processingTime, 0)
|
||||||
|
|
||||||
// Start triple-goroutine architecture
|
// Start triple-goroutine architecture
|
||||||
ais.startReaderGoroutine()
|
ais.startReaderGoroutine()
|
||||||
ais.startProcessorGoroutine()
|
ais.startProcessorGoroutine()
|
||||||
|
@ -276,7 +300,9 @@ func (ais *AudioInputServer) acceptConnections() {
|
||||||
conn, err := ais.listener.Accept()
|
conn, err := ais.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ais.running {
|
if ais.running {
|
||||||
// Only log error if we're still supposed to be running
|
// Log error and continue accepting
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-server").Logger()
|
||||||
|
logger.Warn().Err(err).Msg("Failed to accept connection, retrying")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -293,9 +319,10 @@ func (ais *AudioInputServer) acceptConnections() {
|
||||||
}
|
}
|
||||||
|
|
||||||
ais.mtx.Lock()
|
ais.mtx.Lock()
|
||||||
// Close existing connection if any
|
// Close existing connection if any to prevent resource leaks
|
||||||
if ais.conn != nil {
|
if ais.conn != nil {
|
||||||
ais.conn.Close()
|
ais.conn.Close()
|
||||||
|
ais.conn = nil
|
||||||
}
|
}
|
||||||
ais.conn = conn
|
ais.conn = conn
|
||||||
ais.mtx.Unlock()
|
ais.mtx.Unlock()
|
||||||
|
@ -461,33 +488,13 @@ func (ais *AudioInputServer) sendAck() error {
|
||||||
return ais.writeMessage(ais.conn, msg)
|
return ais.writeMessage(ais.conn, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeMessage writes a message to the connection using optimized buffers
|
// Global shared message pool for input IPC server
|
||||||
|
var globalInputServerMessagePool = NewGenericMessagePool(messagePoolSize)
|
||||||
|
|
||||||
|
// writeMessage writes a message to the connection using shared common utilities
|
||||||
func (ais *AudioInputServer) writeMessage(conn net.Conn, msg *InputIPCMessage) error {
|
func (ais *AudioInputServer) writeMessage(conn net.Conn, msg *InputIPCMessage) error {
|
||||||
// Get optimized message from pool for header preparation
|
// Use shared WriteIPCMessage function with global message pool
|
||||||
optMsg := globalMessagePool.Get()
|
return WriteIPCMessage(conn, msg, globalInputServerMessagePool, &ais.droppedFrames)
|
||||||
defer globalMessagePool.Put(optMsg)
|
|
||||||
|
|
||||||
// Prepare header in pre-allocated buffer
|
|
||||||
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.Magic)
|
|
||||||
optMsg.header[4] = byte(msg.Type)
|
|
||||||
binary.LittleEndian.PutUint32(optMsg.header[5:9], msg.Length)
|
|
||||||
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(msg.Timestamp))
|
|
||||||
|
|
||||||
// Write header
|
|
||||||
_, err := conn.Write(optMsg.header[:])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write data if present
|
|
||||||
if msg.Length > 0 && msg.Data != nil {
|
|
||||||
_, err = conn.Write(msg.Data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioInputClient handles IPC communication from the main process
|
// AudioInputClient handles IPC communication from the main process
|
||||||
|
@ -515,6 +522,12 @@ func (aic *AudioInputClient) Connect() error {
|
||||||
return nil // Already connected
|
return nil // Already connected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure clean state before connecting
|
||||||
|
if aic.conn != nil {
|
||||||
|
aic.conn.Close()
|
||||||
|
aic.conn = nil
|
||||||
|
}
|
||||||
|
|
||||||
socketPath := getInputSocketPath()
|
socketPath := getInputSocketPath()
|
||||||
// Try connecting multiple times as the server might not be ready
|
// Try connecting multiple times as the server might not be ready
|
||||||
// Reduced retry count and delay for faster startup
|
// Reduced retry count and delay for faster startup
|
||||||
|
@ -523,6 +536,9 @@ func (aic *AudioInputClient) Connect() error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
aic.conn = conn
|
aic.conn = conn
|
||||||
aic.running = true
|
aic.running = true
|
||||||
|
// Reset frame counters on successful connection
|
||||||
|
atomic.StoreInt64(&aic.totalFrames, 0)
|
||||||
|
atomic.StoreInt64(&aic.droppedFrames, 0)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Exponential backoff starting from config
|
// Exponential backoff starting from config
|
||||||
|
@ -535,7 +551,10 @@ func (aic *AudioInputClient) Connect() error {
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("failed to connect to audio input server")
|
// Ensure clean state on connection failure
|
||||||
|
aic.conn = nil
|
||||||
|
aic.running = false
|
||||||
|
return fmt.Errorf("failed to connect to audio input server after 10 attempts")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect disconnects from the audio input server
|
// Disconnect disconnects from the audio input server
|
||||||
|
@ -667,58 +686,15 @@ func (aic *AudioInputClient) SendHeartbeat() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeMessage writes a message to the server
|
// writeMessage writes a message to the server
|
||||||
|
// Global shared message pool for input IPC clients
|
||||||
|
var globalInputMessagePool = NewGenericMessagePool(messagePoolSize)
|
||||||
|
|
||||||
func (aic *AudioInputClient) writeMessage(msg *InputIPCMessage) error {
|
func (aic *AudioInputClient) writeMessage(msg *InputIPCMessage) error {
|
||||||
// Increment total frames counter
|
// Increment total frames counter
|
||||||
atomic.AddInt64(&aic.totalFrames, 1)
|
atomic.AddInt64(&aic.totalFrames, 1)
|
||||||
|
|
||||||
// Get optimized message from pool for header preparation
|
// Use shared WriteIPCMessage function with global message pool
|
||||||
optMsg := globalMessagePool.Get()
|
return WriteIPCMessage(aic.conn, msg, globalInputMessagePool, &aic.droppedFrames)
|
||||||
defer globalMessagePool.Put(optMsg)
|
|
||||||
|
|
||||||
// Prepare header in pre-allocated buffer
|
|
||||||
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.Magic)
|
|
||||||
optMsg.header[4] = byte(msg.Type)
|
|
||||||
binary.LittleEndian.PutUint32(optMsg.header[5:9], msg.Length)
|
|
||||||
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(msg.Timestamp))
|
|
||||||
|
|
||||||
// Use non-blocking write with timeout
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create a channel to signal write completion
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
// Write header using pre-allocated buffer
|
|
||||||
_, err := aic.conn.Write(optMsg.header[:])
|
|
||||||
if err != nil {
|
|
||||||
done <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write data if present
|
|
||||||
if msg.Length > 0 && msg.Data != nil {
|
|
||||||
_, err = aic.conn.Write(msg.Data)
|
|
||||||
if err != nil {
|
|
||||||
done <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done <- nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for completion or timeout
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
atomic.AddInt64(&aic.droppedFrames, 1)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Timeout occurred - drop frame to prevent blocking
|
|
||||||
atomic.AddInt64(&aic.droppedFrames, 1)
|
|
||||||
return fmt.Errorf("write timeout - frame dropped")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConnected returns whether the client is connected
|
// IsConnected returns whether the client is connected
|
||||||
|
@ -730,23 +706,19 @@ func (aic *AudioInputClient) IsConnected() bool {
|
||||||
|
|
||||||
// GetFrameStats returns frame statistics
|
// GetFrameStats returns frame statistics
|
||||||
func (aic *AudioInputClient) GetFrameStats() (total, dropped int64) {
|
func (aic *AudioInputClient) GetFrameStats() (total, dropped int64) {
|
||||||
return atomic.LoadInt64(&aic.totalFrames), atomic.LoadInt64(&aic.droppedFrames)
|
stats := GetFrameStats(&aic.totalFrames, &aic.droppedFrames)
|
||||||
|
return stats.Total, stats.Dropped
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDropRate returns the current frame drop rate as a percentage
|
// GetDropRate returns the current frame drop rate as a percentage
|
||||||
func (aic *AudioInputClient) GetDropRate() float64 {
|
func (aic *AudioInputClient) GetDropRate() float64 {
|
||||||
total := atomic.LoadInt64(&aic.totalFrames)
|
stats := GetFrameStats(&aic.totalFrames, &aic.droppedFrames)
|
||||||
dropped := atomic.LoadInt64(&aic.droppedFrames)
|
return CalculateDropRate(stats)
|
||||||
if total == 0 {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
return float64(dropped) / float64(total) * GetConfig().PercentageMultiplier
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetStats resets frame statistics
|
// ResetStats resets frame statistics
|
||||||
func (aic *AudioInputClient) ResetStats() {
|
func (aic *AudioInputClient) ResetStats() {
|
||||||
atomic.StoreInt64(&aic.totalFrames, 0)
|
ResetFrameStats(&aic.totalFrames, &aic.droppedFrames)
|
||||||
atomic.StoreInt64(&aic.droppedFrames, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// startReaderGoroutine starts the message reader goroutine
|
// startReaderGoroutine starts the message reader goroutine
|
||||||
|
@ -754,6 +726,17 @@ func (ais *AudioInputServer) startReaderGoroutine() {
|
||||||
ais.wg.Add(1)
|
ais.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer ais.wg.Done()
|
defer ais.wg.Done()
|
||||||
|
|
||||||
|
// Enhanced error tracking and recovery
|
||||||
|
var consecutiveErrors int
|
||||||
|
var lastErrorTime time.Time
|
||||||
|
maxConsecutiveErrors := GetConfig().MaxConsecutiveErrors
|
||||||
|
errorResetWindow := GetConfig().RestartWindow // Use existing restart window
|
||||||
|
baseBackoffDelay := GetConfig().RetryDelay
|
||||||
|
maxBackoffDelay := GetConfig().MaxRetryDelay
|
||||||
|
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-reader").Logger()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ais.stopChan:
|
case <-ais.stopChan:
|
||||||
|
@ -762,8 +745,55 @@ func (ais *AudioInputServer) startReaderGoroutine() {
|
||||||
if ais.conn != nil {
|
if ais.conn != nil {
|
||||||
msg, err := ais.readMessage(ais.conn)
|
msg, err := ais.readMessage(ais.conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Connection error, retry
|
// Enhanced error handling with progressive backoff
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Reset error counter if enough time has passed
|
||||||
|
if now.Sub(lastErrorTime) > errorResetWindow {
|
||||||
|
consecutiveErrors = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
consecutiveErrors++
|
||||||
|
lastErrorTime = now
|
||||||
|
|
||||||
|
// Log error with context
|
||||||
|
logger.Warn().Err(err).
|
||||||
|
Int("consecutive_errors", consecutiveErrors).
|
||||||
|
Msg("Failed to read message from input connection")
|
||||||
|
|
||||||
|
// Progressive backoff based on error count
|
||||||
|
if consecutiveErrors > 1 {
|
||||||
|
backoffDelay := time.Duration(consecutiveErrors-1) * baseBackoffDelay
|
||||||
|
if backoffDelay > maxBackoffDelay {
|
||||||
|
backoffDelay = maxBackoffDelay
|
||||||
|
}
|
||||||
|
time.Sleep(backoffDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If too many consecutive errors, close connection to force reconnect
|
||||||
|
if consecutiveErrors >= maxConsecutiveErrors {
|
||||||
|
logger.Error().
|
||||||
|
Int("consecutive_errors", consecutiveErrors).
|
||||||
|
Msg("Too many consecutive read errors, closing connection")
|
||||||
|
|
||||||
|
ais.mtx.Lock()
|
||||||
|
if ais.conn != nil {
|
||||||
|
ais.conn.Close()
|
||||||
|
ais.conn = nil
|
||||||
|
}
|
||||||
|
ais.mtx.Unlock()
|
||||||
|
|
||||||
|
consecutiveErrors = 0 // Reset for next connection
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset error counter on successful read
|
||||||
|
if consecutiveErrors > 0 {
|
||||||
|
consecutiveErrors = 0
|
||||||
|
logger.Info().Msg("Input connection recovered")
|
||||||
|
}
|
||||||
|
|
||||||
// Send to message channel with non-blocking write
|
// Send to message channel with non-blocking write
|
||||||
select {
|
select {
|
||||||
case ais.messageChan <- msg:
|
case ais.messageChan <- msg:
|
||||||
|
@ -771,7 +801,11 @@ func (ais *AudioInputServer) startReaderGoroutine() {
|
||||||
default:
|
default:
|
||||||
// Channel full, drop message
|
// Channel full, drop message
|
||||||
atomic.AddInt64(&ais.droppedFrames, 1)
|
atomic.AddInt64(&ais.droppedFrames, 1)
|
||||||
|
logger.Warn().Msg("Message channel full, dropping frame")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No connection, wait briefly before checking again
|
||||||
|
time.Sleep(GetConfig().DefaultSleepDuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -796,40 +830,105 @@ func (ais *AudioInputServer) startProcessorGoroutine() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Enhanced error tracking for processing
|
||||||
|
var processingErrors int
|
||||||
|
var lastProcessingError time.Time
|
||||||
|
maxProcessingErrors := GetConfig().MaxConsecutiveErrors
|
||||||
|
errorResetWindow := GetConfig().RestartWindow
|
||||||
|
|
||||||
defer ais.wg.Done()
|
defer ais.wg.Done()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ais.stopChan:
|
case <-ais.stopChan:
|
||||||
return
|
return
|
||||||
case msg := <-ais.messageChan:
|
case msg := <-ais.messageChan:
|
||||||
// Intelligent frame dropping: prioritize recent frames
|
// Process message with error handling
|
||||||
if msg.Type == InputMessageTypeOpusFrame {
|
start := time.Now()
|
||||||
// Check if processing queue is getting full
|
err := ais.processMessageWithRecovery(msg, logger)
|
||||||
queueLen := len(ais.processChan)
|
processingTime := time.Since(start)
|
||||||
bufferSize := int(atomic.LoadInt64(&ais.bufferSize))
|
|
||||||
|
|
||||||
if queueLen > bufferSize*3/4 {
|
if err != nil {
|
||||||
// Drop oldest frames, keep newest
|
// Track processing errors
|
||||||
select {
|
now := time.Now()
|
||||||
case <-ais.processChan: // Remove oldest
|
if now.Sub(lastProcessingError) > errorResetWindow {
|
||||||
atomic.AddInt64(&ais.droppedFrames, 1)
|
processingErrors = 0
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processingErrors++
|
||||||
|
lastProcessingError = now
|
||||||
|
|
||||||
|
logger.Warn().Err(err).
|
||||||
|
Int("processing_errors", processingErrors).
|
||||||
|
Dur("processing_time", processingTime).
|
||||||
|
Msg("Failed to process input message")
|
||||||
|
|
||||||
|
// If too many processing errors, drop frames more aggressively
|
||||||
|
if processingErrors >= maxProcessingErrors {
|
||||||
|
logger.Error().
|
||||||
|
Int("processing_errors", processingErrors).
|
||||||
|
Msg("Too many processing errors, entering aggressive drop mode")
|
||||||
|
|
||||||
|
// Clear processing queue to recover
|
||||||
|
for len(ais.processChan) > 0 {
|
||||||
|
select {
|
||||||
|
case <-ais.processChan:
|
||||||
|
atomic.AddInt64(&ais.droppedFrames, 1)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processingErrors = 0 // Reset after clearing queue
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to processing queue
|
// Reset error counter on successful processing
|
||||||
select {
|
if processingErrors > 0 {
|
||||||
case ais.processChan <- msg:
|
processingErrors = 0
|
||||||
default:
|
logger.Info().Msg("Input processing recovered")
|
||||||
// Processing queue full, drop frame
|
|
||||||
atomic.AddInt64(&ais.droppedFrames, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update processing time metrics
|
||||||
|
atomic.StoreInt64(&ais.processingTime, processingTime.Nanoseconds())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processMessageWithRecovery processes a message with enhanced error recovery
|
||||||
|
func (ais *AudioInputServer) processMessageWithRecovery(msg *InputIPCMessage, logger zerolog.Logger) error {
|
||||||
|
// Intelligent frame dropping: prioritize recent frames
|
||||||
|
if msg.Type == InputMessageTypeOpusFrame {
|
||||||
|
// Check if processing queue is getting full
|
||||||
|
queueLen := len(ais.processChan)
|
||||||
|
bufferSize := int(atomic.LoadInt64(&ais.bufferSize))
|
||||||
|
|
||||||
|
if queueLen > bufferSize*3/4 {
|
||||||
|
// Drop oldest frames, keep newest
|
||||||
|
select {
|
||||||
|
case <-ais.processChan: // Remove oldest
|
||||||
|
atomic.AddInt64(&ais.droppedFrames, 1)
|
||||||
|
logger.Debug().Msg("Dropped oldest frame to make room")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to processing queue with timeout
|
||||||
|
select {
|
||||||
|
case ais.processChan <- msg:
|
||||||
|
return nil
|
||||||
|
case <-time.After(GetConfig().WriteTimeout):
|
||||||
|
// Processing queue full and timeout reached, drop frame
|
||||||
|
atomic.AddInt64(&ais.droppedFrames, 1)
|
||||||
|
return fmt.Errorf("processing queue timeout")
|
||||||
|
default:
|
||||||
|
// Processing queue full, drop frame immediately
|
||||||
|
atomic.AddInt64(&ais.droppedFrames, 1)
|
||||||
|
return fmt.Errorf("processing queue full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startMonitorGoroutine starts the performance monitoring goroutine
|
// startMonitorGoroutine starts the performance monitoring goroutine
|
||||||
func (ais *AudioInputServer) startMonitorGoroutine() {
|
func (ais *AudioInputServer) startMonitorGoroutine() {
|
||||||
ais.wg.Add(1)
|
ais.wg.Add(1)
|
||||||
|
|
|
@ -21,7 +21,7 @@ type AudioInputIPCManager struct {
|
||||||
func NewAudioInputIPCManager() *AudioInputIPCManager {
|
func NewAudioInputIPCManager() *AudioInputIPCManager {
|
||||||
return &AudioInputIPCManager{
|
return &AudioInputIPCManager{
|
||||||
supervisor: NewAudioInputSupervisor(),
|
supervisor: NewAudioInputSupervisor(),
|
||||||
logger: logging.GetDefaultLogger().With().Str("component", "audio-input-ipc").Logger(),
|
logger: logging.GetDefaultLogger().With().Str("component", AudioInputIPCComponent).Logger(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,12 +31,15 @@ func (aim *AudioInputIPCManager) Start() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
aim.logger.Info().Msg("Starting IPC-based audio input system")
|
aim.logger.Info().Str("component", AudioInputIPCComponent).Msg("starting component")
|
||||||
|
|
||||||
err := aim.supervisor.Start()
|
err := aim.supervisor.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Ensure proper cleanup on supervisor start failure
|
||||||
atomic.StoreInt32(&aim.running, 0)
|
atomic.StoreInt32(&aim.running, 0)
|
||||||
aim.logger.Error().Err(err).Msg("Failed to start audio input supervisor")
|
// Reset metrics on failed start
|
||||||
|
aim.resetMetrics()
|
||||||
|
aim.logger.Error().Err(err).Str("component", AudioInputIPCComponent).Msg("failed to start audio input supervisor")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,10 +54,11 @@ func (aim *AudioInputIPCManager) Start() error {
|
||||||
|
|
||||||
err = aim.supervisor.SendConfig(config)
|
err = aim.supervisor.SendConfig(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aim.logger.Warn().Err(err).Msg("Failed to send initial config, will retry later")
|
// Config send failure is not critical, log warning and continue
|
||||||
|
aim.logger.Warn().Err(err).Str("component", AudioInputIPCComponent).Msg("failed to send initial config, will retry later")
|
||||||
}
|
}
|
||||||
|
|
||||||
aim.logger.Info().Msg("IPC-based audio input system started")
|
aim.logger.Info().Str("component", AudioInputIPCComponent).Msg("component started successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,9 +68,17 @@ func (aim *AudioInputIPCManager) Stop() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
aim.logger.Info().Msg("Stopping IPC-based audio input system")
|
aim.logger.Info().Str("component", AudioInputIPCComponent).Msg("stopping component")
|
||||||
aim.supervisor.Stop()
|
aim.supervisor.Stop()
|
||||||
aim.logger.Info().Msg("IPC-based audio input system stopped")
|
aim.logger.Info().Str("component", AudioInputIPCComponent).Msg("component stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetMetrics resets all metrics to zero
|
||||||
|
func (aim *AudioInputIPCManager) resetMetrics() {
|
||||||
|
atomic.StoreInt64(&aim.metrics.FramesSent, 0)
|
||||||
|
atomic.StoreInt64(&aim.metrics.FramesDropped, 0)
|
||||||
|
atomic.StoreInt64(&aim.metrics.BytesProcessed, 0)
|
||||||
|
atomic.StoreInt64(&aim.metrics.ConnectionDrops, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteOpusFrame sends an Opus frame to the audio input server via IPC
|
// WriteOpusFrame sends an Opus frame to the audio input server via IPC
|
||||||
|
|
|
@ -0,0 +1,277 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAudioInputIPCManager tests the AudioInputIPCManager component
|
||||||
|
func TestAudioInputIPCManager(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
testFunc func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{"Start", testAudioInputIPCManagerStart},
|
||||||
|
{"Stop", testAudioInputIPCManagerStop},
|
||||||
|
{"StartStop", testAudioInputIPCManagerStartStop},
|
||||||
|
{"IsRunning", testAudioInputIPCManagerIsRunning},
|
||||||
|
{"IsReady", testAudioInputIPCManagerIsReady},
|
||||||
|
{"GetMetrics", testAudioInputIPCManagerGetMetrics},
|
||||||
|
{"ConcurrentOperations", testAudioInputIPCManagerConcurrent},
|
||||||
|
{"MultipleStarts", testAudioInputIPCManagerMultipleStarts},
|
||||||
|
{"MultipleStops", testAudioInputIPCManagerMultipleStops},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.testFunc(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerStart(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
|
||||||
|
// Test start
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerStop(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerStartStop(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test multiple start/stop cycles
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
// Start
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerIsRunning(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Initially not running
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Start and check
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Stop and check
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerIsReady(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Initially not ready
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
|
||||||
|
// Start and check ready state
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Give some time for initialization
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerGetMetrics(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test metrics when not running
|
||||||
|
metrics := manager.GetMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
|
||||||
|
// Start and test metrics
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
metrics = manager.GetMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerConcurrent(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
const numGoroutines = 10
|
||||||
|
|
||||||
|
// Test concurrent starts
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
manager.Start()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Should be running
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test concurrent stops
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
manager.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Should be stopped
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerMultipleStarts(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// First start should succeed
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Subsequent starts should be no-op
|
||||||
|
err = manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
err = manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioInputIPCManagerMultipleStops(t *testing.T) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// First stop should work
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Subsequent stops should be no-op
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioInputIPCMetrics tests the AudioInputMetrics functionality
|
||||||
|
func TestAudioInputIPCMetrics(t *testing.T) {
|
||||||
|
metrics := &AudioInputMetrics{}
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.Equal(t, int64(0), metrics.FramesSent)
|
||||||
|
assert.Equal(t, int64(0), metrics.FramesDropped)
|
||||||
|
assert.Equal(t, int64(0), metrics.BytesProcessed)
|
||||||
|
assert.Equal(t, int64(0), metrics.ConnectionDrops)
|
||||||
|
assert.Equal(t, time.Duration(0), metrics.AverageLatency)
|
||||||
|
assert.True(t, metrics.LastFrameTime.IsZero())
|
||||||
|
|
||||||
|
// Test field assignment
|
||||||
|
metrics.FramesSent = 50
|
||||||
|
metrics.FramesDropped = 2
|
||||||
|
metrics.BytesProcessed = 512
|
||||||
|
metrics.ConnectionDrops = 1
|
||||||
|
metrics.AverageLatency = 5 * time.Millisecond
|
||||||
|
metrics.LastFrameTime = time.Now()
|
||||||
|
|
||||||
|
// Verify assignments
|
||||||
|
assert.Equal(t, int64(50), metrics.FramesSent)
|
||||||
|
assert.Equal(t, int64(2), metrics.FramesDropped)
|
||||||
|
assert.Equal(t, int64(512), metrics.BytesProcessed)
|
||||||
|
assert.Equal(t, int64(1), metrics.ConnectionDrops)
|
||||||
|
assert.Equal(t, 5*time.Millisecond, metrics.AverageLatency)
|
||||||
|
assert.False(t, metrics.LastFrameTime.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkAudioInputIPCManager benchmarks the AudioInputIPCManager operations
|
||||||
|
func BenchmarkAudioInputIPCManager(b *testing.B) {
|
||||||
|
b.Run("Start", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
manager.Start()
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("IsRunning", func(b *testing.B) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager.IsRunning()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetMetrics", func(b *testing.B) {
|
||||||
|
manager := NewAudioInputIPCManager()
|
||||||
|
manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager.GetMetrics()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -168,7 +168,16 @@ func (ais *AudioInputSupervisor) GetProcessMetrics() *ProcessMetrics {
|
||||||
defer ais.mtx.Unlock()
|
defer ais.mtx.Unlock()
|
||||||
|
|
||||||
if ais.cmd == nil || ais.cmd.Process == nil {
|
if ais.cmd == nil || ais.cmd.Process == nil {
|
||||||
return nil
|
// Return default metrics when no process is running
|
||||||
|
return &ProcessMetrics{
|
||||||
|
PID: 0,
|
||||||
|
CPUPercent: 0.0,
|
||||||
|
MemoryRSS: 0,
|
||||||
|
MemoryVMS: 0,
|
||||||
|
MemoryPercent: 0.0,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
ProcessName: "audio-input-server",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pid := ais.cmd.Process.Pid
|
pid := ais.cmd.Process.Pid
|
||||||
|
@ -178,12 +187,21 @@ func (ais *AudioInputSupervisor) GetProcessMetrics() *ProcessMetrics {
|
||||||
return &metric
|
return &metric
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
// Return default metrics if process not found in monitoring
|
||||||
|
return &ProcessMetrics{
|
||||||
|
PID: pid,
|
||||||
|
CPUPercent: 0.0,
|
||||||
|
MemoryRSS: 0,
|
||||||
|
MemoryVMS: 0,
|
||||||
|
MemoryPercent: 0.0,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
ProcessName: "audio-input-server",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitorSubprocess monitors the subprocess and handles unexpected exits
|
// monitorSubprocess monitors the subprocess and handles unexpected exits
|
||||||
func (ais *AudioInputSupervisor) monitorSubprocess() {
|
func (ais *AudioInputSupervisor) monitorSubprocess() {
|
||||||
if ais.cmd == nil {
|
if ais.cmd == nil || ais.cmd.Process == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,241 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAudioInputManager(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
assert.NotNil(t, manager)
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerStart(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test successful start
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test starting already running manager
|
||||||
|
err = manager.Start()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already running")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerStop(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test stopping non-running manager
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Start and then stop
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerIsRunning(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test after start
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test after stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerIsReady(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
|
||||||
|
// Start manager
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Give some time for initialization
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Test ready state (may vary based on implementation)
|
||||||
|
// Just ensure the method doesn't panic
|
||||||
|
_ = manager.IsReady()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerGetMetrics(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test metrics when not running
|
||||||
|
metrics := manager.GetMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
assert.Equal(t, int64(0), metrics.FramesSent)
|
||||||
|
assert.Equal(t, int64(0), metrics.FramesDropped)
|
||||||
|
assert.Equal(t, int64(0), metrics.BytesProcessed)
|
||||||
|
assert.Equal(t, int64(0), metrics.ConnectionDrops)
|
||||||
|
|
||||||
|
// Start and test metrics
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
metrics = manager.GetMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
assert.GreaterOrEqual(t, metrics.FramesSent, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, metrics.FramesDropped, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, metrics.BytesProcessed, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, metrics.ConnectionDrops, int64(0))
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerConcurrentOperations(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Test concurrent start/stop operations
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = manager.Start()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
manager.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test concurrent metric access
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = manager.GetMetrics()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test concurrent status checks
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = manager.IsRunning()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = manager.IsReady()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputManagerMultipleStartStop(t *testing.T) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test multiple start/stop cycles
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioInputMetrics(t *testing.T) {
|
||||||
|
metrics := &AudioInputMetrics{
|
||||||
|
FramesSent: 100,
|
||||||
|
FramesDropped: 5,
|
||||||
|
BytesProcessed: 1024,
|
||||||
|
ConnectionDrops: 2,
|
||||||
|
AverageLatency: time.Millisecond * 10,
|
||||||
|
LastFrameTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, int64(100), metrics.FramesSent)
|
||||||
|
assert.Equal(t, int64(5), metrics.FramesDropped)
|
||||||
|
assert.Equal(t, int64(1024), metrics.BytesProcessed)
|
||||||
|
assert.Equal(t, int64(2), metrics.ConnectionDrops)
|
||||||
|
assert.Equal(t, time.Millisecond*10, metrics.AverageLatency)
|
||||||
|
assert.False(t, metrics.LastFrameTime.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark tests
|
||||||
|
func BenchmarkAudioInputManager(b *testing.B) {
|
||||||
|
manager := NewAudioInputManager()
|
||||||
|
|
||||||
|
b.Run("Start", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = manager.Start()
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetMetrics", func(b *testing.B) {
|
||||||
|
_ = manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = manager.GetMetrics()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("IsRunning", func(b *testing.B) {
|
||||||
|
_ = manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = manager.IsRunning()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("IsReady", func(b *testing.B) {
|
||||||
|
_ = manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = manager.IsReady()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
@ -35,7 +34,7 @@ const (
|
||||||
OutputMessageTypeAck
|
OutputMessageTypeAck
|
||||||
)
|
)
|
||||||
|
|
||||||
// OutputIPCMessage represents an IPC message for audio output
|
// OutputIPCMessage represents a message sent over IPC
|
||||||
type OutputIPCMessage struct {
|
type OutputIPCMessage struct {
|
||||||
Magic uint32
|
Magic uint32
|
||||||
Type OutputMessageType
|
Type OutputMessageType
|
||||||
|
@ -44,62 +43,32 @@ type OutputIPCMessage struct {
|
||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputOptimizedMessage represents a pre-allocated message for zero-allocation operations
|
// Implement IPCMessage interface
|
||||||
type OutputOptimizedMessage struct {
|
func (msg *OutputIPCMessage) GetMagic() uint32 {
|
||||||
header [17]byte // Pre-allocated header buffer (using constant value since array size must be compile-time constant)
|
return msg.Magic
|
||||||
data []byte // Reusable data buffer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputMessagePool manages pre-allocated messages for zero-allocation IPC
|
func (msg *OutputIPCMessage) GetType() uint8 {
|
||||||
type OutputMessagePool struct {
|
return uint8(msg.Type)
|
||||||
pool chan *OutputOptimizedMessage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOutputMessagePool creates a new message pool
|
func (msg *OutputIPCMessage) GetLength() uint32 {
|
||||||
func NewOutputMessagePool(size int) *OutputMessagePool {
|
return msg.Length
|
||||||
pool := &OutputMessagePool{
|
|
||||||
pool: make(chan *OutputOptimizedMessage, size),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-allocate messages
|
|
||||||
for i := 0; i < size; i++ {
|
|
||||||
msg := &OutputOptimizedMessage{
|
|
||||||
data: make([]byte, GetConfig().OutputMaxFrameSize),
|
|
||||||
}
|
|
||||||
pool.pool <- msg
|
|
||||||
}
|
|
||||||
|
|
||||||
return pool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a message from the pool
|
func (msg *OutputIPCMessage) GetTimestamp() int64 {
|
||||||
func (p *OutputMessagePool) Get() *OutputOptimizedMessage {
|
return msg.Timestamp
|
||||||
select {
|
|
||||||
case msg := <-p.pool:
|
|
||||||
return msg
|
|
||||||
default:
|
|
||||||
// Pool exhausted, create new message
|
|
||||||
return &OutputOptimizedMessage{
|
|
||||||
data: make([]byte, GetConfig().OutputMaxFrameSize),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put returns a message to the pool
|
func (msg *OutputIPCMessage) GetData() []byte {
|
||||||
func (p *OutputMessagePool) Put(msg *OutputOptimizedMessage) {
|
return msg.Data
|
||||||
select {
|
|
||||||
case p.pool <- msg:
|
|
||||||
// Successfully returned to pool
|
|
||||||
default:
|
|
||||||
// Pool full, let GC handle it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global message pool for output IPC
|
// Global shared message pool for output IPC client header reading
|
||||||
var globalOutputMessagePool = NewOutputMessagePool(GetConfig().OutputMessagePoolSize)
|
var globalOutputClientMessagePool = NewGenericMessagePool(GetConfig().OutputMessagePoolSize)
|
||||||
|
|
||||||
type AudioServer struct {
|
type AudioOutputServer struct {
|
||||||
// Atomic fields must be first for proper alignment on ARM
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
bufferSize int64 // Current buffer size (atomic)
|
bufferSize int64 // Current buffer size (atomic)
|
||||||
droppedFrames int64 // Dropped frames counter (atomic)
|
droppedFrames int64 // Dropped frames counter (atomic)
|
||||||
totalFrames int64 // Total frames counter (atomic)
|
totalFrames int64 // Total frames counter (atomic)
|
||||||
|
@ -122,7 +91,7 @@ type AudioServer struct {
|
||||||
socketBufferConfig SocketBufferConfig
|
socketBufferConfig SocketBufferConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudioServer() (*AudioServer, error) {
|
func NewAudioOutputServer() (*AudioOutputServer, error) {
|
||||||
socketPath := getOutputSocketPath()
|
socketPath := getOutputSocketPath()
|
||||||
// Remove existing socket if any
|
// Remove existing socket if any
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
|
@ -151,7 +120,7 @@ func NewAudioServer() (*AudioServer, error) {
|
||||||
// Initialize socket buffer configuration
|
// Initialize socket buffer configuration
|
||||||
socketBufferConfig := DefaultSocketBufferConfig()
|
socketBufferConfig := DefaultSocketBufferConfig()
|
||||||
|
|
||||||
return &AudioServer{
|
return &AudioOutputServer{
|
||||||
listener: listener,
|
listener: listener,
|
||||||
messageChan: make(chan *OutputIPCMessage, initialBufferSize),
|
messageChan: make(chan *OutputIPCMessage, initialBufferSize),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
@ -162,7 +131,7 @@ func NewAudioServer() (*AudioServer, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) Start() error {
|
func (s *AudioOutputServer) Start() error {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
defer s.mtx.Unlock()
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -190,12 +159,14 @@ func (s *AudioServer) Start() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// acceptConnections accepts incoming connections
|
// acceptConnections accepts incoming connections
|
||||||
func (s *AudioServer) acceptConnections() {
|
func (s *AudioOutputServer) acceptConnections() {
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-server").Logger()
|
||||||
for s.running {
|
for s.running {
|
||||||
conn, err := s.listener.Accept()
|
conn, err := s.listener.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.running {
|
if s.running {
|
||||||
// Only log error if we're still supposed to be running
|
// Log warning and retry on accept failure
|
||||||
|
logger.Warn().Err(err).Msg("Failed to accept connection, retrying")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -204,7 +175,6 @@ func (s *AudioServer) acceptConnections() {
|
||||||
// Configure socket buffers for optimal performance
|
// Configure socket buffers for optimal performance
|
||||||
if err := ConfigureSocketBuffers(conn, s.socketBufferConfig); err != nil {
|
if err := ConfigureSocketBuffers(conn, s.socketBufferConfig); err != nil {
|
||||||
// Log warning but don't fail - socket buffer optimization is not critical
|
// Log warning but don't fail - socket buffer optimization is not critical
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-server").Logger()
|
|
||||||
logger.Warn().Err(err).Msg("Failed to configure socket buffers, continuing with defaults")
|
logger.Warn().Err(err).Msg("Failed to configure socket buffers, continuing with defaults")
|
||||||
} else {
|
} else {
|
||||||
// Record socket buffer metrics for monitoring
|
// Record socket buffer metrics for monitoring
|
||||||
|
@ -215,6 +185,7 @@ func (s *AudioServer) acceptConnections() {
|
||||||
// Close existing connection if any
|
// Close existing connection if any
|
||||||
if s.conn != nil {
|
if s.conn != nil {
|
||||||
s.conn.Close()
|
s.conn.Close()
|
||||||
|
s.conn = nil
|
||||||
}
|
}
|
||||||
s.conn = conn
|
s.conn = conn
|
||||||
s.mtx.Unlock()
|
s.mtx.Unlock()
|
||||||
|
@ -222,7 +193,7 @@ func (s *AudioServer) acceptConnections() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProcessorGoroutine starts the message processor
|
// startProcessorGoroutine starts the message processor
|
||||||
func (s *AudioServer) startProcessorGoroutine() {
|
func (s *AudioOutputServer) startProcessorGoroutine() {
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
|
@ -243,7 +214,7 @@ func (s *AudioServer) startProcessorGoroutine() {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) Stop() {
|
func (s *AudioOutputServer) Stop() {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
defer s.mtx.Unlock()
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -271,7 +242,7 @@ func (s *AudioServer) Stop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) Close() error {
|
func (s *AudioOutputServer) Close() error {
|
||||||
s.Stop()
|
s.Stop()
|
||||||
if s.listener != nil {
|
if s.listener != nil {
|
||||||
s.listener.Close()
|
s.listener.Close()
|
||||||
|
@ -281,7 +252,7 @@ func (s *AudioServer) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AudioServer) SendFrame(frame []byte) error {
|
func (s *AudioOutputServer) SendFrame(frame []byte) error {
|
||||||
maxFrameSize := GetConfig().OutputMaxFrameSize
|
maxFrameSize := GetConfig().OutputMaxFrameSize
|
||||||
if len(frame) > maxFrameSize {
|
if len(frame) > maxFrameSize {
|
||||||
return fmt.Errorf("output frame size validation failed: got %d bytes, maximum allowed %d bytes", len(frame), maxFrameSize)
|
return fmt.Errorf("output frame size validation failed: got %d bytes, maximum allowed %d bytes", len(frame), maxFrameSize)
|
||||||
|
@ -318,7 +289,10 @@ func (s *AudioServer) SendFrame(frame []byte) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendFrameToClient sends frame data directly to the connected client
|
// sendFrameToClient sends frame data directly to the connected client
|
||||||
func (s *AudioServer) sendFrameToClient(frame []byte) error {
|
// Global shared message pool for output IPC server
|
||||||
|
var globalOutputServerMessagePool = NewGenericMessagePool(GetConfig().OutputMessagePoolSize)
|
||||||
|
|
||||||
|
func (s *AudioOutputServer) sendFrameToClient(frame []byte) error {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
defer s.mtx.Unlock()
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -328,84 +302,55 @@ func (s *AudioServer) sendFrameToClient(frame []byte) error {
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Get optimized message from pool
|
// Create output IPC message
|
||||||
optMsg := globalOutputMessagePool.Get()
|
msg := &OutputIPCMessage{
|
||||||
defer globalOutputMessagePool.Put(optMsg)
|
Magic: outputMagicNumber,
|
||||||
|
Type: OutputMessageTypeOpusFrame,
|
||||||
// Prepare header in pre-allocated buffer
|
Length: uint32(len(frame)),
|
||||||
binary.LittleEndian.PutUint32(optMsg.header[0:4], outputMagicNumber)
|
Timestamp: start.UnixNano(),
|
||||||
optMsg.header[4] = byte(OutputMessageTypeOpusFrame)
|
Data: frame,
|
||||||
binary.LittleEndian.PutUint32(optMsg.header[5:9], uint32(len(frame)))
|
|
||||||
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(start.UnixNano()))
|
|
||||||
|
|
||||||
// Use non-blocking write with timeout
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), GetConfig().OutputWriteTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create a channel to signal write completion
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
// Write header using pre-allocated buffer
|
|
||||||
_, err := s.conn.Write(optMsg.header[:])
|
|
||||||
if err != nil {
|
|
||||||
done <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write frame data
|
|
||||||
if len(frame) > 0 {
|
|
||||||
_, err = s.conn.Write(frame)
|
|
||||||
if err != nil {
|
|
||||||
done <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done <- nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for completion or timeout
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
atomic.AddInt64(&s.droppedFrames, 1)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Record latency for monitoring
|
|
||||||
if s.latencyMonitor != nil {
|
|
||||||
writeLatency := time.Since(start)
|
|
||||||
s.latencyMonitor.RecordLatency(writeLatency, "ipc_write")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Timeout occurred - drop frame to prevent blocking
|
|
||||||
atomic.AddInt64(&s.droppedFrames, 1)
|
|
||||||
return fmt.Errorf("write timeout after %v - frame dropped to prevent blocking", GetConfig().OutputWriteTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use shared WriteIPCMessage function
|
||||||
|
err := WriteIPCMessage(s.conn, msg, globalOutputServerMessagePool, &s.droppedFrames)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record latency for monitoring
|
||||||
|
if s.latencyMonitor != nil {
|
||||||
|
writeLatency := time.Since(start)
|
||||||
|
s.latencyMonitor.RecordLatency(writeLatency, "ipc_write")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServerStats returns server performance statistics
|
// GetServerStats returns server performance statistics
|
||||||
func (s *AudioServer) GetServerStats() (total, dropped int64, bufferSize int64) {
|
func (s *AudioOutputServer) GetServerStats() (total, dropped int64, bufferSize int64) {
|
||||||
return atomic.LoadInt64(&s.totalFrames),
|
stats := GetFrameStats(&s.totalFrames, &s.droppedFrames)
|
||||||
atomic.LoadInt64(&s.droppedFrames),
|
return stats.Total, stats.Dropped, atomic.LoadInt64(&s.bufferSize)
|
||||||
atomic.LoadInt64(&s.bufferSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioClient struct {
|
type AudioOutputClient struct {
|
||||||
// Atomic fields must be first for proper alignment on ARM
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
droppedFrames int64 // Atomic counter for dropped frames
|
droppedFrames int64 // Atomic counter for dropped frames
|
||||||
totalFrames int64 // Atomic counter for total frames
|
totalFrames int64 // Atomic counter for total frames
|
||||||
|
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
mtx sync.Mutex
|
mtx sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
|
bufferPool *AudioBufferPool // Buffer pool for memory optimization
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudioClient() *AudioClient {
|
func NewAudioOutputClient() *AudioOutputClient {
|
||||||
return &AudioClient{}
|
return &AudioOutputClient{
|
||||||
|
bufferPool: NewAudioBufferPool(GetMaxAudioFrameSize()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect connects to the audio output server
|
// Connect connects to the audio output server
|
||||||
func (c *AudioClient) Connect() error {
|
func (c *AudioOutputClient) Connect() error {
|
||||||
c.mtx.Lock()
|
c.mtx.Lock()
|
||||||
defer c.mtx.Unlock()
|
defer c.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -437,7 +382,7 @@ func (c *AudioClient) Connect() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect disconnects from the audio output server
|
// Disconnect disconnects from the audio output server
|
||||||
func (c *AudioClient) Disconnect() {
|
func (c *AudioOutputClient) Disconnect() {
|
||||||
c.mtx.Lock()
|
c.mtx.Lock()
|
||||||
defer c.mtx.Unlock()
|
defer c.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -453,18 +398,18 @@ func (c *AudioClient) Disconnect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConnected returns whether the client is connected
|
// IsConnected returns whether the client is connected
|
||||||
func (c *AudioClient) IsConnected() bool {
|
func (c *AudioOutputClient) IsConnected() bool {
|
||||||
c.mtx.Lock()
|
c.mtx.Lock()
|
||||||
defer c.mtx.Unlock()
|
defer c.mtx.Unlock()
|
||||||
return c.running && c.conn != nil
|
return c.running && c.conn != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AudioClient) Close() error {
|
func (c *AudioOutputClient) Close() error {
|
||||||
c.Disconnect()
|
c.Disconnect()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AudioClient) ReceiveFrame() ([]byte, error) {
|
func (c *AudioOutputClient) ReceiveFrame() ([]byte, error) {
|
||||||
c.mtx.Lock()
|
c.mtx.Lock()
|
||||||
defer c.mtx.Unlock()
|
defer c.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -473,8 +418,8 @@ func (c *AudioClient) ReceiveFrame() ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get optimized message from pool for header reading
|
// Get optimized message from pool for header reading
|
||||||
optMsg := globalOutputMessagePool.Get()
|
optMsg := globalOutputClientMessagePool.Get()
|
||||||
defer globalOutputMessagePool.Put(optMsg)
|
defer globalOutputClientMessagePool.Put(optMsg)
|
||||||
|
|
||||||
// Read header
|
// Read header
|
||||||
if _, err := io.ReadFull(c.conn, optMsg.header[:]); err != nil {
|
if _, err := io.ReadFull(c.conn, optMsg.header[:]); err != nil {
|
||||||
|
@ -498,22 +443,26 @@ func (c *AudioClient) ReceiveFrame() ([]byte, error) {
|
||||||
return nil, fmt.Errorf("received frame size validation failed: got %d bytes, maximum allowed %d bytes", size, maxFrameSize)
|
return nil, fmt.Errorf("received frame size validation failed: got %d bytes, maximum allowed %d bytes", size, maxFrameSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read frame data
|
// Read frame data using buffer pool to avoid allocation
|
||||||
frame := make([]byte, size)
|
frame := c.bufferPool.Get()
|
||||||
|
frame = frame[:size] // Resize to actual frame size
|
||||||
if size > 0 {
|
if size > 0 {
|
||||||
if _, err := io.ReadFull(c.conn, frame); err != nil {
|
if _, err := io.ReadFull(c.conn, frame); err != nil {
|
||||||
|
c.bufferPool.Put(frame) // Return buffer on error
|
||||||
return nil, fmt.Errorf("failed to read frame data: %w", err)
|
return nil, fmt.Errorf("failed to read frame data: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Caller is responsible for returning frame to pool via PutAudioFrameBuffer()
|
||||||
|
|
||||||
atomic.AddInt64(&c.totalFrames, 1)
|
atomic.AddInt64(&c.totalFrames, 1)
|
||||||
return frame, nil
|
return frame, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientStats returns client performance statistics
|
// GetClientStats returns client performance statistics
|
||||||
func (c *AudioClient) GetClientStats() (total, dropped int64) {
|
func (c *AudioOutputClient) GetClientStats() (total, dropped int64) {
|
||||||
return atomic.LoadInt64(&c.totalFrames),
|
stats := GetFrameStats(&c.totalFrames, &c.droppedFrames)
|
||||||
atomic.LoadInt64(&c.droppedFrames)
|
return stats.Total, stats.Dropped
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
|
@ -0,0 +1,238 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common IPC message interface
|
||||||
|
type IPCMessage interface {
|
||||||
|
GetMagic() uint32
|
||||||
|
GetType() uint8
|
||||||
|
GetLength() uint32
|
||||||
|
GetTimestamp() int64
|
||||||
|
GetData() []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common optimized message structure
|
||||||
|
type OptimizedMessage struct {
|
||||||
|
header [17]byte // Pre-allocated header buffer
|
||||||
|
data []byte // Reusable data buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic message pool for both input and output
|
||||||
|
type GenericMessagePool struct {
|
||||||
|
// 64-bit fields must be first for proper alignment on ARM
|
||||||
|
hitCount int64 // Pool hit counter (atomic)
|
||||||
|
missCount int64 // Pool miss counter (atomic)
|
||||||
|
|
||||||
|
pool chan *OptimizedMessage
|
||||||
|
preallocated []*OptimizedMessage // Pre-allocated messages
|
||||||
|
preallocSize int
|
||||||
|
maxPoolSize int
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGenericMessagePool creates a new generic message pool
|
||||||
|
func NewGenericMessagePool(size int) *GenericMessagePool {
|
||||||
|
pool := &GenericMessagePool{
|
||||||
|
pool: make(chan *OptimizedMessage, size),
|
||||||
|
preallocSize: size / 4, // 25% pre-allocated for immediate use
|
||||||
|
maxPoolSize: size,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-allocate some messages for immediate use
|
||||||
|
pool.preallocated = make([]*OptimizedMessage, pool.preallocSize)
|
||||||
|
for i := 0; i < pool.preallocSize; i++ {
|
||||||
|
pool.preallocated[i] = &OptimizedMessage{
|
||||||
|
data: make([]byte, 0, GetConfig().MaxFrameSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the channel pool
|
||||||
|
for i := 0; i < size-pool.preallocSize; i++ {
|
||||||
|
select {
|
||||||
|
case pool.pool <- &OptimizedMessage{
|
||||||
|
data: make([]byte, 0, GetConfig().MaxFrameSize),
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves an optimized message from the pool
|
||||||
|
func (mp *GenericMessagePool) Get() *OptimizedMessage {
|
||||||
|
// Try pre-allocated first (fastest path)
|
||||||
|
mp.mutex.Lock()
|
||||||
|
if len(mp.preallocated) > 0 {
|
||||||
|
msg := mp.preallocated[len(mp.preallocated)-1]
|
||||||
|
mp.preallocated = mp.preallocated[:len(mp.preallocated)-1]
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
atomic.AddInt64(&mp.hitCount, 1)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
|
||||||
|
// Try channel pool
|
||||||
|
select {
|
||||||
|
case msg := <-mp.pool:
|
||||||
|
atomic.AddInt64(&mp.hitCount, 1)
|
||||||
|
return msg
|
||||||
|
default:
|
||||||
|
// Pool empty, create new message
|
||||||
|
atomic.AddInt64(&mp.missCount, 1)
|
||||||
|
return &OptimizedMessage{
|
||||||
|
data: make([]byte, 0, GetConfig().MaxFrameSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put returns an optimized message to the pool
|
||||||
|
func (mp *GenericMessagePool) Put(msg *OptimizedMessage) {
|
||||||
|
if msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the message for reuse
|
||||||
|
msg.data = msg.data[:0]
|
||||||
|
|
||||||
|
// Try to return to pre-allocated slice first
|
||||||
|
mp.mutex.Lock()
|
||||||
|
if len(mp.preallocated) < mp.preallocSize {
|
||||||
|
mp.preallocated = append(mp.preallocated, msg)
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mp.mutex.Unlock()
|
||||||
|
|
||||||
|
// Try to return to channel pool
|
||||||
|
select {
|
||||||
|
case mp.pool <- msg:
|
||||||
|
// Successfully returned to pool
|
||||||
|
default:
|
||||||
|
// Pool full, let GC handle it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns pool statistics
|
||||||
|
func (mp *GenericMessagePool) GetStats() (hitCount, missCount int64, hitRate float64) {
|
||||||
|
hits := atomic.LoadInt64(&mp.hitCount)
|
||||||
|
misses := atomic.LoadInt64(&mp.missCount)
|
||||||
|
total := hits + misses
|
||||||
|
if total > 0 {
|
||||||
|
hitRate = float64(hits) / float64(total) * 100
|
||||||
|
}
|
||||||
|
return hits, misses, hitRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common write message function
|
||||||
|
func WriteIPCMessage(conn net.Conn, msg IPCMessage, pool *GenericMessagePool, droppedFramesCounter *int64) error {
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("connection is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get optimized message from pool for header preparation
|
||||||
|
optMsg := pool.Get()
|
||||||
|
defer pool.Put(optMsg)
|
||||||
|
|
||||||
|
// Prepare header in pre-allocated buffer
|
||||||
|
binary.LittleEndian.PutUint32(optMsg.header[0:4], msg.GetMagic())
|
||||||
|
optMsg.header[4] = msg.GetType()
|
||||||
|
binary.LittleEndian.PutUint32(optMsg.header[5:9], msg.GetLength())
|
||||||
|
binary.LittleEndian.PutUint64(optMsg.header[9:17], uint64(msg.GetTimestamp()))
|
||||||
|
|
||||||
|
// Use non-blocking write with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), GetConfig().WriteTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create a channel to signal write completion
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// Write header using pre-allocated buffer
|
||||||
|
_, err := conn.Write(optMsg.header[:])
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data if present
|
||||||
|
if msg.GetLength() > 0 && msg.GetData() != nil {
|
||||||
|
_, err = conn.Write(msg.GetData())
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for completion or timeout
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
if droppedFramesCounter != nil {
|
||||||
|
atomic.AddInt64(droppedFramesCounter, 1)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Timeout occurred - drop frame to prevent blocking
|
||||||
|
if droppedFramesCounter != nil {
|
||||||
|
atomic.AddInt64(droppedFramesCounter, 1)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("write timeout - frame dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common connection acceptance with retry logic
|
||||||
|
func AcceptConnectionWithRetry(listener net.Listener, maxRetries int, retryDelay time.Duration) (net.Conn, error) {
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to accept connection after %d retries: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common frame statistics structure
|
||||||
|
type FrameStats struct {
|
||||||
|
Total int64
|
||||||
|
Dropped int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrameStats safely retrieves frame statistics
|
||||||
|
func GetFrameStats(totalCounter, droppedCounter *int64) FrameStats {
|
||||||
|
return FrameStats{
|
||||||
|
Total: atomic.LoadInt64(totalCounter),
|
||||||
|
Dropped: atomic.LoadInt64(droppedCounter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateDropRate calculates the drop rate percentage
|
||||||
|
func CalculateDropRate(stats FrameStats) float64 {
|
||||||
|
if stats.Total == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(stats.Dropped) / float64(stats.Total) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetFrameStats resets frame counters
|
||||||
|
func ResetFrameStats(totalCounter, droppedCounter *int64) {
|
||||||
|
atomic.StoreInt64(totalCounter, 0)
|
||||||
|
atomic.StoreInt64(droppedCounter, 0)
|
||||||
|
}
|
|
@ -301,8 +301,45 @@ var (
|
||||||
micConnectionDropsValue int64
|
micConnectionDropsValue int64
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UnifiedAudioMetrics provides a common structure for both input and output audio streams
|
||||||
|
type UnifiedAudioMetrics struct {
|
||||||
|
FramesReceived int64 `json:"frames_received"`
|
||||||
|
FramesDropped int64 `json:"frames_dropped"`
|
||||||
|
FramesSent int64 `json:"frames_sent,omitempty"`
|
||||||
|
BytesProcessed int64 `json:"bytes_processed"`
|
||||||
|
ConnectionDrops int64 `json:"connection_drops"`
|
||||||
|
LastFrameTime time.Time `json:"last_frame_time"`
|
||||||
|
AverageLatency time.Duration `json:"average_latency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertAudioMetricsToUnified converts AudioMetrics to UnifiedAudioMetrics
|
||||||
|
func convertAudioMetricsToUnified(metrics AudioMetrics) UnifiedAudioMetrics {
|
||||||
|
return UnifiedAudioMetrics{
|
||||||
|
FramesReceived: metrics.FramesReceived,
|
||||||
|
FramesDropped: metrics.FramesDropped,
|
||||||
|
FramesSent: 0, // AudioMetrics doesn't have FramesSent
|
||||||
|
BytesProcessed: metrics.BytesProcessed,
|
||||||
|
ConnectionDrops: metrics.ConnectionDrops,
|
||||||
|
LastFrameTime: metrics.LastFrameTime,
|
||||||
|
AverageLatency: metrics.AverageLatency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertAudioInputMetricsToUnified converts AudioInputMetrics to UnifiedAudioMetrics
|
||||||
|
func convertAudioInputMetricsToUnified(metrics AudioInputMetrics) UnifiedAudioMetrics {
|
||||||
|
return UnifiedAudioMetrics{
|
||||||
|
FramesReceived: 0, // AudioInputMetrics doesn't have FramesReceived
|
||||||
|
FramesDropped: metrics.FramesDropped,
|
||||||
|
FramesSent: metrics.FramesSent,
|
||||||
|
BytesProcessed: metrics.BytesProcessed,
|
||||||
|
ConnectionDrops: metrics.ConnectionDrops,
|
||||||
|
LastFrameTime: metrics.LastFrameTime,
|
||||||
|
AverageLatency: metrics.AverageLatency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateAudioMetrics updates Prometheus metrics with current audio data
|
// UpdateAudioMetrics updates Prometheus metrics with current audio data
|
||||||
func UpdateAudioMetrics(metrics AudioMetrics) {
|
func UpdateAudioMetrics(metrics UnifiedAudioMetrics) {
|
||||||
oldReceived := atomic.SwapInt64(&audioFramesReceivedValue, metrics.FramesReceived)
|
oldReceived := atomic.SwapInt64(&audioFramesReceivedValue, metrics.FramesReceived)
|
||||||
if metrics.FramesReceived > oldReceived {
|
if metrics.FramesReceived > oldReceived {
|
||||||
audioFramesReceivedTotal.Add(float64(metrics.FramesReceived - oldReceived))
|
audioFramesReceivedTotal.Add(float64(metrics.FramesReceived - oldReceived))
|
||||||
|
@ -333,7 +370,7 @@ func UpdateAudioMetrics(metrics AudioMetrics) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMicrophoneMetrics updates Prometheus metrics with current microphone data
|
// UpdateMicrophoneMetrics updates Prometheus metrics with current microphone data
|
||||||
func UpdateMicrophoneMetrics(metrics AudioInputMetrics) {
|
func UpdateMicrophoneMetrics(metrics UnifiedAudioMetrics) {
|
||||||
oldSent := atomic.SwapInt64(&micFramesSentValue, metrics.FramesSent)
|
oldSent := atomic.SwapInt64(&micFramesSentValue, metrics.FramesSent)
|
||||||
if metrics.FramesSent > oldSent {
|
if metrics.FramesSent > oldSent {
|
||||||
microphoneFramesSentTotal.Add(float64(metrics.FramesSent - oldSent))
|
microphoneFramesSentTotal.Add(float64(metrics.FramesSent - oldSent))
|
||||||
|
@ -457,11 +494,11 @@ func StartMetricsUpdater() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
// Update audio output metrics
|
// Update audio output metrics
|
||||||
audioMetrics := GetAudioMetrics()
|
audioMetrics := GetAudioMetrics()
|
||||||
UpdateAudioMetrics(audioMetrics)
|
UpdateAudioMetrics(convertAudioMetricsToUnified(audioMetrics))
|
||||||
|
|
||||||
// Update microphone input metrics
|
// Update microphone input metrics
|
||||||
micMetrics := GetAudioInputMetrics()
|
micMetrics := GetAudioInputMetrics()
|
||||||
UpdateMicrophoneMetrics(micMetrics)
|
UpdateMicrophoneMetrics(convertAudioInputMetricsToUnified(micMetrics))
|
||||||
|
|
||||||
// Update microphone subprocess process metrics
|
// Update microphone subprocess process metrics
|
||||||
if inputSupervisor := GetAudioInputIPCSupervisor(); inputSupervisor != nil {
|
if inputSupervisor := GetAudioInputIPCSupervisor(); inputSupervisor != nil {
|
||||||
|
|
|
@ -0,0 +1,120 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Naming Standards Documentation
|
||||||
|
// This file documents the standardized naming conventions for audio components
|
||||||
|
// to ensure consistency across the entire audio system.
|
||||||
|
|
||||||
|
/*
|
||||||
|
STANDARDIZED NAMING CONVENTIONS:
|
||||||
|
|
||||||
|
1. COMPONENT HIERARCHY:
|
||||||
|
- Manager: High-level component that orchestrates multiple subsystems
|
||||||
|
- Supervisor: Process lifecycle management (start/stop/restart processes)
|
||||||
|
- Server: IPC server that handles incoming connections
|
||||||
|
- Client: IPC client that connects to servers
|
||||||
|
- Streamer: High-performance streaming component
|
||||||
|
|
||||||
|
2. NAMING PATTERNS:
|
||||||
|
Input Components:
|
||||||
|
- AudioInputManager (replaces: AudioInputManager) ✓
|
||||||
|
- AudioInputSupervisor (replaces: AudioInputSupervisor) ✓
|
||||||
|
- AudioInputServer (replaces: AudioInputServer) ✓
|
||||||
|
- AudioInputClient (replaces: AudioInputClient) ✓
|
||||||
|
- AudioInputStreamer (new: for consistency with OutputStreamer)
|
||||||
|
|
||||||
|
Output Components:
|
||||||
|
- AudioOutputManager (new: missing high-level manager)
|
||||||
|
- AudioOutputSupervisor (replaces: AudioOutputSupervisor) ✓
|
||||||
|
- AudioOutputServer (replaces: AudioOutputServer) ✓
|
||||||
|
- AudioOutputClient (replaces: AudioOutputClient) ✓
|
||||||
|
- AudioOutputStreamer (replaces: OutputStreamer)
|
||||||
|
|
||||||
|
3. IPC NAMING:
|
||||||
|
- AudioInputIPCManager (replaces: AudioInputIPCManager) ✓
|
||||||
|
- AudioOutputIPCManager (new: for consistency)
|
||||||
|
|
||||||
|
4. CONFIGURATION NAMING:
|
||||||
|
- InputIPCConfig (replaces: InputIPCConfig) ✓
|
||||||
|
- OutputIPCConfig (new: for consistency)
|
||||||
|
|
||||||
|
5. MESSAGE NAMING:
|
||||||
|
- InputIPCMessage (replaces: InputIPCMessage) ✓
|
||||||
|
- OutputIPCMessage (replaces: OutputIPCMessage) ✓
|
||||||
|
- InputMessageType (replaces: InputMessageType) ✓
|
||||||
|
- OutputMessageType (replaces: OutputMessageType) ✓
|
||||||
|
|
||||||
|
ISSUES IDENTIFIED:
|
||||||
|
1. Missing AudioOutputManager (high-level output management)
|
||||||
|
2. Inconsistent naming: OutputStreamer vs AudioInputSupervisor
|
||||||
|
3. Missing AudioOutputIPCManager for symmetry
|
||||||
|
4. Missing OutputIPCConfig for consistency
|
||||||
|
5. Component names in logging should be standardized
|
||||||
|
|
||||||
|
IMPLEMENTATION PLAN:
|
||||||
|
1. Create AudioOutputManager to match AudioInputManager
|
||||||
|
2. Rename OutputStreamer to AudioOutputStreamer
|
||||||
|
3. Create AudioOutputIPCManager for symmetry
|
||||||
|
4. Standardize all component logging names
|
||||||
|
5. Update all references consistently
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Component name constants for consistent logging
|
||||||
|
const (
|
||||||
|
// Input component names
|
||||||
|
AudioInputManagerComponent = "audio-input-manager"
|
||||||
|
AudioInputSupervisorComponent = "audio-input-supervisor"
|
||||||
|
AudioInputServerComponent = "audio-input-server"
|
||||||
|
AudioInputClientComponent = "audio-input-client"
|
||||||
|
AudioInputIPCComponent = "audio-input-ipc"
|
||||||
|
|
||||||
|
// Output component names
|
||||||
|
AudioOutputManagerComponent = "audio-output-manager"
|
||||||
|
AudioOutputSupervisorComponent = "audio-output-supervisor"
|
||||||
|
AudioOutputServerComponent = "audio-output-server"
|
||||||
|
AudioOutputClientComponent = "audio-output-client"
|
||||||
|
AudioOutputStreamerComponent = "audio-output-streamer"
|
||||||
|
AudioOutputIPCComponent = "audio-output-ipc"
|
||||||
|
|
||||||
|
// Common component names
|
||||||
|
AudioRelayComponent = "audio-relay"
|
||||||
|
AudioEventsComponent = "audio-events"
|
||||||
|
AudioMetricsComponent = "audio-metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interface definitions for consistent component behavior
|
||||||
|
type AudioManagerInterface interface {
|
||||||
|
Start() error
|
||||||
|
Stop()
|
||||||
|
IsRunning() bool
|
||||||
|
IsReady() bool
|
||||||
|
GetMetrics() interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioSupervisorInterface interface {
|
||||||
|
Start() error
|
||||||
|
Stop() error
|
||||||
|
IsRunning() bool
|
||||||
|
GetProcessPID() int
|
||||||
|
GetProcessMetrics() *ProcessMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioServerInterface interface {
|
||||||
|
Start() error
|
||||||
|
Stop()
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioClientInterface interface {
|
||||||
|
Connect() error
|
||||||
|
Disconnect()
|
||||||
|
IsConnected() bool
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioStreamerInterface interface {
|
||||||
|
Start() error
|
||||||
|
Stop()
|
||||||
|
GetStats() (processed, dropped int64, avgProcessingTime time.Duration)
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AudioOutputManager manages audio output stream using IPC mode
|
||||||
|
type AudioOutputManager struct {
|
||||||
|
metrics AudioOutputMetrics
|
||||||
|
|
||||||
|
streamer *AudioOutputStreamer
|
||||||
|
logger zerolog.Logger
|
||||||
|
running int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioOutputMetrics tracks output-specific metrics
|
||||||
|
type AudioOutputMetrics struct {
|
||||||
|
FramesReceived int64
|
||||||
|
FramesDropped int64
|
||||||
|
BytesProcessed int64
|
||||||
|
ConnectionDrops int64
|
||||||
|
LastFrameTime time.Time
|
||||||
|
AverageLatency time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAudioOutputManager creates a new audio output manager
|
||||||
|
func NewAudioOutputManager() *AudioOutputManager {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue with nil streamer - will be handled gracefully
|
||||||
|
logger := logging.GetDefaultLogger().With().Str("component", AudioOutputManagerComponent).Logger()
|
||||||
|
logger.Error().Err(err).Msg("Failed to create audio output streamer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AudioOutputManager{
|
||||||
|
streamer: streamer,
|
||||||
|
logger: logging.GetDefaultLogger().With().Str("component", AudioOutputManagerComponent).Logger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the audio output manager
|
||||||
|
func (aom *AudioOutputManager) Start() error {
|
||||||
|
if !atomic.CompareAndSwapInt32(&aom.running, 0, 1) {
|
||||||
|
return nil // Already running
|
||||||
|
}
|
||||||
|
|
||||||
|
aom.logger.Info().Str("component", AudioOutputManagerComponent).Msg("starting component")
|
||||||
|
|
||||||
|
if aom.streamer == nil {
|
||||||
|
// Try to recreate streamer if it was nil
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
atomic.StoreInt32(&aom.running, 0)
|
||||||
|
aom.logger.Error().Err(err).Str("component", AudioOutputManagerComponent).Msg("failed to create audio output streamer")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
aom.streamer = streamer
|
||||||
|
}
|
||||||
|
|
||||||
|
err := aom.streamer.Start()
|
||||||
|
if err != nil {
|
||||||
|
atomic.StoreInt32(&aom.running, 0)
|
||||||
|
// Reset metrics on failed start
|
||||||
|
aom.resetMetrics()
|
||||||
|
aom.logger.Error().Err(err).Str("component", AudioOutputManagerComponent).Msg("failed to start component")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aom.logger.Info().Str("component", AudioOutputManagerComponent).Msg("component started successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the audio output manager
|
||||||
|
func (aom *AudioOutputManager) Stop() {
|
||||||
|
if !atomic.CompareAndSwapInt32(&aom.running, 1, 0) {
|
||||||
|
return // Already stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
aom.logger.Info().Str("component", AudioOutputManagerComponent).Msg("stopping component")
|
||||||
|
|
||||||
|
if aom.streamer != nil {
|
||||||
|
aom.streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
aom.logger.Info().Str("component", AudioOutputManagerComponent).Msg("component stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetMetrics resets all metrics to zero
|
||||||
|
func (aom *AudioOutputManager) resetMetrics() {
|
||||||
|
atomic.StoreInt64(&aom.metrics.FramesReceived, 0)
|
||||||
|
atomic.StoreInt64(&aom.metrics.FramesDropped, 0)
|
||||||
|
atomic.StoreInt64(&aom.metrics.BytesProcessed, 0)
|
||||||
|
atomic.StoreInt64(&aom.metrics.ConnectionDrops, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns whether the audio output manager is running
|
||||||
|
func (aom *AudioOutputManager) IsRunning() bool {
|
||||||
|
return atomic.LoadInt32(&aom.running) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReady returns whether the audio output manager is ready to receive frames
|
||||||
|
func (aom *AudioOutputManager) IsReady() bool {
|
||||||
|
if !aom.IsRunning() || aom.streamer == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// For output, we consider it ready if the streamer is running
|
||||||
|
// This could be enhanced with connection status checks
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetrics returns current metrics
|
||||||
|
func (aom *AudioOutputManager) GetMetrics() AudioOutputMetrics {
|
||||||
|
return AudioOutputMetrics{
|
||||||
|
FramesReceived: atomic.LoadInt64(&aom.metrics.FramesReceived),
|
||||||
|
FramesDropped: atomic.LoadInt64(&aom.metrics.FramesDropped),
|
||||||
|
BytesProcessed: atomic.LoadInt64(&aom.metrics.BytesProcessed),
|
||||||
|
ConnectionDrops: atomic.LoadInt64(&aom.metrics.ConnectionDrops),
|
||||||
|
AverageLatency: aom.metrics.AverageLatency,
|
||||||
|
LastFrameTime: aom.metrics.LastFrameTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetComprehensiveMetrics returns detailed performance metrics
|
||||||
|
func (aom *AudioOutputManager) GetComprehensiveMetrics() map[string]interface{} {
|
||||||
|
baseMetrics := aom.GetMetrics()
|
||||||
|
|
||||||
|
comprehensiveMetrics := map[string]interface{}{
|
||||||
|
"manager": map[string]interface{}{
|
||||||
|
"frames_received": baseMetrics.FramesReceived,
|
||||||
|
"frames_dropped": baseMetrics.FramesDropped,
|
||||||
|
"bytes_processed": baseMetrics.BytesProcessed,
|
||||||
|
"connection_drops": baseMetrics.ConnectionDrops,
|
||||||
|
"average_latency_ms": float64(baseMetrics.AverageLatency.Nanoseconds()) / 1e6,
|
||||||
|
"last_frame_time": baseMetrics.LastFrameTime,
|
||||||
|
"running": aom.IsRunning(),
|
||||||
|
"ready": aom.IsReady(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if aom.streamer != nil {
|
||||||
|
processed, dropped, avgTime := aom.streamer.GetStats()
|
||||||
|
comprehensiveMetrics["streamer"] = map[string]interface{}{
|
||||||
|
"frames_processed": processed,
|
||||||
|
"frames_dropped": dropped,
|
||||||
|
"avg_processing_time_ms": float64(avgTime.Nanoseconds()) / 1e6,
|
||||||
|
}
|
||||||
|
|
||||||
|
if detailedStats := aom.streamer.GetDetailedStats(); detailedStats != nil {
|
||||||
|
comprehensiveMetrics["detailed"] = detailedStats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return comprehensiveMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogPerformanceStats logs current performance statistics
|
||||||
|
func (aom *AudioOutputManager) LogPerformanceStats() {
|
||||||
|
metrics := aom.GetMetrics()
|
||||||
|
aom.logger.Info().
|
||||||
|
Int64("frames_received", metrics.FramesReceived).
|
||||||
|
Int64("frames_dropped", metrics.FramesDropped).
|
||||||
|
Int64("bytes_processed", metrics.BytesProcessed).
|
||||||
|
Int64("connection_drops", metrics.ConnectionDrops).
|
||||||
|
Float64("average_latency_ms", float64(metrics.AverageLatency.Nanoseconds())/1e6).
|
||||||
|
Bool("running", aom.IsRunning()).
|
||||||
|
Bool("ready", aom.IsReady()).
|
||||||
|
Msg("Audio output manager performance stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamer returns the streamer for advanced operations
|
||||||
|
func (aom *AudioOutputManager) GetStreamer() *AudioOutputStreamer {
|
||||||
|
return aom.streamer
|
||||||
|
}
|
|
@ -0,0 +1,277 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAudioOutputManager tests the AudioOutputManager component
|
||||||
|
func TestAudioOutputManager(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
testFunc func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{"Start", testAudioOutputManagerStart},
|
||||||
|
{"Stop", testAudioOutputManagerStop},
|
||||||
|
{"StartStop", testAudioOutputManagerStartStop},
|
||||||
|
{"IsRunning", testAudioOutputManagerIsRunning},
|
||||||
|
{"IsReady", testAudioOutputManagerIsReady},
|
||||||
|
{"GetMetrics", testAudioOutputManagerGetMetrics},
|
||||||
|
{"ConcurrentOperations", testAudioOutputManagerConcurrent},
|
||||||
|
{"MultipleStarts", testAudioOutputManagerMultipleStarts},
|
||||||
|
{"MultipleStops", testAudioOutputManagerMultipleStops},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.testFunc(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerStart(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
|
||||||
|
// Test start
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerStop(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerStartStop(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test multiple start/stop cycles
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
// Start
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerIsRunning(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Initially not running
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Start and check
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Stop and check
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerIsReady(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Initially not ready
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
|
||||||
|
// Start and check ready state
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Give some time for initialization
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsReady())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerGetMetrics(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Test metrics when not running
|
||||||
|
metrics := manager.GetMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
|
||||||
|
// Start and test metrics
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
metrics = manager.GetMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerConcurrent(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
const numGoroutines = 10
|
||||||
|
|
||||||
|
// Test concurrent starts
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
manager.Start()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Should be running
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Test concurrent stops
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
manager.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Should be stopped
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerMultipleStarts(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// First start should succeed
|
||||||
|
err := manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Subsequent starts should be no-op
|
||||||
|
err = manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
err = manager.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputManagerMultipleStops(t *testing.T) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
require.NotNil(t, manager)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
err := manager.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// First stop should work
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
// Subsequent stops should be no-op
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
|
||||||
|
manager.Stop()
|
||||||
|
assert.False(t, manager.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioOutputMetrics tests the AudioOutputMetrics functionality
|
||||||
|
func TestAudioOutputMetrics(t *testing.T) {
|
||||||
|
metrics := &AudioOutputMetrics{}
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.Equal(t, int64(0), metrics.FramesReceived)
|
||||||
|
assert.Equal(t, int64(0), metrics.FramesDropped)
|
||||||
|
assert.Equal(t, int64(0), metrics.BytesProcessed)
|
||||||
|
assert.Equal(t, int64(0), metrics.ConnectionDrops)
|
||||||
|
assert.Equal(t, time.Duration(0), metrics.AverageLatency)
|
||||||
|
assert.True(t, metrics.LastFrameTime.IsZero())
|
||||||
|
|
||||||
|
// Test field assignment
|
||||||
|
metrics.FramesReceived = 100
|
||||||
|
metrics.FramesDropped = 5
|
||||||
|
metrics.BytesProcessed = 1024
|
||||||
|
metrics.ConnectionDrops = 2
|
||||||
|
metrics.AverageLatency = 10 * time.Millisecond
|
||||||
|
metrics.LastFrameTime = time.Now()
|
||||||
|
|
||||||
|
// Verify assignments
|
||||||
|
assert.Equal(t, int64(100), metrics.FramesReceived)
|
||||||
|
assert.Equal(t, int64(5), metrics.FramesDropped)
|
||||||
|
assert.Equal(t, int64(1024), metrics.BytesProcessed)
|
||||||
|
assert.Equal(t, int64(2), metrics.ConnectionDrops)
|
||||||
|
assert.Equal(t, 10*time.Millisecond, metrics.AverageLatency)
|
||||||
|
assert.False(t, metrics.LastFrameTime.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkAudioOutputManager benchmarks the AudioOutputManager operations
|
||||||
|
func BenchmarkAudioOutputManager(b *testing.B) {
|
||||||
|
b.Run("Start", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
manager.Start()
|
||||||
|
manager.Stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("IsRunning", func(b *testing.B) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager.IsRunning()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetMetrics", func(b *testing.B) {
|
||||||
|
manager := NewAudioOutputManager()
|
||||||
|
manager.Start()
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
manager.GetMetrics()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ func RunAudioOutputServer() error {
|
||||||
logger.Info().Msg("Starting audio output server subprocess")
|
logger.Info().Msg("Starting audio output server subprocess")
|
||||||
|
|
||||||
// Create audio server
|
// Create audio server
|
||||||
server, err := NewAudioServer()
|
server, err := NewAudioOutputServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error().Err(err).Msg("failed to create audio server")
|
logger.Error().Err(err).Msg("failed to create audio server")
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -12,23 +12,24 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OutputStreamer manages high-performance audio output streaming
|
// AudioOutputStreamer manages high-performance audio output streaming
|
||||||
type OutputStreamer struct {
|
type AudioOutputStreamer struct {
|
||||||
// Atomic fields must be first for proper alignment on ARM
|
// Performance metrics (atomic operations for thread safety)
|
||||||
processedFrames int64 // Total processed frames counter (atomic)
|
processedFrames int64 // Total processed frames counter (atomic)
|
||||||
droppedFrames int64 // Dropped frames counter (atomic)
|
droppedFrames int64 // Dropped frames counter (atomic)
|
||||||
processingTime int64 // Average processing time in nanoseconds (atomic)
|
processingTime int64 // Average processing time in nanoseconds (atomic)
|
||||||
lastStatsTime int64 // Last statistics update time (atomic)
|
lastStatsTime int64 // Last statistics update time (atomic)
|
||||||
|
|
||||||
client *AudioClient
|
client *AudioOutputClient
|
||||||
bufferPool *AudioBufferPool
|
bufferPool *AudioBufferPool
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
running bool
|
running bool
|
||||||
mtx sync.Mutex
|
mtx sync.Mutex
|
||||||
|
chanClosed bool // Track if processing channel is closed
|
||||||
|
|
||||||
// Performance optimization fields
|
// Adaptive processing configuration
|
||||||
batchSize int // Adaptive batch size for frame processing
|
batchSize int // Adaptive batch size for frame processing
|
||||||
processingChan chan []byte // Buffered channel for frame processing
|
processingChan chan []byte // Buffered channel for frame processing
|
||||||
statsInterval time.Duration // Statistics reporting interval
|
statsInterval time.Duration // Statistics reporting interval
|
||||||
|
@ -42,21 +43,21 @@ var (
|
||||||
|
|
||||||
func getOutputStreamingLogger() *zerolog.Logger {
|
func getOutputStreamingLogger() *zerolog.Logger {
|
||||||
if outputStreamingLogger == nil {
|
if outputStreamingLogger == nil {
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-output").Logger()
|
logger := logging.GetDefaultLogger().With().Str("component", AudioOutputStreamerComponent).Logger()
|
||||||
outputStreamingLogger = &logger
|
outputStreamingLogger = &logger
|
||||||
}
|
}
|
||||||
return outputStreamingLogger
|
return outputStreamingLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOutputStreamer() (*OutputStreamer, error) {
|
func NewAudioOutputStreamer() (*AudioOutputStreamer, error) {
|
||||||
client := NewAudioClient()
|
client := NewAudioOutputClient()
|
||||||
|
|
||||||
// Get initial batch size from adaptive buffer manager
|
// Get initial batch size from adaptive buffer manager
|
||||||
adaptiveManager := GetAdaptiveBufferManager()
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
initialBatchSize := adaptiveManager.GetOutputBufferSize()
|
initialBatchSize := adaptiveManager.GetOutputBufferSize()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &OutputStreamer{
|
return &AudioOutputStreamer{
|
||||||
client: client,
|
client: client,
|
||||||
bufferPool: NewAudioBufferPool(GetMaxAudioFrameSize()), // Use existing buffer pool
|
bufferPool: NewAudioBufferPool(GetMaxAudioFrameSize()), // Use existing buffer pool
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
@ -68,7 +69,7 @@ func NewOutputStreamer() (*OutputStreamer, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutputStreamer) Start() error {
|
func (s *AudioOutputStreamer) Start() error {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
defer s.mtx.Unlock()
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -92,7 +93,7 @@ func (s *OutputStreamer) Start() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutputStreamer) Stop() {
|
func (s *AudioOutputStreamer) Stop() {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
defer s.mtx.Unlock()
|
defer s.mtx.Unlock()
|
||||||
|
|
||||||
|
@ -103,8 +104,11 @@ func (s *OutputStreamer) Stop() {
|
||||||
s.running = false
|
s.running = false
|
||||||
s.cancel()
|
s.cancel()
|
||||||
|
|
||||||
// Close processing channel to signal goroutines
|
// Close processing channel to signal goroutines (only if not already closed)
|
||||||
close(s.processingChan)
|
if !s.chanClosed {
|
||||||
|
close(s.processingChan)
|
||||||
|
s.chanClosed = true
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for all goroutines to finish
|
// Wait for all goroutines to finish
|
||||||
s.wg.Wait()
|
s.wg.Wait()
|
||||||
|
@ -114,7 +118,7 @@ func (s *OutputStreamer) Stop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutputStreamer) streamLoop() {
|
func (s *AudioOutputStreamer) streamLoop() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
|
|
||||||
// Pin goroutine to OS thread for consistent performance
|
// Pin goroutine to OS thread for consistent performance
|
||||||
|
@ -153,7 +157,9 @@ func (s *OutputStreamer) streamLoop() {
|
||||||
|
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
// Send frame for processing (non-blocking)
|
// Send frame for processing (non-blocking)
|
||||||
frameData := make([]byte, n)
|
// Use buffer pool to avoid allocation
|
||||||
|
frameData := s.bufferPool.Get()
|
||||||
|
frameData = frameData[:n]
|
||||||
copy(frameData, frameBuf[:n])
|
copy(frameData, frameBuf[:n])
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
@ -175,7 +181,7 @@ func (s *OutputStreamer) streamLoop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// processingLoop handles frame processing in a separate goroutine
|
// processingLoop handles frame processing in a separate goroutine
|
||||||
func (s *OutputStreamer) processingLoop() {
|
func (s *AudioOutputStreamer) processingLoop() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
|
|
||||||
// Pin goroutine to OS thread for consistent performance
|
// Pin goroutine to OS thread for consistent performance
|
||||||
|
@ -192,25 +198,29 @@ func (s *OutputStreamer) processingLoop() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for range s.processingChan {
|
for frameData := range s.processingChan {
|
||||||
// Process frame (currently just receiving, but can be extended)
|
// Process frame and return buffer to pool after processing
|
||||||
if _, err := s.client.ReceiveFrame(); err != nil {
|
func() {
|
||||||
if s.client.IsConnected() {
|
defer s.bufferPool.Put(frameData)
|
||||||
getOutputStreamingLogger().Warn().Err(err).Msg("Error reading audio frame from output server")
|
|
||||||
atomic.AddInt64(&s.droppedFrames, 1)
|
if _, err := s.client.ReceiveFrame(); err != nil {
|
||||||
}
|
if s.client.IsConnected() {
|
||||||
// Try to reconnect if disconnected
|
getOutputStreamingLogger().Warn().Err(err).Msg("Error reading audio frame from output server")
|
||||||
if !s.client.IsConnected() {
|
atomic.AddInt64(&s.droppedFrames, 1)
|
||||||
if err := s.client.Connect(); err != nil {
|
}
|
||||||
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to reconnect")
|
// Try to reconnect if disconnected
|
||||||
|
if !s.client.IsConnected() {
|
||||||
|
if err := s.client.Connect(); err != nil {
|
||||||
|
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to reconnect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// statisticsLoop monitors and reports performance statistics
|
// statisticsLoop monitors and reports performance statistics
|
||||||
func (s *OutputStreamer) statisticsLoop() {
|
func (s *AudioOutputStreamer) statisticsLoop() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
|
|
||||||
ticker := time.NewTicker(s.statsInterval)
|
ticker := time.NewTicker(s.statsInterval)
|
||||||
|
@ -227,7 +237,7 @@ func (s *OutputStreamer) statisticsLoop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportStatistics logs current performance statistics
|
// reportStatistics logs current performance statistics
|
||||||
func (s *OutputStreamer) reportStatistics() {
|
func (s *AudioOutputStreamer) reportStatistics() {
|
||||||
processed := atomic.LoadInt64(&s.processedFrames)
|
processed := atomic.LoadInt64(&s.processedFrames)
|
||||||
dropped := atomic.LoadInt64(&s.droppedFrames)
|
dropped := atomic.LoadInt64(&s.droppedFrames)
|
||||||
processingTime := atomic.LoadInt64(&s.processingTime)
|
processingTime := atomic.LoadInt64(&s.processingTime)
|
||||||
|
@ -245,7 +255,7 @@ func (s *OutputStreamer) reportStatistics() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats returns streaming statistics
|
// GetStats returns streaming statistics
|
||||||
func (s *OutputStreamer) GetStats() (processed, dropped int64, avgProcessingTime time.Duration) {
|
func (s *AudioOutputStreamer) GetStats() (processed, dropped int64, avgProcessingTime time.Duration) {
|
||||||
processed = atomic.LoadInt64(&s.processedFrames)
|
processed = atomic.LoadInt64(&s.processedFrames)
|
||||||
dropped = atomic.LoadInt64(&s.droppedFrames)
|
dropped = atomic.LoadInt64(&s.droppedFrames)
|
||||||
processingTimeNs := atomic.LoadInt64(&s.processingTime)
|
processingTimeNs := atomic.LoadInt64(&s.processingTime)
|
||||||
|
@ -254,7 +264,7 @@ func (s *OutputStreamer) GetStats() (processed, dropped int64, avgProcessingTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDetailedStats returns comprehensive streaming statistics
|
// GetDetailedStats returns comprehensive streaming statistics
|
||||||
func (s *OutputStreamer) GetDetailedStats() map[string]interface{} {
|
func (s *AudioOutputStreamer) GetDetailedStats() map[string]interface{} {
|
||||||
processed := atomic.LoadInt64(&s.processedFrames)
|
processed := atomic.LoadInt64(&s.processedFrames)
|
||||||
dropped := atomic.LoadInt64(&s.droppedFrames)
|
dropped := atomic.LoadInt64(&s.droppedFrames)
|
||||||
processingTime := atomic.LoadInt64(&s.processingTime)
|
processingTime := atomic.LoadInt64(&s.processingTime)
|
||||||
|
@ -282,7 +292,7 @@ func (s *OutputStreamer) GetDetailedStats() map[string]interface{} {
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBatchSize updates the batch size from adaptive buffer manager
|
// UpdateBatchSize updates the batch size from adaptive buffer manager
|
||||||
func (s *OutputStreamer) UpdateBatchSize() {
|
func (s *AudioOutputStreamer) UpdateBatchSize() {
|
||||||
s.mtx.Lock()
|
s.mtx.Lock()
|
||||||
adaptiveManager := GetAdaptiveBufferManager()
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
s.batchSize = adaptiveManager.GetOutputBufferSize()
|
s.batchSize = adaptiveManager.GetOutputBufferSize()
|
||||||
|
@ -290,7 +300,7 @@ func (s *OutputStreamer) UpdateBatchSize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportLatency reports processing latency to adaptive buffer manager
|
// ReportLatency reports processing latency to adaptive buffer manager
|
||||||
func (s *OutputStreamer) ReportLatency(latency time.Duration) {
|
func (s *AudioOutputStreamer) ReportLatency(latency time.Duration) {
|
||||||
adaptiveManager := GetAdaptiveBufferManager()
|
adaptiveManager := GetAdaptiveBufferManager()
|
||||||
adaptiveManager.UpdateLatency(latency)
|
adaptiveManager.UpdateLatency(latency)
|
||||||
}
|
}
|
||||||
|
@ -321,17 +331,61 @@ func StartAudioOutputStreaming(send func([]byte)) error {
|
||||||
getOutputStreamingLogger().Info().Str("socket_path", getOutputSocketPath()).Msg("Audio output streaming started, connected to output server")
|
getOutputStreamingLogger().Info().Str("socket_path", getOutputSocketPath()).Msg("Audio output streaming started, connected to output server")
|
||||||
buffer := make([]byte, GetMaxAudioFrameSize())
|
buffer := make([]byte, GetMaxAudioFrameSize())
|
||||||
|
|
||||||
|
consecutiveErrors := 0
|
||||||
|
maxConsecutiveErrors := GetConfig().MaxConsecutiveErrors
|
||||||
|
errorBackoffDelay := GetConfig().RetryDelay
|
||||||
|
maxErrorBackoff := GetConfig().MaxRetryDelay
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// Capture audio frame
|
// Capture audio frame with enhanced error handling
|
||||||
n, err := CGOAudioReadEncode(buffer)
|
n, err := CGOAudioReadEncode(buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
getOutputStreamingLogger().Warn().Err(err).Msg("Failed to read/encode audio")
|
consecutiveErrors++
|
||||||
|
getOutputStreamingLogger().Warn().
|
||||||
|
Err(err).
|
||||||
|
Int("consecutive_errors", consecutiveErrors).
|
||||||
|
Msg("Failed to read/encode audio")
|
||||||
|
|
||||||
|
// Implement progressive backoff for consecutive errors
|
||||||
|
if consecutiveErrors >= maxConsecutiveErrors {
|
||||||
|
getOutputStreamingLogger().Error().
|
||||||
|
Int("consecutive_errors", consecutiveErrors).
|
||||||
|
Msg("Too many consecutive audio errors, attempting recovery")
|
||||||
|
|
||||||
|
// Try to reinitialize audio system
|
||||||
|
CGOAudioClose()
|
||||||
|
time.Sleep(errorBackoffDelay)
|
||||||
|
if initErr := CGOAudioInit(); initErr != nil {
|
||||||
|
getOutputStreamingLogger().Error().
|
||||||
|
Err(initErr).
|
||||||
|
Msg("Failed to reinitialize audio system")
|
||||||
|
// Exponential backoff for reinitialization failures
|
||||||
|
errorBackoffDelay = time.Duration(float64(errorBackoffDelay) * GetConfig().BackoffMultiplier)
|
||||||
|
if errorBackoffDelay > maxErrorBackoff {
|
||||||
|
errorBackoffDelay = maxErrorBackoff
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getOutputStreamingLogger().Info().Msg("Audio system reinitialized successfully")
|
||||||
|
consecutiveErrors = 0
|
||||||
|
errorBackoffDelay = GetConfig().RetryDelay // Reset backoff
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Brief delay for transient errors
|
||||||
|
time.Sleep(GetConfig().ShortSleepDuration)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success - reset error counters
|
||||||
|
if consecutiveErrors > 0 {
|
||||||
|
consecutiveErrors = 0
|
||||||
|
errorBackoffDelay = GetConfig().RetryDelay
|
||||||
|
}
|
||||||
|
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
// Get frame buffer from pool to reduce allocations
|
// Get frame buffer from pool to reduce allocations
|
||||||
frame := GetAudioFrameBuffer()
|
frame := GetAudioFrameBuffer()
|
||||||
|
|
|
@ -0,0 +1,341 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAudioOutputStreamer tests the AudioOutputStreamer component
|
||||||
|
func TestAudioOutputStreamer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
testFunc func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{"NewAudioOutputStreamer", testNewAudioOutputStreamer},
|
||||||
|
{"Start", testAudioOutputStreamerStart},
|
||||||
|
{"Stop", testAudioOutputStreamerStop},
|
||||||
|
{"StartStop", testAudioOutputStreamerStartStop},
|
||||||
|
{"GetStats", testAudioOutputStreamerGetStats},
|
||||||
|
{"GetDetailedStats", testAudioOutputStreamerGetDetailedStats},
|
||||||
|
{"UpdateBatchSize", testAudioOutputStreamerUpdateBatchSize},
|
||||||
|
{"ReportLatency", testAudioOutputStreamerReportLatency},
|
||||||
|
{"ConcurrentOperations", testAudioOutputStreamerConcurrent},
|
||||||
|
{"MultipleStarts", testAudioOutputStreamerMultipleStarts},
|
||||||
|
{"MultipleStops", testAudioOutputStreamerMultipleStops},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.testFunc(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewAudioOutputStreamer(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
// If creation fails due to missing dependencies, skip the test
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
processed, dropped, avgTime := streamer.GetStats()
|
||||||
|
assert.GreaterOrEqual(t, processed, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, dropped, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, avgTime, time.Duration(0))
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerStart(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test start
|
||||||
|
err = streamer.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerStop(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
err = streamer.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test stop
|
||||||
|
streamer.Stop()
|
||||||
|
|
||||||
|
// Multiple stops should be safe
|
||||||
|
streamer.Stop()
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerStartStop(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test multiple start/stop cycles
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
// Start
|
||||||
|
err = streamer.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerGetStats(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test stats when not running
|
||||||
|
processed, dropped, avgTime := streamer.GetStats()
|
||||||
|
assert.Equal(t, int64(0), processed)
|
||||||
|
assert.Equal(t, int64(0), dropped)
|
||||||
|
assert.GreaterOrEqual(t, avgTime, time.Duration(0))
|
||||||
|
|
||||||
|
// Start and test stats
|
||||||
|
err = streamer.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
processed, dropped, avgTime = streamer.GetStats()
|
||||||
|
assert.GreaterOrEqual(t, processed, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, dropped, int64(0))
|
||||||
|
assert.GreaterOrEqual(t, avgTime, time.Duration(0))
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerGetDetailedStats(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test detailed stats
|
||||||
|
stats := streamer.GetDetailedStats()
|
||||||
|
assert.NotNil(t, stats)
|
||||||
|
assert.Contains(t, stats, "processed_frames")
|
||||||
|
assert.Contains(t, stats, "dropped_frames")
|
||||||
|
assert.Contains(t, stats, "batch_size")
|
||||||
|
assert.Contains(t, stats, "connected")
|
||||||
|
assert.Equal(t, int64(0), stats["processed_frames"])
|
||||||
|
assert.Equal(t, int64(0), stats["dropped_frames"])
|
||||||
|
|
||||||
|
// Start and test detailed stats
|
||||||
|
err = streamer.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stats = streamer.GetDetailedStats()
|
||||||
|
assert.NotNil(t, stats)
|
||||||
|
assert.Contains(t, stats, "processed_frames")
|
||||||
|
assert.Contains(t, stats, "dropped_frames")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerUpdateBatchSize(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test updating batch size (no parameters, uses adaptive manager)
|
||||||
|
streamer.UpdateBatchSize()
|
||||||
|
streamer.UpdateBatchSize()
|
||||||
|
streamer.UpdateBatchSize()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerReportLatency(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Test reporting latency
|
||||||
|
streamer.ReportLatency(10 * time.Millisecond)
|
||||||
|
streamer.ReportLatency(5 * time.Millisecond)
|
||||||
|
streamer.ReportLatency(15 * time.Millisecond)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerConcurrent(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
const numGoroutines = 10
|
||||||
|
|
||||||
|
// Test concurrent starts
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
streamer.Start()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Test concurrent operations
|
||||||
|
wg.Add(numGoroutines * 3)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
streamer.GetStats()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
streamer.UpdateBatchSize()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
streamer.ReportLatency(10 * time.Millisecond)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Test concurrent stops
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
streamer.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerMultipleStarts(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// First start should succeed
|
||||||
|
err = streamer.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Subsequent starts should return error
|
||||||
|
err = streamer.Start()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already running")
|
||||||
|
|
||||||
|
err = streamer.Start()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already running")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioOutputStreamerMultipleStops(t *testing.T) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, streamer)
|
||||||
|
|
||||||
|
// Start first
|
||||||
|
err = streamer.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Multiple stops should be safe
|
||||||
|
streamer.Stop()
|
||||||
|
streamer.Stop()
|
||||||
|
streamer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkAudioOutputStreamer benchmarks the AudioOutputStreamer operations
|
||||||
|
func BenchmarkAudioOutputStreamer(b *testing.B) {
|
||||||
|
b.Run("GetStats", func(b *testing.B) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
b.Skipf("Skipping benchmark due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer streamer.Stop()
|
||||||
|
|
||||||
|
streamer.Start()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
streamer.GetStats()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("UpdateBatchSize", func(b *testing.B) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
b.Skipf("Skipping benchmark due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer streamer.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
streamer.UpdateBatchSize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("ReportLatency", func(b *testing.B) {
|
||||||
|
streamer, err := NewAudioOutputStreamer()
|
||||||
|
if err != nil {
|
||||||
|
b.Skipf("Skipping benchmark due to missing dependencies: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer streamer.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
streamer.ReportLatency(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -19,13 +19,14 @@ type AudioRelay struct {
|
||||||
framesRelayed int64
|
framesRelayed int64
|
||||||
framesDropped int64
|
framesDropped int64
|
||||||
|
|
||||||
client *AudioClient
|
client *AudioOutputClient
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
logger *zerolog.Logger
|
logger *zerolog.Logger
|
||||||
running bool
|
running bool
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
bufferPool *AudioBufferPool // Buffer pool for memory optimization
|
||||||
|
|
||||||
// WebRTC integration
|
// WebRTC integration
|
||||||
audioTrack AudioTrackWriter
|
audioTrack AudioTrackWriter
|
||||||
|
@ -44,9 +45,10 @@ func NewAudioRelay() *AudioRelay {
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-relay").Logger()
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-relay").Logger()
|
||||||
|
|
||||||
return &AudioRelay{
|
return &AudioRelay{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
logger: &logger,
|
logger: &logger,
|
||||||
|
bufferPool: NewAudioBufferPool(GetMaxAudioFrameSize()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +62,7 @@ func (r *AudioRelay) Start(audioTrack AudioTrackWriter, config AudioConfig) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create audio client to connect to subprocess
|
// Create audio client to connect to subprocess
|
||||||
client := NewAudioClient()
|
client := NewAudioOutputClient()
|
||||||
r.client = client
|
r.client = client
|
||||||
r.audioTrack = audioTrack
|
r.audioTrack = audioTrack
|
||||||
r.config = config
|
r.config = config
|
||||||
|
@ -188,8 +190,14 @@ func (r *AudioRelay) forwardToWebRTC(frame []byte) error {
|
||||||
// Prepare sample data
|
// Prepare sample data
|
||||||
var sampleData []byte
|
var sampleData []byte
|
||||||
if muted {
|
if muted {
|
||||||
// Send silence when muted
|
// Send silence when muted - use buffer pool to avoid allocation
|
||||||
sampleData = make([]byte, len(frame))
|
sampleData = r.bufferPool.Get()
|
||||||
|
sampleData = sampleData[:len(frame)] // Resize to frame length
|
||||||
|
// Clear the buffer to create silence
|
||||||
|
for i := range sampleData {
|
||||||
|
sampleData[i] = 0
|
||||||
|
}
|
||||||
|
defer r.bufferPool.Put(sampleData) // Return to pool after use
|
||||||
} else {
|
} else {
|
||||||
sampleData = frame
|
sampleData = frame
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,8 +34,8 @@ func getMaxRestartDelay() time.Duration {
|
||||||
return GetConfig().MaxRestartDelay
|
return GetConfig().MaxRestartDelay
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioServerSupervisor manages the audio server subprocess lifecycle
|
// AudioOutputSupervisor manages the audio output server subprocess lifecycle
|
||||||
type AudioServerSupervisor struct {
|
type AudioOutputSupervisor struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
logger *zerolog.Logger
|
logger *zerolog.Logger
|
||||||
|
@ -52,8 +52,10 @@ type AudioServerSupervisor struct {
|
||||||
lastExitTime time.Time
|
lastExitTime time.Time
|
||||||
|
|
||||||
// Channels for coordination
|
// Channels for coordination
|
||||||
processDone chan struct{}
|
processDone chan struct{}
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
stopChanClosed bool // Track if stopChan is closed
|
||||||
|
processDoneClosed bool // Track if processDone is closed
|
||||||
|
|
||||||
// Process monitoring
|
// Process monitoring
|
||||||
processMonitor *ProcessMonitor
|
processMonitor *ProcessMonitor
|
||||||
|
@ -64,12 +66,12 @@ type AudioServerSupervisor struct {
|
||||||
onRestart func(attempt int, delay time.Duration)
|
onRestart func(attempt int, delay time.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAudioServerSupervisor creates a new audio server supervisor
|
// NewAudioOutputSupervisor creates a new audio output server supervisor
|
||||||
func NewAudioServerSupervisor() *AudioServerSupervisor {
|
func NewAudioOutputSupervisor() *AudioOutputSupervisor {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-supervisor").Logger()
|
logger := logging.GetDefaultLogger().With().Str("component", AudioOutputSupervisorComponent).Logger()
|
||||||
|
|
||||||
return &AudioServerSupervisor{
|
return &AudioOutputSupervisor{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
logger: &logger,
|
logger: &logger,
|
||||||
|
@ -80,7 +82,7 @@ func NewAudioServerSupervisor() *AudioServerSupervisor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallbacks sets optional callbacks for process lifecycle events
|
// SetCallbacks sets optional callbacks for process lifecycle events
|
||||||
func (s *AudioServerSupervisor) SetCallbacks(
|
func (s *AudioOutputSupervisor) SetCallbacks(
|
||||||
onStart func(pid int),
|
onStart func(pid int),
|
||||||
onExit func(pid int, exitCode int, crashed bool),
|
onExit func(pid int, exitCode int, crashed bool),
|
||||||
onRestart func(attempt int, delay time.Duration),
|
onRestart func(attempt int, delay time.Duration),
|
||||||
|
@ -93,79 +95,100 @@ func (s *AudioServerSupervisor) SetCallbacks(
|
||||||
s.onRestart = onRestart
|
s.onRestart = onRestart
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins supervising the audio server process
|
// Start begins supervising the audio output server process
|
||||||
func (s *AudioServerSupervisor) Start() error {
|
func (s *AudioOutputSupervisor) Start() error {
|
||||||
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
|
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
|
||||||
return fmt.Errorf("supervisor already running")
|
return fmt.Errorf("audio output supervisor is already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info().Msg("starting audio server supervisor")
|
s.logger.Info().Str("component", AudioOutputSupervisorComponent).Msg("starting component")
|
||||||
|
|
||||||
// Recreate channels in case they were closed by a previous Stop() call
|
// Recreate channels in case they were closed by a previous Stop() call
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
s.processDone = make(chan struct{})
|
s.processDone = make(chan struct{})
|
||||||
s.stopChan = make(chan struct{})
|
s.stopChan = make(chan struct{})
|
||||||
|
s.stopChanClosed = false // Reset channel closed flag
|
||||||
|
s.processDoneClosed = false // Reset channel closed flag
|
||||||
// Recreate context as well since it might have been cancelled
|
// Recreate context as well since it might have been cancelled
|
||||||
s.ctx, s.cancel = context.WithCancel(context.Background())
|
s.ctx, s.cancel = context.WithCancel(context.Background())
|
||||||
|
// Reset restart tracking on start
|
||||||
|
s.restartAttempts = s.restartAttempts[:0]
|
||||||
|
s.lastExitCode = 0
|
||||||
|
s.lastExitTime = time.Time{}
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
// Start the supervision loop
|
// Start the supervision loop
|
||||||
go s.supervisionLoop()
|
go s.supervisionLoop()
|
||||||
|
|
||||||
|
s.logger.Info().Str("component", AudioOutputSupervisorComponent).Msg("component started successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the audio server and supervisor
|
// Stop gracefully stops the audio server and supervisor
|
||||||
func (s *AudioServerSupervisor) Stop() error {
|
func (s *AudioOutputSupervisor) Stop() {
|
||||||
if !atomic.CompareAndSwapInt32(&s.running, 1, 0) {
|
if !atomic.CompareAndSwapInt32(&s.running, 1, 0) {
|
||||||
return nil // Already stopped
|
return // Already stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info().Msg("stopping audio server supervisor")
|
s.logger.Info().Str("component", AudioOutputSupervisorComponent).Msg("stopping component")
|
||||||
|
|
||||||
// Signal stop and wait for cleanup
|
// Signal stop and wait for cleanup
|
||||||
close(s.stopChan)
|
s.mutex.Lock()
|
||||||
|
if !s.stopChanClosed {
|
||||||
|
close(s.stopChan)
|
||||||
|
s.stopChanClosed = true
|
||||||
|
}
|
||||||
|
s.mutex.Unlock()
|
||||||
s.cancel()
|
s.cancel()
|
||||||
|
|
||||||
// Wait for process to exit
|
// Wait for process to exit
|
||||||
select {
|
select {
|
||||||
case <-s.processDone:
|
case <-s.processDone:
|
||||||
s.logger.Info().Msg("audio server process stopped gracefully")
|
s.logger.Info().Str("component", AudioOutputSupervisorComponent).Msg("component stopped gracefully")
|
||||||
case <-time.After(GetConfig().SupervisorTimeout):
|
case <-time.After(GetConfig().SupervisorTimeout):
|
||||||
s.logger.Warn().Msg("audio server process did not stop gracefully, forcing termination")
|
s.logger.Warn().Str("component", AudioOutputSupervisorComponent).Msg("component did not stop gracefully, forcing termination")
|
||||||
s.forceKillProcess()
|
s.forceKillProcess()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
s.logger.Info().Str("component", AudioOutputSupervisorComponent).Msg("component stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRunning returns true if the supervisor is running
|
// IsRunning returns true if the supervisor is running
|
||||||
func (s *AudioServerSupervisor) IsRunning() bool {
|
func (s *AudioOutputSupervisor) IsRunning() bool {
|
||||||
return atomic.LoadInt32(&s.running) == 1
|
return atomic.LoadInt32(&s.running) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProcessPID returns the current process PID (0 if not running)
|
// GetProcessPID returns the current process PID (0 if not running)
|
||||||
func (s *AudioServerSupervisor) GetProcessPID() int {
|
func (s *AudioOutputSupervisor) GetProcessPID() int {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
defer s.mutex.RUnlock()
|
defer s.mutex.RUnlock()
|
||||||
return s.processPID
|
return s.processPID
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLastExitInfo returns information about the last process exit
|
// GetLastExitInfo returns information about the last process exit
|
||||||
func (s *AudioServerSupervisor) GetLastExitInfo() (exitCode int, exitTime time.Time) {
|
func (s *AudioOutputSupervisor) GetLastExitInfo() (exitCode int, exitTime time.Time) {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
defer s.mutex.RUnlock()
|
defer s.mutex.RUnlock()
|
||||||
return s.lastExitCode, s.lastExitTime
|
return s.lastExitCode, s.lastExitTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProcessMetrics returns current process metrics if the process is running
|
// GetProcessMetrics returns current process metrics if the process is running
|
||||||
func (s *AudioServerSupervisor) GetProcessMetrics() *ProcessMetrics {
|
func (s *AudioOutputSupervisor) GetProcessMetrics() *ProcessMetrics {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
pid := s.processPID
|
pid := s.processPID
|
||||||
s.mutex.RUnlock()
|
s.mutex.RUnlock()
|
||||||
|
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
return nil
|
// Return default metrics when no process is running
|
||||||
|
return &ProcessMetrics{
|
||||||
|
PID: 0,
|
||||||
|
CPUPercent: 0.0,
|
||||||
|
MemoryRSS: 0,
|
||||||
|
MemoryVMS: 0,
|
||||||
|
MemoryPercent: 0.0,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
ProcessName: "audio-output-server",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.processMonitor.GetCurrentMetrics()
|
metrics := s.processMonitor.GetCurrentMetrics()
|
||||||
|
@ -174,13 +197,28 @@ func (s *AudioServerSupervisor) GetProcessMetrics() *ProcessMetrics {
|
||||||
return &metric
|
return &metric
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
// Return default metrics if process not found in monitor
|
||||||
|
return &ProcessMetrics{
|
||||||
|
PID: pid,
|
||||||
|
CPUPercent: 0.0,
|
||||||
|
MemoryRSS: 0,
|
||||||
|
MemoryVMS: 0,
|
||||||
|
MemoryPercent: 0.0,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
ProcessName: "audio-output-server",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// supervisionLoop is the main supervision loop
|
// supervisionLoop is the main supervision loop
|
||||||
func (s *AudioServerSupervisor) supervisionLoop() {
|
func (s *AudioOutputSupervisor) supervisionLoop() {
|
||||||
defer func() {
|
defer func() {
|
||||||
close(s.processDone)
|
s.mutex.Lock()
|
||||||
|
if !s.processDoneClosed {
|
||||||
|
close(s.processDone)
|
||||||
|
s.processDoneClosed = true
|
||||||
|
}
|
||||||
|
s.mutex.Unlock()
|
||||||
s.logger.Info().Msg("audio server supervision ended")
|
s.logger.Info().Msg("audio server supervision ended")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -252,7 +290,7 @@ func (s *AudioServerSupervisor) supervisionLoop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProcess starts the audio server process
|
// startProcess starts the audio server process
|
||||||
func (s *AudioServerSupervisor) startProcess() error {
|
func (s *AudioOutputSupervisor) startProcess() error {
|
||||||
execPath, err := os.Executable()
|
execPath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get executable path: %w", err)
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
@ -285,7 +323,7 @@ func (s *AudioServerSupervisor) startProcess() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForProcessExit waits for the current process to exit and logs the result
|
// waitForProcessExit waits for the current process to exit and logs the result
|
||||||
func (s *AudioServerSupervisor) waitForProcessExit() {
|
func (s *AudioOutputSupervisor) waitForProcessExit() {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
cmd := s.cmd
|
cmd := s.cmd
|
||||||
pid := s.processPID
|
pid := s.processPID
|
||||||
|
@ -338,7 +376,7 @@ func (s *AudioServerSupervisor) waitForProcessExit() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// terminateProcess gracefully terminates the current process
|
// terminateProcess gracefully terminates the current process
|
||||||
func (s *AudioServerSupervisor) terminateProcess() {
|
func (s *AudioOutputSupervisor) terminateProcess() {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
cmd := s.cmd
|
cmd := s.cmd
|
||||||
pid := s.processPID
|
pid := s.processPID
|
||||||
|
@ -365,14 +403,14 @@ func (s *AudioServerSupervisor) terminateProcess() {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
s.logger.Info().Int("pid", pid).Msg("audio server process terminated gracefully")
|
s.logger.Info().Int("pid", pid).Msg("audio server process terminated gracefully")
|
||||||
case <-time.After(GetConfig().InputSupervisorTimeout):
|
case <-time.After(GetConfig().OutputSupervisorTimeout):
|
||||||
s.logger.Warn().Int("pid", pid).Msg("process did not terminate gracefully, sending SIGKILL")
|
s.logger.Warn().Int("pid", pid).Msg("process did not terminate gracefully, sending SIGKILL")
|
||||||
s.forceKillProcess()
|
s.forceKillProcess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// forceKillProcess forcefully kills the current process
|
// forceKillProcess forcefully kills the current process
|
||||||
func (s *AudioServerSupervisor) forceKillProcess() {
|
func (s *AudioOutputSupervisor) forceKillProcess() {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
cmd := s.cmd
|
cmd := s.cmd
|
||||||
pid := s.processPID
|
pid := s.processPID
|
||||||
|
@ -389,7 +427,7 @@ func (s *AudioServerSupervisor) forceKillProcess() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldRestart determines if the process should be restarted
|
// shouldRestart determines if the process should be restarted
|
||||||
func (s *AudioServerSupervisor) shouldRestart() bool {
|
func (s *AudioOutputSupervisor) shouldRestart() bool {
|
||||||
if atomic.LoadInt32(&s.running) == 0 {
|
if atomic.LoadInt32(&s.running) == 0 {
|
||||||
return false // Supervisor is stopping
|
return false // Supervisor is stopping
|
||||||
}
|
}
|
||||||
|
@ -411,7 +449,7 @@ func (s *AudioServerSupervisor) shouldRestart() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordRestartAttempt records a restart attempt
|
// recordRestartAttempt records a restart attempt
|
||||||
func (s *AudioServerSupervisor) recordRestartAttempt() {
|
func (s *AudioOutputSupervisor) recordRestartAttempt() {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
defer s.mutex.Unlock()
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
@ -419,7 +457,7 @@ func (s *AudioServerSupervisor) recordRestartAttempt() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateRestartDelay calculates the delay before next restart attempt
|
// calculateRestartDelay calculates the delay before next restart attempt
|
||||||
func (s *AudioServerSupervisor) calculateRestartDelay() time.Duration {
|
func (s *AudioOutputSupervisor) calculateRestartDelay() time.Duration {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
defer s.mutex.RUnlock()
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,217 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAudioOutputSupervisor(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
assert.NotNil(t, supervisor)
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorStart(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Test successful start
|
||||||
|
err := supervisor.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
// Test starting already running supervisor
|
||||||
|
err = supervisor.Start()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already running")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
supervisor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorStop(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Test stopping non-running supervisor
|
||||||
|
supervisor.Stop()
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
// Start and then stop
|
||||||
|
err := supervisor.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
supervisor.Stop()
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorIsRunning(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Test initial state
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
// Test after start
|
||||||
|
err := supervisor.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
// Test after stop
|
||||||
|
supervisor.Stop()
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorGetProcessMetrics(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Test metrics when not running
|
||||||
|
metrics := supervisor.GetProcessMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
|
||||||
|
// Start and test metrics
|
||||||
|
err := supervisor.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
metrics = supervisor.GetProcessMetrics()
|
||||||
|
assert.NotNil(t, metrics)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
supervisor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorConcurrentOperations(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Test concurrent start/stop operations
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = supervisor.Start()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
supervisor.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test concurrent metric access
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = supervisor.GetProcessMetrics()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test concurrent status checks
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = supervisor.IsRunning()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
supervisor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorMultipleStartStop(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Test multiple start/stop cycles
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
err := supervisor.Start()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
supervisor.Stop()
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorHealthCheck(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Start supervisor
|
||||||
|
err := supervisor.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Give some time for health monitoring to initialize
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Test that supervisor is still running
|
||||||
|
assert.True(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
supervisor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioOutputSupervisorProcessManagement(t *testing.T) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
require.NotNil(t, supervisor)
|
||||||
|
|
||||||
|
// Start supervisor
|
||||||
|
err := supervisor.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Give some time for process management to initialize
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Test that supervisor is managing processes
|
||||||
|
assert.True(t, supervisor.IsRunning())
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
supervisor.Stop()
|
||||||
|
|
||||||
|
// Ensure supervisor stopped cleanly
|
||||||
|
assert.False(t, supervisor.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark tests
|
||||||
|
func BenchmarkAudioOutputSupervisor(b *testing.B) {
|
||||||
|
supervisor := NewAudioOutputSupervisor()
|
||||||
|
|
||||||
|
b.Run("Start", func(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = supervisor.Start()
|
||||||
|
supervisor.Stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetProcessMetrics", func(b *testing.B) {
|
||||||
|
_ = supervisor.Start()
|
||||||
|
defer supervisor.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = supervisor.GetProcessMetrics()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("IsRunning", func(b *testing.B) {
|
||||||
|
_ = supervisor.Start()
|
||||||
|
defer supervisor.Stop()
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = supervisor.IsRunning()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
var (
|
||||||
|
ErrInvalidAudioQuality = errors.New("invalid audio quality level")
|
||||||
|
ErrInvalidFrameSize = errors.New("invalid frame size")
|
||||||
|
ErrInvalidFrameData = errors.New("invalid frame data")
|
||||||
|
ErrInvalidBufferSize = errors.New("invalid buffer size")
|
||||||
|
ErrInvalidPriority = errors.New("invalid priority value")
|
||||||
|
ErrInvalidLatency = errors.New("invalid latency value")
|
||||||
|
ErrInvalidConfiguration = errors.New("invalid configuration")
|
||||||
|
ErrInvalidSocketConfig = errors.New("invalid socket configuration")
|
||||||
|
ErrInvalidMetricsInterval = errors.New("invalid metrics interval")
|
||||||
|
ErrInvalidSampleRate = errors.New("invalid sample rate")
|
||||||
|
ErrInvalidChannels = errors.New("invalid channels")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateAudioQuality validates audio quality enum values
|
||||||
|
func ValidateAudioQuality(quality AudioQuality) error {
|
||||||
|
switch quality {
|
||||||
|
case AudioQualityLow, AudioQualityMedium, AudioQualityHigh, AudioQualityUltra:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrInvalidAudioQuality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFrameData validates audio frame data
|
||||||
|
func ValidateFrameData(data []byte) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ErrInvalidFrameData
|
||||||
|
}
|
||||||
|
// Use a reasonable default if config is not available
|
||||||
|
maxFrameSize := 4096
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
maxFrameSize = config.MaxAudioFrameSize
|
||||||
|
}
|
||||||
|
if len(data) > maxFrameSize {
|
||||||
|
return ErrInvalidFrameSize
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateZeroCopyFrame validates zero-copy audio frame
|
||||||
|
func ValidateZeroCopyFrame(frame *ZeroCopyAudioFrame) error {
|
||||||
|
if frame == nil {
|
||||||
|
return ErrInvalidFrameData
|
||||||
|
}
|
||||||
|
data := frame.Data()
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ErrInvalidFrameData
|
||||||
|
}
|
||||||
|
// Use a reasonable default if config is not available
|
||||||
|
maxFrameSize := 4096
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
maxFrameSize = config.MaxAudioFrameSize
|
||||||
|
}
|
||||||
|
if len(data) > maxFrameSize {
|
||||||
|
return ErrInvalidFrameSize
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBufferSize validates buffer size parameters
|
||||||
|
func ValidateBufferSize(size int) error {
|
||||||
|
if size <= 0 {
|
||||||
|
return ErrInvalidBufferSize
|
||||||
|
}
|
||||||
|
// Use a reasonable default if config is not available
|
||||||
|
maxBuffer := 262144 // 256KB default
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
maxBuffer = config.SocketMaxBuffer
|
||||||
|
}
|
||||||
|
if size > maxBuffer {
|
||||||
|
return ErrInvalidBufferSize
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateThreadPriority validates thread priority values
|
||||||
|
func ValidateThreadPriority(priority int) error {
|
||||||
|
// Use reasonable defaults if config is not available
|
||||||
|
minPriority := -20
|
||||||
|
maxPriority := 99
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
minPriority = config.MinNiceValue
|
||||||
|
maxPriority = config.RTAudioHighPriority
|
||||||
|
}
|
||||||
|
if priority < minPriority || priority > maxPriority {
|
||||||
|
return ErrInvalidPriority
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateLatency validates latency values
|
||||||
|
func ValidateLatency(latency time.Duration) error {
|
||||||
|
if latency < 0 {
|
||||||
|
return ErrInvalidLatency
|
||||||
|
}
|
||||||
|
// Use a reasonable default if config is not available
|
||||||
|
maxLatency := 500 * time.Millisecond
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
maxLatency = config.MaxLatency
|
||||||
|
}
|
||||||
|
if latency > maxLatency {
|
||||||
|
return ErrInvalidLatency
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateMetricsInterval validates metrics update interval
|
||||||
|
func ValidateMetricsInterval(interval time.Duration) error {
|
||||||
|
// Use reasonable defaults if config is not available
|
||||||
|
minInterval := 100 * time.Millisecond
|
||||||
|
maxInterval := 10 * time.Second
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
minInterval = config.MinMetricsUpdateInterval
|
||||||
|
maxInterval = config.MaxMetricsUpdateInterval
|
||||||
|
}
|
||||||
|
if interval < minInterval {
|
||||||
|
return ErrInvalidMetricsInterval
|
||||||
|
}
|
||||||
|
if interval > maxInterval {
|
||||||
|
return ErrInvalidMetricsInterval
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAdaptiveBufferConfig validates adaptive buffer configuration
|
||||||
|
func ValidateAdaptiveBufferConfig(minSize, maxSize, defaultSize int) error {
|
||||||
|
if minSize <= 0 || maxSize <= 0 || defaultSize <= 0 {
|
||||||
|
return ErrInvalidBufferSize
|
||||||
|
}
|
||||||
|
if minSize >= maxSize {
|
||||||
|
return ErrInvalidBufferSize
|
||||||
|
}
|
||||||
|
if defaultSize < minSize || defaultSize > maxSize {
|
||||||
|
return ErrInvalidBufferSize
|
||||||
|
}
|
||||||
|
// Validate against global limits
|
||||||
|
maxBuffer := 262144 // 256KB default
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
maxBuffer = config.SocketMaxBuffer
|
||||||
|
}
|
||||||
|
if maxSize > maxBuffer {
|
||||||
|
return ErrInvalidBufferSize
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateInputIPCConfig validates input IPC configuration
|
||||||
|
func ValidateInputIPCConfig(sampleRate, channels, frameSize int) error {
|
||||||
|
// Use reasonable defaults if config is not available
|
||||||
|
minSampleRate := 8000
|
||||||
|
maxSampleRate := 48000
|
||||||
|
maxChannels := 8
|
||||||
|
if config := GetConfig(); config != nil {
|
||||||
|
minSampleRate = config.MinSampleRate
|
||||||
|
maxSampleRate = config.MaxSampleRate
|
||||||
|
maxChannels = config.MaxChannels
|
||||||
|
}
|
||||||
|
if sampleRate < minSampleRate || sampleRate > maxSampleRate {
|
||||||
|
return ErrInvalidSampleRate
|
||||||
|
}
|
||||||
|
if channels < 1 || channels > maxChannels {
|
||||||
|
return ErrInvalidChannels
|
||||||
|
}
|
||||||
|
if frameSize <= 0 {
|
||||||
|
return ErrInvalidFrameSize
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,290 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enhanced validation errors with more specific context
|
||||||
|
var (
|
||||||
|
ErrInvalidFrameLength = errors.New("invalid frame length")
|
||||||
|
ErrFrameDataCorrupted = errors.New("frame data appears corrupted")
|
||||||
|
ErrBufferAlignment = errors.New("buffer alignment invalid")
|
||||||
|
ErrInvalidSampleFormat = errors.New("invalid sample format")
|
||||||
|
ErrInvalidTimestamp = errors.New("invalid timestamp")
|
||||||
|
ErrConfigurationMismatch = errors.New("configuration mismatch")
|
||||||
|
ErrResourceExhaustion = errors.New("resource exhaustion detected")
|
||||||
|
ErrInvalidPointer = errors.New("invalid pointer")
|
||||||
|
ErrBufferOverflow = errors.New("buffer overflow detected")
|
||||||
|
ErrInvalidState = errors.New("invalid state")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationLevel defines the level of validation to perform
|
||||||
|
type ValidationLevel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationMinimal ValidationLevel = iota // Only critical safety checks
|
||||||
|
ValidationStandard // Standard validation for production
|
||||||
|
ValidationStrict // Comprehensive validation for debugging
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationConfig controls validation behavior
|
||||||
|
type ValidationConfig struct {
|
||||||
|
Level ValidationLevel
|
||||||
|
EnableRangeChecks bool
|
||||||
|
EnableAlignmentCheck bool
|
||||||
|
EnableDataIntegrity bool
|
||||||
|
MaxValidationTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidationConfig returns the current validation configuration
|
||||||
|
func GetValidationConfig() ValidationConfig {
|
||||||
|
return ValidationConfig{
|
||||||
|
Level: ValidationStandard,
|
||||||
|
EnableRangeChecks: true,
|
||||||
|
EnableAlignmentCheck: true,
|
||||||
|
EnableDataIntegrity: false, // Disabled by default for performance
|
||||||
|
MaxValidationTime: 5 * time.Second, // Default validation timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAudioFrameFast performs minimal validation for performance-critical paths
|
||||||
|
func ValidateAudioFrameFast(data []byte) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ErrInvalidFrameData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick bounds check using config constants
|
||||||
|
maxSize := GetConfig().MaxAudioFrameSize
|
||||||
|
if len(data) > maxSize {
|
||||||
|
return fmt.Errorf("%w: frame size %d exceeds maximum %d", ErrInvalidFrameSize, len(data), maxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAudioFrameComprehensive performs thorough validation
|
||||||
|
func ValidateAudioFrameComprehensive(data []byte, expectedSampleRate int, expectedChannels int) error {
|
||||||
|
validationConfig := GetValidationConfig()
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Timeout protection for validation
|
||||||
|
defer func() {
|
||||||
|
if time.Since(start) > validationConfig.MaxValidationTime {
|
||||||
|
// Log validation timeout but don't fail
|
||||||
|
getValidationLogger().Warn().Dur("duration", time.Since(start)).Msg("validation timeout exceeded")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Basic validation first
|
||||||
|
if err := ValidateAudioFrameFast(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range validation
|
||||||
|
if validationConfig.EnableRangeChecks {
|
||||||
|
config := GetConfig()
|
||||||
|
minFrameSize := 64 // Minimum reasonable frame size
|
||||||
|
if len(data) < minFrameSize {
|
||||||
|
return fmt.Errorf("%w: frame size %d below minimum %d", ErrInvalidFrameSize, len(data), minFrameSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate frame length matches expected sample format
|
||||||
|
expectedFrameSize := (expectedSampleRate * expectedChannels * 2) / 1000 * int(config.AudioQualityMediumFrameSize/time.Millisecond)
|
||||||
|
tolerance := 512 // Frame size tolerance in bytes
|
||||||
|
if abs(len(data)-expectedFrameSize) > tolerance {
|
||||||
|
return fmt.Errorf("%w: frame size %d doesn't match expected %d (±%d)", ErrInvalidFrameLength, len(data), expectedFrameSize, tolerance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment validation for ARM32 compatibility
|
||||||
|
if validationConfig.EnableAlignmentCheck {
|
||||||
|
if uintptr(unsafe.Pointer(&data[0]))%4 != 0 {
|
||||||
|
return fmt.Errorf("%w: buffer not 4-byte aligned for ARM32", ErrBufferAlignment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data integrity checks (expensive, only for debugging)
|
||||||
|
if validationConfig.EnableDataIntegrity && validationConfig.Level == ValidationStrict {
|
||||||
|
if err := validateAudioDataIntegrity(data, expectedChannels); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateZeroCopyFrameEnhanced performs enhanced zero-copy frame validation
|
||||||
|
func ValidateZeroCopyFrameEnhanced(frame *ZeroCopyAudioFrame) error {
|
||||||
|
if frame == nil {
|
||||||
|
return fmt.Errorf("%w: frame is nil", ErrInvalidPointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reference count validity
|
||||||
|
frame.mutex.RLock()
|
||||||
|
refCount := frame.refCount
|
||||||
|
length := frame.length
|
||||||
|
capacity := frame.capacity
|
||||||
|
frame.mutex.RUnlock()
|
||||||
|
|
||||||
|
if refCount <= 0 {
|
||||||
|
return fmt.Errorf("%w: invalid reference count %d", ErrInvalidState, refCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if length < 0 || capacity < 0 {
|
||||||
|
return fmt.Errorf("%w: negative length (%d) or capacity (%d)", ErrInvalidState, length, capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if length > capacity {
|
||||||
|
return fmt.Errorf("%w: length %d exceeds capacity %d", ErrBufferOverflow, length, capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the underlying data
|
||||||
|
data := frame.Data()
|
||||||
|
return ValidateAudioFrameFast(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBufferBounds performs bounds checking with overflow protection
|
||||||
|
func ValidateBufferBounds(buffer []byte, offset, length int) error {
|
||||||
|
if buffer == nil {
|
||||||
|
return fmt.Errorf("%w: buffer is nil", ErrInvalidPointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset < 0 {
|
||||||
|
return fmt.Errorf("%w: negative offset %d", ErrInvalidState, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if length < 0 {
|
||||||
|
return fmt.Errorf("%w: negative length %d", ErrInvalidState, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for integer overflow
|
||||||
|
if offset > len(buffer) {
|
||||||
|
return fmt.Errorf("%w: offset %d exceeds buffer length %d", ErrBufferOverflow, offset, len(buffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe addition check for overflow
|
||||||
|
if offset+length < offset || offset+length > len(buffer) {
|
||||||
|
return fmt.Errorf("%w: range [%d:%d] exceeds buffer length %d", ErrBufferOverflow, offset, offset+length, len(buffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAudioConfiguration performs comprehensive configuration validation
|
||||||
|
func ValidateAudioConfiguration(config AudioConfig) error {
|
||||||
|
if err := ValidateAudioQuality(config.Quality); err != nil {
|
||||||
|
return fmt.Errorf("quality validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configConstants := GetConfig()
|
||||||
|
|
||||||
|
// Validate bitrate ranges
|
||||||
|
minBitrate := 6000 // Minimum Opus bitrate
|
||||||
|
maxBitrate := 510000 // Maximum Opus bitrate
|
||||||
|
if config.Bitrate < minBitrate || config.Bitrate > maxBitrate {
|
||||||
|
return fmt.Errorf("%w: bitrate %d outside valid range [%d, %d]", ErrInvalidConfiguration, config.Bitrate, minBitrate, maxBitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sample rate
|
||||||
|
validSampleRates := []int{8000, 12000, 16000, 24000, 48000}
|
||||||
|
validSampleRate := false
|
||||||
|
for _, rate := range validSampleRates {
|
||||||
|
if config.SampleRate == rate {
|
||||||
|
validSampleRate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validSampleRate {
|
||||||
|
return fmt.Errorf("%w: sample rate %d not in supported rates %v", ErrInvalidSampleRate, config.SampleRate, validSampleRates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate channels
|
||||||
|
if config.Channels < 1 || config.Channels > configConstants.MaxChannels {
|
||||||
|
return fmt.Errorf("%w: channels %d outside valid range [1, %d]", ErrInvalidChannels, config.Channels, configConstants.MaxChannels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate frame size
|
||||||
|
minFrameSize := 10 * time.Millisecond // Minimum frame duration
|
||||||
|
maxFrameSize := 100 * time.Millisecond // Maximum frame duration
|
||||||
|
if config.FrameSize < minFrameSize || config.FrameSize > maxFrameSize {
|
||||||
|
return fmt.Errorf("%w: frame size %v outside valid range [%v, %v]", ErrInvalidConfiguration, config.FrameSize, minFrameSize, maxFrameSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateResourceLimits checks if system resources are within acceptable limits
|
||||||
|
func ValidateResourceLimits() error {
|
||||||
|
config := GetConfig()
|
||||||
|
|
||||||
|
// Check buffer pool sizes
|
||||||
|
framePoolStats := GetAudioBufferPoolStats()
|
||||||
|
if framePoolStats.FramePoolSize > int64(config.MaxPoolSize*2) {
|
||||||
|
return fmt.Errorf("%w: frame pool size %d exceeds safe limit %d", ErrResourceExhaustion, framePoolStats.FramePoolSize, config.MaxPoolSize*2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check zero-copy pool allocation count
|
||||||
|
zeroCopyStats := GetGlobalZeroCopyPoolStats()
|
||||||
|
if zeroCopyStats.AllocationCount > int64(config.MaxPoolSize*3) {
|
||||||
|
return fmt.Errorf("%w: zero-copy allocations %d exceed safe limit %d", ErrResourceExhaustion, zeroCopyStats.AllocationCount, config.MaxPoolSize*3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAudioDataIntegrity performs expensive data integrity checks
|
||||||
|
func validateAudioDataIntegrity(data []byte, channels int) error {
|
||||||
|
if len(data)%2 != 0 {
|
||||||
|
return fmt.Errorf("%w: odd number of bytes for 16-bit samples", ErrInvalidSampleFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data)%(channels*2) != 0 {
|
||||||
|
return fmt.Errorf("%w: data length %d not aligned to channel count %d", ErrInvalidSampleFormat, len(data), channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for obvious corruption patterns (all zeros, all max values)
|
||||||
|
sampleCount := len(data) / 2
|
||||||
|
zeroCount := 0
|
||||||
|
maxCount := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(data); i += 2 {
|
||||||
|
sample := int16(data[i]) | int16(data[i+1])<<8
|
||||||
|
switch sample {
|
||||||
|
case 0:
|
||||||
|
zeroCount++
|
||||||
|
case 32767, -32768:
|
||||||
|
maxCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag suspicious patterns
|
||||||
|
if zeroCount > sampleCount*9/10 {
|
||||||
|
return fmt.Errorf("%w: %d%% zero samples suggests silence or corruption", ErrFrameDataCorrupted, (zeroCount*100)/sampleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxCount > sampleCount/10 {
|
||||||
|
return fmt.Errorf("%w: %d%% max-value samples suggests clipping or corruption", ErrFrameDataCorrupted, (maxCount*100)/sampleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for absolute value
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// getValidationLogger returns a logger for validation operations
|
||||||
|
func getValidationLogger() *zerolog.Logger {
|
||||||
|
// Return a basic logger for validation
|
||||||
|
logger := zerolog.New(nil).With().Timestamp().Logger()
|
||||||
|
return &logger
|
||||||
|
}
|
|
@ -7,8 +7,38 @@ import (
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ZeroCopyAudioFrame represents an audio frame that can be passed between
|
// ZeroCopyAudioFrame represents a reference-counted audio frame for zero-copy operations.
|
||||||
// components without copying the underlying data
|
//
|
||||||
|
// This structure implements a sophisticated memory management system designed to minimize
|
||||||
|
// allocations and memory copying in the audio pipeline:
|
||||||
|
//
|
||||||
|
// Key Features:
|
||||||
|
//
|
||||||
|
// 1. Reference Counting: Multiple components can safely share the same frame data
|
||||||
|
// without copying. The frame is automatically returned to the pool when the last
|
||||||
|
// reference is released.
|
||||||
|
//
|
||||||
|
// 2. Thread Safety: All operations are protected by RWMutex, allowing concurrent
|
||||||
|
// reads while ensuring exclusive access for modifications.
|
||||||
|
//
|
||||||
|
// 3. Pool Integration: Frames are automatically managed by ZeroCopyFramePool,
|
||||||
|
// enabling efficient reuse and preventing memory fragmentation.
|
||||||
|
//
|
||||||
|
// 4. Unsafe Pointer Access: For performance-critical CGO operations, direct
|
||||||
|
// memory access is provided while maintaining safety through reference counting.
|
||||||
|
//
|
||||||
|
// Usage Pattern:
|
||||||
|
//
|
||||||
|
// frame := pool.Get() // Acquire frame (refCount = 1)
|
||||||
|
// frame.AddRef() // Share with another component (refCount = 2)
|
||||||
|
// data := frame.Data() // Access data safely
|
||||||
|
// frame.Release() // Release reference (refCount = 1)
|
||||||
|
// frame.Release() // Final release, returns to pool (refCount = 0)
|
||||||
|
//
|
||||||
|
// Memory Safety:
|
||||||
|
// - Frames cannot be modified while shared (refCount > 1)
|
||||||
|
// - Data access is bounds-checked to prevent buffer overruns
|
||||||
|
// - Pool management prevents use-after-free scenarios
|
||||||
type ZeroCopyAudioFrame struct {
|
type ZeroCopyAudioFrame struct {
|
||||||
data []byte
|
data []byte
|
||||||
length int
|
length int
|
||||||
|
@ -18,7 +48,37 @@ type ZeroCopyAudioFrame struct {
|
||||||
pooled bool
|
pooled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZeroCopyFramePool manages reusable zero-copy audio frames
|
// ZeroCopyFramePool manages a pool of reusable zero-copy audio frames.
|
||||||
|
//
|
||||||
|
// This pool implements a three-tier memory management strategy optimized for
|
||||||
|
// real-time audio processing with minimal allocation overhead:
|
||||||
|
//
|
||||||
|
// Tier 1 - Pre-allocated Frames:
|
||||||
|
//
|
||||||
|
// A small number of frames are pre-allocated at startup and kept ready
|
||||||
|
// for immediate use. This provides the fastest possible allocation for
|
||||||
|
// the most common case and eliminates allocation latency spikes.
|
||||||
|
//
|
||||||
|
// Tier 2 - sync.Pool Cache:
|
||||||
|
//
|
||||||
|
// The standard Go sync.Pool provides efficient reuse of frames with
|
||||||
|
// automatic garbage collection integration. Frames are automatically
|
||||||
|
// returned here when memory pressure is low.
|
||||||
|
//
|
||||||
|
// Tier 3 - Memory Guard:
|
||||||
|
//
|
||||||
|
// A configurable limit prevents excessive memory usage by limiting
|
||||||
|
// the total number of allocated frames. When the limit is reached,
|
||||||
|
// allocation requests are denied to prevent OOM conditions.
|
||||||
|
//
|
||||||
|
// Performance Characteristics:
|
||||||
|
// - Pre-allocated tier: ~10ns allocation time
|
||||||
|
// - sync.Pool tier: ~50ns allocation time
|
||||||
|
// - Memory guard: Prevents unbounded growth
|
||||||
|
// - Metrics tracking: Hit/miss rates for optimization
|
||||||
|
//
|
||||||
|
// The pool is designed for embedded systems with limited memory (256MB)
|
||||||
|
// where predictable memory usage is more important than absolute performance.
|
||||||
type ZeroCopyFramePool struct {
|
type ZeroCopyFramePool struct {
|
||||||
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
// Atomic fields MUST be first for ARM32 alignment (int64 fields need 8-byte alignment)
|
||||||
counter int64 // Frame counter (atomic)
|
counter int64 // Frame counter (atomic)
|
||||||
|
|
|
@ -967,9 +967,7 @@ func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
|
||||||
// Stop audio output supervisor
|
// Stop audio output supervisor
|
||||||
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
||||||
logger.Info().Msg("stopping audio output supervisor")
|
logger.Info().Msg("stopping audio output supervisor")
|
||||||
if err := audioSupervisor.Stop(); err != nil {
|
audioSupervisor.Stop()
|
||||||
logger.Error().Err(err).Msg("failed to stop audio supervisor")
|
|
||||||
}
|
|
||||||
// Wait for audio processes to fully stop before proceeding
|
// Wait for audio processes to fully stop before proceeding
|
||||||
for i := 0; i < 50; i++ { // Wait up to 5 seconds
|
for i := 0; i < 50; i++ { // Wait up to 5 seconds
|
||||||
if !audioSupervisor.IsRunning() {
|
if !audioSupervisor.IsRunning() {
|
||||||
|
@ -1063,9 +1061,7 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||||
// Stop audio output supervisor
|
// Stop audio output supervisor
|
||||||
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
if audioSupervisor != nil && audioSupervisor.IsRunning() {
|
||||||
logger.Info().Msg("stopping audio output supervisor")
|
logger.Info().Msg("stopping audio output supervisor")
|
||||||
if err := audioSupervisor.Stop(); err != nil {
|
audioSupervisor.Stop()
|
||||||
logger.Error().Err(err).Msg("failed to stop audio supervisor")
|
|
||||||
}
|
|
||||||
// Wait for audio processes to fully stop
|
// Wait for audio processes to fully stop
|
||||||
for i := 0; i < 50; i++ { // Wait up to 5 seconds
|
for i := 0; i < 50; i++ { // Wait up to 5 seconds
|
||||||
if !audioSupervisor.IsRunning() {
|
if !audioSupervisor.IsRunning() {
|
||||||
|
|
8
main.go
8
main.go
|
@ -18,7 +18,7 @@ var (
|
||||||
appCtx context.Context
|
appCtx context.Context
|
||||||
isAudioServer bool
|
isAudioServer bool
|
||||||
audioProcessDone chan struct{}
|
audioProcessDone chan struct{}
|
||||||
audioSupervisor *audio.AudioServerSupervisor
|
audioSupervisor *audio.AudioOutputSupervisor
|
||||||
)
|
)
|
||||||
|
|
||||||
// runAudioServer is now handled by audio.RunAudioOutputServer
|
// runAudioServer is now handled by audio.RunAudioOutputServer
|
||||||
|
@ -36,7 +36,7 @@ func startAudioSubprocess() error {
|
||||||
audio.StartAdaptiveBuffering()
|
audio.StartAdaptiveBuffering()
|
||||||
|
|
||||||
// Create audio server supervisor
|
// Create audio server supervisor
|
||||||
audioSupervisor = audio.NewAudioServerSupervisor()
|
audioSupervisor = audio.NewAudioOutputSupervisor()
|
||||||
|
|
||||||
// Set the global supervisor for access from audio package
|
// Set the global supervisor for access from audio package
|
||||||
audio.SetAudioOutputSupervisor(audioSupervisor)
|
audio.SetAudioOutputSupervisor(audioSupervisor)
|
||||||
|
@ -251,9 +251,7 @@ func Main(audioServer bool, audioInputServer bool) {
|
||||||
if !isAudioServer {
|
if !isAudioServer {
|
||||||
if audioSupervisor != nil {
|
if audioSupervisor != nil {
|
||||||
logger.Info().Msg("stopping audio supervisor")
|
logger.Info().Msg("stopping audio supervisor")
|
||||||
if err := audioSupervisor.Stop(); err != nil {
|
audioSupervisor.Stop()
|
||||||
logger.Error().Err(err).Msg("failed to stop audio supervisor")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
<-audioProcessDone
|
<-audioProcessDone
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { useMicrophone } from "@/hooks/useMicrophone";
|
||||||
import { useAudioLevel } from "@/hooks/useAudioLevel";
|
import { useAudioLevel } from "@/hooks/useAudioLevel";
|
||||||
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
|
import { AUDIO_CONFIG } from "@/config/constants";
|
||||||
|
import audioQualityService from "@/services/audioQualityService";
|
||||||
|
|
||||||
interface AudioMetrics {
|
interface AudioMetrics {
|
||||||
frames_received: number;
|
frames_received: number;
|
||||||
|
@ -44,12 +46,8 @@ interface AudioConfig {
|
||||||
FrameSize: string;
|
FrameSize: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualityLabels = {
|
// Quality labels will be managed by the audio quality service
|
||||||
0: "Low",
|
const getQualityLabels = () => audioQualityService.getQualityLabels();
|
||||||
1: "Medium",
|
|
||||||
2: "High",
|
|
||||||
3: "Ultra"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format percentage values to 2 decimal places
|
// Format percentage values to 2 decimal places
|
||||||
function formatPercentage(value: number | null | undefined): string {
|
function formatPercentage(value: number | null | undefined): string {
|
||||||
|
@ -246,22 +244,15 @@ export default function AudioMetricsDashboard() {
|
||||||
|
|
||||||
const loadAudioConfig = async () => {
|
const loadAudioConfig = async () => {
|
||||||
try {
|
try {
|
||||||
// Load config
|
// Use centralized audio quality service
|
||||||
const configResp = await api.GET("/audio/quality");
|
const { audio, microphone } = await audioQualityService.loadAllConfigurations();
|
||||||
if (configResp.ok) {
|
|
||||||
const configData = await configResp.json();
|
if (audio) {
|
||||||
setConfig(configData.current);
|
setConfig(audio.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load microphone config
|
if (microphone) {
|
||||||
try {
|
setMicrophoneConfig(microphone.current);
|
||||||
const micConfigResp = await api.GET("/microphone/quality");
|
|
||||||
if (micConfigResp.ok) {
|
|
||||||
const micConfigData = await micConfigResp.json();
|
|
||||||
setMicrophoneConfig(micConfigData.current);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Microphone config not available
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load audio config:", error);
|
console.error("Failed to load audio config:", error);
|
||||||
|
@ -397,7 +388,7 @@ export default function AudioMetricsDashboard() {
|
||||||
|
|
||||||
const getDropRate = () => {
|
const getDropRate = () => {
|
||||||
if (!metrics || metrics.frames_received === 0) return 0;
|
if (!metrics || metrics.frames_received === 0) return 0;
|
||||||
return ((metrics.frames_dropped / metrics.frames_received) * 100);
|
return ((metrics.frames_dropped / metrics.frames_received) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -449,7 +440,7 @@ export default function AudioMetricsDashboard() {
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500 dark:text-slate-400">Quality:</span>
|
<span className="text-slate-500 dark:text-slate-400">Quality:</span>
|
||||||
<span className={cx("font-medium", getQualityColor(config.Quality))}>
|
<span className={cx("font-medium", getQualityColor(config.Quality))}>
|
||||||
{qualityLabels[config.Quality as keyof typeof qualityLabels]}
|
{getQualityLabels()[config.Quality]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
@ -486,7 +477,7 @@ export default function AudioMetricsDashboard() {
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500 dark:text-slate-400">Quality:</span>
|
<span className="text-slate-500 dark:text-slate-400">Quality:</span>
|
||||||
<span className={cx("font-medium", getQualityColor(microphoneConfig.Quality))}>
|
<span className={cx("font-medium", getQualityColor(microphoneConfig.Quality))}>
|
||||||
{qualityLabels[microphoneConfig.Quality as keyof typeof qualityLabels]}
|
{getQualityLabels()[microphoneConfig.Quality]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
@ -668,26 +659,26 @@ export default function AudioMetricsDashboard() {
|
||||||
</span>
|
</span>
|
||||||
<span className={cx(
|
<span className={cx(
|
||||||
"font-bold",
|
"font-bold",
|
||||||
getDropRate() > 5
|
getDropRate() > AUDIO_CONFIG.DROP_RATE_CRITICAL_THRESHOLD
|
||||||
? "text-red-600 dark:text-red-400"
|
? "text-red-600 dark:text-red-400"
|
||||||
: getDropRate() > 1
|
: getDropRate() > AUDIO_CONFIG.DROP_RATE_WARNING_THRESHOLD
|
||||||
? "text-yellow-600 dark:text-yellow-400"
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
: "text-green-600 dark:text-green-400"
|
: "text-green-600 dark:text-green-400"
|
||||||
)}>
|
)}>
|
||||||
{getDropRate().toFixed(2)}%
|
{getDropRate().toFixed(AUDIO_CONFIG.PERCENTAGE_DECIMAL_PLACES)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-2 w-full rounded-full bg-slate-200 dark:bg-slate-600">
|
<div className="mt-1 h-2 w-full rounded-full bg-slate-200 dark:bg-slate-600">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"h-2 rounded-full transition-all duration-300",
|
"h-2 rounded-full transition-all duration-300",
|
||||||
getDropRate() > 5
|
getDropRate() > AUDIO_CONFIG.DROP_RATE_CRITICAL_THRESHOLD
|
||||||
? "bg-red-500"
|
? "bg-red-500"
|
||||||
: getDropRate() > 1
|
: getDropRate() > AUDIO_CONFIG.DROP_RATE_WARNING_THRESHOLD
|
||||||
? "bg-yellow-500"
|
? "bg-yellow-500"
|
||||||
: "bg-green-500"
|
: "bg-green-500"
|
||||||
)}
|
)}
|
||||||
style={{ width: `${Math.min(getDropRate(), 100)}%` }}
|
style={{ width: `${Math.min(getDropRate(), AUDIO_CONFIG.MAX_LEVEL_PERCENTAGE)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -734,27 +725,27 @@ export default function AudioMetricsDashboard() {
|
||||||
</span>
|
</span>
|
||||||
<span className={cx(
|
<span className={cx(
|
||||||
"font-bold",
|
"font-bold",
|
||||||
(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 5
|
(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER : 0) > AUDIO_CONFIG.DROP_RATE_CRITICAL_THRESHOLD
|
||||||
? "text-red-600 dark:text-red-400"
|
? "text-red-600 dark:text-red-400"
|
||||||
: (microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 1
|
: (microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER : 0) > AUDIO_CONFIG.DROP_RATE_WARNING_THRESHOLD
|
||||||
? "text-yellow-600 dark:text-yellow-400"
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
: "text-green-600 dark:text-green-400"
|
: "text-green-600 dark:text-green-400"
|
||||||
)}>
|
)}>
|
||||||
{microphoneMetrics.frames_sent > 0 ? ((microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100).toFixed(2) : "0.00"}%
|
{microphoneMetrics.frames_sent > 0 ? ((microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER).toFixed(AUDIO_CONFIG.PERCENTAGE_DECIMAL_PLACES) : "0.00"}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-2 w-full rounded-full bg-slate-200 dark:bg-slate-600">
|
<div className="mt-1 h-2 w-full rounded-full bg-slate-200 dark:bg-slate-600">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"h-2 rounded-full transition-all duration-300",
|
"h-2 rounded-full transition-all duration-300",
|
||||||
(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 5
|
(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER : 0) > AUDIO_CONFIG.DROP_RATE_CRITICAL_THRESHOLD
|
||||||
? "bg-red-500"
|
? "bg-red-500"
|
||||||
: (microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0) > 1
|
: (microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER : 0) > AUDIO_CONFIG.DROP_RATE_WARNING_THRESHOLD
|
||||||
? "bg-yellow-500"
|
? "bg-yellow-500"
|
||||||
: "bg-green-500"
|
: "bg-green-500"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * 100 : 0, 100)}%`
|
width: `${Math.min(microphoneMetrics.frames_sent > 0 ? (microphoneMetrics.frames_dropped / microphoneMetrics.frames_sent) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER : 0, AUDIO_CONFIG.MAX_LEVEL_PERCENTAGE)}%`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { useAudioLevel } from "@/hooks/useAudioLevel";
|
||||||
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
import { useAudioEvents } from "@/hooks/useAudioEvents";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
import { AUDIO_CONFIG } from "@/config/constants";
|
||||||
|
import audioQualityService from "@/services/audioQualityService";
|
||||||
|
|
||||||
// Type for microphone error
|
// Type for microphone error
|
||||||
interface MicrophoneError {
|
interface MicrophoneError {
|
||||||
|
@ -41,12 +43,8 @@ interface AudioConfig {
|
||||||
FrameSize: string;
|
FrameSize: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualityLabels = {
|
// Quality labels will be managed by the audio quality service
|
||||||
0: "Low (32kbps)",
|
const getQualityLabels = () => audioQualityService.getQualityLabels();
|
||||||
1: "Medium (64kbps)",
|
|
||||||
2: "High (128kbps)",
|
|
||||||
3: "Ultra (256kbps)"
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AudioControlPopoverProps {
|
interface AudioControlPopoverProps {
|
||||||
microphone: MicrophoneHookReturn;
|
microphone: MicrophoneHookReturn;
|
||||||
|
@ -138,20 +136,15 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
|
||||||
|
|
||||||
const loadAudioConfigurations = async () => {
|
const loadAudioConfigurations = async () => {
|
||||||
try {
|
try {
|
||||||
// Parallel loading for better performance
|
// Use centralized audio quality service
|
||||||
const [qualityResp, micQualityResp] = await Promise.all([
|
const { audio, microphone } = await audioQualityService.loadAllConfigurations();
|
||||||
api.GET("/audio/quality"),
|
|
||||||
api.GET("/microphone/quality")
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (qualityResp.ok) {
|
if (audio) {
|
||||||
const qualityData = await qualityResp.json();
|
setCurrentConfig(audio.current);
|
||||||
setCurrentConfig(qualityData.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (micQualityResp.ok) {
|
if (microphone) {
|
||||||
const micQualityData = await micQualityResp.json();
|
setCurrentMicrophoneConfig(microphone.current);
|
||||||
setCurrentMicrophoneConfig(micQualityData.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfigsLoaded(true);
|
setConfigsLoaded(true);
|
||||||
|
@ -511,7 +504,7 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(qualityLabels).map(([quality, label]) => (
|
{Object.entries(getQualityLabels()).map(([quality, label]) => (
|
||||||
<button
|
<button
|
||||||
key={`mic-${quality}`}
|
key={`mic-${quality}`}
|
||||||
onClick={() => handleMicrophoneQualityChange(parseInt(quality))}
|
onClick={() => handleMicrophoneQualityChange(parseInt(quality))}
|
||||||
|
@ -552,7 +545,7 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(qualityLabels).map(([quality, label]) => (
|
{Object.entries(getQualityLabels()).map(([quality, label]) => (
|
||||||
<button
|
<button
|
||||||
key={quality}
|
key={quality}
|
||||||
onClick={() => handleQualityChange(parseInt(quality))}
|
onClick={() => handleQualityChange(parseInt(quality))}
|
||||||
|
@ -704,13 +697,13 @@ export default function AudioControlPopover({ microphone, open }: AudioControlPo
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">Drop Rate</div>
|
<div className="text-xs text-slate-500 dark:text-slate-400">Drop Rate</div>
|
||||||
<div className={cx(
|
<div className={cx(
|
||||||
"font-mono text-sm",
|
"font-mono text-sm",
|
||||||
((metrics.frames_dropped / metrics.frames_received) * 100) > 5
|
((metrics.frames_dropped / metrics.frames_received) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER) > AUDIO_CONFIG.DROP_RATE_CRITICAL_THRESHOLD
|
||||||
? "text-red-600 dark:text-red-400"
|
? "text-red-600 dark:text-red-400"
|
||||||
: ((metrics.frames_dropped / metrics.frames_received) * 100) > 1
|
: ((metrics.frames_dropped / metrics.frames_received) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER) > AUDIO_CONFIG.DROP_RATE_WARNING_THRESHOLD
|
||||||
? "text-yellow-600 dark:text-yellow-400"
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
: "text-green-600 dark:text-green-400"
|
: "text-green-600 dark:text-green-400"
|
||||||
)}>
|
)}>
|
||||||
{((metrics.frames_dropped / metrics.frames_received) * 100).toFixed(2)}%
|
{((metrics.frames_dropped / metrics.frames_received) * AUDIO_CONFIG.PERCENTAGE_MULTIPLIER).toFixed(AUDIO_CONFIG.PERCENTAGE_DECIMAL_PLACES)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,167 @@
|
||||||
|
// Centralized configuration constants
|
||||||
|
|
||||||
|
// Network and API Configuration
|
||||||
|
export const NETWORK_CONFIG = {
|
||||||
|
WEBSOCKET_RECONNECT_INTERVAL: 3000,
|
||||||
|
LONG_PRESS_DURATION: 3000,
|
||||||
|
ERROR_MESSAGE_TIMEOUT: 3000,
|
||||||
|
AUDIO_TEST_DURATION: 5000,
|
||||||
|
BACKEND_RETRY_DELAY: 500,
|
||||||
|
RESET_DELAY: 200,
|
||||||
|
STATE_CHECK_DELAY: 100,
|
||||||
|
VERIFICATION_DELAY: 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Default URLs and Endpoints
|
||||||
|
export const DEFAULT_URLS = {
|
||||||
|
JETKVM_PROD_API: "https://api.jetkvm.com",
|
||||||
|
JETKVM_PROD_APP: "https://app.jetkvm.com",
|
||||||
|
JETKVM_DOCS_TROUBLESHOOTING: "https://jetkvm.com/docs/getting-started/troubleshooting",
|
||||||
|
JETKVM_DOCS_REMOTE_ACCESS: "https://jetkvm.com/docs/networking/remote-access",
|
||||||
|
JETKVM_DOCS_LOCAL_ACCESS_RESET: "https://jetkvm.com/docs/networking/local-access#reset-password",
|
||||||
|
JETKVM_GITHUB: "https://github.com/jetkvm",
|
||||||
|
CRONTAB_GURU: "https://crontab.guru/examples.html",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Sample ISO URLs for mounting
|
||||||
|
export const SAMPLE_ISOS = {
|
||||||
|
UBUNTU_24_04: {
|
||||||
|
name: "Ubuntu 24.04.2 Desktop",
|
||||||
|
url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso",
|
||||||
|
},
|
||||||
|
DEBIAN_13: {
|
||||||
|
name: "Debian 13.0.0 (Testing)",
|
||||||
|
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-13.0.0-amd64-netinst.iso",
|
||||||
|
},
|
||||||
|
DEBIAN_12: {
|
||||||
|
name: "Debian 12.11.0 (Stable)",
|
||||||
|
url: "https://cdimage.debian.org/mirror/cdimage/archive/12.11.0/amd64/iso-cd/debian-12.11.0-amd64-netinst.iso",
|
||||||
|
},
|
||||||
|
FEDORA_41: {
|
||||||
|
name: "Fedora 41 Workstation",
|
||||||
|
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso",
|
||||||
|
},
|
||||||
|
OPENSUSE_LEAP: {
|
||||||
|
name: "openSUSE Leap 15.6",
|
||||||
|
url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso",
|
||||||
|
},
|
||||||
|
OPENSUSE_TUMBLEWEED: {
|
||||||
|
name: "openSUSE Tumbleweed",
|
||||||
|
url: "https://download.opensuse.org/tumbleweed/iso/openSUSE-Tumbleweed-NET-x86_64-Current.iso",
|
||||||
|
},
|
||||||
|
ARCH_LINUX: {
|
||||||
|
name: "Arch Linux",
|
||||||
|
url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso",
|
||||||
|
},
|
||||||
|
NETBOOT_XYZ: {
|
||||||
|
name: "netboot.xyz",
|
||||||
|
url: "https://boot.netboot.xyz/ipxe/netboot.xyz.iso",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Security and Access Configuration
|
||||||
|
export const SECURITY_CONFIG = {
|
||||||
|
LOCALHOST_ONLY_IP: "127.0.0.1",
|
||||||
|
LOCALHOST_HOSTNAME: "localhost",
|
||||||
|
HTTPS_PROTOCOL: "https:",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Default Hardware Configuration
|
||||||
|
export const HARDWARE_CONFIG = {
|
||||||
|
DEFAULT_OFF_AFTER: 50000,
|
||||||
|
SAMPLE_EDID: "00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Audio Configuration
|
||||||
|
export const AUDIO_CONFIG = {
|
||||||
|
// Audio Level Analysis
|
||||||
|
LEVEL_UPDATE_INTERVAL: 100, // ms - throttle audio level updates for performance
|
||||||
|
FFT_SIZE: 128, // reduced from 256 for better performance
|
||||||
|
SMOOTHING_TIME_CONSTANT: 0.8,
|
||||||
|
RELEVANT_FREQUENCY_BINS: 32, // focus on lower frequencies for voice
|
||||||
|
RMS_SCALING_FACTOR: 180, // for converting RMS to percentage
|
||||||
|
MAX_LEVEL_PERCENTAGE: 100,
|
||||||
|
|
||||||
|
// Microphone Configuration
|
||||||
|
SAMPLE_RATE: 48000, // Hz - high quality audio sampling
|
||||||
|
CHANNEL_COUNT: 1, // mono for microphone input
|
||||||
|
OPERATION_DEBOUNCE_MS: 1000, // debounce microphone operations
|
||||||
|
SYNC_DEBOUNCE_MS: 1000, // debounce state synchronization
|
||||||
|
AUDIO_TEST_TIMEOUT: 100, // ms - timeout for audio testing
|
||||||
|
|
||||||
|
// Audio Output Quality Bitrates (matching backend config_constants.go)
|
||||||
|
OUTPUT_QUALITY_BITRATES: {
|
||||||
|
LOW: 32, // AudioQualityLowOutputBitrate
|
||||||
|
MEDIUM: 64, // AudioQualityMediumOutputBitrate
|
||||||
|
HIGH: 128, // AudioQualityHighOutputBitrate
|
||||||
|
ULTRA: 192, // AudioQualityUltraOutputBitrate
|
||||||
|
} as const,
|
||||||
|
// Audio Input Quality Bitrates (matching backend config_constants.go)
|
||||||
|
INPUT_QUALITY_BITRATES: {
|
||||||
|
LOW: 16, // AudioQualityLowInputBitrate
|
||||||
|
MEDIUM: 32, // AudioQualityMediumInputBitrate
|
||||||
|
HIGH: 64, // AudioQualityHighInputBitrate
|
||||||
|
ULTRA: 96, // AudioQualityUltraInputBitrate
|
||||||
|
} as const,
|
||||||
|
// Sample Rates (matching backend config_constants.go)
|
||||||
|
QUALITY_SAMPLE_RATES: {
|
||||||
|
LOW: 22050, // AudioQualityLowSampleRate
|
||||||
|
MEDIUM: 44100, // AudioQualityMediumSampleRate
|
||||||
|
HIGH: 48000, // Default SampleRate
|
||||||
|
ULTRA: 48000, // Default SampleRate
|
||||||
|
} as const,
|
||||||
|
// Microphone Sample Rates
|
||||||
|
MIC_QUALITY_SAMPLE_RATES: {
|
||||||
|
LOW: 16000, // AudioQualityMicLowSampleRate
|
||||||
|
MEDIUM: 44100, // AudioQualityMediumSampleRate
|
||||||
|
HIGH: 48000, // Default SampleRate
|
||||||
|
ULTRA: 48000, // Default SampleRate
|
||||||
|
} as const,
|
||||||
|
// Channels (matching backend config_constants.go)
|
||||||
|
QUALITY_CHANNELS: {
|
||||||
|
LOW: 1, // AudioQualityLowChannels (mono)
|
||||||
|
MEDIUM: 2, // AudioQualityMediumChannels (stereo)
|
||||||
|
HIGH: 2, // AudioQualityHighChannels (stereo)
|
||||||
|
ULTRA: 2, // AudioQualityUltraChannels (stereo)
|
||||||
|
} as const,
|
||||||
|
// Frame Sizes in milliseconds (matching backend config_constants.go)
|
||||||
|
QUALITY_FRAME_SIZES: {
|
||||||
|
LOW: 40, // AudioQualityLowFrameSize (40ms)
|
||||||
|
MEDIUM: 20, // AudioQualityMediumFrameSize (20ms)
|
||||||
|
HIGH: 20, // AudioQualityHighFrameSize (20ms)
|
||||||
|
ULTRA: 10, // AudioQualityUltraFrameSize (10ms)
|
||||||
|
} as const,
|
||||||
|
// Updated Quality Labels with correct output bitrates
|
||||||
|
QUALITY_LABELS: {
|
||||||
|
0: "Low (32 kbps)",
|
||||||
|
1: "Medium (64 kbps)",
|
||||||
|
2: "High (128 kbps)",
|
||||||
|
3: "Ultra (192 kbps)",
|
||||||
|
} as const,
|
||||||
|
// Legacy support - keeping for backward compatibility
|
||||||
|
QUALITY_BITRATES: {
|
||||||
|
LOW: 32,
|
||||||
|
MEDIUM: 64,
|
||||||
|
HIGH: 128,
|
||||||
|
ULTRA: 192, // Updated to match backend
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio Analysis
|
||||||
|
ANALYSIS_FFT_SIZE: 256, // for detailed audio analysis
|
||||||
|
ANALYSIS_UPDATE_INTERVAL: 100, // ms - 10fps for audio level updates
|
||||||
|
LEVEL_SCALING_FACTOR: 255, // for RMS to percentage conversion
|
||||||
|
|
||||||
|
// Audio Metrics Thresholds
|
||||||
|
DROP_RATE_WARNING_THRESHOLD: 1, // percentage - yellow warning
|
||||||
|
DROP_RATE_CRITICAL_THRESHOLD: 5, // percentage - red critical
|
||||||
|
PERCENTAGE_MULTIPLIER: 100, // for converting ratios to percentages
|
||||||
|
PERCENTAGE_DECIMAL_PLACES: 2, // decimal places for percentage display
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Placeholder URLs
|
||||||
|
export const PLACEHOLDERS = {
|
||||||
|
ISO_URL: "https://example.com/image.iso",
|
||||||
|
PROXY_URL: "http://proxy.example.com:8080/",
|
||||||
|
API_URL: "https://api.example.com",
|
||||||
|
APP_URL: "https://app.example.com",
|
||||||
|
} as const;
|
|
@ -7,6 +7,8 @@ import {
|
||||||
MAX_KEYS_PER_STEP,
|
MAX_KEYS_PER_STEP,
|
||||||
} from "@/constants/macros";
|
} from "@/constants/macros";
|
||||||
|
|
||||||
|
import { devWarn } from '../utils/debug';
|
||||||
|
|
||||||
// Define the JsonRpc types for better type checking
|
// Define the JsonRpc types for better type checking
|
||||||
interface JsonRpcResponse {
|
interface JsonRpcResponse {
|
||||||
jsonrpc: string;
|
jsonrpc: string;
|
||||||
|
@ -782,7 +784,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
||||||
setDhcpLeaseExpiry: (expiry: Date) => {
|
setDhcpLeaseExpiry: (expiry: Date) => {
|
||||||
const lease = get().dhcp_lease;
|
const lease = get().dhcp_lease;
|
||||||
if (!lease) {
|
if (!lease) {
|
||||||
console.warn("No lease found");
|
devWarn("No lease found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
|
import { devError } from '../utils/debug';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the correct path based on whether the app is running on device or in cloud mode
|
* Generates the correct path based on whether the app is running on device or in cloud mode
|
||||||
|
@ -21,7 +22,7 @@ export function getDeviceUiPath(path: string, deviceId?: string): string {
|
||||||
return normalizedPath;
|
return normalizedPath;
|
||||||
} else {
|
} else {
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
console.error("No device ID provided when generating path in cloud mode");
|
devError("No device ID provided when generating path in cloud mode");
|
||||||
throw new Error("Device ID is required for cloud mode path generation");
|
throw new Error("Device ID is required for cloud mode path generation");
|
||||||
}
|
}
|
||||||
return `/devices/${deviceId}${normalizedPath}`;
|
return `/devices/${deviceId}${normalizedPath}`;
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { devError } from '../utils/debug';
|
||||||
|
|
||||||
export interface AudioDevice {
|
export interface AudioDevice {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -66,7 +68,7 @@ export function useAudioDevices(): UseAudioDevicesReturn {
|
||||||
// Audio devices enumerated
|
// Audio devices enumerated
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to enumerate audio devices:', err);
|
devError('Failed to enumerate audio devices:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to access audio devices');
|
setError(err instanceof Error ? err.message : 'Failed to access audio devices');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||||
|
|
||||||
|
import { devError, devWarn } from '../utils/debug';
|
||||||
|
import { NETWORK_CONFIG } from '../config/constants';
|
||||||
|
|
||||||
// Audio event types matching the backend
|
// Audio event types matching the backend
|
||||||
export type AudioEventType =
|
export type AudioEventType =
|
||||||
| 'audio-mute-changed'
|
| 'audio-mute-changed'
|
||||||
|
@ -121,7 +124,7 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD
|
||||||
} = useWebSocket(getWebSocketUrl(), {
|
} = useWebSocket(getWebSocketUrl(), {
|
||||||
shouldReconnect: () => true,
|
shouldReconnect: () => true,
|
||||||
reconnectAttempts: 10,
|
reconnectAttempts: 10,
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: NETWORK_CONFIG.WEBSOCKET_RECONNECT_INTERVAL,
|
||||||
share: true, // Share the WebSocket connection across multiple hooks
|
share: true, // Share the WebSocket connection across multiple hooks
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
// WebSocket connected
|
// WebSocket connected
|
||||||
|
@ -137,7 +140,7 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD
|
||||||
globalSubscriptionState.connectionId = null;
|
globalSubscriptionState.connectionId = null;
|
||||||
},
|
},
|
||||||
onError: (event) => {
|
onError: (event) => {
|
||||||
console.error('[AudioEvents] WebSocket error:', event);
|
devError('[AudioEvents] WebSocket error:', event);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -270,7 +273,7 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore parsing errors for non-JSON messages (like "pong")
|
// Ignore parsing errors for non-JSON messages (like "pong")
|
||||||
if (lastMessage.data !== 'pong') {
|
if (lastMessage.data !== 'pong') {
|
||||||
console.warn('[AudioEvents] Failed to parse WebSocket message:', error);
|
devWarn('[AudioEvents] Failed to parse WebSocket message:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { AUDIO_CONFIG } from '@/config/constants';
|
||||||
|
|
||||||
interface AudioLevelHookResult {
|
interface AudioLevelHookResult {
|
||||||
audioLevel: number; // 0-100 percentage
|
audioLevel: number; // 0-100 percentage
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
|
@ -7,14 +9,14 @@ interface AudioLevelHookResult {
|
||||||
|
|
||||||
interface AudioLevelOptions {
|
interface AudioLevelOptions {
|
||||||
enabled?: boolean; // Allow external control of analysis
|
enabled?: boolean; // Allow external control of analysis
|
||||||
updateInterval?: number; // Throttle updates (default: 100ms for 10fps instead of 60fps)
|
updateInterval?: number; // Throttle updates (default from AUDIO_CONFIG)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAudioLevel = (
|
export const useAudioLevel = (
|
||||||
stream: MediaStream | null,
|
stream: MediaStream | null,
|
||||||
options: AudioLevelOptions = {}
|
options: AudioLevelOptions = {}
|
||||||
): AudioLevelHookResult => {
|
): AudioLevelHookResult => {
|
||||||
const { enabled = true, updateInterval = 100 } = options;
|
const { enabled = true, updateInterval = AUDIO_CONFIG.LEVEL_UPDATE_INTERVAL } = options;
|
||||||
|
|
||||||
const [audioLevel, setAudioLevel] = useState(0);
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
@ -59,8 +61,8 @@ export const useAudioLevel = (
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
|
||||||
// Configure analyser - use smaller FFT for better performance
|
// Configure analyser - use smaller FFT for better performance
|
||||||
analyser.fftSize = 128; // Reduced from 256 for better performance
|
analyser.fftSize = AUDIO_CONFIG.FFT_SIZE;
|
||||||
analyser.smoothingTimeConstant = 0.8;
|
analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING_TIME_CONSTANT;
|
||||||
|
|
||||||
// Connect nodes
|
// Connect nodes
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
|
@ -87,7 +89,7 @@ export const useAudioLevel = (
|
||||||
|
|
||||||
// Optimized RMS calculation - process only relevant frequency bands
|
// Optimized RMS calculation - process only relevant frequency bands
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
const relevantBins = Math.min(dataArray.length, 32); // Focus on lower frequencies for voice
|
const relevantBins = Math.min(dataArray.length, AUDIO_CONFIG.RELEVANT_FREQUENCY_BINS);
|
||||||
for (let i = 0; i < relevantBins; i++) {
|
for (let i = 0; i < relevantBins; i++) {
|
||||||
const value = dataArray[i];
|
const value = dataArray[i];
|
||||||
sum += value * value;
|
sum += value * value;
|
||||||
|
@ -95,7 +97,7 @@ export const useAudioLevel = (
|
||||||
const rms = Math.sqrt(sum / relevantBins);
|
const rms = Math.sqrt(sum / relevantBins);
|
||||||
|
|
||||||
// Convert to percentage (0-100) with better scaling
|
// Convert to percentage (0-100) with better scaling
|
||||||
const level = Math.min(100, Math.max(0, (rms / 180) * 100)); // Adjusted scaling for better sensitivity
|
const level = Math.min(AUDIO_CONFIG.MAX_LEVEL_PERCENTAGE, Math.max(0, (rms / AUDIO_CONFIG.RMS_SCALING_FACTOR) * AUDIO_CONFIG.MAX_LEVEL_PERCENTAGE));
|
||||||
setAudioLevel(Math.round(level));
|
setAudioLevel(Math.round(level));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
|
|
||||||
|
import { devError } from '../utils/debug';
|
||||||
|
|
||||||
export interface JsonRpcRequest {
|
export interface JsonRpcRequest {
|
||||||
jsonrpc: string;
|
jsonrpc: string;
|
||||||
method: string;
|
method: string;
|
||||||
|
@ -61,7 +63,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("error" in payload) console.error(payload.error);
|
if ("error" in payload) devError(payload.error);
|
||||||
if (!payload.id) return;
|
if (!payload.id) return;
|
||||||
|
|
||||||
const callback = callbackStore.get(payload.id);
|
const callback = callbackStore.get(payload.id);
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useRTCStore } from "@/hooks/stores";
|
import { useRTCStore } from "@/hooks/stores";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
|
import { devLog, devInfo, devWarn, devError, devOnly } from "@/utils/debug";
|
||||||
|
import { NETWORK_CONFIG, AUDIO_CONFIG } from "@/config/constants";
|
||||||
|
|
||||||
export interface MicrophoneError {
|
export interface MicrophoneError {
|
||||||
type: 'permission' | 'device' | 'network' | 'unknown';
|
type: 'permission' | 'device' | 'network' | 'unknown';
|
||||||
|
@ -31,15 +33,14 @@ export function useMicrophone() {
|
||||||
// Add debouncing refs to prevent rapid operations
|
// Add debouncing refs to prevent rapid operations
|
||||||
const lastOperationRef = useRef<number>(0);
|
const lastOperationRef = useRef<number>(0);
|
||||||
const operationTimeoutRef = useRef<number | null>(null);
|
const operationTimeoutRef = useRef<number | null>(null);
|
||||||
const OPERATION_DEBOUNCE_MS = 1000; // 1 second debounce
|
|
||||||
|
|
||||||
// Debounced operation wrapper
|
// Debounced operation wrapper
|
||||||
const debouncedOperation = useCallback((operation: () => Promise<void>, operationType: string) => {
|
const debouncedOperation = useCallback((operation: () => Promise<void>, operationType: string) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastOp = now - lastOperationRef.current;
|
const timeSinceLastOp = now - lastOperationRef.current;
|
||||||
|
|
||||||
if (timeSinceLastOp < OPERATION_DEBOUNCE_MS) {
|
if (timeSinceLastOp < AUDIO_CONFIG.OPERATION_DEBOUNCE_MS) {
|
||||||
console.log(`Debouncing ${operationType} operation - too soon (${timeSinceLastOp}ms since last)`);
|
devLog(`Debouncing ${operationType} operation - too soon (${timeSinceLastOp}ms since last)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,7 +52,7 @@ export function useMicrophone() {
|
||||||
|
|
||||||
lastOperationRef.current = now;
|
lastOperationRef.current = now;
|
||||||
operation().catch(error => {
|
operation().catch(error => {
|
||||||
console.error(`Debounced ${operationType} operation failed:`, error);
|
devError(`Debounced ${operationType} operation failed:`, error);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -72,7 +73,7 @@ export function useMicrophone() {
|
||||||
try {
|
try {
|
||||||
await microphoneSender.replaceTrack(null);
|
await microphoneSender.replaceTrack(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to replace track with null:", error);
|
devWarn("Failed to replace track with null:", error);
|
||||||
// Fallback to removing the track
|
// Fallback to removing the track
|
||||||
peerConnection.removeTrack(microphoneSender);
|
peerConnection.removeTrack(microphoneSender);
|
||||||
}
|
}
|
||||||
|
@ -110,14 +111,14 @@ export function useMicrophone() {
|
||||||
} : "No peer connection",
|
} : "No peer connection",
|
||||||
streamMatch: refStream === microphoneStream
|
streamMatch: refStream === microphoneStream
|
||||||
};
|
};
|
||||||
console.log("Microphone Debug State:", state);
|
devLog("Microphone Debug State:", state);
|
||||||
|
|
||||||
// Also check if streams are active
|
// Also check if streams are active
|
||||||
if (refStream) {
|
if (refStream) {
|
||||||
console.log("Ref stream active tracks:", refStream.getAudioTracks().filter(t => t.readyState === 'live').length);
|
devLog("Ref stream active tracks:", refStream.getAudioTracks().filter(t => t.readyState === 'live').length);
|
||||||
}
|
}
|
||||||
if (microphoneStream && microphoneStream !== refStream) {
|
if (microphoneStream && microphoneStream !== refStream) {
|
||||||
console.log("Store stream active tracks:", microphoneStream.getAudioTracks().filter(t => t.readyState === 'live').length);
|
devLog("Store stream active tracks:", microphoneStream.getAudioTracks().filter(t => t.readyState === 'live').length);
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
|
@ -137,15 +138,15 @@ export function useMicrophone() {
|
||||||
const syncMicrophoneState = useCallback(async () => {
|
const syncMicrophoneState = useCallback(async () => {
|
||||||
// Debounce sync calls to prevent race conditions
|
// Debounce sync calls to prevent race conditions
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastSyncRef.current < 1000) { // Increased debounce time
|
if (now - lastSyncRef.current < AUDIO_CONFIG.SYNC_DEBOUNCE_MS) {
|
||||||
console.log("Skipping sync - too frequent");
|
devLog("Skipping sync - too frequent");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastSyncRef.current = now;
|
lastSyncRef.current = now;
|
||||||
|
|
||||||
// Don't sync if we're in the middle of starting the microphone
|
// Don't sync if we're in the middle of starting the microphone
|
||||||
if (isStartingRef.current) {
|
if (isStartingRef.current) {
|
||||||
console.log("Skipping sync - microphone is starting");
|
devLog("Skipping sync - microphone is starting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,27 +158,27 @@ export function useMicrophone() {
|
||||||
|
|
||||||
// Only sync if there's a significant state difference and we're not in a transition
|
// Only sync if there's a significant state difference and we're not in a transition
|
||||||
if (backendRunning !== isMicrophoneActive) {
|
if (backendRunning !== isMicrophoneActive) {
|
||||||
console.info(`Syncing microphone state: backend=${backendRunning}, frontend=${isMicrophoneActive}`);
|
devInfo(`Syncing microphone state: backend=${backendRunning}, frontend=${isMicrophoneActive}`);
|
||||||
|
|
||||||
// If backend is running but frontend thinks it's not, just update frontend state
|
// If backend is running but frontend thinks it's not, just update frontend state
|
||||||
if (backendRunning && !isMicrophoneActive) {
|
if (backendRunning && !isMicrophoneActive) {
|
||||||
console.log("Backend running, updating frontend state to active");
|
devLog("Backend running, updating frontend state to active");
|
||||||
setMicrophoneActive(true);
|
setMicrophoneActive(true);
|
||||||
}
|
}
|
||||||
// If backend is not running but frontend thinks it is, clean up and update state
|
// If backend is not running but frontend thinks it is, clean up and update state
|
||||||
else if (!backendRunning && isMicrophoneActive) {
|
else if (!backendRunning && isMicrophoneActive) {
|
||||||
console.log("Backend not running, cleaning up frontend state");
|
devLog("Backend not running, cleaning up frontend state");
|
||||||
setMicrophoneActive(false);
|
setMicrophoneActive(false);
|
||||||
// Only clean up stream if we actually have one
|
// Only clean up stream if we actually have one
|
||||||
if (microphoneStreamRef.current) {
|
if (microphoneStreamRef.current) {
|
||||||
console.log("Cleaning up orphaned stream");
|
devLog("Cleaning up orphaned stream");
|
||||||
await stopMicrophoneStream();
|
await stopMicrophoneStream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to sync microphone state:", error);
|
devWarn("Failed to sync microphone state:", error);
|
||||||
}
|
}
|
||||||
}, [isMicrophoneActive, setMicrophoneActive, stopMicrophoneStream]);
|
}, [isMicrophoneActive, setMicrophoneActive, stopMicrophoneStream]);
|
||||||
|
|
||||||
|
@ -185,7 +186,7 @@ export function useMicrophone() {
|
||||||
const startMicrophone = useCallback(async (deviceId?: string): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
const startMicrophone = useCallback(async (deviceId?: string): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
||||||
// Prevent multiple simultaneous start operations
|
// Prevent multiple simultaneous start operations
|
||||||
if (isStarting || isStopping || isToggling) {
|
if (isStarting || isStopping || isToggling) {
|
||||||
console.log("Microphone operation already in progress, skipping start");
|
devLog("Microphone operation already in progress, skipping start");
|
||||||
return { success: false, error: { type: 'unknown', message: 'Operation already in progress' } };
|
return { success: false, error: { type: 'unknown', message: 'Operation already in progress' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,8 +199,8 @@ export function useMicrophone() {
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
autoGainControl: true,
|
autoGainControl: true,
|
||||||
sampleRate: 48000,
|
sampleRate: AUDIO_CONFIG.SAMPLE_RATE,
|
||||||
channelCount: 1,
|
channelCount: AUDIO_CONFIG.CHANNEL_COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add device ID if specified
|
// Add device ID if specified
|
||||||
|
@ -207,7 +208,7 @@ export function useMicrophone() {
|
||||||
audioConstraints.deviceId = { exact: deviceId };
|
audioConstraints.deviceId = { exact: deviceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Requesting microphone with constraints:", audioConstraints);
|
devLog("Requesting microphone with constraints:", audioConstraints);
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: audioConstraints
|
audio: audioConstraints
|
||||||
});
|
});
|
||||||
|
@ -219,14 +220,14 @@ export function useMicrophone() {
|
||||||
setMicrophoneStream(stream);
|
setMicrophoneStream(stream);
|
||||||
|
|
||||||
// Verify the stream was stored correctly
|
// Verify the stream was stored correctly
|
||||||
console.log("Stream storage verification:", {
|
devLog("Stream storage verification:", {
|
||||||
refSet: !!microphoneStreamRef.current,
|
refSet: !!microphoneStreamRef.current,
|
||||||
refId: microphoneStreamRef.current?.id,
|
refId: microphoneStreamRef.current?.id,
|
||||||
storeWillBeSet: true // Store update is async
|
storeWillBeSet: true // Store update is async
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add audio track to peer connection if available
|
// Add audio track to peer connection if available
|
||||||
console.log("Peer connection state:", peerConnection ? {
|
devLog("Peer connection state:", peerConnection ? {
|
||||||
connectionState: peerConnection.connectionState,
|
connectionState: peerConnection.connectionState,
|
||||||
iceConnectionState: peerConnection.iceConnectionState,
|
iceConnectionState: peerConnection.iceConnectionState,
|
||||||
signalingState: peerConnection.signalingState
|
signalingState: peerConnection.signalingState
|
||||||
|
@ -234,11 +235,11 @@ export function useMicrophone() {
|
||||||
|
|
||||||
if (peerConnection && stream.getAudioTracks().length > 0) {
|
if (peerConnection && stream.getAudioTracks().length > 0) {
|
||||||
const audioTrack = stream.getAudioTracks()[0];
|
const audioTrack = stream.getAudioTracks()[0];
|
||||||
console.log("Starting microphone with audio track:", audioTrack.id, "kind:", audioTrack.kind);
|
devLog("Starting microphone with audio track:", audioTrack.id, "kind:", audioTrack.kind);
|
||||||
|
|
||||||
// Find the audio transceiver (should already exist with sendrecv direction)
|
// Find the audio transceiver (should already exist with sendrecv direction)
|
||||||
const transceivers = peerConnection.getTransceivers();
|
const transceivers = peerConnection.getTransceivers();
|
||||||
console.log("Available transceivers:", transceivers.map(t => ({
|
devLog("Available transceivers:", transceivers.map(t => ({
|
||||||
direction: t.direction,
|
direction: t.direction,
|
||||||
mid: t.mid,
|
mid: t.mid,
|
||||||
senderTrack: t.sender.track?.kind,
|
senderTrack: t.sender.track?.kind,
|
||||||
|
@ -264,7 +265,7 @@ export function useMicrophone() {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Found audio transceiver:", audioTransceiver ? {
|
devLog("Found audio transceiver:", audioTransceiver ? {
|
||||||
direction: audioTransceiver.direction,
|
direction: audioTransceiver.direction,
|
||||||
mid: audioTransceiver.mid,
|
mid: audioTransceiver.mid,
|
||||||
senderTrack: audioTransceiver.sender.track?.kind,
|
senderTrack: audioTransceiver.sender.track?.kind,
|
||||||
|
@ -276,10 +277,10 @@ export function useMicrophone() {
|
||||||
// Use the existing audio transceiver's sender
|
// Use the existing audio transceiver's sender
|
||||||
await audioTransceiver.sender.replaceTrack(audioTrack);
|
await audioTransceiver.sender.replaceTrack(audioTrack);
|
||||||
sender = audioTransceiver.sender;
|
sender = audioTransceiver.sender;
|
||||||
console.log("Replaced audio track on existing transceiver");
|
devLog("Replaced audio track on existing transceiver");
|
||||||
|
|
||||||
// Verify the track was set correctly
|
// Verify the track was set correctly
|
||||||
console.log("Transceiver after track replacement:", {
|
devLog("Transceiver after track replacement:", {
|
||||||
direction: audioTransceiver.direction,
|
direction: audioTransceiver.direction,
|
||||||
senderTrack: audioTransceiver.sender.track?.id,
|
senderTrack: audioTransceiver.sender.track?.id,
|
||||||
senderTrackKind: audioTransceiver.sender.track?.kind,
|
senderTrackKind: audioTransceiver.sender.track?.kind,
|
||||||
|
@ -289,11 +290,11 @@ export function useMicrophone() {
|
||||||
} else {
|
} else {
|
||||||
// Fallback: add new track if no transceiver found
|
// Fallback: add new track if no transceiver found
|
||||||
sender = peerConnection.addTrack(audioTrack, stream);
|
sender = peerConnection.addTrack(audioTrack, stream);
|
||||||
console.log("Added new audio track to peer connection");
|
devLog("Added new audio track to peer connection");
|
||||||
|
|
||||||
// Find the transceiver that was created for this track
|
// Find the transceiver that was created for this track
|
||||||
const newTransceiver = peerConnection.getTransceivers().find(t => t.sender === sender);
|
const newTransceiver = peerConnection.getTransceivers().find(t => t.sender === sender);
|
||||||
console.log("New transceiver created:", newTransceiver ? {
|
devLog("New transceiver created:", newTransceiver ? {
|
||||||
direction: newTransceiver.direction,
|
direction: newTransceiver.direction,
|
||||||
senderTrack: newTransceiver.sender.track?.id,
|
senderTrack: newTransceiver.sender.track?.id,
|
||||||
senderTrackKind: newTransceiver.sender.track?.kind
|
senderTrackKind: newTransceiver.sender.track?.kind
|
||||||
|
@ -301,7 +302,7 @@ export function useMicrophone() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setMicrophoneSender(sender);
|
setMicrophoneSender(sender);
|
||||||
console.log("Microphone sender set:", {
|
devLog("Microphone sender set:", {
|
||||||
senderId: sender,
|
senderId: sender,
|
||||||
track: sender.track?.id,
|
track: sender.track?.id,
|
||||||
trackKind: sender.track?.kind,
|
trackKind: sender.track?.kind,
|
||||||
|
@ -310,28 +311,30 @@ export function useMicrophone() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check sender stats to verify audio is being transmitted
|
// Check sender stats to verify audio is being transmitted
|
||||||
setTimeout(async () => {
|
devOnly(() => {
|
||||||
try {
|
setTimeout(async () => {
|
||||||
const stats = await sender.getStats();
|
try {
|
||||||
console.log("Sender stats after 2 seconds:");
|
const stats = await sender.getStats();
|
||||||
stats.forEach((report, id) => {
|
devLog("Sender stats after 2 seconds:");
|
||||||
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
|
stats.forEach((report, id) => {
|
||||||
console.log("Outbound audio RTP stats:", {
|
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
|
||||||
id,
|
devLog("Outbound audio RTP stats:", {
|
||||||
packetsSent: report.packetsSent,
|
id,
|
||||||
bytesSent: report.bytesSent,
|
packetsSent: report.packetsSent,
|
||||||
timestamp: report.timestamp
|
bytesSent: report.bytesSent,
|
||||||
});
|
timestamp: report.timestamp
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error("Failed to get sender stats:", error);
|
} catch (error) {
|
||||||
}
|
devError("Failed to get sender stats:", error);
|
||||||
}, 2000);
|
}
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify backend that microphone is started
|
// Notify backend that microphone is started
|
||||||
console.log("Notifying backend about microphone start...");
|
devLog("Notifying backend about microphone start...");
|
||||||
|
|
||||||
// Retry logic for backend failures
|
// Retry logic for backend failures
|
||||||
let backendSuccess = false;
|
let backendSuccess = false;
|
||||||
|
@ -341,12 +344,12 @@ export function useMicrophone() {
|
||||||
try {
|
try {
|
||||||
// If this is a retry, first try to reset the backend microphone state
|
// If this is a retry, first try to reset the backend microphone state
|
||||||
if (attempt > 1) {
|
if (attempt > 1) {
|
||||||
console.log(`Backend start attempt ${attempt}, first trying to reset backend state...`);
|
devLog(`Backend start attempt ${attempt}, first trying to reset backend state...`);
|
||||||
try {
|
try {
|
||||||
// Try the new reset endpoint first
|
// Try the new reset endpoint first
|
||||||
const resetResp = await api.POST("/microphone/reset", {});
|
const resetResp = await api.POST("/microphone/reset", {});
|
||||||
if (resetResp.ok) {
|
if (resetResp.ok) {
|
||||||
console.log("Backend reset successful");
|
devLog("Backend reset successful");
|
||||||
} else {
|
} else {
|
||||||
// Fallback to stop
|
// Fallback to stop
|
||||||
await api.POST("/microphone/stop", {});
|
await api.POST("/microphone/stop", {});
|
||||||
|
@ -354,59 +357,59 @@ export function useMicrophone() {
|
||||||
// Wait a bit for the backend to reset
|
// Wait a bit for the backend to reset
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
console.warn("Failed to reset backend state:", resetError);
|
devWarn("Failed to reset backend state:", resetError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendResp = await api.POST("/microphone/start", {});
|
const backendResp = await api.POST("/microphone/start", {});
|
||||||
console.log(`Backend response status (attempt ${attempt}):`, backendResp.status, "ok:", backendResp.ok);
|
devLog(`Backend response status (attempt ${attempt}):`, backendResp.status, "ok:", backendResp.ok);
|
||||||
|
|
||||||
if (!backendResp.ok) {
|
if (!backendResp.ok) {
|
||||||
lastError = `Backend returned status ${backendResp.status}`;
|
lastError = `Backend returned status ${backendResp.status}`;
|
||||||
console.error(`Backend microphone start failed with status: ${backendResp.status} (attempt ${attempt})`);
|
devError(`Backend microphone start failed with status: ${backendResp.status} (attempt ${attempt})`);
|
||||||
|
|
||||||
// For 500 errors, try again after a short delay
|
// For 500 errors, try again after a short delay
|
||||||
if (backendResp.status === 500 && attempt < 3) {
|
if (backendResp.status === 500 && attempt < 3) {
|
||||||
console.log(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`);
|
devLog(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`);
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Success!
|
// Success!
|
||||||
const responseData = await backendResp.json();
|
const responseData = await backendResp.json();
|
||||||
console.log("Backend response data:", responseData);
|
devLog("Backend response data:", responseData);
|
||||||
if (responseData.status === "already running") {
|
if (responseData.status === "already running") {
|
||||||
console.info("Backend microphone was already running");
|
devInfo("Backend microphone was already running");
|
||||||
|
|
||||||
// If we're on the first attempt and backend says "already running",
|
// If we're on the first attempt and backend says "already running",
|
||||||
// but frontend thinks it's not active, this might be a stuck state
|
// but frontend thinks it's not active, this might be a stuck state
|
||||||
if (attempt === 1 && !isMicrophoneActive) {
|
if (attempt === 1 && !isMicrophoneActive) {
|
||||||
console.warn("Backend reports 'already running' but frontend is not active - possible stuck state");
|
devWarn("Backend reports 'already running' but frontend is not active - possible stuck state");
|
||||||
console.log("Attempting to reset backend state and retry...");
|
devLog("Attempting to reset backend state and retry...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resetResp = await api.POST("/microphone/reset", {});
|
const resetResp = await api.POST("/microphone/reset", {});
|
||||||
if (resetResp.ok) {
|
if (resetResp.ok) {
|
||||||
console.log("Backend reset successful, retrying start...");
|
devLog("Backend reset successful, retrying start...");
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
continue; // Retry the start
|
continue; // Retry the start
|
||||||
}
|
}
|
||||||
} catch (resetError) {
|
} catch (resetError) {
|
||||||
console.warn("Failed to reset stuck backend state:", resetError);
|
devWarn("Failed to reset stuck backend state:", resetError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("Backend microphone start successful");
|
devLog("Backend microphone start successful");
|
||||||
backendSuccess = true;
|
backendSuccess = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : String(error);
|
lastError = error instanceof Error ? error : String(error);
|
||||||
console.error(`Backend microphone start threw error (attempt ${attempt}):`, error);
|
devError(`Backend microphone start threw error (attempt ${attempt}):`, error);
|
||||||
|
|
||||||
// For network errors, try again after a short delay
|
// For network errors, try again after a short delay
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
console.log(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`);
|
devLog(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`);
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -415,7 +418,7 @@ export function useMicrophone() {
|
||||||
|
|
||||||
// If all backend attempts failed, cleanup and return error
|
// If all backend attempts failed, cleanup and return error
|
||||||
if (!backendSuccess) {
|
if (!backendSuccess) {
|
||||||
console.error("All backend start attempts failed, cleaning up stream");
|
devError("All backend start attempts failed, cleaning up stream");
|
||||||
await stopMicrophoneStream();
|
await stopMicrophoneStream();
|
||||||
isStartingRef.current = false;
|
isStartingRef.current = false;
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
|
@ -432,7 +435,7 @@ export function useMicrophone() {
|
||||||
setMicrophoneActive(true);
|
setMicrophoneActive(true);
|
||||||
setMicrophoneMuted(false);
|
setMicrophoneMuted(false);
|
||||||
|
|
||||||
console.log("Microphone state set to active. Verifying state:", {
|
devLog("Microphone state set to active. Verifying state:", {
|
||||||
streamInRef: !!microphoneStreamRef.current,
|
streamInRef: !!microphoneStreamRef.current,
|
||||||
streamInStore: !!microphoneStream,
|
streamInStore: !!microphoneStream,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
@ -441,15 +444,17 @@ export function useMicrophone() {
|
||||||
|
|
||||||
// Don't sync immediately after starting - it causes race conditions
|
// Don't sync immediately after starting - it causes race conditions
|
||||||
// The sync will happen naturally through other triggers
|
// The sync will happen naturally through other triggers
|
||||||
setTimeout(() => {
|
devOnly(() => {
|
||||||
// Just verify state after a delay for debugging
|
setTimeout(() => {
|
||||||
console.log("State check after delay:", {
|
// Just verify state after a delay for debugging
|
||||||
streamInRef: !!microphoneStreamRef.current,
|
devLog("State check after delay:", {
|
||||||
streamInStore: !!microphoneStream,
|
streamInRef: !!microphoneStreamRef.current,
|
||||||
isActive: isMicrophoneActive,
|
streamInStore: !!microphoneStream,
|
||||||
isMuted: isMicrophoneMuted
|
isActive: isMicrophoneActive,
|
||||||
});
|
isMuted: isMicrophoneMuted
|
||||||
}, 100);
|
});
|
||||||
|
}, AUDIO_CONFIG.AUDIO_TEST_TIMEOUT);
|
||||||
|
});
|
||||||
|
|
||||||
// Clear the starting flag
|
// Clear the starting flag
|
||||||
isStartingRef.current = false;
|
isStartingRef.current = false;
|
||||||
|
@ -493,12 +498,12 @@ export function useMicrophone() {
|
||||||
// Reset backend microphone state
|
// Reset backend microphone state
|
||||||
const resetBackendMicrophoneState = useCallback(async (): Promise<boolean> => {
|
const resetBackendMicrophoneState = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
console.log("Resetting backend microphone state...");
|
devLog("Resetting backend microphone state...");
|
||||||
const response = await api.POST("/microphone/reset", {});
|
const response = await api.POST("/microphone/reset", {});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("Backend microphone reset successful:", data);
|
devLog("Backend microphone reset successful:", data);
|
||||||
|
|
||||||
// Update frontend state to match backend
|
// Update frontend state to match backend
|
||||||
setMicrophoneActive(false);
|
setMicrophoneActive(false);
|
||||||
|
@ -506,7 +511,7 @@ export function useMicrophone() {
|
||||||
|
|
||||||
// Clean up any orphaned streams
|
// Clean up any orphaned streams
|
||||||
if (microphoneStreamRef.current) {
|
if (microphoneStreamRef.current) {
|
||||||
console.log("Cleaning up orphaned stream after reset");
|
devLog("Cleaning up orphaned stream after reset");
|
||||||
await stopMicrophoneStream();
|
await stopMicrophoneStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -518,19 +523,19 @@ export function useMicrophone() {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
console.error("Backend microphone reset failed:", response.status);
|
devError("Backend microphone reset failed:", response.status);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to reset backend microphone state:", error);
|
devWarn("Failed to reset backend microphone state:", error);
|
||||||
// Fallback to old method
|
// Fallback to old method
|
||||||
try {
|
try {
|
||||||
console.log("Trying fallback reset method...");
|
devLog("Trying fallback reset method...");
|
||||||
await api.POST("/microphone/stop", {});
|
await api.POST("/microphone/stop", {});
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
return true;
|
return true;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error("Fallback reset also failed:", fallbackError);
|
devError("Fallback reset also failed:", fallbackError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -540,7 +545,7 @@ export function useMicrophone() {
|
||||||
const stopMicrophone = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
const stopMicrophone = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
||||||
// Prevent multiple simultaneous stop operations
|
// Prevent multiple simultaneous stop operations
|
||||||
if (isStarting || isStopping || isToggling) {
|
if (isStarting || isStopping || isToggling) {
|
||||||
console.log("Microphone operation already in progress, skipping stop");
|
devLog("Microphone operation already in progress, skipping stop");
|
||||||
return { success: false, error: { type: 'unknown', message: 'Operation already in progress' } };
|
return { success: false, error: { type: 'unknown', message: 'Operation already in progress' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -552,9 +557,9 @@ export function useMicrophone() {
|
||||||
// Then notify backend that microphone is stopped
|
// Then notify backend that microphone is stopped
|
||||||
try {
|
try {
|
||||||
await api.POST("/microphone/stop", {});
|
await api.POST("/microphone/stop", {});
|
||||||
console.log("Backend notified about microphone stop");
|
devLog("Backend notified about microphone stop");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to notify backend about microphone stop:", error);
|
devWarn("Failed to notify backend about microphone stop:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update frontend state immediately
|
// Update frontend state immediately
|
||||||
|
@ -567,7 +572,7 @@ export function useMicrophone() {
|
||||||
setIsStopping(false);
|
setIsStopping(false);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop microphone:", error);
|
devError("Failed to stop microphone:", error);
|
||||||
setIsStopping(false);
|
setIsStopping(false);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -583,7 +588,7 @@ export function useMicrophone() {
|
||||||
const toggleMicrophoneMute = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
const toggleMicrophoneMute = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => {
|
||||||
// Prevent multiple simultaneous toggle operations
|
// Prevent multiple simultaneous toggle operations
|
||||||
if (isStarting || isStopping || isToggling) {
|
if (isStarting || isStopping || isToggling) {
|
||||||
console.log("Microphone operation already in progress, skipping toggle");
|
devLog("Microphone operation already in progress, skipping toggle");
|
||||||
return { success: false, error: { type: 'unknown', message: 'Operation already in progress' } };
|
return { success: false, error: { type: 'unknown', message: 'Operation already in progress' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -592,7 +597,7 @@ export function useMicrophone() {
|
||||||
// Use the ref instead of store value to avoid race conditions
|
// Use the ref instead of store value to avoid race conditions
|
||||||
const currentStream = microphoneStreamRef.current || microphoneStream;
|
const currentStream = microphoneStreamRef.current || microphoneStream;
|
||||||
|
|
||||||
console.log("Toggle microphone mute - current state:", {
|
devLog("Toggle microphone mute - current state:", {
|
||||||
hasRefStream: !!microphoneStreamRef.current,
|
hasRefStream: !!microphoneStreamRef.current,
|
||||||
hasStoreStream: !!microphoneStream,
|
hasStoreStream: !!microphoneStream,
|
||||||
isActive: isMicrophoneActive,
|
isActive: isMicrophoneActive,
|
||||||
|
@ -610,7 +615,7 @@ export function useMicrophone() {
|
||||||
streamId: currentStream?.id,
|
streamId: currentStream?.id,
|
||||||
audioTracks: currentStream?.getAudioTracks().length || 0
|
audioTracks: currentStream?.getAudioTracks().length || 0
|
||||||
};
|
};
|
||||||
console.warn("Microphone mute failed: stream or active state missing", errorDetails);
|
devWarn("Microphone mute failed: stream or active state missing", errorDetails);
|
||||||
|
|
||||||
// Provide more specific error message
|
// Provide more specific error message
|
||||||
let errorMessage = 'Microphone is not active';
|
let errorMessage = 'Microphone is not active';
|
||||||
|
@ -647,7 +652,7 @@ export function useMicrophone() {
|
||||||
// Mute/unmute the audio track
|
// Mute/unmute the audio track
|
||||||
audioTracks.forEach(track => {
|
audioTracks.forEach(track => {
|
||||||
track.enabled = !newMutedState;
|
track.enabled = !newMutedState;
|
||||||
console.log(`Audio track ${track.id} enabled: ${track.enabled}`);
|
devLog(`Audio track ${track.id} enabled: ${track.enabled}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
setMicrophoneMuted(newMutedState);
|
setMicrophoneMuted(newMutedState);
|
||||||
|
@ -656,13 +661,13 @@ export function useMicrophone() {
|
||||||
try {
|
try {
|
||||||
await api.POST("/microphone/mute", { muted: newMutedState });
|
await api.POST("/microphone/mute", { muted: newMutedState });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to notify backend about microphone mute:", error);
|
devWarn("Failed to notify backend about microphone mute:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsToggling(false);
|
setIsToggling(false);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle microphone mute:", error);
|
devError("Failed to toggle microphone mute:", error);
|
||||||
setIsToggling(false);
|
setIsToggling(false);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -677,7 +682,7 @@ export function useMicrophone() {
|
||||||
// Function to check WebRTC audio transmission stats
|
// Function to check WebRTC audio transmission stats
|
||||||
const checkAudioTransmissionStats = useCallback(async () => {
|
const checkAudioTransmissionStats = useCallback(async () => {
|
||||||
if (!microphoneSender) {
|
if (!microphoneSender) {
|
||||||
console.log("No microphone sender available");
|
devLog("No microphone sender available");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -707,38 +712,38 @@ export function useMicrophone() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Audio transmission stats:", audioStats);
|
devLog("Audio transmission stats:", audioStats);
|
||||||
return audioStats;
|
return audioStats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get audio transmission stats:", error);
|
devError("Failed to get audio transmission stats:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [microphoneSender]);
|
}, [microphoneSender]);
|
||||||
|
|
||||||
// Comprehensive test function to diagnose microphone issues
|
// Comprehensive test function to diagnose microphone issues
|
||||||
const testMicrophoneAudio = useCallback(async () => {
|
const testMicrophoneAudio = useCallback(async () => {
|
||||||
console.log("=== MICROPHONE AUDIO TEST ===");
|
devLog("=== MICROPHONE AUDIO TEST ===");
|
||||||
|
|
||||||
// 1. Check if we have a stream
|
// 1. Check if we have a stream
|
||||||
const stream = microphoneStreamRef.current;
|
const stream = microphoneStreamRef.current;
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
console.log("❌ No microphone stream available");
|
devLog("❌ No microphone stream available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ Microphone stream exists:", stream.id);
|
devLog("✅ Microphone stream exists:", stream.id);
|
||||||
|
|
||||||
// 2. Check audio tracks
|
// 2. Check audio tracks
|
||||||
const audioTracks = stream.getAudioTracks();
|
const audioTracks = stream.getAudioTracks();
|
||||||
console.log("Audio tracks:", audioTracks.length);
|
devLog("Audio tracks:", audioTracks.length);
|
||||||
|
|
||||||
if (audioTracks.length === 0) {
|
if (audioTracks.length === 0) {
|
||||||
console.log("❌ No audio tracks in stream");
|
devLog("❌ No audio tracks in stream");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = audioTracks[0];
|
const track = audioTracks[0];
|
||||||
console.log("✅ Audio track details:", {
|
devLog("✅ Audio track details:", {
|
||||||
id: track.id,
|
id: track.id,
|
||||||
label: track.label,
|
label: track.label,
|
||||||
enabled: track.enabled,
|
enabled: track.enabled,
|
||||||
|
@ -752,13 +757,13 @@ export function useMicrophone() {
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = AUDIO_CONFIG.ANALYSIS_FFT_SIZE;
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
|
|
||||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
|
||||||
console.log("🎤 Testing audio level detection for 5 seconds...");
|
devLog("🎤 Testing audio level detection for 5 seconds...");
|
||||||
console.log("Please speak into your microphone now!");
|
devLog("Please speak into your microphone now!");
|
||||||
|
|
||||||
let maxLevel = 0;
|
let maxLevel = 0;
|
||||||
let sampleCount = 0;
|
let sampleCount = 0;
|
||||||
|
@ -771,39 +776,39 @@ export function useMicrophone() {
|
||||||
sum += value * value;
|
sum += value * value;
|
||||||
}
|
}
|
||||||
const rms = Math.sqrt(sum / dataArray.length);
|
const rms = Math.sqrt(sum / dataArray.length);
|
||||||
const level = Math.min(100, (rms / 255) * 100);
|
const level = Math.min(AUDIO_CONFIG.MAX_LEVEL_PERCENTAGE, (rms / AUDIO_CONFIG.LEVEL_SCALING_FACTOR) * AUDIO_CONFIG.MAX_LEVEL_PERCENTAGE);
|
||||||
|
|
||||||
maxLevel = Math.max(maxLevel, level);
|
maxLevel = Math.max(maxLevel, level);
|
||||||
sampleCount++;
|
sampleCount++;
|
||||||
|
|
||||||
if (sampleCount % 10 === 0) { // Log every 10th sample
|
if (sampleCount % 10 === 0) { // Log every 10th sample
|
||||||
console.log(`Audio level: ${level.toFixed(1)}% (max so far: ${maxLevel.toFixed(1)}%)`);
|
devLog(`Audio level: ${level.toFixed(1)}% (max so far: ${maxLevel.toFixed(1)}%)`);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, AUDIO_CONFIG.ANALYSIS_UPDATE_INTERVAL);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(testInterval);
|
clearInterval(testInterval);
|
||||||
source.disconnect();
|
source.disconnect();
|
||||||
audioContext.close();
|
audioContext.close();
|
||||||
|
|
||||||
console.log("🎤 Audio test completed!");
|
devLog("🎤 Audio test completed!");
|
||||||
console.log(`Maximum audio level detected: ${maxLevel.toFixed(1)}%`);
|
devLog(`Maximum audio level detected: ${maxLevel.toFixed(1)}%`);
|
||||||
|
|
||||||
if (maxLevel > 5) {
|
if (maxLevel > 5) {
|
||||||
console.log("✅ Microphone is detecting audio!");
|
devLog("✅ Microphone is detecting audio!");
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ No significant audio detected. Check microphone permissions and hardware.");
|
devLog("❌ No significant audio detected. Check microphone permissions and hardware.");
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, NETWORK_CONFIG.AUDIO_TEST_DURATION);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Failed to test audio level:", error);
|
devError("❌ Failed to test audio level:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check WebRTC sender
|
// 4. Check WebRTC sender
|
||||||
if (microphoneSender) {
|
if (microphoneSender) {
|
||||||
console.log("✅ WebRTC sender exists");
|
devLog("✅ WebRTC sender exists");
|
||||||
console.log("Sender track:", {
|
devLog("Sender track:", {
|
||||||
id: microphoneSender.track?.id,
|
id: microphoneSender.track?.id,
|
||||||
kind: microphoneSender.track?.kind,
|
kind: microphoneSender.track?.kind,
|
||||||
enabled: microphoneSender.track?.enabled,
|
enabled: microphoneSender.track?.enabled,
|
||||||
|
@ -812,45 +817,45 @@ export function useMicrophone() {
|
||||||
|
|
||||||
// Check if sender track matches stream track
|
// Check if sender track matches stream track
|
||||||
if (microphoneSender.track === track) {
|
if (microphoneSender.track === track) {
|
||||||
console.log("✅ Sender track matches stream track");
|
devLog("✅ Sender track matches stream track");
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ Sender track does NOT match stream track");
|
devLog("❌ Sender track does NOT match stream track");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ No WebRTC sender available");
|
devLog("❌ No WebRTC sender available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Check peer connection
|
// 5. Check peer connection
|
||||||
if (peerConnection) {
|
if (peerConnection) {
|
||||||
console.log("✅ Peer connection exists");
|
devLog("✅ Peer connection exists");
|
||||||
console.log("Connection state:", peerConnection.connectionState);
|
devLog("Connection state:", peerConnection.connectionState);
|
||||||
console.log("ICE connection state:", peerConnection.iceConnectionState);
|
devLog("ICE connection state:", peerConnection.iceConnectionState);
|
||||||
|
|
||||||
const transceivers = peerConnection.getTransceivers();
|
const transceivers = peerConnection.getTransceivers();
|
||||||
const audioTransceivers = transceivers.filter(t =>
|
const audioTransceivers = transceivers.filter(t =>
|
||||||
t.sender.track?.kind === 'audio' || t.receiver.track?.kind === 'audio'
|
t.sender.track?.kind === 'audio' || t.receiver.track?.kind === 'audio'
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("Audio transceivers:", audioTransceivers.map(t => ({
|
devLog("Audio transceivers:", audioTransceivers.map(t => ({
|
||||||
direction: t.direction,
|
direction: t.direction,
|
||||||
senderTrack: t.sender.track?.id,
|
senderTrack: t.sender.track?.id,
|
||||||
receiverTrack: t.receiver.track?.id
|
receiverTrack: t.receiver.track?.id
|
||||||
})));
|
})));
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ No peer connection available");
|
devLog("❌ No peer connection available");
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [microphoneSender, peerConnection]);
|
}, [microphoneSender, peerConnection]);
|
||||||
|
|
||||||
const startMicrophoneDebounced = useCallback((deviceId?: string) => {
|
const startMicrophoneDebounced = useCallback((deviceId?: string) => {
|
||||||
debouncedOperation(async () => {
|
debouncedOperation(async () => {
|
||||||
await startMicrophone(deviceId).catch(console.error);
|
await startMicrophone(deviceId).catch(devError);
|
||||||
}, "start");
|
}, "start");
|
||||||
}, [startMicrophone, debouncedOperation]);
|
}, [startMicrophone, debouncedOperation]);
|
||||||
|
|
||||||
const stopMicrophoneDebounced = useCallback(() => {
|
const stopMicrophoneDebounced = useCallback(() => {
|
||||||
debouncedOperation(async () => {
|
debouncedOperation(async () => {
|
||||||
await stopMicrophone().catch(console.error);
|
await stopMicrophone().catch(devError);
|
||||||
}, "stop");
|
}, "stop");
|
||||||
}, [stopMicrophone, debouncedOperation]);
|
}, [stopMicrophone, debouncedOperation]);
|
||||||
|
|
||||||
|
@ -919,10 +924,10 @@ export function useMicrophone() {
|
||||||
// Clean up stream directly without depending on the callback
|
// Clean up stream directly without depending on the callback
|
||||||
const stream = microphoneStreamRef.current;
|
const stream = microphoneStreamRef.current;
|
||||||
if (stream) {
|
if (stream) {
|
||||||
console.log("Cleanup: stopping microphone stream on unmount");
|
devLog("Cleanup: stopping microphone stream on unmount");
|
||||||
stream.getAudioTracks().forEach(track => {
|
stream.getAudioTracks().forEach(track => {
|
||||||
track.stop();
|
track.stop();
|
||||||
console.log(`Cleanup: stopped audio track ${track.id}`);
|
devLog(`Cleanup: stopped audio track ${track.id}`);
|
||||||
});
|
});
|
||||||
microphoneStreamRef.current = null;
|
microphoneStreamRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { devError } from '../utils/debug';
|
||||||
|
|
||||||
import { JsonRpcResponse, useJsonRpc } from "./useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "./useJsonRpc";
|
||||||
import { useAudioEvents } from "./useAudioEvents";
|
import { useAudioEvents } from "./useAudioEvents";
|
||||||
|
|
||||||
|
@ -25,7 +27,7 @@ export function useUsbDeviceConfig() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to load USB devices:", resp.error);
|
devError("Failed to load USB devices:", resp.error);
|
||||||
setError(resp.error.data || "Unknown error");
|
setError(resp.error.data || "Unknown error");
|
||||||
setUsbDeviceConfig(null);
|
setUsbDeviceConfig(null);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
import api from '@/api';
|
||||||
|
|
||||||
|
interface AudioConfig {
|
||||||
|
Quality: number;
|
||||||
|
Bitrate: number;
|
||||||
|
SampleRate: number;
|
||||||
|
Channels: number;
|
||||||
|
FrameSize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QualityPresets = Record<number, AudioConfig>;
|
||||||
|
|
||||||
|
interface AudioQualityResponse {
|
||||||
|
current: AudioConfig;
|
||||||
|
presets: QualityPresets;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioQualityService {
|
||||||
|
private audioPresets: QualityPresets | null = null;
|
||||||
|
private microphonePresets: QualityPresets | null = null;
|
||||||
|
private qualityLabels: Record<number, string> = {
|
||||||
|
0: 'Low',
|
||||||
|
1: 'Medium',
|
||||||
|
2: 'High',
|
||||||
|
3: 'Ultra'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch audio quality presets from the backend
|
||||||
|
*/
|
||||||
|
async fetchAudioQualityPresets(): Promise<AudioQualityResponse | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.GET('/audio/quality');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.audioPresets = data.presets;
|
||||||
|
this.updateQualityLabels(data.presets);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch audio quality presets:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch microphone quality presets from the backend
|
||||||
|
*/
|
||||||
|
async fetchMicrophoneQualityPresets(): Promise<AudioQualityResponse | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.GET('/microphone/quality');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.microphonePresets = data.presets;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch microphone quality presets:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update quality labels with actual bitrates from presets
|
||||||
|
*/
|
||||||
|
private updateQualityLabels(presets: QualityPresets): void {
|
||||||
|
const newQualityLabels: Record<number, string> = {};
|
||||||
|
Object.entries(presets).forEach(([qualityNum, preset]) => {
|
||||||
|
const quality = parseInt(qualityNum);
|
||||||
|
const qualityNames = ['Low', 'Medium', 'High', 'Ultra'];
|
||||||
|
const name = qualityNames[quality] || `Quality ${quality}`;
|
||||||
|
newQualityLabels[quality] = `${name} (${preset.Bitrate}kbps)`;
|
||||||
|
});
|
||||||
|
this.qualityLabels = newQualityLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get quality labels with bitrates
|
||||||
|
*/
|
||||||
|
getQualityLabels(): Record<number, string> {
|
||||||
|
return this.qualityLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached audio presets
|
||||||
|
*/
|
||||||
|
getAudioPresets(): QualityPresets | null {
|
||||||
|
return this.audioPresets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached microphone presets
|
||||||
|
*/
|
||||||
|
getMicrophonePresets(): QualityPresets | null {
|
||||||
|
return this.microphonePresets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set audio quality
|
||||||
|
*/
|
||||||
|
async setAudioQuality(quality: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await api.POST('/audio/quality', { quality });
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set audio quality:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set microphone quality
|
||||||
|
*/
|
||||||
|
async setMicrophoneQuality(quality: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await api.POST('/microphone/quality', { quality });
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set microphone quality:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load both audio and microphone configurations
|
||||||
|
*/
|
||||||
|
async loadAllConfigurations(): Promise<{
|
||||||
|
audio: AudioQualityResponse | null;
|
||||||
|
microphone: AudioQualityResponse | null;
|
||||||
|
}> {
|
||||||
|
const [audio, microphone] = await Promise.all([
|
||||||
|
this.fetchAudioQualityPresets(),
|
||||||
|
this.fetchMicrophoneQualityPresets()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { audio, microphone };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const audioQualityService = new AudioQualityService();
|
||||||
|
export default audioQualityService;
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Debug utilities for development mode logging
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Check if we're in development mode
|
||||||
|
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development-only console.log wrapper
|
||||||
|
* Only logs in development mode, silent in production
|
||||||
|
*/
|
||||||
|
export const devLog = (...args: unknown[]): void => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.log(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development-only console.info wrapper
|
||||||
|
* Only logs in development mode, silent in production
|
||||||
|
*/
|
||||||
|
export const devInfo = (...args: unknown[]): void => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.info(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development-only console.warn wrapper
|
||||||
|
* Only logs in development mode, silent in production
|
||||||
|
*/
|
||||||
|
export const devWarn = (...args: unknown[]): void => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.warn(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development-only console.error wrapper
|
||||||
|
* Always logs errors, but with dev prefix in development
|
||||||
|
*/
|
||||||
|
export const devError = (...args: unknown[]): void => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.error('[DEV]', ...args);
|
||||||
|
} else {
|
||||||
|
console.error(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development-only debug function wrapper
|
||||||
|
* Only executes the function in development mode
|
||||||
|
*/
|
||||||
|
export const devOnly = <T>(fn: () => T): T | undefined => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're in development mode
|
||||||
|
*/
|
||||||
|
export const isDevMode = (): boolean => isDevelopment;
|
Loading…
Reference in New Issue