Refactor audio subsystem for improved maintainability

Changes:
- Consolidate duplicate stop logic into helper functions
- Fix RPC getAudioConfig to return actual runtime values instead of
  inconsistent defaults (bitrate was returning 128 vs actual 192)
- Improve setAudioTrack mutex handling to eliminate nested locking
- Simplify ALSA error retry logic by reorganizing conditional branches
- Split CGO Connect() into separate input/output methods for clarity
- Use map lookup for sample rate validation instead of long if-chain
- Add inline comments documenting validation steps

All changes preserve existing functionality while reducing code
duplication and improving readability. Tested with both HDMI and
USB audio sources.
This commit is contained in:
Alex P 2025-11-19 13:42:51 +02:00
parent 0dbf2dfda9
commit ee23e3bf22
7 changed files with 185 additions and 156 deletions

138
audio.go
View File

@ -13,7 +13,6 @@ import (
var ( var (
audioMutex sync.Mutex audioMutex sync.Mutex
setAudioTrackMutex sync.Mutex // Prevents concurrent setAudioTrack() calls
inputSourceMutex sync.Mutex // Serializes Connect() and WriteMessage() calls to input source inputSourceMutex sync.Mutex // Serializes Connect() and WriteMessage() calls to input source
outputSource atomic.Pointer[audio.AudioSource] outputSource atomic.Pointer[audio.AudioSource]
inputSource atomic.Pointer[audio.AudioSource] inputSource atomic.Pointer[audio.AudioSource]
@ -31,9 +30,8 @@ var (
func getAlsaDevice(source string) string { func getAlsaDevice(source string) string {
if source == "hdmi" { if source == "hdmi" {
return "hw:0,0" return "hw:0,0"
} else {
return "hw:1,0"
} }
return "hw:1,0"
} }
func initAudio() { func initAudio() {
@ -49,33 +47,47 @@ func initAudio() {
func getAudioConfig() audio.AudioConfig { func getAudioConfig() audio.AudioConfig {
cfg := audio.DefaultAudioConfig() cfg := audio.DefaultAudioConfig()
// Validate and apply bitrate
if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 { if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 {
cfg.Bitrate = uint16(config.AudioBitrate) cfg.Bitrate = uint16(config.AudioBitrate)
} else if config.AudioBitrate != 0 { } else if config.AudioBitrate != 0 {
audioLogger.Warn().Int("bitrate", config.AudioBitrate).Uint16("default", cfg.Bitrate).Msg("Invalid audio bitrate, using default") audioLogger.Warn().Int("bitrate", config.AudioBitrate).Uint16("default", cfg.Bitrate).Msg("Invalid audio bitrate, using default")
} }
// Validate and apply complexity
if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 { if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 {
cfg.Complexity = uint8(config.AudioComplexity) cfg.Complexity = uint8(config.AudioComplexity)
} else { } else {
audioLogger.Warn().Int("complexity", config.AudioComplexity).Uint8("default", cfg.Complexity).Msg("Invalid audio complexity, using default") audioLogger.Warn().Int("complexity", config.AudioComplexity).Uint8("default", cfg.Complexity).Msg("Invalid audio complexity, using default")
} }
// Apply boolean flags directly
cfg.DTXEnabled = config.AudioDTXEnabled cfg.DTXEnabled = config.AudioDTXEnabled
cfg.FECEnabled = config.AudioFECEnabled cfg.FECEnabled = config.AudioFECEnabled
// Validate and apply buffer periods
if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 { if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 {
cfg.BufferPeriods = uint8(config.AudioBufferPeriods) cfg.BufferPeriods = uint8(config.AudioBufferPeriods)
} else if config.AudioBufferPeriods != 0 { } else if config.AudioBufferPeriods != 0 {
audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Uint8("default", cfg.BufferPeriods).Msg("Invalid buffer periods, using default") audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Uint8("default", cfg.BufferPeriods).Msg("Invalid buffer periods, using default")
} }
if config.AudioSampleRate == 32000 || config.AudioSampleRate == 44100 || config.AudioSampleRate == 48000 || config.AudioSampleRate == 96000 {
// Validate and apply sample rate using a map for valid rates
validRates := map[int]bool{32000: true, 44100: true, 48000: true, 96000: true}
if validRates[config.AudioSampleRate] {
cfg.SampleRate = uint32(config.AudioSampleRate) cfg.SampleRate = uint32(config.AudioSampleRate)
} else if config.AudioSampleRate != 0 { } else if config.AudioSampleRate != 0 {
audioLogger.Warn().Int("sample_rate", config.AudioSampleRate).Uint32("default", cfg.SampleRate).Msg("Invalid sample rate, using default") audioLogger.Warn().Int("sample_rate", config.AudioSampleRate).Uint32("default", cfg.SampleRate).Msg("Invalid sample rate, using default")
} }
// Validate and apply packet loss percentage
if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 { if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 {
cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc) cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc)
} else { } else {
audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Uint8("default", cfg.PacketLossPerc).Msg("Invalid packet loss percentage, using default") audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Uint8("default", cfg.PacketLossPerc).Msg("Invalid packet loss percentage, using default")
} }
return cfg return cfg
} }
@ -144,32 +156,42 @@ func startInputAudioUnderMutex(alsaPlaybackDevice string) {
} }
} }
func stopOutputAudio() { // stopAudioComponents safely stops and cleans up audio components
func stopAudioComponents(relay *atomic.Pointer[audio.OutputRelay], source *atomic.Pointer[audio.AudioSource]) {
audioMutex.Lock() audioMutex.Lock()
outRelay := outputRelay.Swap(nil) oldRelay := relay.Swap(nil)
outSource := outputSource.Swap(nil) oldSource := source.Swap(nil)
audioMutex.Unlock() audioMutex.Unlock()
if outRelay != nil { if oldRelay != nil {
outRelay.Stop() oldRelay.Stop()
} }
if outSource != nil { if oldSource != nil {
(*outSource).Disconnect() (*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()
}
}
func stopOutputAudio() {
stopAudioComponents(&outputRelay, &outputSource)
}
func stopInputAudio() { func stopInputAudio() {
audioMutex.Lock() stopAudioComponentsInput(&inputRelay, &inputSource)
inRelay := inputRelay.Swap(nil)
inSource := inputSource.Swap(nil)
audioMutex.Unlock()
if inRelay != nil {
inRelay.Stop()
}
if inSource != nil {
(*inSource).Disconnect()
}
} }
func stopAudio() { func stopAudio() {
@ -195,15 +217,24 @@ func onWebRTCDisconnect() {
} }
func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) { func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
setAudioTrackMutex.Lock() audioMutex.Lock()
defer setAudioTrackMutex.Unlock() defer audioMutex.Unlock()
stopOutputAudio() // Stop output without mutex (already holding audioMutex)
outRelay := outputRelay.Swap(nil)
outSource := outputSource.Swap(nil)
if outRelay != nil {
outRelay.Stop()
}
if outSource != nil {
(*outSource).Disconnect()
}
currentAudioTrack = audioTrack currentAudioTrack = audioTrack
if err := startAudio(); err != nil { // Start audio without taking mutex again (already holding audioMutex)
audioLogger.Error().Err(err).Msg("Failed to start with new audio track") if audioInitialized && activeConnections.Load() > 0 && audioOutputEnabled.Load() && currentAudioTrack != nil {
startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource))
} }
} }
@ -218,14 +249,10 @@ func SetAudioOutputEnabled(enabled bool) error {
return nil return nil
} }
if enabled { if enabled && activeConnections.Load() > 0 {
if activeConnections.Load() > 0 {
return startAudio() return startAudio()
} }
} else {
stopOutputAudio() stopOutputAudio()
}
return nil return nil
} }
@ -234,14 +261,10 @@ func SetAudioInputEnabled(enabled bool) error {
return nil return nil
} }
if enabled { if enabled && activeConnections.Load() > 0 {
if activeConnections.Load() > 0 {
return startAudio() return startAudio()
} }
} else {
stopInputAudio() stopInputAudio()
}
return nil return nil
} }
@ -290,6 +313,7 @@ func handleInputTrackForSession(track *webrtc.TrackRemote) {
trackLogger.Debug().Msg("starting input track handler") trackLogger.Debug().Msg("starting input track handler")
for { for {
// Check if we've been superseded by another track
currentTrackID := currentInputTrack.Load() currentTrackID := currentInputTrack.Load()
if currentTrackID != nil && *currentTrackID != myTrackID { if currentTrackID != nil && *currentTrackID != myTrackID {
trackLogger.Debug(). trackLogger.Debug().
@ -298,6 +322,7 @@ func handleInputTrackForSession(track *webrtc.TrackRemote) {
return return
} }
// Read RTP packet
rtpPacket, _, err := track.ReadRTP() rtpPacket, _, err := track.ReadRTP()
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
@ -308,42 +333,51 @@ func handleInputTrackForSession(track *webrtc.TrackRemote) {
continue continue
} }
opusData := rtpPacket.Payload // Skip empty payloads
if len(opusData) == 0 { if len(rtpPacket.Payload) == 0 {
continue continue
} }
// Skip if input is disabled
if !audioInputEnabled.Load() { if !audioInputEnabled.Load() {
continue continue
} }
// Early check to avoid mutex acquisition if source is nil (optimization) // Process the audio packet
if err := processInputPacket(rtpPacket.Payload); err != nil {
trackLogger.Warn().Err(err).Msg("failed to process audio packet")
}
}
}
// processInputPacket handles writing audio data to the input source
func processInputPacket(opusData []byte) error {
// Early check to avoid mutex acquisition if source is nil
if inputSource.Load() == nil { if inputSource.Load() == nil {
continue return nil
} }
inputSourceMutex.Lock() inputSourceMutex.Lock()
defer inputSourceMutex.Unlock()
// Reload source inside mutex to ensure we have the currently active source // Reload source inside mutex to ensure we have the currently active source
// This prevents races with startInputAudioUnderMutex swapping the source
source := inputSource.Load() source := inputSource.Load()
if source == nil { if source == nil {
inputSourceMutex.Unlock() return nil
continue
} }
// Ensure source is connected
if !(*source).IsConnected() { if !(*source).IsConnected() {
if err := (*source).Connect(); err != nil { if err := (*source).Connect(); err != nil {
inputSourceMutex.Unlock() return err
continue
} }
} }
err = (*source).WriteMessage(0, opusData) // Write the message
inputSourceMutex.Unlock() if err := (*source).WriteMessage(0, opusData); err != nil {
if err != nil {
audioLogger.Warn().Err(err).Msg("failed to write audio message")
(*source).Disconnect() (*source).Disconnect()
return err
} }
}
return nil
} }

