mirror of https://github.com/jetkvm/kvm.git
Fix critical audio race conditions and improve reliability
- Replace volatile with C11 atomics for proper ARM memory barriers - Fix race condition in audio source swapping (swap to nil before cleanup) - Prevent double-close of ALSA handles via atomic ownership claim - Add exponential backoff with 10-retry circuit breaker to prevent infinite loops - Improve error propagation to report dual failures - Add defensive null checks for concurrent access safety - Simplify UI error handling with helper functions - Fix TypeScript compilation error in packet loss dropdown
This commit is contained in:
parent
8d69780061
commit
1d570a8cbf
173
audio.go
173
audio.go
|
|
@ -1,6 +1,7 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
|
@ -39,7 +40,7 @@ func initAudio() {
|
|||
|
||||
ensureConfigLoaded()
|
||||
audioOutputEnabled.Store(config.AudioOutputEnabled)
|
||||
audioInputEnabled.Store(true)
|
||||
audioInputEnabled.Store(config.AudioInputAutoEnable)
|
||||
|
||||
audioLogger.Debug().Msg("Audio subsystem initialized")
|
||||
audioInitialized = true
|
||||
|
|
@ -48,18 +49,25 @@ func initAudio() {
|
|||
func getAudioConfig() audio.AudioConfig {
|
||||
cfg := audio.DefaultAudioConfig()
|
||||
|
||||
// Helper to validate and apply numeric ranges
|
||||
validateAndApply := func(value int, min int, max int, paramName string) (int, bool) {
|
||||
if value >= min && value <= max {
|
||||
return value, true
|
||||
}
|
||||
if value != 0 {
|
||||
audioLogger.Warn().Int(paramName, value).Msgf("Invalid %s, using default", paramName)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Validate and apply bitrate
|
||||
if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 {
|
||||
cfg.Bitrate = uint16(config.AudioBitrate)
|
||||
} else if config.AudioBitrate != 0 {
|
||||
audioLogger.Warn().Int("bitrate", config.AudioBitrate).Uint16("default", cfg.Bitrate).Msg("Invalid audio bitrate, using default")
|
||||
if bitrate, valid := validateAndApply(config.AudioBitrate, 64, 256, "audio bitrate"); valid {
|
||||
cfg.Bitrate = uint16(bitrate)
|
||||
}
|
||||
|
||||
// Validate and apply complexity
|
||||
if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 {
|
||||
cfg.Complexity = uint8(config.AudioComplexity)
|
||||
} else {
|
||||
audioLogger.Warn().Int("complexity", config.AudioComplexity).Uint8("default", cfg.Complexity).Msg("Invalid audio complexity, using default")
|
||||
if complexity, valid := validateAndApply(config.AudioComplexity, 0, 10, "audio complexity"); valid {
|
||||
cfg.Complexity = uint8(complexity)
|
||||
}
|
||||
|
||||
// Apply boolean flags directly
|
||||
|
|
@ -67,10 +75,8 @@ func getAudioConfig() audio.AudioConfig {
|
|||
cfg.FECEnabled = config.AudioFECEnabled
|
||||
|
||||
// Validate and apply buffer periods
|
||||
if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 {
|
||||
cfg.BufferPeriods = uint8(config.AudioBufferPeriods)
|
||||
} else if config.AudioBufferPeriods != 0 {
|
||||
audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Uint8("default", cfg.BufferPeriods).Msg("Invalid buffer periods, using default")
|
||||
if periods, valid := validateAndApply(config.AudioBufferPeriods, 2, 24, "buffer periods"); valid {
|
||||
cfg.BufferPeriods = uint8(periods)
|
||||
}
|
||||
|
||||
// Validate and apply sample rate using a map for valid rates
|
||||
|
|
@ -82,10 +88,8 @@ func getAudioConfig() audio.AudioConfig {
|
|||
}
|
||||
|
||||
// Validate and apply packet loss percentage
|
||||
if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 {
|
||||
cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc)
|
||||
} else {
|
||||
audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Uint8("default", cfg.PacketLossPerc).Msg("Invalid packet loss percentage, using default")
|
||||
if pktLoss, valid := validateAndApply(config.AudioPacketLossPerc, 0, 100, "packet loss percentage"); valid {
|
||||
cfg.PacketLossPerc = uint8(pktLoss)
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
|
@ -107,91 +111,98 @@ func startAudio() error {
|
|||
|
||||
ensureConfigLoaded()
|
||||
|
||||
var outputErr, inputErr error
|
||||
if audioOutputEnabled.Load() && currentAudioTrack != nil {
|
||||
startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource))
|
||||
outputErr = startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource))
|
||||
}
|
||||
|
||||
if audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
|
||||
startInputAudioUnderMutex(getAlsaDevice("usb"))
|
||||
inputErr = startInputAudioUnderMutex(getAlsaDevice("usb"))
|
||||
}
|
||||
|
||||
return nil
|
||||
if outputErr != nil && inputErr != nil {
|
||||
return fmt.Errorf("audio start failed - output: %w, input: %v", outputErr, inputErr)
|
||||
}
|
||||
if outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return inputErr
|
||||
}
|
||||
|
||||
func startOutputAudioUnderMutex(alsaOutputDevice string) {
|
||||
newSource := audio.NewCgoOutputSource(alsaOutputDevice, getAudioConfig())
|
||||
oldSource := outputSource.Swap(&newSource)
|
||||
newRelay := audio.NewOutputRelay(&newSource, currentAudioTrack)
|
||||
oldRelay := outputRelay.Swap(newRelay)
|
||||
func startOutputAudioUnderMutex(alsaOutputDevice string) error {
|
||||
oldRelay := outputRelay.Swap(nil)
|
||||
oldSource := outputSource.Swap(nil)
|
||||
|
||||
if oldRelay != nil {
|
||||
oldRelay.Stop()
|
||||
}
|
||||
|
||||
if oldSource != nil {
|
||||
(*oldSource).Disconnect()
|
||||
}
|
||||
|
||||
newSource := audio.NewCgoOutputSource(alsaOutputDevice, getAudioConfig())
|
||||
newRelay := audio.NewOutputRelay(&newSource, currentAudioTrack)
|
||||
|
||||
if err := newRelay.Start(); err != nil {
|
||||
audioLogger.Error().Err(err).Str("alsaOutputDevice", alsaOutputDevice).Msg("Failed to start audio output relay")
|
||||
return err
|
||||
}
|
||||
|
||||
outputSource.Swap(&newSource)
|
||||
outputRelay.Swap(newRelay)
|
||||
return nil
|
||||
}
|
||||
|
||||
func startInputAudioUnderMutex(alsaPlaybackDevice string) {
|
||||
newSource := audio.NewCgoInputSource(alsaPlaybackDevice, getAudioConfig())
|
||||
oldSource := inputSource.Swap(&newSource)
|
||||
newRelay := audio.NewInputRelay(&newSource)
|
||||
oldRelay := inputRelay.Swap(newRelay)
|
||||
func startInputAudioUnderMutex(alsaPlaybackDevice string) error {
|
||||
oldRelay := inputRelay.Swap(nil)
|
||||
oldSource := inputSource.Swap(nil)
|
||||
|
||||
if oldRelay != nil {
|
||||
oldRelay.Stop()
|
||||
}
|
||||
|
||||
if oldSource != nil {
|
||||
(*oldSource).Disconnect()
|
||||
}
|
||||
|
||||
newSource := audio.NewCgoInputSource(alsaPlaybackDevice, getAudioConfig())
|
||||
newRelay := audio.NewInputRelay(&newSource)
|
||||
|
||||
if err := newRelay.Start(); err != nil {
|
||||
audioLogger.Error().Err(err).Str("alsaPlaybackDevice", alsaPlaybackDevice).Msg("Failed to start input relay")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// stopAudioComponents safely stops and cleans up audio components
|
||||
func stopAudioComponents(relay *atomic.Pointer[audio.OutputRelay], source *atomic.Pointer[audio.AudioSource]) {
|
||||
audioMutex.Lock()
|
||||
oldRelay := relay.Swap(nil)
|
||||
oldSource := source.Swap(nil)
|
||||
audioMutex.Unlock()
|
||||
|
||||
if oldRelay != nil {
|
||||
oldRelay.Stop()
|
||||
}
|
||||
if oldSource != nil {
|
||||
(*oldSource).Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// stopAudioComponentsInput safely stops and cleans up input audio components
|
||||
func stopAudioComponentsInput(relay *atomic.Pointer[audio.InputRelay], source *atomic.Pointer[audio.AudioSource]) {
|
||||
audioMutex.Lock()
|
||||
oldRelay := relay.Swap(nil)
|
||||
oldSource := source.Swap(nil)
|
||||
audioMutex.Unlock()
|
||||
|
||||
if oldRelay != nil {
|
||||
oldRelay.Stop()
|
||||
}
|
||||
if oldSource != nil {
|
||||
(*oldSource).Disconnect()
|
||||
}
|
||||
inputSource.Swap(&newSource)
|
||||
inputRelay.Swap(newRelay)
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopOutputAudio() {
|
||||
stopAudioComponents(&outputRelay, &outputSource)
|
||||
audioMutex.Lock()
|
||||
oldRelay := outputRelay.Swap(nil)
|
||||
oldSource := outputSource.Swap(nil)
|
||||
audioMutex.Unlock()
|
||||
|
||||
if oldRelay != nil {
|
||||
oldRelay.Stop()
|
||||
}
|
||||
if oldSource != nil {
|
||||
(*oldSource).Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func stopInputAudio() {
|
||||
stopAudioComponentsInput(&inputRelay, &inputSource)
|
||||
audioMutex.Lock()
|
||||
oldRelay := inputRelay.Swap(nil)
|
||||
oldSource := inputSource.Swap(nil)
|
||||
audioMutex.Unlock()
|
||||
|
||||
if oldRelay != nil {
|
||||
oldRelay.Stop()
|
||||
}
|
||||
if oldSource != nil {
|
||||
(*oldSource).Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func stopAudio() {
|
||||
|
|
@ -234,13 +245,16 @@ func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
|
|||
|
||||
// Start audio without taking mutex again (already holding audioMutex)
|
||||
if audioInitialized && activeConnections.Load() > 0 && audioOutputEnabled.Load() && currentAudioTrack != nil {
|
||||
startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource))
|
||||
if err := startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource)); err != nil {
|
||||
audioLogger.Error().Err(err).Msg("Failed to start output audio after track change")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPendingInputTrack(track *webrtc.TrackRemote) {
|
||||
trackID := track.ID()
|
||||
currentInputTrack.Store(&trackID)
|
||||
trackID := new(string)
|
||||
*trackID = track.ID()
|
||||
currentInputTrack.Store(trackID)
|
||||
go handleInputTrackForSession(track)
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +282,15 @@ func SetAudioInputEnabled(enabled bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// SetAudioOutputSource switches between HDMI (hw:0,0) and USB (hw:1,0) audio capture.
|
||||
//
|
||||
// The function returns immediately after updating and persisting the config change,
|
||||
// while the actual audio device switch happens asynchronously in the background:
|
||||
// - Config save is synchronous to ensure the change persists even if the process crashes
|
||||
// - Audio restart is async to avoid blocking the RPC caller during ALSA reconfiguration
|
||||
//
|
||||
// Note: The HDMI audio device (hw:0,0) can take 30-60 seconds to initialize due to
|
||||
// TC358743 hardware characteristics. Callers receive success before audio actually switches.
|
||||
func SetAudioOutputSource(source string) error {
|
||||
if source != "hdmi" && source != "usb" {
|
||||
return nil
|
||||
|
|
@ -280,6 +303,13 @@ func SetAudioOutputSource(source string) error {
|
|||
|
||||
config.AudioOutputSource = source
|
||||
|
||||
// Save config synchronously before starting async audio operations
|
||||
if err := SaveConfig(); err != nil {
|
||||
audioLogger.Error().Err(err).Msg("Failed to save config after audio source change")
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle audio restart asynchronously
|
||||
go func() {
|
||||
stopOutputAudio()
|
||||
if err := startAudio(); err != nil {
|
||||
|
|
@ -287,12 +317,6 @@ func SetAudioOutputSource(source string) error {
|
|||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := SaveConfig(); err != nil {
|
||||
audioLogger.Error().Err(err).Msg("Failed to save config after audio source change")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +398,11 @@ func processInputPacket(opusData []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Defensive null check - ensure dereferenced pointer is valid
|
||||
if *source == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure source is connected
|
||||
if !(*source).IsConnected() {
|
||||
if err := (*source).Connect(); err != nil {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ type Config struct {
|
|||
IncludePreRelease bool `json:"include_pre_release"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
LocalAuthToken string `json:"local_auth_token"`
|
||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
||||
LocalAuthMode string `json:"localAuthMode"` // Uses camelCase for backwards compatibility with existing configs
|
||||
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
#include <time.h>
|
||||
#include <signal.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
|
||||
// ARM NEON SIMD support (required - JetKVM hardware provides ARM Cortex-A7 with NEON)
|
||||
#include <arm_neon.h>
|
||||
|
|
@ -46,19 +47,20 @@ static OpusDecoder *decoder = NULL;
|
|||
|
||||
// Audio format (S16_LE @ 48kHz)
|
||||
static uint32_t sample_rate = 48000;
|
||||
static uint8_t capture_channels = 2; // OUTPUT: HDMI stereo → client
|
||||
static uint8_t playback_channels = 1; // INPUT: Client mono mic → device
|
||||
static uint8_t capture_channels = 2; // OUTPUT: HDMI/USB stereo → client (configurable via update_audio_constants)
|
||||
static uint8_t playback_channels = 1; // INPUT: Client mono mic → device (always mono for USB audio gadget)
|
||||
static uint16_t frame_size = 960; // 20ms frames at 48kHz
|
||||
|
||||
static uint32_t opus_bitrate = 192000;
|
||||
static uint8_t opus_complexity = 8;
|
||||
static uint16_t max_packet_size = 1500;
|
||||
|
||||
#define OPUS_VBR 1
|
||||
#define OPUS_VBR_CONSTRAINT 1
|
||||
#define OPUS_SIGNAL_TYPE 3002
|
||||
#define OPUS_BANDWIDTH 1104
|
||||
#define OPUS_LSB_DEPTH 16
|
||||
// Opus encoder configuration constants (see opus_defines.h for full enum values)
|
||||
#define OPUS_VBR 1 // Variable bitrate mode enabled
|
||||
#define OPUS_VBR_CONSTRAINT 1 // Constrained VBR maintains bitrate ceiling
|
||||
#define OPUS_SIGNAL_TYPE 3002 // OPUS_SIGNAL_MUSIC (optimized for music/audio content)
|
||||
#define OPUS_BANDWIDTH 1104 // OPUS_BANDWIDTH_FULLBAND (0-20kHz frequency range)
|
||||
#define OPUS_LSB_DEPTH 16 // 16-bit PCM sample depth (S16_LE format)
|
||||
|
||||
static uint8_t opus_dtx_enabled = 1;
|
||||
static uint8_t opus_fec_enabled = 1;
|
||||
|
|
@ -70,10 +72,15 @@ static uint32_t sleep_milliseconds = 1;
|
|||
static uint8_t max_attempts_global = 5;
|
||||
static uint32_t max_backoff_us_global = 500000;
|
||||
|
||||
static volatile int capture_stop_requested = 0;
|
||||
static volatile int playback_stop_requested = 0;
|
||||
static atomic_int capture_stop_requested = 0;
|
||||
static atomic_int playback_stop_requested = 0;
|
||||
|
||||
// Mutexes to protect concurrent access to ALSA handles during close
|
||||
// These prevent race conditions when jetkvm_audio_*_close() is called while
|
||||
// jetkvm_audio_read_encode() or jetkvm_audio_decode_write() are executing.
|
||||
// The hot path functions acquire these mutexes briefly to validate handle
|
||||
// pointers, then release before slow ALSA/Opus operations to avoid holding
|
||||
// locks during I/O. Handle comparison checks detect races after operations.
|
||||
static pthread_mutex_t capture_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_mutex_t playback_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
|
|
@ -131,17 +138,21 @@ void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16
|
|||
/**
|
||||
* Initialize ALSA device names from environment variables
|
||||
* Must be called before jetkvm_audio_capture_init or jetkvm_audio_playback_init
|
||||
*
|
||||
* Device mapping (set via ALSA_CAPTURE_DEVICE/ALSA_PLAYBACK_DEVICE):
|
||||
* hw:0,0 = TC358743 HDMI audio input (for OUTPUT path capture)
|
||||
* hw:1,0 = USB Audio Gadget (for OUTPUT path capture or INPUT path playback)
|
||||
*/
|
||||
static void init_alsa_devices_from_env(void) {
|
||||
// Always read from environment to support device switching
|
||||
alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE");
|
||||
if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') {
|
||||
alsa_capture_device = "hw:1,0"; // Default to USB gadget
|
||||
alsa_capture_device = "hw:1,0"; // Default: USB gadget audio for capture
|
||||
}
|
||||
|
||||
alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE");
|
||||
if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') {
|
||||
alsa_playback_device = "hw:1,0"; // Default to USB gadget
|
||||
alsa_playback_device = "hw:1,0"; // Default: USB gadget audio for playback
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,16 +238,22 @@ static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream
|
|||
* Handle ALSA I/O errors with recovery attempts
|
||||
* @param handle Pointer to PCM handle to use for recovery operations
|
||||
* @param valid_handle Pointer to the valid handle to check against (for race detection)
|
||||
* @param stop_flag Pointer to volatile stop flag
|
||||
* @param stop_flag Pointer to atomic stop flag
|
||||
* @param mutex Mutex to unlock on error
|
||||
* @param pcm_rc Error code from ALSA I/O operation
|
||||
* @param recovery_attempts Pointer to uint8_t recovery attempt counter
|
||||
* @param sleep_ms Milliseconds to sleep during recovery
|
||||
* @param max_attempts Maximum recovery attempts allowed
|
||||
* @return 1=retry, 0=skip frame, -1=error (mutex already unlocked)
|
||||
* @return Three possible outcomes:
|
||||
* 1 = Retry operation (error was recovered, mutex still held by caller)
|
||||
* 0 = Skip this frame and continue (mutex still held, caller must unlock)
|
||||
* -1 = Fatal error, abort operation (mutex ALREADY UNLOCKED by this function)
|
||||
*
|
||||
* CRITICAL: On return value -1, the mutex has already been unlocked. The caller
|
||||
* must NOT unlock again or proceed with further I/O operations.
|
||||
*/
|
||||
static int handle_alsa_error(snd_pcm_t *handle, snd_pcm_t **valid_handle,
|
||||
volatile int *stop_flag, pthread_mutex_t *mutex,
|
||||
atomic_int *stop_flag, pthread_mutex_t *mutex,
|
||||
int pcm_rc, uint8_t *recovery_attempts,
|
||||
uint32_t sleep_ms, uint8_t max_attempts) {
|
||||
int err;
|
||||
|
|
@ -448,8 +465,7 @@ int jetkvm_audio_capture_init() {
|
|||
|
||||
if (encoder != NULL || pcm_capture_handle != NULL) {
|
||||
capture_initialized = 0;
|
||||
capture_stop_requested = 1;
|
||||
__sync_synchronize();
|
||||
atomic_store(&capture_stop_requested, 1);
|
||||
|
||||
if (pcm_capture_handle) {
|
||||
snd_pcm_drop(pcm_capture_handle);
|
||||
|
|
@ -474,7 +490,7 @@ int jetkvm_audio_capture_init() {
|
|||
fprintf(stderr, "Failed to open ALSA capture device %s: %s\n",
|
||||
alsa_capture_device, snd_strerror(err));
|
||||
fflush(stderr);
|
||||
capture_stop_requested = 0;
|
||||
atomic_store(&capture_stop_requested, 0);
|
||||
capture_initializing = 0;
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -486,7 +502,7 @@ int jetkvm_audio_capture_init() {
|
|||
snd_pcm_t *handle = pcm_capture_handle;
|
||||
pcm_capture_handle = NULL;
|
||||
snd_pcm_close(handle);
|
||||
capture_stop_requested = 0;
|
||||
atomic_store(&capture_stop_requested, 0);
|
||||
capture_initializing = 0;
|
||||
return -2;
|
||||
}
|
||||
|
|
@ -503,7 +519,7 @@ int jetkvm_audio_capture_init() {
|
|||
pcm_capture_handle = NULL;
|
||||
snd_pcm_close(handle);
|
||||
}
|
||||
capture_stop_requested = 0;
|
||||
atomic_store(&capture_stop_requested, 0);
|
||||
capture_initializing = 0;
|
||||
return -3;
|
||||
}
|
||||
|
|
@ -521,7 +537,7 @@ int jetkvm_audio_capture_init() {
|
|||
opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(opus_packet_loss_perc));
|
||||
|
||||
capture_initialized = 1;
|
||||
capture_stop_requested = 0;
|
||||
atomic_store(&capture_stop_requested, 0);
|
||||
capture_initializing = 0;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -539,7 +555,7 @@ __attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf)
|
|||
uint8_t recovery_attempts = 0;
|
||||
const uint8_t max_recovery_attempts = 3;
|
||||
|
||||
if (__builtin_expect(capture_stop_requested, 0)) {
|
||||
if (__builtin_expect(atomic_load(&capture_stop_requested), 0)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
@ -556,16 +572,12 @@ __attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf)
|
|||
}
|
||||
|
||||
retry_read:
|
||||
if (__builtin_expect(capture_stop_requested, 0)) {
|
||||
if (__builtin_expect(atomic_load(&capture_stop_requested), 0)) {
|
||||
pthread_mutex_unlock(&capture_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
snd_pcm_t *handle = pcm_capture_handle;
|
||||
if (!handle) {
|
||||
pthread_mutex_unlock(&capture_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pcm_rc = snd_pcm_readi(handle, pcm_buffer, frame_size);
|
||||
|
||||
|
|
@ -601,11 +613,6 @@ retry_read:
|
|||
|
||||
nb_bytes = opus_encode(enc, pcm_buffer, frame_size, out, max_packet_size);
|
||||
|
||||
if (enc != encoder) {
|
||||
pthread_mutex_unlock(&capture_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&capture_mutex);
|
||||
return nb_bytes;
|
||||
}
|
||||
|
|
@ -634,7 +641,7 @@ int jetkvm_audio_playback_init() {
|
|||
|
||||
if (decoder != NULL || pcm_playback_handle != NULL) {
|
||||
playback_initialized = 0;
|
||||
playback_stop_requested = 1;
|
||||
atomic_store(&playback_stop_requested, 1);
|
||||
__sync_synchronize();
|
||||
|
||||
if (pcm_playback_handle) {
|
||||
|
|
@ -662,7 +669,7 @@ int jetkvm_audio_playback_init() {
|
|||
fflush(stderr);
|
||||
err = safe_alsa_open(&pcm_playback_handle, "default", SND_PCM_STREAM_PLAYBACK);
|
||||
if (err < 0) {
|
||||
playback_stop_requested = 0;
|
||||
atomic_store(&playback_stop_requested, 0);
|
||||
playback_initializing = 0;
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -675,7 +682,7 @@ int jetkvm_audio_playback_init() {
|
|||
snd_pcm_t *handle = pcm_playback_handle;
|
||||
pcm_playback_handle = NULL;
|
||||
snd_pcm_close(handle);
|
||||
playback_stop_requested = 0;
|
||||
atomic_store(&playback_stop_requested, 0);
|
||||
playback_initializing = 0;
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -690,13 +697,13 @@ int jetkvm_audio_playback_init() {
|
|||
snd_pcm_t *handle = pcm_playback_handle;
|
||||
pcm_playback_handle = NULL;
|
||||
snd_pcm_close(handle);
|
||||
playback_stop_requested = 0;
|
||||
atomic_store(&playback_stop_requested, 0);
|
||||
playback_initializing = 0;
|
||||
return -2;
|
||||
}
|
||||
|
||||
playback_initialized = 1;
|
||||
playback_stop_requested = 0;
|
||||
atomic_store(&playback_stop_requested, 0);
|
||||
playback_initializing = 0;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -715,7 +722,7 @@ __attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf,
|
|||
uint8_t recovery_attempts = 0;
|
||||
const uint8_t max_recovery_attempts = 3;
|
||||
|
||||
if (__builtin_expect(playback_stop_requested, 0)) {
|
||||
if (__builtin_expect(atomic_load(&playback_stop_requested), 0)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
|
@ -744,24 +751,9 @@ __attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf,
|
|||
// decode_fec=0 means normal decode (FEC data is used automatically when present)
|
||||
pcm_frames = opus_decode(dec, in, opus_size, pcm_buffer, frame_size, 0);
|
||||
|
||||
if (dec != decoder) {
|
||||
pthread_mutex_unlock(&playback_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (__builtin_expect(pcm_frames < 0, 0)) {
|
||||
if (!dec || dec != decoder) {
|
||||
pthread_mutex_unlock(&playback_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pcm_frames = opus_decode(dec, NULL, 0, pcm_buffer, frame_size, 1);
|
||||
|
||||
if (dec != decoder) {
|
||||
pthread_mutex_unlock(&playback_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (pcm_frames < 0) {
|
||||
pthread_mutex_unlock(&playback_mutex);
|
||||
return -1;
|
||||
|
|
@ -769,17 +761,12 @@ __attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf,
|
|||
}
|
||||
|
||||
retry_write:
|
||||
if (__builtin_expect(playback_stop_requested, 0)) {
|
||||
if (__builtin_expect(atomic_load(&playback_stop_requested), 0)) {
|
||||
pthread_mutex_unlock(&playback_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
snd_pcm_t *handle = pcm_playback_handle;
|
||||
if (!handle) {
|
||||
pthread_mutex_unlock(&playback_mutex);
|
||||
return -1;
|
||||
}
|
||||
|
||||
pcm_rc = snd_pcm_writei(handle, pcm_buffer, pcm_frames);
|
||||
|
||||
if (handle != pcm_playback_handle) {
|
||||
|
|
@ -816,19 +803,18 @@ retry_write:
|
|||
*/
|
||||
typedef void (*codec_destroy_fn)(void*);
|
||||
|
||||
static void close_audio_stream(volatile int *stop_requested, volatile int *initializing,
|
||||
static void close_audio_stream(atomic_int *stop_requested, volatile int *initializing,
|
||||
volatile int *initialized, pthread_mutex_t *mutex,
|
||||
snd_pcm_t **pcm_handle, void **codec,
|
||||
codec_destroy_fn destroy_codec) {
|
||||
*stop_requested = 1;
|
||||
__sync_synchronize();
|
||||
atomic_store(stop_requested, 1);
|
||||
|
||||
while (*initializing) {
|
||||
sched_yield();
|
||||
}
|
||||
|
||||
if (__sync_bool_compare_and_swap(initialized, 1, 0) == 0) {
|
||||
*stop_requested = 0;
|
||||
atomic_store(stop_requested, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -837,23 +823,23 @@ static void close_audio_stream(volatile int *stop_requested, volatile int *initi
|
|||
|
||||
pthread_mutex_lock(mutex);
|
||||
|
||||
if (*pcm_handle) {
|
||||
snd_pcm_drop(*pcm_handle);
|
||||
}
|
||||
|
||||
if (*codec) {
|
||||
destroy_codec(*codec);
|
||||
*codec = NULL;
|
||||
}
|
||||
|
||||
if (*pcm_handle) {
|
||||
snd_pcm_close(*pcm_handle);
|
||||
snd_pcm_t *handle_to_close = *pcm_handle;
|
||||
void *codec_to_destroy = *codec;
|
||||
*pcm_handle = NULL;
|
||||
}
|
||||
*codec = NULL;
|
||||
|
||||
pthread_mutex_unlock(mutex);
|
||||
|
||||
*stop_requested = 0;
|
||||
if (handle_to_close) {
|
||||
snd_pcm_drop(handle_to_close);
|
||||
snd_pcm_close(handle_to_close);
|
||||
}
|
||||
|
||||
if (codec_to_destroy) {
|
||||
destroy_codec(codec_to_destroy);
|
||||
}
|
||||
|
||||
atomic_store(stop_requested, 0);
|
||||
}
|
||||
|
||||
void jetkvm_audio_playback_close() {
|
||||
|
|
|
|||
|
|
@ -70,27 +70,49 @@ func (r *OutputRelay) Stop() {
|
|||
func (r *OutputRelay) relayLoop() {
|
||||
defer close(r.stopped)
|
||||
|
||||
const reconnectDelay = 1 * time.Second
|
||||
const initialDelay = 1 * time.Second
|
||||
const maxDelay = 30 * time.Second
|
||||
const maxRetries = 10
|
||||
|
||||
retryDelay := initialDelay
|
||||
consecutiveFailures := 0
|
||||
|
||||
for r.running.Load() {
|
||||
if !(*r.source).IsConnected() {
|
||||
if err := (*r.source).Connect(); err != nil {
|
||||
r.logger.Debug().Err(err).Msg("failed to connect, will retry")
|
||||
time.Sleep(reconnectDelay)
|
||||
consecutiveFailures++
|
||||
if consecutiveFailures >= maxRetries {
|
||||
r.logger.Error().Int("failures", consecutiveFailures).Msg("Max connection retries exceeded, stopping relay")
|
||||
return
|
||||
}
|
||||
r.logger.Debug().Err(err).Int("failures", consecutiveFailures).Dur("retry_delay", retryDelay).Msg("failed to connect, will retry")
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay = min(retryDelay*2, maxDelay)
|
||||
continue
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
retryDelay = initialDelay
|
||||
}
|
||||
|
||||
msgType, payload, err := (*r.source).ReadMessage()
|
||||
if err != nil {
|
||||
if r.running.Load() {
|
||||
r.logger.Warn().Err(err).Msg("read error, reconnecting")
|
||||
consecutiveFailures++
|
||||
if consecutiveFailures >= maxRetries {
|
||||
r.logger.Error().Int("failures", consecutiveFailures).Msg("Max read retries exceeded, stopping relay")
|
||||
return
|
||||
}
|
||||
r.logger.Warn().Err(err).Int("failures", consecutiveFailures).Msg("read error, reconnecting")
|
||||
(*r.source).Disconnect()
|
||||
time.Sleep(reconnectDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay = min(retryDelay*2, maxDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
consecutiveFailures = 0
|
||||
retryDelay = initialDelay
|
||||
|
||||
if msgType == ipcMsgTypeOpus && len(payload) > 0 {
|
||||
r.sample.Data = payload
|
||||
if err := r.audioTrack.WriteSample(r.sample); err != nil {
|
||||
|
|
|
|||
|
|
@ -229,7 +229,10 @@ func rpcSetEDID(edid string) error {
|
|||
}
|
||||
|
||||
config.EdidString = edid
|
||||
_ = SaveConfig()
|
||||
if err := SaveConfig(); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to save config after EDID change")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ export default function AudioPopover() {
|
|||
const [micLoading, setMicLoading] = useState(false);
|
||||
const isHttps = isSecureContext();
|
||||
|
||||
// Helper function to handle RPC errors consistently
|
||||
const handleRpcError = (resp: JsonRpcResponse, errorMsg?: string): boolean => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(errorMsg || String(resp.error.data || m.unknown_error()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
|
|
@ -46,12 +55,13 @@ export default function AudioPopover() {
|
|||
const errorMsg = enabled
|
||||
? m.audio_output_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
|
||||
: m.audio_output_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
|
||||
notifications.error(errorMsg);
|
||||
} else {
|
||||
handleRpcError(resp, errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioOutputEnabled(enabled);
|
||||
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
|
||||
notifications.success(successMsg);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
|
|
@ -63,10 +73,11 @@ export default function AudioPopover() {
|
|||
const errorMsg = enabled
|
||||
? m.audio_input_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
|
||||
: m.audio_input_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
|
||||
notifications.error(errorMsg);
|
||||
} else {
|
||||
setMicrophoneEnabled(enabled);
|
||||
handleRpcError(resp, errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setMicrophoneEnabled(enabled);
|
||||
});
|
||||
}, [send, setMicrophoneEnabled]);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,26 @@ interface AudioConfigResult {
|
|||
packet_loss_perc: number;
|
||||
}
|
||||
|
||||
// Backend default values - single source of truth
|
||||
// UI display defaults - used to mark default options in dropdown menus
|
||||
// Note: These should match backend defaults in config.go, but are fetched dynamically from API
|
||||
const AUDIO_DEFAULTS = {
|
||||
bitrate: 192,
|
||||
complexity: 8,
|
||||
packetLossPerc: 0,
|
||||
packetLossPerc: 20, // Backend default is 20, not 0
|
||||
} as const;
|
||||
|
||||
export default function SettingsAudioRoute() {
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
// Helper function to handle RPC errors consistently
|
||||
const handleRpcError = (resp: JsonRpcResponse, defaultMsg?: string) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(String(resp.error.data || defaultMsg || m.unknown_error()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const {
|
||||
setAudioOutputEnabled,
|
||||
setAudioInputAutoEnable,
|
||||
|
|
@ -92,6 +103,7 @@ export default function SettingsAudioRoute() {
|
|||
notifications.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioOutputEnabled(enabled);
|
||||
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
|
||||
notifications.success(successMsg);
|
||||
|
|
@ -101,9 +113,13 @@ export default function SettingsAudioRoute() {
|
|||
const handleAudioOutputSourceChange = (source: string) => {
|
||||
send("setAudioOutputSource", { source }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(m.audio_settings_output_source_failed({ error: String(resp.error.data || m.unknown_error()) }));
|
||||
const errorMsg = m.audio_settings_output_source_failed({
|
||||
error: String(resp.error.data || m.unknown_error())
|
||||
});
|
||||
notifications.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioOutputSource(source);
|
||||
notifications.success(m.audio_settings_output_source_success());
|
||||
});
|
||||
|
|
@ -111,10 +127,8 @@ export default function SettingsAudioRoute() {
|
|||
|
||||
const handleAudioInputAutoEnableChange = (enabled: boolean) => {
|
||||
send("setAudioInputAutoEnable", { enabled }, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(String(resp.error.data || m.unknown_error()));
|
||||
return;
|
||||
}
|
||||
if (handleRpcError(resp)) return;
|
||||
|
||||
setAudioInputAutoEnable(enabled);
|
||||
const successMsg = enabled
|
||||
? m.audio_input_auto_enable_enabled()
|
||||
|
|
@ -123,39 +137,36 @@ export default function SettingsAudioRoute() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleAudioConfigChange = (
|
||||
bitrate: number,
|
||||
complexity: number,
|
||||
dtxEnabled: boolean,
|
||||
fecEnabled: boolean,
|
||||
bufferPeriods: number,
|
||||
packetLossPerc: number
|
||||
) => {
|
||||
send(
|
||||
"setAudioConfig",
|
||||
{ bitrate, complexity, dtxEnabled, fecEnabled, bufferPeriods, packetLossPerc },
|
||||
(resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(String(resp.error.data || m.unknown_error()));
|
||||
return;
|
||||
}
|
||||
setAudioBitrate(bitrate);
|
||||
setAudioComplexity(complexity);
|
||||
setAudioDTXEnabled(dtxEnabled);
|
||||
setAudioFECEnabled(fecEnabled);
|
||||
setAudioBufferPeriods(bufferPeriods);
|
||||
setAudioPacketLossPerc(packetLossPerc);
|
||||
// Create a configuration object from current state
|
||||
const getCurrentConfig = () => ({
|
||||
bitrate: audioBitrate,
|
||||
complexity: audioComplexity,
|
||||
dtxEnabled: audioDTXEnabled,
|
||||
fecEnabled: audioFECEnabled,
|
||||
bufferPeriods: audioBufferPeriods,
|
||||
packetLossPerc: audioPacketLossPerc,
|
||||
});
|
||||
|
||||
const handleAudioConfigChange = (updates: Partial<typeof getCurrentConfig>) => {
|
||||
const config = { ...getCurrentConfig(), ...updates };
|
||||
|
||||
send("setAudioConfig", config, (resp: JsonRpcResponse) => {
|
||||
if (handleRpcError(resp)) return;
|
||||
|
||||
// Update all state values from the config
|
||||
setAudioBitrate(config.bitrate);
|
||||
setAudioComplexity(config.complexity);
|
||||
setAudioDTXEnabled(config.dtxEnabled);
|
||||
setAudioFECEnabled(config.fecEnabled);
|
||||
setAudioBufferPeriods(config.bufferPeriods);
|
||||
setAudioPacketLossPerc(config.packetLossPerc);
|
||||
notifications.success(m.audio_settings_config_updated());
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestartAudio = () => {
|
||||
send("restartAudioOutput", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(String(resp.error.data || m.unknown_error()));
|
||||
return;
|
||||
}
|
||||
if (handleRpcError(resp)) return;
|
||||
notifications.success(m.audio_settings_applied());
|
||||
});
|
||||
};
|
||||
|
|
@ -217,16 +228,7 @@ export default function SettingsAudioRoute() {
|
|||
{ value: "192", label: `192 kbps${192 === AUDIO_DEFAULTS.bitrate ? m.audio_settings_default_suffix() : ''}` },
|
||||
{ value: "256", label: "256 kbps" },
|
||||
]}
|
||||
onChange={(e) =>
|
||||
handleAudioConfigChange(
|
||||
parseInt(e.target.value),
|
||||
audioComplexity,
|
||||
audioDTXEnabled,
|
||||
audioFECEnabled,
|
||||
audioBufferPeriods,
|
||||
audioPacketLossPerc
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleAudioConfigChange({ bitrate: parseInt(e.target.value) })}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
|
@ -244,16 +246,7 @@ export default function SettingsAudioRoute() {
|
|||
{ value: "8", label: `8${8 === AUDIO_DEFAULTS.complexity ? m.audio_settings_default_suffix() : ''}` },
|
||||
{ value: "10", label: "10 (best quality)" },
|
||||
]}
|
||||
onChange={(e) =>
|
||||
handleAudioConfigChange(
|
||||
audioBitrate,
|
||||
parseInt(e.target.value),
|
||||
audioDTXEnabled,
|
||||
audioFECEnabled,
|
||||
audioBufferPeriods,
|
||||
audioPacketLossPerc
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleAudioConfigChange({ complexity: parseInt(e.target.value) })}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
|
@ -263,16 +256,7 @@ export default function SettingsAudioRoute() {
|
|||
>
|
||||
<Checkbox
|
||||
checked={audioDTXEnabled}
|
||||
onChange={(e) =>
|
||||
handleAudioConfigChange(
|
||||
audioBitrate,
|
||||
audioComplexity,
|
||||
e.target.checked,
|
||||
audioFECEnabled,
|
||||
audioBufferPeriods,
|
||||
audioPacketLossPerc
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleAudioConfigChange({ dtxEnabled: e.target.checked })}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
|
@ -282,16 +266,7 @@ export default function SettingsAudioRoute() {
|
|||
>
|
||||
<Checkbox
|
||||
checked={audioFECEnabled}
|
||||
onChange={(e) =>
|
||||
handleAudioConfigChange(
|
||||
audioBitrate,
|
||||
audioComplexity,
|
||||
audioDTXEnabled,
|
||||
e.target.checked,
|
||||
audioBufferPeriods,
|
||||
audioPacketLossPerc
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleAudioConfigChange({ fecEnabled: e.target.checked })}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
|
@ -309,16 +284,7 @@ export default function SettingsAudioRoute() {
|
|||
{ value: "16", label: "16 (320ms)" },
|
||||
{ value: "24", label: "24 (480ms)" },
|
||||
]}
|
||||
onChange={(e) =>
|
||||
handleAudioConfigChange(
|
||||
audioBitrate,
|
||||
audioComplexity,
|
||||
audioDTXEnabled,
|
||||
audioFECEnabled,
|
||||
parseInt(e.target.value),
|
||||
audioPacketLossPerc
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleAudioConfigChange({ bufferPeriods: parseInt(e.target.value) })}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
|
@ -327,11 +293,15 @@ export default function SettingsAudioRoute() {
|
|||
description={m.audio_settings_sample_rate_description()}
|
||||
>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||
{audioSampleRate === 32000 && "32 kHz"}
|
||||
{audioSampleRate === 44100 && "44.1 kHz"}
|
||||
{audioSampleRate === 48000 && "48 kHz"}
|
||||
{audioSampleRate === 96000 && "96 kHz"}
|
||||
{![32000, 44100, 48000, 96000].includes(audioSampleRate) && `${audioSampleRate} Hz`}
|
||||
{(() => {
|
||||
const rateMap: Record<number, string> = {
|
||||
32000: "32 kHz",
|
||||
44100: "44.1 kHz",
|
||||
48000: "48 kHz",
|
||||
96000: "96 kHz"
|
||||
};
|
||||
return rateMap[audioSampleRate] || `${audioSampleRate} Hz`;
|
||||
})()}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
(auto-detected from source)
|
||||
</span>
|
||||
|
|
@ -346,24 +316,15 @@ export default function SettingsAudioRoute() {
|
|||
size="SM"
|
||||
value={String(audioPacketLossPerc)}
|
||||
options={[
|
||||
{ value: "0", label: `0%${0 === AUDIO_DEFAULTS.packetLossPerc ? m.audio_settings_default_lan_suffix() : m.audio_settings_no_compensation_suffix()}` },
|
||||
{ value: "0", label: `0%${m.audio_settings_no_compensation_suffix()}` },
|
||||
{ value: "5", label: "5%" },
|
||||
{ value: "10", label: "10%" },
|
||||
{ value: "15", label: "15%" },
|
||||
{ value: "20", label: "20%" },
|
||||
{ value: "20", label: `20%${20 === AUDIO_DEFAULTS.packetLossPerc ? m.audio_settings_default_suffix() : ''}` },
|
||||
{ value: "25", label: "25%" },
|
||||
{ value: "30", label: "30%" },
|
||||
]}
|
||||
onChange={(e) =>
|
||||
handleAudioConfigChange(
|
||||
audioBitrate,
|
||||
audioComplexity,
|
||||
audioDTXEnabled,
|
||||
audioFECEnabled,
|
||||
audioBufferPeriods,
|
||||
parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleAudioConfigChange({ packetLossPerc: parseInt(e.target.value) })}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue