Add runtime configurable audio parameters with UI controls

- Add config fields for bitrate, complexity, DTX, FEC, buffer periods
- Add RPC methods for get/set audio config and restart
- Add UI settings page with controls for all audio parameters
- Add Apply Settings button to restart audio with new config
- Add config migration for backwards compatibility
- Add translations for all 9 languages
- Clean up redundant comments and optimize log levels
This commit is contained in:
Alex P 2025-11-17 21:51:08 +02:00
parent e79c6f730e
commit 922a7158e7
19 changed files with 616 additions and 166 deletions

106
audio.go
View File

@ -39,7 +39,23 @@ func initAudio() {
audioInitialized = true audioInitialized = true
} }
// startAudio starts audio sources and relays (skips already running ones) func getAudioConfig() audio.AudioConfig {
ensureConfigLoaded()
cfg := audio.DefaultAudioConfig()
if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 {
cfg.Bitrate = uint16(config.AudioBitrate)
}
if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 {
cfg.Complexity = uint8(config.AudioComplexity)
}
cfg.DTXEnabled = config.AudioDTXEnabled
cfg.FECEnabled = config.AudioFECEnabled
if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 {
cfg.BufferPeriods = uint8(config.AudioBufferPeriods)
}
return cfg
}
func startAudio() error { func startAudio() error {
audioMutex.Lock() audioMutex.Lock()
defer audioMutex.Unlock() defer audioMutex.Unlock()
@ -49,30 +65,32 @@ func startAudio() error {
return nil return nil
} }
// Start output audio if not running, enabled, and we have a track
if outputSource == nil && audioOutputEnabled.Load() && currentAudioTrack != nil { if outputSource == nil && audioOutputEnabled.Load() && currentAudioTrack != nil {
ensureConfigLoaded() ensureConfigLoaded()
alsaDevice := "hw:1,0" // USB audio (default) alsaDevice := "hw:1,0"
if config.AudioOutputSource == "hdmi" { if config.AudioOutputSource == "hdmi" {
alsaDevice = "hw:0,0" // HDMI audio alsaDevice = "hw:0,0"
} }
outputSource = audio.NewCgoOutputSource(alsaDevice) source := audio.NewCgoOutputSource(alsaDevice)
source.SetConfig(getAudioConfig())
outputSource = source
outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack) outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack)
if err := outputRelay.Start(); err != nil { if err := outputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start audio output relay") audioLogger.Error().Err(err).Msg("Failed to start audio output relay")
} }
} }
// Start input audio if not running, USB audio enabled, and input enabled
ensureConfigLoaded() ensureConfigLoaded()
if inputSource.Load() == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio { if inputSource.Load() == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
alsaPlaybackDevice := "hw:1,0" // USB speakers alsaPlaybackDevice := "hw:1,0"
var source audio.AudioSource = audio.NewCgoInputSource(alsaPlaybackDevice) source := audio.NewCgoInputSource(alsaPlaybackDevice)
inputSource.Store(&source) source.SetConfig(getAudioConfig())
var audioSource audio.AudioSource = source
inputSource.Store(&audioSource)
inputRelay = audio.NewInputRelay(source) inputRelay = audio.NewInputRelay(audioSource)
if err := inputRelay.Start(); err != nil { if err := inputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start input relay") audioLogger.Error().Err(err).Msg("Failed to start input relay")
} }
@ -158,11 +176,12 @@ func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
audioMutex.Lock() audioMutex.Lock()
if currentAudioTrack != nil && audioOutputEnabled.Load() { if currentAudioTrack != nil && audioOutputEnabled.Load() {
ensureConfigLoaded() ensureConfigLoaded()
alsaDevice := "hw:1,0" // USB audio (default) alsaDevice := "hw:1,0"
if config.AudioOutputSource == "hdmi" { if config.AudioOutputSource == "hdmi" {
alsaDevice = "hw:0,0" // HDMI audio alsaDevice = "hw:0,0"
} }
newSource := audio.NewCgoOutputSource(alsaDevice) newSource := audio.NewCgoOutputSource(alsaDevice)
newSource.SetConfig(getAudioConfig())
newRelay := audio.NewOutputRelay(newSource, currentAudioTrack) newRelay := audio.NewOutputRelay(newSource, currentAudioTrack)
outputSource = newSource outputSource = newSource
outputRelay = newRelay outputRelay = newRelay
@ -187,10 +206,9 @@ func setPendingInputTrack(track *webrtc.TrackRemote) {
go handleInputTrackForSession(track) go handleInputTrackForSession(track)
} }
// SetAudioOutputEnabled enables or disables audio output
func SetAudioOutputEnabled(enabled bool) error { func SetAudioOutputEnabled(enabled bool) error {
if audioOutputEnabled.Swap(enabled) == enabled { if audioOutputEnabled.Swap(enabled) == enabled {
return nil // Already in desired state return nil
} }
if enabled { if enabled {
@ -204,10 +222,9 @@ func SetAudioOutputEnabled(enabled bool) error {
return nil return nil
} }
// SetAudioInputEnabled enables or disables audio input
func SetAudioInputEnabled(enabled bool) error { func SetAudioInputEnabled(enabled bool) error {
if audioInputEnabled.Swap(enabled) == enabled { if audioInputEnabled.Swap(enabled) == enabled {
return nil // Already in desired state return nil
} }
if enabled { if enabled {
@ -221,7 +238,6 @@ func SetAudioInputEnabled(enabled bool) error {
return nil return nil
} }
// SetAudioOutputSource switches between HDMI and USB audio sources
func SetAudioOutputSource(source string) error { func SetAudioOutputSource(source string) error {
if source != "hdmi" && source != "usb" { if source != "hdmi" && source != "usb" {
return nil return nil
@ -237,12 +253,13 @@ func SetAudioOutputSource(source string) error {
stopOutputAudio() stopOutputAudio()
if audioOutputEnabled.Load() && activeConnections.Load() > 0 && currentAudioTrack != nil { if audioOutputEnabled.Load() && activeConnections.Load() > 0 && currentAudioTrack != nil {
alsaDevice := "hw:1,0" // USB alsaDevice := "hw:1,0"
if source == "hdmi" { if source == "hdmi" {
alsaDevice = "hw:0,0" // HDMI alsaDevice = "hw:0,0"
} }
newSource := audio.NewCgoOutputSource(alsaDevice) newSource := audio.NewCgoOutputSource(alsaDevice)
newSource.SetConfig(getAudioConfig())
newRelay := audio.NewOutputRelay(newSource, currentAudioTrack) newRelay := audio.NewOutputRelay(newSource, currentAudioTrack)
audioMutex.Lock() audioMutex.Lock()
@ -258,54 +275,79 @@ func SetAudioOutputSource(source string) error {
return SaveConfig() return SaveConfig()
} }
// handleInputTrackForSession runs for the entire WebRTC session lifetime func RestartAudioOutput() {
// It continuously reads from the track and sends to whatever relay is currently active audioMutex.Lock()
hasActiveOutput := outputSource != nil && currentAudioTrack != nil && audioOutputEnabled.Load()
audioMutex.Unlock()
if !hasActiveOutput {
return
}
audioLogger.Info().Msg("Restarting audio output")
stopOutputAudio()
ensureConfigLoaded()
alsaDevice := "hw:1,0"
if config.AudioOutputSource == "hdmi" {
alsaDevice = "hw:0,0"
}
newSource := audio.NewCgoOutputSource(alsaDevice)
newSource.SetConfig(getAudioConfig())
newRelay := audio.NewOutputRelay(newSource, currentAudioTrack)
audioMutex.Lock()
outputSource = newSource
outputRelay = newRelay
audioMutex.Unlock()
if err := newRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to restart audio output")
}
}
func handleInputTrackForSession(track *webrtc.TrackRemote) { func handleInputTrackForSession(track *webrtc.TrackRemote) {
myTrackID := track.ID() myTrackID := track.ID()
audioLogger.Debug(). audioLogger.Debug().
Str("codec", track.Codec().MimeType). Str("codec", track.Codec().MimeType).
Str("track_id", myTrackID). Str("track_id", myTrackID).
Msg("starting session-lifetime track handler") Msg("starting input track handler")
for { for {
// Check if we've been superseded by a new track
currentTrackID := currentInputTrack.Load() currentTrackID := currentInputTrack.Load()
if currentTrackID != nil && *currentTrackID != myTrackID { if currentTrackID != nil && *currentTrackID != myTrackID {
audioLogger.Debug(). audioLogger.Debug().
Str("my_track_id", myTrackID). Str("my_track_id", myTrackID).
Str("current_track_id", *currentTrackID). Str("current_track_id", *currentTrackID).
Msg("audio track handler exiting - superseded by new track") Msg("input track handler exiting - superseded")
return return
} }
// Read RTP packet (must always read to keep track alive)
rtpPacket, _, err := track.ReadRTP() rtpPacket, _, err := track.ReadRTP()
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
audioLogger.Debug().Str("track_id", myTrackID).Msg("audio track ended") audioLogger.Debug().Str("track_id", myTrackID).Msg("input track ended")
return return
} }
audioLogger.Warn().Err(err).Str("track_id", myTrackID).Msg("failed to read RTP packet") audioLogger.Warn().Err(err).Str("track_id", myTrackID).Msg("failed to read RTP packet")
continue continue
} }
// Extract Opus payload
opusData := rtpPacket.Payload opusData := rtpPacket.Payload
if len(opusData) == 0 { if len(opusData) == 0 {
continue continue
} }
// Only send if input is enabled
if !audioInputEnabled.Load() { if !audioInputEnabled.Load() {
continue // Drop frame but keep reading continue
} }
// Lock-free source access (hot path optimization)
source := inputSource.Load() source := inputSource.Load()
if source == nil { if source == nil {
continue // No relay, drop frame but keep reading continue
} }
inputSourceMutex.Lock() inputSourceMutex.Lock()

View File

@ -110,6 +110,11 @@ type Config struct {
AudioInputAutoEnable bool `json:"audio_input_auto_enable"` AudioInputAutoEnable bool `json:"audio_input_auto_enable"`
AudioOutputEnabled bool `json:"audio_output_enabled"` AudioOutputEnabled bool `json:"audio_output_enabled"`
AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb" AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb"
AudioBitrate int `json:"audio_bitrate"` // kbps (64-256)
AudioComplexity int `json:"audio_complexity"` // 0-10
AudioDTXEnabled bool `json:"audio_dtx_enabled"`
AudioFECEnabled bool `json:"audio_fec_enabled"`
AudioBufferPeriods int `json:"audio_buffer_periods"` // 2-24
} }
func (c *Config) GetDisplayRotation() uint16 { func (c *Config) GetDisplayRotation() uint16 {
@ -186,6 +191,11 @@ func getDefaultConfig() Config {
AudioInputAutoEnable: false, AudioInputAutoEnable: false,
AudioOutputEnabled: true, AudioOutputEnabled: true,
AudioOutputSource: "usb", AudioOutputSource: "usb",
AudioBitrate: 128,
AudioComplexity: 5,
AudioDTXEnabled: true,
AudioFECEnabled: true,
AudioBufferPeriods: 12,
} }
} }
@ -256,6 +266,15 @@ func LoadConfig() {
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
} }
if loadedConfig.AudioBitrate == 0 {
defaults := getDefaultConfig()
loadedConfig.AudioBitrate = defaults.AudioBitrate
loadedConfig.AudioComplexity = defaults.AudioComplexity
loadedConfig.AudioDTXEnabled = defaults.AudioDTXEnabled
loadedConfig.AudioFECEnabled = defaults.AudioFECEnabled
loadedConfig.AudioBufferPeriods = defaults.AudioBufferPeriods
}
// fixup old keyboard layout value // fixup old keyboard layout value
if loadedConfig.KeyboardLayout == "en_US" { if loadedConfig.KeyboardLayout == "en_US" {
loadedConfig.KeyboardLayout = "en-US" loadedConfig.KeyboardLayout = "en-US"

View File

@ -2,7 +2,6 @@
* JetKVM Audio Processing Module * JetKVM Audio Processing Module
* *
* Bidirectional audio processing optimized for ARM NEON SIMD: * Bidirectional audio processing optimized for ARM NEON SIMD:
* TODO: Remove USB Gadget audio once new system image release is made available
* - OUTPUT PATH: TC358743 HDMI or USB Gadget audio Client speakers * - OUTPUT PATH: TC358743 HDMI or USB Gadget audio Client speakers
* Pipeline: ALSA hw:0,0 or hw:1,0 capture Opus encode (128kbps, FEC enabled) * Pipeline: ALSA hw:0,0 or hw:1,0 capture Opus encode (128kbps, FEC enabled)
* *
@ -56,18 +55,20 @@ static uint8_t channels = 2;
static uint16_t frame_size = 960; // 20ms frames at 48kHz static uint16_t frame_size = 960; // 20ms frames at 48kHz
static uint32_t opus_bitrate = 128000; static uint32_t opus_bitrate = 128000;
static uint8_t opus_complexity = 5; // Higher complexity for better quality static uint8_t opus_complexity = 5;
static uint16_t max_packet_size = 1500; static uint16_t max_packet_size = 1500;
// Opus encoder constants (hardcoded for production) #define OPUS_VBR 1
#define OPUS_VBR 1 // VBR enabled #define OPUS_VBR_CONSTRAINT 1
#define OPUS_VBR_CONSTRAINT 1 // Constrained VBR (prevents bitrate starvation at low volumes) #define OPUS_SIGNAL_TYPE 3002
#define OPUS_SIGNAL_TYPE 3002 // OPUS_SIGNAL_MUSIC (better transient handling) #define OPUS_BANDWIDTH 1104
#define OPUS_BANDWIDTH 1104 // OPUS_BANDWIDTH_SUPERWIDEBAND (16kHz) #define OPUS_LSB_DEPTH 16
#define OPUS_DTX 1 // DTX enabled (bandwidth optimization)
#define OPUS_LSB_DEPTH 16 // 16-bit depth static uint8_t opus_dtx_enabled = 1;
static uint8_t opus_fec_enabled = 1;
static uint8_t opus_packet_loss_perc = 20;
static uint8_t buffer_period_count = 12;
// ALSA retry configuration
static uint32_t sleep_microseconds = 1000; static uint32_t sleep_microseconds = 1000;
static uint32_t sleep_milliseconds = 1; static uint32_t sleep_milliseconds = 1;
static uint8_t max_attempts_global = 5; static uint8_t max_attempts_global = 5;
@ -86,43 +87,45 @@ int jetkvm_audio_decode_write(void *opus_buf, int opus_size);
void update_audio_constants(uint32_t bitrate, uint8_t complexity, void update_audio_constants(uint32_t bitrate, uint8_t complexity,
uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt, uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff); uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff,
uint8_t dtx_enabled, uint8_t fec_enabled, uint8_t buf_periods);
void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt, void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff); uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff,
uint8_t buf_periods);
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity); int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity);
/**
* Sync encoder configuration from Go to C
*/
void update_audio_constants(uint32_t bitrate, uint8_t complexity, void update_audio_constants(uint32_t bitrate, uint8_t complexity,
uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt, uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff) { uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff,
opus_bitrate = bitrate; uint8_t dtx_enabled, uint8_t fec_enabled, uint8_t buf_periods) {
opus_complexity = complexity; opus_bitrate = (bitrate >= 64000 && bitrate <= 256000) ? bitrate : 128000;
sample_rate = sr; opus_complexity = (complexity <= 10) ? complexity : 5;
channels = ch; sample_rate = sr > 0 ? sr : 48000;
frame_size = fs; channels = (ch == 1 || ch == 2) ? ch : 2;
max_packet_size = max_pkt; frame_size = fs > 0 ? fs : 960;
sleep_microseconds = sleep_us; max_packet_size = max_pkt > 0 ? max_pkt : 1500;
sleep_milliseconds = sleep_us / 1000; // Precompute for snd_pcm_wait sleep_microseconds = sleep_us > 0 ? sleep_us : 1000;
max_attempts_global = max_attempts; sleep_milliseconds = sleep_microseconds / 1000;
max_backoff_us_global = max_backoff; max_attempts_global = max_attempts > 0 ? max_attempts : 5;
max_backoff_us_global = max_backoff > 0 ? max_backoff : 500000;
opus_dtx_enabled = dtx_enabled ? 1 : 0;
opus_fec_enabled = fec_enabled ? 1 : 0;
buffer_period_count = (buf_periods >= 2 && buf_periods <= 24) ? buf_periods : 12;
} }
/**
* Sync decoder configuration from Go to C (no encoder-only params)
*/
void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt, void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff) { uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff,
sample_rate = sr; uint8_t buf_periods) {
channels = ch; sample_rate = sr > 0 ? sr : 48000;
frame_size = fs; channels = (ch == 1 || ch == 2) ? ch : 2;
max_packet_size = max_pkt; frame_size = fs > 0 ? fs : 960;
sleep_microseconds = sleep_us; max_packet_size = max_pkt > 0 ? max_pkt : 1500;
sleep_milliseconds = sleep_us / 1000; // Precompute for snd_pcm_wait sleep_microseconds = sleep_us > 0 ? sleep_us : 1000;
max_attempts_global = max_attempts; sleep_milliseconds = sleep_microseconds / 1000;
max_backoff_us_global = max_backoff; max_attempts_global = max_attempts > 0 ? max_attempts : 5;
max_backoff_us_global = max_backoff > 0 ? max_backoff : 500000;
buffer_period_count = (buf_periods >= 2 && buf_periods <= 24) ? buf_periods : 12;
} }
/** /**
@ -233,18 +236,17 @@ static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream
attempt++; attempt++;
// Exponential backoff with bit shift (faster than multiplication)
if (err == -EBUSY || err == -EAGAIN) { if (err == -EBUSY || err == -EAGAIN) {
precise_sleep_us(backoff_us); precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global; backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000;
} else if (err == -ENODEV || err == -ENOENT) { } else if (err == -ENODEV || err == -ENOENT) {
precise_sleep_us(backoff_us << 1); precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global; backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000;
} else if (err == -EPERM || err == -EACCES) { } else if (err == -EPERM || err == -EACCES) {
precise_sleep_us(backoff_us >> 1); precise_sleep_us(backoff_us >> 1);
} else { } else {
precise_sleep_us(backoff_us); precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global; backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000;
} }
} }
return err; return err;
@ -285,13 +287,13 @@ static int configure_alsa_device(snd_pcm_t *handle, const char *device_name) {
if (err < 0) return err; if (err < 0) return err;
} }
snd_pcm_uframes_t period_size = frame_size; // Optimized: use full frame as period snd_pcm_uframes_t period_size = frame_size;
if (period_size < 64) period_size = 64; if (period_size < 64) period_size = 64;
err = snd_pcm_hw_params_set_period_size_near(handle, params, &period_size, 0); err = snd_pcm_hw_params_set_period_size_near(handle, params, &period_size, 0);
if (err < 0) return err; if (err < 0) return err;
snd_pcm_uframes_t buffer_size = period_size * 12; // 12 periods = 240ms buffer for better jitter tolerance snd_pcm_uframes_t buffer_size = period_size * buffer_period_count;
err = snd_pcm_hw_params_set_buffer_size_near(handle, params, &buffer_size); err = snd_pcm_hw_params_set_buffer_size_near(handle, params, &buffer_size);
if (err < 0) return err; if (err < 0) return err;
@ -378,11 +380,11 @@ int jetkvm_audio_capture_init() {
opus_encoder_ctl(encoder, OPUS_SET_VBR_CONSTRAINT(OPUS_VBR_CONSTRAINT)); opus_encoder_ctl(encoder, OPUS_SET_VBR_CONSTRAINT(OPUS_VBR_CONSTRAINT));
opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_TYPE)); opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_TYPE));
opus_encoder_ctl(encoder, OPUS_SET_BANDWIDTH(OPUS_BANDWIDTH)); opus_encoder_ctl(encoder, OPUS_SET_BANDWIDTH(OPUS_BANDWIDTH));
opus_encoder_ctl(encoder, OPUS_SET_DTX(OPUS_DTX)); opus_encoder_ctl(encoder, OPUS_SET_DTX(opus_dtx_enabled));
opus_encoder_ctl(encoder, OPUS_SET_LSB_DEPTH(OPUS_LSB_DEPTH)); opus_encoder_ctl(encoder, OPUS_SET_LSB_DEPTH(OPUS_LSB_DEPTH));
opus_encoder_ctl(encoder, OPUS_SET_INBAND_FEC(1)); opus_encoder_ctl(encoder, OPUS_SET_INBAND_FEC(opus_fec_enabled));
opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(20)); opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(opus_packet_loss_perc));
capture_initialized = 1; capture_initialized = 1;
capture_initializing = 0; capture_initializing = 0;

View File

@ -21,21 +21,20 @@ import (
) )
const ( const (
ipcMaxFrameSize = 1024 // Max Opus frame size: 128kbps @ 20ms = ~600 bytes ipcMaxFrameSize = 1024
) )
// CgoSource implements AudioSource via direct CGO calls to C audio functions (in-process)
type CgoSource struct { type CgoSource struct {
direction string // "output" or "input" direction string
alsaDevice string alsaDevice string
initialized bool initialized bool
connected bool connected bool
mu sync.Mutex mu sync.Mutex
logger zerolog.Logger logger zerolog.Logger
opusBuf []byte // Reusable buffer for Opus packets opusBuf []byte
config AudioConfig
} }
// NewCgoOutputSource creates a new CGO audio source for output (HDMI/USB → browser)
func NewCgoOutputSource(alsaDevice string) *CgoSource { func NewCgoOutputSource(alsaDevice string) *CgoSource {
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger() logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger()
@ -44,10 +43,10 @@ func NewCgoOutputSource(alsaDevice string) *CgoSource {
alsaDevice: alsaDevice, alsaDevice: alsaDevice,
logger: logger, logger: logger,
opusBuf: make([]byte, ipcMaxFrameSize), opusBuf: make([]byte, ipcMaxFrameSize),
config: DefaultAudioConfig(),
} }
} }
// NewCgoInputSource creates a new CGO audio source for input (browser → USB speakers)
func NewCgoInputSource(alsaDevice string) *CgoSource { func NewCgoInputSource(alsaDevice string) *CgoSource {
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger() logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger()
@ -56,10 +55,16 @@ func NewCgoInputSource(alsaDevice string) *CgoSource {
alsaDevice: alsaDevice, alsaDevice: alsaDevice,
logger: logger, logger: logger,
opusBuf: make([]byte, ipcMaxFrameSize), opusBuf: make([]byte, ipcMaxFrameSize),
config: DefaultAudioConfig(),
} }
} }
// Connect initializes the C audio subsystem func (c *CgoSource) SetConfig(cfg AudioConfig) {
c.mu.Lock()
defer c.mu.Unlock()
c.config = cfg
}
func (c *CgoSource) Connect() error { func (c *CgoSource) Connect() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -68,46 +73,61 @@ func (c *CgoSource) Connect() error {
return nil return nil
} }
// Set ALSA device via environment for C code to read via init_alsa_devices_from_env()
if c.direction == "output" { if c.direction == "output" {
// Set capture device for output path via environment variable
os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice) os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice)
// Initialize constants dtx := C.uchar(0)
if c.config.DTXEnabled {
dtx = C.uchar(1)
}
fec := C.uchar(0)
if c.config.FECEnabled {
fec = C.uchar(1)
}
c.logger.Debug().
Uint16("bitrate_kbps", c.config.Bitrate).
Uint8("complexity", c.config.Complexity).
Bool("dtx", c.config.DTXEnabled).
Bool("fec", c.config.FECEnabled).
Uint8("buffer_periods", c.config.BufferPeriods).
Str("alsa_device", c.alsaDevice).
Msg("Initializing audio capture")
C.update_audio_constants( C.update_audio_constants(
C.uint(128000), // bitrate C.uint(uint32(c.config.Bitrate)*1000),
C.uchar(5), // complexity C.uchar(c.config.Complexity),
C.uint(48000), // sample_rate C.uint(48000),
C.uchar(2), // channels C.uchar(2),
C.ushort(960), // frame_size C.ushort(960),
C.ushort(1500), // max_packet_size C.ushort(1500),
C.uint(1000), // sleep_us C.uint(1000),
C.uchar(5), // max_attempts C.uchar(5),
C.uint(500000), // max_backoff_us C.uint(500000),
dtx,
fec,
C.uchar(c.config.BufferPeriods),
) )
// Initialize capture (HDMI/USB → browser)
rc := C.jetkvm_audio_capture_init() rc := C.jetkvm_audio_capture_init()
if rc != 0 { if rc != 0 {
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture") c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture")
return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc) return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc)
} }
} else { } else {
// Set playback device for input path via environment variable
os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice) os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice)
// Initialize decoder constants
C.update_audio_decoder_constants( C.update_audio_decoder_constants(
C.uint(48000), // sample_rate C.uint(48000),
C.uchar(2), // channels C.uchar(2),
C.ushort(960), // frame_size C.ushort(960),
C.ushort(1500), // max_packet_size C.ushort(1500),
C.uint(1000), // sleep_us C.uint(1000),
C.uchar(5), // max_attempts C.uchar(5),
C.uint(500000), // max_backoff_us C.uint(500000),
C.uchar(c.config.BufferPeriods),
) )
// Initialize playback (browser → USB speakers)
rc := C.jetkvm_audio_playback_init() rc := C.jetkvm_audio_playback_init()
if rc != 0 { if rc != 0 {
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback") c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback")
@ -120,7 +140,6 @@ func (c *CgoSource) Connect() error {
return nil return nil
} }
// Disconnect closes the C audio subsystem
func (c *CgoSource) Disconnect() { func (c *CgoSource) Disconnect() {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -138,17 +157,12 @@ func (c *CgoSource) Disconnect() {
c.connected = false c.connected = false
} }
// IsConnected returns true if currently connected
func (c *CgoSource) IsConnected() bool { func (c *CgoSource) IsConnected() bool {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.connected return c.connected
} }
// ReadMessage reads the next audio frame from C audio subsystem
// For output path: reads HDMI/USB audio and encodes to Opus
// For input path: not used (input uses WriteMessage instead)
// Returns message type (0 = Opus), payload data, and error
func (c *CgoSource) ReadMessage() (uint8, []byte, error) { func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -161,25 +175,18 @@ func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
return 0, nil, fmt.Errorf("ReadMessage only supported for output direction") return 0, nil, fmt.Errorf("ReadMessage only supported for output direction")
} }
// Call C function to read HDMI/USB audio and encode to Opus
// Returns Opus packet size (>0) or error (<0)
opusSize := C.jetkvm_audio_read_encode(unsafe.Pointer(&c.opusBuf[0])) opusSize := C.jetkvm_audio_read_encode(unsafe.Pointer(&c.opusBuf[0]))
if opusSize < 0 { if opusSize < 0 {
return 0, nil, fmt.Errorf("jetkvm_audio_read_encode failed: %d", opusSize) return 0, nil, fmt.Errorf("jetkvm_audio_read_encode failed: %d", opusSize)
} }
if opusSize == 0 { if opusSize == 0 {
// No data available (silence/DTX)
return 0, nil, nil return 0, nil, nil
} }
// Return slice of opusBuf - caller must use immediately
return ipcMsgTypeOpus, c.opusBuf[:opusSize], nil return ipcMsgTypeOpus, c.opusBuf[:opusSize], nil
} }
// WriteMessage writes an Opus packet to the C audio subsystem for playback
// Only used for input path (browser → USB speakers)
func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error { func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -193,7 +200,6 @@ func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
} }
if msgType != ipcMsgTypeOpus { if msgType != ipcMsgTypeOpus {
// Ignore non-Opus messages
return nil return nil
} }
@ -201,9 +207,7 @@ func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
return nil return nil
} }
// Call C function to decode Opus and write to USB speakers
rc := C.jetkvm_audio_decode_write(unsafe.Pointer(&payload[0]), C.int(len(payload))) rc := C.jetkvm_audio_decode_write(unsafe.Pointer(&payload[0]), C.int(len(payload)))
if rc < 0 { if rc < 0 {
return fmt.Errorf("jetkvm_audio_decode_write failed: %d", rc) return fmt.Errorf("jetkvm_audio_decode_write failed: %d", rc)
} }

View File

@ -33,3 +33,7 @@ func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error { func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
panic("audio CGO source not supported on this platform") panic("audio CGO source not supported on this platform")
} }
func (c *CgoSource) SetConfig(cfg AudioConfig) {
panic("audio CGO source not supported on this platform")
}

View File

@ -12,7 +12,6 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
// OutputRelay forwards audio from AudioSource (CGO) to WebRTC (browser)
type OutputRelay struct { type OutputRelay struct {
source AudioSource source AudioSource
audioTrack *webrtc.TrackLocalStaticSample audioTrack *webrtc.TrackLocalStaticSample
@ -23,12 +22,10 @@ type OutputRelay struct {
sample media.Sample sample media.Sample
stopped chan struct{} stopped chan struct{}
// Stats (Uint32: overflows after 2.7 years @ 50fps, faster atomics on 32-bit ARM)
framesRelayed atomic.Uint32 framesRelayed atomic.Uint32
framesDropped atomic.Uint32 framesDropped atomic.Uint32
} }
// NewOutputRelay creates a relay for output audio (device → browser)
func NewOutputRelay(source AudioSource, audioTrack *webrtc.TrackLocalStaticSample) *OutputRelay { func NewOutputRelay(source AudioSource, audioTrack *webrtc.TrackLocalStaticSample) *OutputRelay {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-relay").Logger() logger := logging.GetDefaultLogger().With().Str("component", "audio-output-relay").Logger()
@ -46,7 +43,6 @@ func NewOutputRelay(source AudioSource, audioTrack *webrtc.TrackLocalStaticSampl
} }
} }
// Start begins relaying audio frames
func (r *OutputRelay) Start() error { func (r *OutputRelay) Start() error {
if r.running.Swap(true) { if r.running.Swap(true) {
return fmt.Errorf("output relay already running") return fmt.Errorf("output relay already running")
@ -57,7 +53,6 @@ func (r *OutputRelay) Start() error {
return nil return nil
} }
// Stop stops the relay and waits for goroutine to exit
func (r *OutputRelay) Stop() { func (r *OutputRelay) Stop() {
if !r.running.Swap(false) { if !r.running.Swap(false) {
return return
@ -72,14 +67,12 @@ func (r *OutputRelay) Stop() {
Msg("output relay stopped") Msg("output relay stopped")
} }
// relayLoop continuously reads from audio source and writes to WebRTC
func (r *OutputRelay) relayLoop() { func (r *OutputRelay) relayLoop() {
defer close(r.stopped) defer close(r.stopped)
const reconnectDelay = 1 * time.Second const reconnectDelay = 1 * time.Second
for r.running.Load() { for r.running.Load() {
// Ensure connected
if !r.source.IsConnected() { if !r.source.IsConnected() {
if err := r.source.Connect(); err != nil { if err := r.source.Connect(); err != nil {
r.logger.Debug().Err(err).Msg("failed to connect, will retry") r.logger.Debug().Err(err).Msg("failed to connect, will retry")
@ -88,10 +81,8 @@ func (r *OutputRelay) relayLoop() {
} }
} }
// Read message from audio source
msgType, payload, err := r.source.ReadMessage() msgType, payload, err := r.source.ReadMessage()
if err != nil { if err != nil {
// Connection error - reconnect
if r.running.Load() { if r.running.Load() {
r.logger.Warn().Err(err).Msg("read error, reconnecting") r.logger.Warn().Err(err).Msg("read error, reconnecting")
r.source.Disconnect() r.source.Disconnect()
@ -100,11 +91,8 @@ func (r *OutputRelay) relayLoop() {
continue continue
} }
// Handle message
if msgType == ipcMsgTypeOpus && len(payload) > 0 { if msgType == ipcMsgTypeOpus && len(payload) > 0 {
// Reuse sample struct (zero-allocation hot path)
r.sample.Data = payload r.sample.Data = payload
if err := r.audioTrack.WriteSample(r.sample); err != nil { if err := r.audioTrack.WriteSample(r.sample); err != nil {
r.framesDropped.Add(1) r.framesDropped.Add(1)
r.logger.Warn().Err(err).Msg("failed to write sample to WebRTC") r.logger.Warn().Err(err).Msg("failed to write sample to WebRTC")
@ -115,7 +103,6 @@ func (r *OutputRelay) relayLoop() {
} }
} }
// InputRelay forwards audio from WebRTC (browser microphone) to AudioSource (USB audio)
type InputRelay struct { type InputRelay struct {
source AudioSource source AudioSource
ctx context.Context ctx context.Context
@ -124,7 +111,6 @@ type InputRelay struct {
running atomic.Bool running atomic.Bool
} }
// NewInputRelay creates a relay for input audio (browser → device)
func NewInputRelay(source AudioSource) *InputRelay { func NewInputRelay(source AudioSource) *InputRelay {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-relay").Logger() logger := logging.GetDefaultLogger().With().Str("component", "audio-input-relay").Logger()
@ -137,7 +123,6 @@ func NewInputRelay(source AudioSource) *InputRelay {
} }
} }
// Start begins relaying audio frames
func (r *InputRelay) Start() error { func (r *InputRelay) Start() error {
if r.running.Swap(true) { if r.running.Swap(true) {
return fmt.Errorf("input relay already running") return fmt.Errorf("input relay already running")
@ -147,7 +132,6 @@ func (r *InputRelay) Start() error {
return nil return nil
} }
// Stop stops the relay
func (r *InputRelay) Stop() { func (r *InputRelay) Stop() {
if !r.running.Swap(false) { if !r.running.Swap(false) {
return return

View File

@ -1,28 +1,32 @@
package audio package audio
// IPC message types
const ( const (
ipcMsgTypeOpus = 0 // Message type for Opus audio data ipcMsgTypeOpus = 0
) )
// AudioSource provides audio frames via CGO (in-process) C audio functions type AudioConfig struct {
type AudioSource interface { Bitrate uint16
// ReadMessage reads the next audio message Complexity uint8
// Returns message type, payload data, and error BufferPeriods uint8
// Blocks until data is available or error occurs DTXEnabled bool
// Used for output path (device → browser) FECEnabled bool
ReadMessage() (msgType uint8, payload []byte, err error) }
// WriteMessage writes an audio message func DefaultAudioConfig() AudioConfig {
// Used for input path (browser → device) return AudioConfig{
WriteMessage(msgType uint8, payload []byte) error Bitrate: 128,
Complexity: 5,
// IsConnected returns true if the source is connected and ready BufferPeriods: 12,
IsConnected() bool DTXEnabled: true,
FECEnabled: true,
// Connect initializes the C audio subsystem }
Connect() error }
// Disconnect closes the connection and releases resources type AudioSource interface {
Disconnect() ReadMessage() (msgType uint8, payload []byte, err error)
WriteMessage(msgType uint8, payload []byte) error
IsConnected() bool
Connect() error
Disconnect()
SetConfig(cfg AudioConfig)
} }

View File

@ -1000,6 +1000,60 @@ func rpcSetAudioOutputSource(source string) error {
return SetAudioOutputSource(source) return SetAudioOutputSource(source)
} }
type AudioConfigResponse struct {
Bitrate int `json:"bitrate"`
Complexity int `json:"complexity"`
DTXEnabled bool `json:"dtx_enabled"`
FECEnabled bool `json:"fec_enabled"`
BufferPeriods int `json:"buffer_periods"`
}
func rpcGetAudioConfig() (AudioConfigResponse, error) {
ensureConfigLoaded()
bitrate := config.AudioBitrate
if bitrate < 64 || bitrate > 256 {
bitrate = 128
}
bufferPeriods := config.AudioBufferPeriods
if bufferPeriods < 2 || bufferPeriods > 24 {
bufferPeriods = 12
}
return AudioConfigResponse{
Bitrate: bitrate,
Complexity: config.AudioComplexity,
DTXEnabled: config.AudioDTXEnabled,
FECEnabled: config.AudioFECEnabled,
BufferPeriods: bufferPeriods,
}, nil
}
func rpcSetAudioConfig(bitrate int, complexity int, dtxEnabled bool, fecEnabled bool, bufferPeriods int) error {
ensureConfigLoaded()
if bitrate < 64 || bitrate > 256 {
return fmt.Errorf("bitrate must be between 64 and 256 kbps")
}
if complexity < 0 || complexity > 10 {
return fmt.Errorf("complexity must be between 0 and 10")
}
if bufferPeriods < 2 || bufferPeriods > 24 {
return fmt.Errorf("buffer periods must be between 2 and 24")
}
config.AudioBitrate = bitrate
config.AudioComplexity = complexity
config.AudioDTXEnabled = dtxEnabled
config.AudioFECEnabled = fecEnabled
config.AudioBufferPeriods = bufferPeriods
return SaveConfig()
}
func rpcRestartAudioOutput() error {
RestartAudioOutput()
return nil
}
func rpcGetAudioInputAutoEnable() (bool, error) { func rpcGetAudioInputAutoEnable() (bool, error) {
ensureConfigLoaded() ensureConfigLoaded()
return config.AudioInputAutoEnable, nil return config.AudioInputAutoEnable, nil
@ -1340,6 +1394,9 @@ var rpcHandlers = map[string]RPCHandler{
"getAudioOutputSource": {Func: rpcGetAudioOutputSource}, "getAudioOutputSource": {Func: rpcGetAudioOutputSource},
"setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}}, "setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}},
"refreshHdmiConnection": {Func: rpcRefreshHdmiConnection}, "refreshHdmiConnection": {Func: rpcRefreshHdmiConnection},
"getAudioConfig": {Func: rpcGetAudioConfig},
"setAudioConfig": {Func: rpcSetAudioConfig, Params: []string{"bitrate", "complexity", "dtxEnabled", "fecEnabled", "bufferPeriods"}},
"restartAudioOutput": {Func: rpcRestartAudioOutput},
"getAudioInputAutoEnable": {Func: rpcGetAudioInputAutoEnable}, "getAudioInputAutoEnable": {Func: rpcGetAudioInputAutoEnable},
"setAudioInputAutoEnable": {Func: rpcSetAudioInputAutoEnable, Params: []string{"enabled"}}, "setAudioInputAutoEnable": {Func: rpcSetAudioInputAutoEnable, Params: []string{"enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk", "audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)", "audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Lydkodningsbitrate (højere = bedre kvalitet, mere båndbredde)",
"audio_settings_complexity_title": "Opus Kompleksitet",
"audio_settings_complexity_description": "Encoder-kompleksitet (0-10, højere = bedre kvalitet, mere CPU)",
"audio_settings_dtx_title": "DTX (Diskontinuerlig Transmission)",
"audio_settings_dtx_description": "Spar båndbredde under stilhed",
"audio_settings_fec_title": "FEC (Fremadrettet Fejlkorrektion)",
"audio_settings_fec_description": "Forbedre lydkvaliteten på tabende forbindelser",
"audio_settings_buffer_title": "Bufferperioder",
"audio_settings_buffer_description": "ALSA bufferstørrelse (højere = mere stabil, mere latens)",
"audio_settings_config_updated": "Lydkonfiguration opdateret",
"audio_settings_apply_button": "Anvend indstillinger",
"audio_settings_applied": "Lydindstillinger anvendt",
"action_bar_extension": "Udvidelse", "action_bar_extension": "Udvidelse",
"action_bar_fullscreen": "Fuldskærm", "action_bar_fullscreen": "Fuldskærm",
"action_bar_settings": "Indstillinger", "action_bar_settings": "Indstillinger",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren", "audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren",
"audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)", "audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Audio-Codierungsbitrate (höher = bessere Qualität, mehr Bandbreite)",
"audio_settings_complexity_title": "Opus Komplexität",
"audio_settings_complexity_description": "Encoder-Komplexität (0-10, höher = bessere Qualität, mehr CPU)",
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
"audio_settings_dtx_description": "Bandbreite während Stille sparen",
"audio_settings_fec_title": "FEC (Forward Error Correction)",
"audio_settings_fec_description": "Audioqualität bei verlustbehafteten Verbindungen verbessern",
"audio_settings_buffer_title": "Pufferperioden",
"audio_settings_buffer_description": "ALSA-Puffergröße (höher = stabiler, mehr Latenz)",
"audio_settings_config_updated": "Audiokonfiguration aktualisiert",
"audio_settings_apply_button": "Einstellungen anwenden",
"audio_settings_applied": "Audioeinstellungen angewendet",
"action_bar_extension": "Erweiterung", "action_bar_extension": "Erweiterung",
"action_bar_fullscreen": "Vollbild", "action_bar_fullscreen": "Vollbild",
"action_bar_settings": "Einstellungen", "action_bar_settings": "Einstellungen",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Auto-enable Microphone", "audio_settings_auto_enable_microphone_title": "Auto-enable Microphone",
"audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)", "audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Audio encoding bitrate (higher = better quality, more bandwidth)",
"audio_settings_complexity_title": "Opus Complexity",
"audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)",
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
"audio_settings_dtx_description": "Save bandwidth during silence",
"audio_settings_fec_title": "FEC (Forward Error Correction)",
"audio_settings_fec_description": "Improve audio quality on lossy connections",
"audio_settings_buffer_title": "Buffer Periods",
"audio_settings_buffer_description": "ALSA buffer size (higher = more stable, more latency)",
"audio_settings_config_updated": "Audio configuration updated",
"audio_settings_apply_button": "Apply Settings",
"audio_settings_applied": "Audio settings applied",
"action_bar_extension": "Extension", "action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen", "action_bar_fullscreen": "Fullscreen",
"action_bar_settings": "Settings", "action_bar_settings": "Settings",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente", "audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente",
"audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)", "audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)",
"audio_settings_bitrate_title": "Bitrate Opus",
"audio_settings_bitrate_description": "Tasa de bits de codificación de audio (mayor = mejor calidad, más ancho de banda)",
"audio_settings_complexity_title": "Complejidad Opus",
"audio_settings_complexity_description": "Complejidad del codificador (0-10, mayor = mejor calidad, más CPU)",
"audio_settings_dtx_title": "DTX (Transmisión Discontinua)",
"audio_settings_dtx_description": "Ahorrar ancho de banda durante el silencio",
"audio_settings_fec_title": "FEC (Corrección de Errores)",
"audio_settings_fec_description": "Mejorar la calidad de audio en conexiones con pérdida",
"audio_settings_buffer_title": "Períodos de Buffer",
"audio_settings_buffer_description": "Tamaño del buffer ALSA (mayor = más estable, más latencia)",
"audio_settings_config_updated": "Configuración de audio actualizada",
"audio_settings_apply_button": "Aplicar configuración",
"audio_settings_applied": "Configuración de audio aplicada",
"action_bar_extension": "Extensión", "action_bar_extension": "Extensión",
"action_bar_fullscreen": "Pantalla completa", "action_bar_fullscreen": "Pantalla completa",
"action_bar_settings": "Ajustes", "action_bar_settings": "Ajustes",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone", "audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone",
"audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)", "audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)",
"audio_settings_bitrate_title": "Débit Opus",
"audio_settings_bitrate_description": "Débit d'encodage audio (plus élevé = meilleure qualité, plus de bande passante)",
"audio_settings_complexity_title": "Complexité Opus",
"audio_settings_complexity_description": "Complexité de l'encodeur (0-10, plus élevé = meilleure qualité, plus de CPU)",
"audio_settings_dtx_title": "DTX (Transmission Discontinue)",
"audio_settings_dtx_description": "Économiser la bande passante pendant le silence",
"audio_settings_fec_title": "FEC (Correction d'Erreur)",
"audio_settings_fec_description": "Améliorer la qualité audio sur les connexions avec perte",
"audio_settings_buffer_title": "Périodes de Tampon",
"audio_settings_buffer_description": "Taille du tampon ALSA (plus élevé = plus stable, plus de latence)",
"audio_settings_config_updated": "Configuration audio mise à jour",
"audio_settings_apply_button": "Appliquer les paramètres",
"audio_settings_applied": "Paramètres audio appliqués",
"action_bar_extension": "Extension", "action_bar_extension": "Extension",
"action_bar_fullscreen": "Plein écran", "action_bar_fullscreen": "Plein écran",
"action_bar_settings": "Paramètres", "action_bar_settings": "Paramètres",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono", "audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono",
"audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)", "audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)",
"audio_settings_bitrate_title": "Bitrate Opus",
"audio_settings_bitrate_description": "Bitrate di codifica audio (più alto = migliore qualità, più banda)",
"audio_settings_complexity_title": "Complessità Opus",
"audio_settings_complexity_description": "Complessità dell'encoder (0-10, più alto = migliore qualità, più CPU)",
"audio_settings_dtx_title": "DTX (Trasmissione Discontinua)",
"audio_settings_dtx_description": "Risparmia banda durante il silenzio",
"audio_settings_fec_title": "FEC (Correzione Errori)",
"audio_settings_fec_description": "Migliora la qualità audio su connessioni con perdita",
"audio_settings_buffer_title": "Periodi Buffer",
"audio_settings_buffer_description": "Dimensione buffer ALSA (più alto = più stabile, più latenza)",
"audio_settings_config_updated": "Configurazione audio aggiornata",
"audio_settings_apply_button": "Applica impostazioni",
"audio_settings_applied": "Impostazioni audio applicate",
"action_bar_extension": "Estensione", "action_bar_extension": "Estensione",
"action_bar_fullscreen": "A schermo intero", "action_bar_fullscreen": "A schermo intero",
"action_bar_settings": "Impostazioni", "action_bar_settings": "Impostazioni",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk", "audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)", "audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Lydkodingsbitrate (høyere = bedre kvalitet, mer båndbredde)",
"audio_settings_complexity_title": "Opus Kompleksitet",
"audio_settings_complexity_description": "Encoder-kompleksitet (0-10, høyere = bedre kvalitet, mer CPU)",
"audio_settings_dtx_title": "DTX (Diskontinuerlig Overføring)",
"audio_settings_dtx_description": "Spar båndbredde under stillhet",
"audio_settings_fec_title": "FEC (Fremadrettet Feilkorreksjon)",
"audio_settings_fec_description": "Forbedre lydkvaliteten på tapende tilkoblinger",
"audio_settings_buffer_title": "Bufferperioder",
"audio_settings_buffer_description": "ALSA bufferstørrelse (høyere = mer stabil, mer latens)",
"audio_settings_config_updated": "Lydkonfigurasjon oppdatert",
"audio_settings_apply_button": "Bruk innstillinger",
"audio_settings_applied": "Lydinnstillinger brukt",
"action_bar_extension": "Forlengelse", "action_bar_extension": "Forlengelse",
"action_bar_fullscreen": "Fullskjerm", "action_bar_fullscreen": "Fullskjerm",
"action_bar_settings": "Innstillinger", "action_bar_settings": "Innstillinger",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt", "audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt",
"audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)", "audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Ljudkodningsbitrate (högre = bättre kvalitet, mer bandbredd)",
"audio_settings_complexity_title": "Opus Komplexitet",
"audio_settings_complexity_description": "Encoder-komplexitet (0-10, högre = bättre kvalitet, mer CPU)",
"audio_settings_dtx_title": "DTX (Diskontinuerlig Överföring)",
"audio_settings_dtx_description": "Spara bandbredd under tystnad",
"audio_settings_fec_title": "FEC (Framåtriktad Felkorrigering)",
"audio_settings_fec_description": "Förbättra ljudkvaliteten på förlustdrabbade anslutningar",
"audio_settings_buffer_title": "Bufferperioder",
"audio_settings_buffer_description": "ALSA bufferstorlek (högre = mer stabil, mer latens)",
"audio_settings_config_updated": "Ljudkonfiguration uppdaterad",
"audio_settings_apply_button": "Tillämpa inställningar",
"audio_settings_applied": "Ljudinställningar tillämpade",
"action_bar_extension": "Förlängning", "action_bar_extension": "Förlängning",
"action_bar_fullscreen": "Helskärm", "action_bar_fullscreen": "Helskärm",
"action_bar_settings": "Inställningar", "action_bar_settings": "Inställningar",

View File

@ -86,6 +86,19 @@
"audio_settings_usb_label": "USB", "audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "自动启用麦克风", "audio_settings_auto_enable_microphone_title": "自动启用麦克风",
"audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)", "audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)",
"audio_settings_bitrate_title": "Opus 比特率",
"audio_settings_bitrate_description": "音频编码比特率(越高 = 质量越好,带宽越大)",
"audio_settings_complexity_title": "Opus 复杂度",
"audio_settings_complexity_description": "编码器复杂度0-10越高 = 质量越好CPU 使用越多)",
"audio_settings_dtx_title": "DTX不连续传输",
"audio_settings_dtx_description": "在静音时节省带宽",
"audio_settings_fec_title": "FEC前向纠错",
"audio_settings_fec_description": "改善有损连接上的音频质量",
"audio_settings_buffer_title": "缓冲周期",
"audio_settings_buffer_description": "ALSA 缓冲大小(越高 = 越稳定,延迟越高)",
"audio_settings_config_updated": "音频配置已更新",
"audio_settings_apply_button": "应用设置",
"audio_settings_applied": "音频设置已应用",
"action_bar_extension": "扩展", "action_bar_extension": "扩展",
"action_bar_fullscreen": "全屏", "action_bar_fullscreen": "全屏",
"action_bar_settings": "设置", "action_bar_settings": "设置",

View File

@ -392,6 +392,18 @@ export interface SettingsState {
audioInputAutoEnable: boolean; audioInputAutoEnable: boolean;
setAudioInputAutoEnable: (enabled: boolean) => void; setAudioInputAutoEnable: (enabled: boolean) => void;
// Audio codec settings
audioBitrate: number;
setAudioBitrate: (value: number) => void;
audioComplexity: number;
setAudioComplexity: (value: number) => void;
audioDTXEnabled: boolean;
setAudioDTXEnabled: (enabled: boolean) => void;
audioFECEnabled: boolean;
setAudioFECEnabled: (enabled: boolean) => void;
audioBufferPeriods: number;
setAudioBufferPeriods: (value: number) => void;
resetMicrophoneState: () => void; resetMicrophoneState: () => void;
} }
@ -450,6 +462,17 @@ export const useSettingsStore = create(
audioInputAutoEnable: false, audioInputAutoEnable: false,
setAudioInputAutoEnable: (enabled: boolean) => set({ audioInputAutoEnable: enabled }), setAudioInputAutoEnable: (enabled: boolean) => set({ audioInputAutoEnable: enabled }),
audioBitrate: 128,
setAudioBitrate: (value: number) => set({ audioBitrate: value }),
audioComplexity: 5,
setAudioComplexity: (value: number) => set({ audioComplexity: value }),
audioDTXEnabled: true,
setAudioDTXEnabled: (enabled: boolean) => set({ audioDTXEnabled: enabled }),
audioFECEnabled: true,
setAudioFECEnabled: (enabled: boolean) => set({ audioFECEnabled: enabled }),
audioBufferPeriods: 12,
setAudioBufferPeriods: (value: number) => set({ audioBufferPeriods: value }),
resetMicrophoneState: () => set({ microphoneEnabled: false }), resetMicrophoneState: () => set({ microphoneEnabled: false }),
}), }),
{ {

View File

@ -10,9 +10,34 @@ import { m } from "@localizations/messages.js";
import notifications from "../notifications"; import notifications from "../notifications";
interface AudioConfigResult {
bitrate: number;
complexity: number;
dtx_enabled: boolean;
fec_enabled: boolean;
buffer_periods: number;
}
export default function SettingsAudioRoute() { export default function SettingsAudioRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource, audioOutputEnabled, audioInputAutoEnable, audioOutputSource } = useSettingsStore(); const {
setAudioOutputEnabled,
setAudioInputAutoEnable,
setAudioOutputSource,
audioOutputEnabled,
audioInputAutoEnable,
audioOutputSource,
audioBitrate,
setAudioBitrate,
audioComplexity,
setAudioComplexity,
audioDTXEnabled,
setAudioDTXEnabled,
audioFECEnabled,
setAudioFECEnabled,
audioBufferPeriods,
setAudioBufferPeriods,
} = useSettingsStore();
useEffect(() => { useEffect(() => {
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
@ -29,7 +54,17 @@ export default function SettingsAudioRoute() {
if ("error" in resp) return; if ("error" in resp) return;
setAudioOutputSource(resp.result as string); setAudioOutputSource(resp.result as string);
}); });
}, [send, setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource]);
send("getAudioConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const config = resp.result as AudioConfigResult;
setAudioBitrate(config.bitrate);
setAudioComplexity(config.complexity);
setAudioDTXEnabled(config.dtx_enabled);
setAudioFECEnabled(config.fec_enabled);
setAudioBufferPeriods(config.buffer_periods);
});
}, [send, setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource, setAudioBitrate, setAudioComplexity, setAudioDTXEnabled, setAudioFECEnabled, setAudioBufferPeriods]);
const handleAudioOutputEnabledChange = (enabled: boolean) => { const handleAudioOutputEnabledChange = (enabled: boolean) => {
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
@ -71,6 +106,41 @@ export default function SettingsAudioRoute() {
}); });
}; };
const handleAudioConfigChange = (
bitrate: number,
complexity: number,
dtxEnabled: boolean,
fecEnabled: boolean,
bufferPeriods: number
) => {
send(
"setAudioConfig",
{ bitrate, complexity, dtxEnabled, fecEnabled, bufferPeriods },
(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);
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;
}
notifications.success(m.audio_settings_applied());
});
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -112,6 +182,130 @@ export default function SettingsAudioRoute() {
onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)} onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>
<SettingsItem
title={m.audio_settings_bitrate_title()}
description={m.audio_settings_bitrate_description()}
>
<SelectMenuBasic
size="SM"
value={String(audioBitrate)}
options={[
{ value: "64", label: "64 kbps" },
{ value: "96", label: "96 kbps" },
{ value: "128", label: "128 kbps" },
{ value: "160", label: "160 kbps" },
{ value: "192", label: "192 kbps" },
{ value: "256", label: "256 kbps" },
]}
onChange={(e) =>
handleAudioConfigChange(
parseInt(e.target.value),
audioComplexity,
audioDTXEnabled,
audioFECEnabled,
audioBufferPeriods
)
}
/>
</SettingsItem>
<SettingsItem
title={m.audio_settings_complexity_title()}
description={m.audio_settings_complexity_description()}
>
<SelectMenuBasic
size="SM"
value={String(audioComplexity)}
options={[
{ value: "0", label: "0 (fastest)" },
{ value: "2", label: "2" },
{ value: "5", label: "5 (balanced)" },
{ value: "8", label: "8" },
{ value: "10", label: "10 (best)" },
]}
onChange={(e) =>
handleAudioConfigChange(
audioBitrate,
parseInt(e.target.value),
audioDTXEnabled,
audioFECEnabled,
audioBufferPeriods
)
}
/>
</SettingsItem>
<SettingsItem
title={m.audio_settings_dtx_title()}
description={m.audio_settings_dtx_description()}
>
<Checkbox
checked={audioDTXEnabled}
onChange={(e) =>
handleAudioConfigChange(
audioBitrate,
audioComplexity,
e.target.checked,
audioFECEnabled,
audioBufferPeriods
)
}
/>
</SettingsItem>
<SettingsItem
title={m.audio_settings_fec_title()}
description={m.audio_settings_fec_description()}
>
<Checkbox
checked={audioFECEnabled}
onChange={(e) =>
handleAudioConfigChange(
audioBitrate,
audioComplexity,
audioDTXEnabled,
e.target.checked,
audioBufferPeriods
)
}
/>
</SettingsItem>
<SettingsItem
title={m.audio_settings_buffer_title()}
description={m.audio_settings_buffer_description()}
>
<SelectMenuBasic
size="SM"
value={String(audioBufferPeriods)}
options={[
{ value: "4", label: "4 (80ms)" },
{ value: "8", label: "8 (160ms)" },
{ value: "12", label: "12 (240ms)" },
{ value: "16", label: "16 (320ms)" },
{ value: "24", label: "24 (480ms)" },
]}
onChange={(e) =>
handleAudioConfigChange(
audioBitrate,
audioComplexity,
audioDTXEnabled,
audioFECEnabled,
parseInt(e.target.value)
)
}
/>
</SettingsItem>
<div className="pt-4">
<button
onClick={handleRestartAudio}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{m.audio_settings_apply_button()}
</button>
</div>
</div> </div>
</div> </div>
); );