View File

@ -209,19 +209,17 @@ static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream
attempt++; attempt++;
if (err == -EBUSY || err == -EAGAIN) { // Apply different sleep strategies based on error type
precise_sleep_us(backoff_us); if (err == -EPERM || err == -EACCES) {
backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000; precise_sleep_us(backoff_us >> 1); // Shorter wait for permission errors
} else if (err == -ENODEV || err == -ENOENT) {
precise_sleep_us(backoff_us);
backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000;
} else if (err == -EPERM || err == -EACCES) {
precise_sleep_us(backoff_us >> 1);
} else { } else {
precise_sleep_us(backoff_us); precise_sleep_us(backoff_us);
// Exponential backoff for all retry-worthy errors
if (err == -EBUSY || err == -EAGAIN || err == -ENODEV || err == -ENOENT) {
backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000; backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000;
} }
} }
}
return err; return err;
} }

View File

@ -75,16 +75,13 @@ func (c *CgoSource) Connect() error {
} }
if c.outputDevice { if c.outputDevice {
os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice) return c.connectOutput()
}
return c.connectInput()
}
dtx := C.uchar(0) func (c *CgoSource) connectOutput() error {
if c.config.DTXEnabled { os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice)
dtx = C.uchar(1)
}
fec := C.uchar(0)
if c.config.FECEnabled {
fec = C.uchar(1)
}
c.logger.Debug(). c.logger.Debug().
Uint16("bitrate_kbps", c.config.Bitrate). Uint16("bitrate_kbps", c.config.Bitrate).
@ -100,14 +97,14 @@ func (c *CgoSource) Connect() error {
C.uint(uint32(c.config.Bitrate)*1000), C.uint(uint32(c.config.Bitrate)*1000),
C.uchar(c.config.Complexity), C.uchar(c.config.Complexity),
C.uint(c.config.SampleRate), C.uint(c.config.SampleRate),
C.uchar(2), C.uchar(2), // capture_channels
C.ushort(960), C.ushort(960), // frame_size
C.ushort(1500), C.ushort(1500), // max_packet_size
C.uint(1000), C.uint(1000), // sleep_us
C.uchar(5), C.uchar(5), // max_attempts
C.uint(500000), C.uint(500000), // max_backoff
dtx, boolToUchar(c.config.DTXEnabled),
fec, boolToUchar(c.config.FECEnabled),
C.uchar(c.config.BufferPeriods), C.uchar(c.config.BufferPeriods),
C.uchar(c.config.PacketLossPerc), C.uchar(c.config.PacketLossPerc),
) )
@ -117,17 +114,22 @@ func (c *CgoSource) Connect() error {
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 {
c.connected = true
return nil
}
func (c *CgoSource) connectInput() error {
os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice) os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice)
C.update_audio_decoder_constants( C.update_audio_decoder_constants(
C.uint(c.config.SampleRate), C.uint(c.config.SampleRate),
C.uchar(1), C.uchar(1), // playback_channels
C.ushort(960), C.ushort(960), // frame_size
C.ushort(1500), C.ushort(1500), // max_packet_size
C.uint(1000), C.uint(1000), // sleep_us
C.uchar(5), C.uchar(5), // max_attempts
C.uint(500000), C.uint(500000), // max_backoff
C.uchar(c.config.BufferPeriods), C.uchar(c.config.BufferPeriods),
) )
@ -136,12 +138,18 @@ func (c *CgoSource) Connect() error {
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")
return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc) return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc)
} }
}
c.connected = true c.connected = true
return nil return nil
} }
func boolToUchar(b bool) C.uchar {
if b {
return C.uchar(1)
}
return C.uchar(0)
}
func (c *CgoSource) Disconnect() { func (c *CgoSource) Disconnect() {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()

View File

@ -1,6 +1,8 @@
// Code generated by "go run gen.go". DO NOT EDIT. // Code generated by "go run gen.go". DO NOT EDIT.
//
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go //go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
package tzdata package tzdata
var TimeZones = []string{ var TimeZones = []string{
"Africa/Abidjan", "Africa/Abidjan",
"Africa/Accra", "Africa/Accra",

View File

@ -1030,30 +1030,15 @@ type AudioConfigResponse struct {
func rpcGetAudioConfig() (AudioConfigResponse, error) { func rpcGetAudioConfig() (AudioConfigResponse, error) {
ensureConfigLoaded() ensureConfigLoaded()
bitrate := config.AudioBitrate cfg := getAudioConfig()
if bitrate < 64 || bitrate > 256 {
bitrate = 128
}
bufferPeriods := config.AudioBufferPeriods
if bufferPeriods < 2 || bufferPeriods > 24 {
bufferPeriods = 12
}
sampleRate := config.AudioSampleRate
if sampleRate != 32000 && sampleRate != 44100 && sampleRate != 48000 && sampleRate != 96000 {
sampleRate = 48000
}
packetLossPerc := config.AudioPacketLossPerc
if packetLossPerc < 0 || packetLossPerc > 100 {
packetLossPerc = 20
}
return AudioConfigResponse{ return AudioConfigResponse{
Bitrate: bitrate, Bitrate: int(cfg.Bitrate),
Complexity: config.AudioComplexity, Complexity: int(cfg.Complexity),
DTXEnabled: config.AudioDTXEnabled, DTXEnabled: cfg.DTXEnabled,
FECEnabled: config.AudioFECEnabled, FECEnabled: cfg.FECEnabled,
BufferPeriods: bufferPeriods, BufferPeriods: int(cfg.BufferPeriods),
SampleRate: sampleRate, SampleRate: int(cfg.SampleRate),
PacketLossPerc: packetLossPerc, PacketLossPerc: int(cfg.PacketLossPerc),
}, nil }, nil
} }

View File

@ -53,6 +53,7 @@ export default function SettingsAudioRoute() {
} = useSettingsStore(); } = useSettingsStore();
useEffect(() => { useEffect(() => {
// Load boolean settings
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
setAudioOutputEnabled(resp.result as boolean); setAudioOutputEnabled(resp.result as boolean);
@ -68,6 +69,7 @@ export default function SettingsAudioRoute() {
setAudioOutputSource(resp.result as string); setAudioOutputSource(resp.result as string);
}); });
// Load complex audio configuration
send("getAudioConfig", {}, (resp: JsonRpcResponse) => { send("getAudioConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
const config = resp.result as AudioConfigResult; const config = resp.result as AudioConfigResult;

View File

@ -47,7 +47,7 @@ export default function SettingsVideoRoute() {
const [edid, setEdid] = useState<string | null>(null); const [edid, setEdid] = useState<string | null>(null);
const [edidLoading, setEdidLoading] = useState(true); const [edidLoading, setEdidLoading] = useState(true);
const [defaultEdid, setDefaultEdid] = useState<string>(""); const [defaultEdid, setDefaultEdid] = useState<string>("");
const [edids, setEdids] = useState<Array<{value: string, label: string}>>([]); const [edids, setEdids] = useState<{value: string, label: string}[]>([]);
const { debugMode } = useSettingsStore(); const { debugMode } = useSettingsStore();
// Video enhancement settings from store // Video enhancement settings from store
const { const {