mirror of https://github.com/jetkvm/kvm.git
Simplify audio configuration and error handling
- Replace helper function in getAudioConfig with explicit validation - Consolidate audio default application in LoadConfig - Streamline relay retry logic with inline conditions - Extract closeFile and openHidFile helpers in USB gadget - Simplify setPendingInputTrack pointer handling - Improve error handling clarity in startAudio and updateUsbRelatedConfig - Clean up processInputPacket mutex usage
This commit is contained in:
parent
3ed663b4d1
commit
5f7c90649a
|
|
@ -4,14 +4,11 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Sudo wrapper function
|
# Sudo wrapper function
|
||||||
SUDO_PATH=$(which sudo 2>/dev/null || echo "")
|
|
||||||
function use_sudo() {
|
function use_sudo() {
|
||||||
if [ "$UID" -eq 0 ]; then
|
if [ "$UID" -eq 0 ] || [ -z "$(which sudo 2>/dev/null)" ]; then
|
||||||
"$@"
|
"$@"
|
||||||
elif [ -n "$SUDO_PATH" ]; then
|
|
||||||
${SUDO_PATH} -E "$@"
|
|
||||||
else
|
else
|
||||||
"$@"
|
sudo -E "$@"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
100
audio.go
100
audio.go
|
|
@ -49,49 +49,47 @@ func initAudio() {
|
||||||
func getAudioConfig() audio.AudioConfig {
|
func getAudioConfig() audio.AudioConfig {
|
||||||
cfg := audio.DefaultAudioConfig()
|
cfg := audio.DefaultAudioConfig()
|
||||||
|
|
||||||
// Helper to validate numeric ranges and return sanitized values
|
// Apply bitrate (64-256 kbps)
|
||||||
// Returns (value, true) if valid, (0, false) if invalid
|
if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 {
|
||||||
validateAndApply := func(value int, min int, max int, paramName string) (int, bool) {
|
cfg.Bitrate = uint16(config.AudioBitrate)
|
||||||
if value >= min && value <= max {
|
} else if config.AudioBitrate != 0 {
|
||||||
return value, true
|
audioLogger.Warn().Int("bitrate", config.AudioBitrate).Msg("Invalid audio bitrate, using default")
|
||||||
}
|
|
||||||
if value != 0 {
|
|
||||||
audioLogger.Warn().Int(paramName, value).Msgf("Invalid %s, using default", paramName)
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and apply bitrate
|
// Apply complexity (0-10)
|
||||||
if bitrate, valid := validateAndApply(config.AudioBitrate, 64, 256, "audio bitrate"); valid {
|
if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 {
|
||||||
cfg.Bitrate = uint16(bitrate)
|
cfg.Complexity = uint8(config.AudioComplexity)
|
||||||
|
} else if config.AudioComplexity != 0 {
|
||||||
|
audioLogger.Warn().Int("complexity", config.AudioComplexity).Msg("Invalid audio complexity, using default")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and apply complexity
|
// Apply buffer periods (2-24)
|
||||||
if complexity, valid := validateAndApply(config.AudioComplexity, 0, 10, "audio complexity"); valid {
|
if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 {
|
||||||
cfg.Complexity = uint8(complexity)
|
cfg.BufferPeriods = uint8(config.AudioBufferPeriods)
|
||||||
|
} else if config.AudioBufferPeriods != 0 {
|
||||||
|
audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Msg("Invalid buffer periods, using default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sample rate (Opus supports: 8k, 12k, 16k, 24k, 48k)
|
||||||
|
switch config.AudioSampleRate {
|
||||||
|
case 8000, 12000, 16000, 24000, 48000:
|
||||||
|
cfg.SampleRate = uint32(config.AudioSampleRate)
|
||||||
|
default:
|
||||||
|
if config.AudioSampleRate != 0 {
|
||||||
|
audioLogger.Warn().Int("sample_rate", config.AudioSampleRate).Msg("Invalid sample rate, using default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply packet loss percentage (0-100)
|
||||||
|
if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 {
|
||||||
|
cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc)
|
||||||
|
} else if config.AudioPacketLossPerc != 0 {
|
||||||
|
audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Msg("Invalid packet loss percentage, using default")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.DTXEnabled = config.AudioDTXEnabled
|
cfg.DTXEnabled = config.AudioDTXEnabled
|
||||||
cfg.FECEnabled = config.AudioFECEnabled
|
cfg.FECEnabled = config.AudioFECEnabled
|
||||||
|
|
||||||
// Validate and apply buffer periods
|
|
||||||
if periods, valid := validateAndApply(config.AudioBufferPeriods, 2, 24, "buffer periods"); valid {
|
|
||||||
cfg.BufferPeriods = uint8(periods)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opus-compatible rates only: 8k, 12k, 16k, 24k, 48k
|
|
||||||
validRates := map[int]bool{8000: true, 12000: true, 16000: true, 24000: true, 48000: true}
|
|
||||||
if validRates[config.AudioSampleRate] {
|
|
||||||
cfg.SampleRate = uint32(config.AudioSampleRate)
|
|
||||||
} else if config.AudioSampleRate != 0 {
|
|
||||||
audioLogger.Warn().Int("sample_rate", config.AudioSampleRate).Uint32("default", cfg.SampleRate).Msg("Invalid sample rate, using default")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and apply packet loss percentage
|
|
||||||
if pktLoss, valid := validateAndApply(config.AudioPacketLossPerc, 0, 100, "packet loss percentage"); valid {
|
|
||||||
cfg.PacketLossPerc = uint8(pktLoss)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,13 +118,17 @@ func startAudio() error {
|
||||||
inputErr = startInputAudioUnderMutex(getAlsaDevice("usb"))
|
inputErr = startInputAudioUnderMutex(getAlsaDevice("usb"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputErr != nil && inputErr != nil {
|
// Simplified error handling - both errors are worth reporting
|
||||||
return fmt.Errorf("audio start failed - output: %w, input: %v", outputErr, inputErr)
|
if outputErr != nil || inputErr != 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
|
||||||
}
|
}
|
||||||
if outputErr != nil {
|
return nil
|
||||||
return outputErr
|
|
||||||
}
|
|
||||||
return inputErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startOutputAudioUnderMutex(alsaOutputDevice string) error {
|
func startOutputAudioUnderMutex(alsaOutputDevice string) error {
|
||||||
|
|
@ -250,9 +252,8 @@ func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPendingInputTrack(track *webrtc.TrackRemote) {
|
func setPendingInputTrack(track *webrtc.TrackRemote) {
|
||||||
trackID := new(string)
|
trackID := track.ID()
|
||||||
*trackID = track.ID()
|
currentInputTrack.Store(&trackID)
|
||||||
currentInputTrack.Store(trackID)
|
|
||||||
go handleInputTrackForSession(track)
|
go handleInputTrackForSession(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,22 +398,11 @@ func handleInputTrackForSession(track *webrtc.TrackRemote) {
|
||||||
|
|
||||||
// processInputPacket handles writing audio data to the input source
|
// processInputPacket handles writing audio data to the input source
|
||||||
func processInputPacket(opusData []byte) error {
|
func processInputPacket(opusData []byte) error {
|
||||||
// Early check to avoid mutex acquisition if source is nil
|
|
||||||
if inputSource.Load() == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
inputSourceMutex.Lock()
|
inputSourceMutex.Lock()
|
||||||
defer inputSourceMutex.Unlock()
|
defer inputSourceMutex.Unlock()
|
||||||
|
|
||||||
// Reload source inside mutex to ensure we have the currently active source
|
|
||||||
source := inputSource.Load()
|
source := inputSource.Load()
|
||||||
if source == nil {
|
if source == nil || *source == nil {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defensive null check - ensure dereferenced pointer is valid
|
|
||||||
if *source == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
10
config.go
10
config.go
|
|
@ -290,6 +290,7 @@ func LoadConfig() {
|
||||||
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply audio defaults for new configs
|
||||||
if loadedConfig.AudioBitrate == 0 {
|
if loadedConfig.AudioBitrate == 0 {
|
||||||
defaults := getDefaultConfig()
|
defaults := getDefaultConfig()
|
||||||
loadedConfig.AudioBitrate = defaults.AudioBitrate
|
loadedConfig.AudioBitrate = defaults.AudioBitrate
|
||||||
|
|
@ -297,13 +298,8 @@ func LoadConfig() {
|
||||||
loadedConfig.AudioDTXEnabled = defaults.AudioDTXEnabled
|
loadedConfig.AudioDTXEnabled = defaults.AudioDTXEnabled
|
||||||
loadedConfig.AudioFECEnabled = defaults.AudioFECEnabled
|
loadedConfig.AudioFECEnabled = defaults.AudioFECEnabled
|
||||||
loadedConfig.AudioBufferPeriods = defaults.AudioBufferPeriods
|
loadedConfig.AudioBufferPeriods = defaults.AudioBufferPeriods
|
||||||
}
|
loadedConfig.AudioSampleRate = defaults.AudioSampleRate
|
||||||
|
loadedConfig.AudioPacketLossPerc = defaults.AudioPacketLossPerc
|
||||||
if loadedConfig.AudioSampleRate == 0 {
|
|
||||||
loadedConfig.AudioSampleRate = getDefaultConfig().AudioSampleRate
|
|
||||||
}
|
|
||||||
if loadedConfig.AudioPacketLossPerc == 0 {
|
|
||||||
loadedConfig.AudioPacketLossPerc = getDefaultConfig().AudioPacketLossPerc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixup old keyboard layout value
|
// fixup old keyboard layout value
|
||||||
|
|
|
||||||
|
|
@ -70,54 +70,54 @@ func (r *OutputRelay) Stop() {
|
||||||
func (r *OutputRelay) relayLoop() {
|
func (r *OutputRelay) relayLoop() {
|
||||||
defer close(r.stopped)
|
defer close(r.stopped)
|
||||||
|
|
||||||
const initialDelay = 1 * time.Second
|
|
||||||
const maxDelay = 30 * time.Second
|
|
||||||
const maxRetries = 10
|
const maxRetries = 10
|
||||||
|
retryDelay := 1 * time.Second
|
||||||
retryDelay := initialDelay
|
|
||||||
consecutiveFailures := 0
|
consecutiveFailures := 0
|
||||||
|
|
||||||
for r.running.Load() {
|
for r.running.Load() {
|
||||||
|
// Connect if not connected
|
||||||
if !(*r.source).IsConnected() {
|
if !(*r.source).IsConnected() {
|
||||||
if err := (*r.source).Connect(); err != nil {
|
if err := (*r.source).Connect(); err != nil {
|
||||||
consecutiveFailures++
|
if consecutiveFailures++; consecutiveFailures >= maxRetries {
|
||||||
if consecutiveFailures >= maxRetries {
|
r.logger.Error().Int("failures", consecutiveFailures).Msg("Max retries exceeded, stopping relay")
|
||||||
r.logger.Error().Int("failures", consecutiveFailures).Msg("Max connection retries exceeded, stopping relay")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.logger.Debug().Err(err).Int("failures", consecutiveFailures).Dur("retry_delay", retryDelay).Msg("failed to connect, will retry")
|
r.logger.Debug().Err(err).Int("failures", consecutiveFailures).Msg("Connection failed, retrying")
|
||||||
time.Sleep(retryDelay)
|
time.Sleep(retryDelay)
|
||||||
retryDelay = min(retryDelay*2, maxDelay)
|
retryDelay = min(retryDelay*2, 30*time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
consecutiveFailures = 0
|
consecutiveFailures = 0
|
||||||
retryDelay = initialDelay
|
retryDelay = 1 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read message from source
|
||||||
msgType, payload, err := (*r.source).ReadMessage()
|
msgType, payload, err := (*r.source).ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if r.running.Load() {
|
if !r.running.Load() {
|
||||||
consecutiveFailures++
|
break
|
||||||
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(retryDelay)
|
|
||||||
retryDelay = min(retryDelay*2, maxDelay)
|
|
||||||
}
|
}
|
||||||
|
if consecutiveFailures++; 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(retryDelay)
|
||||||
|
retryDelay = min(retryDelay*2, 30*time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset retry state on success
|
||||||
consecutiveFailures = 0
|
consecutiveFailures = 0
|
||||||
retryDelay = initialDelay
|
retryDelay = 1 * time.Second
|
||||||
|
|
||||||
|
// Write audio sample to WebRTC
|
||||||
if msgType == ipcMsgTypeOpus && len(payload) > 0 {
|
if msgType == ipcMsgTypeOpus && len(payload) > 0 {
|
||||||
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")
|
||||||
} else {
|
} else {
|
||||||
r.framesRelayed.Add(1)
|
r.framesRelayed.Add(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,58 +204,47 @@ func (u *UsbGadget) Close() error {
|
||||||
func (u *UsbGadget) CloseHidFiles() {
|
func (u *UsbGadget) CloseHidFiles() {
|
||||||
u.log.Debug().Msg("closing HID files")
|
u.log.Debug().Msg("closing HID files")
|
||||||
|
|
||||||
// Close keyboard HID file
|
closeFile := func(file **os.File, name string) {
|
||||||
if u.keyboardHidFile != nil {
|
if *file != nil {
|
||||||
if err := u.keyboardHidFile.Close(); err != nil {
|
if err := (*file).Close(); err != nil {
|
||||||
u.log.Debug().Err(err).Msg("failed to close keyboard HID file")
|
u.log.Debug().Err(err).Msgf("failed to close %s HID file", name)
|
||||||
|
}
|
||||||
|
*file = nil
|
||||||
}
|
}
|
||||||
u.keyboardHidFile = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close absolute mouse HID file
|
closeFile(&u.keyboardHidFile, "keyboard")
|
||||||
if u.absMouseHidFile != nil {
|
closeFile(&u.absMouseHidFile, "absolute mouse")
|
||||||
if err := u.absMouseHidFile.Close(); err != nil {
|
closeFile(&u.relMouseHidFile, "relative mouse")
|
||||||
u.log.Debug().Err(err).Msg("failed to close absolute mouse HID file")
|
|
||||||
}
|
|
||||||
u.absMouseHidFile = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close relative mouse HID file
|
|
||||||
if u.relMouseHidFile != nil {
|
|
||||||
if err := u.relMouseHidFile.Close(); err != nil {
|
|
||||||
u.log.Debug().Err(err).Msg("failed to close relative mouse HID file")
|
|
||||||
}
|
|
||||||
u.relMouseHidFile = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreOpenHidFiles opens all HID files to reduce input latency
|
// PreOpenHidFiles opens all HID files to reduce input latency
|
||||||
func (u *UsbGadget) PreOpenHidFiles() {
|
func (u *UsbGadget) PreOpenHidFiles() {
|
||||||
// Add a small delay to allow USB gadget reconfiguration to complete
|
// Small delay for USB gadget reconfiguration to complete
|
||||||
// This prevents "no such device or address" errors when trying to open HID files
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
openHidFile := func(file **os.File, path string, name string) {
|
||||||
|
if *file == nil {
|
||||||
|
f, err := os.OpenFile(path, os.O_RDWR, 0666)
|
||||||
|
if err != nil {
|
||||||
|
u.log.Debug().Err(err).Msgf("failed to pre-open %s HID file", name)
|
||||||
|
} else {
|
||||||
|
*file = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if u.enabledDevices.Keyboard {
|
if u.enabledDevices.Keyboard {
|
||||||
if err := u.openKeyboardHidFile(); err != nil {
|
if err := u.openKeyboardHidFile(); err != nil {
|
||||||
u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file")
|
u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.enabledDevices.AbsoluteMouse {
|
if u.enabledDevices.AbsoluteMouse {
|
||||||
if u.absMouseHidFile == nil {
|
openHidFile(&u.absMouseHidFile, "/dev/hidg1", "absolute mouse")
|
||||||
var err error
|
|
||||||
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
|
|
||||||
if err != nil {
|
|
||||||
u.log.Debug().Err(err).Msg("failed to pre-open absolute mouse HID file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.enabledDevices.RelativeMouse {
|
if u.enabledDevices.RelativeMouse {
|
||||||
if u.relMouseHidFile == nil {
|
openHidFile(&u.relMouseHidFile, "/dev/hidg2", "relative mouse")
|
||||||
var err error
|
|
||||||
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
|
|
||||||
if err != nil {
|
|
||||||
u.log.Debug().Err(err).Msg("failed to pre-open relative mouse HID file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
jsonrpc.go
16
jsonrpc.go
|
|
@ -866,30 +866,30 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
||||||
func updateUsbRelatedConfig(wasUsbAudioEnabled bool) error {
|
func updateUsbRelatedConfig(wasUsbAudioEnabled bool) error {
|
||||||
ensureConfigLoaded()
|
ensureConfigLoaded()
|
||||||
nowHasUsbAudio := config.UsbDevices != nil && config.UsbDevices.Audio
|
nowHasUsbAudio := config.UsbDevices != nil && config.UsbDevices.Audio
|
||||||
outputSourceIsUsb := config.AudioOutputSource == "usb"
|
|
||||||
|
|
||||||
// must stop input audio before reconfiguring
|
// Stop audio before reconfiguring USB gadget
|
||||||
stopInputAudio()
|
stopInputAudio()
|
||||||
|
if config.AudioOutputSource == "usb" {
|
||||||
// if we're currently sourcing audio from USB, stop the output audio before reconfiguring
|
|
||||||
if outputSourceIsUsb {
|
|
||||||
stopOutputAudio()
|
stopOutputAudio()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-switch to HDMI audio output when USB audio was selected and is now disabled
|
// Auto-switch to HDMI when USB audio disabled
|
||||||
if wasUsbAudioEnabled && !nowHasUsbAudio && config.AudioOutputSource == "usb" {
|
if wasUsbAudioEnabled && !nowHasUsbAudio && config.AudioOutputSource == "usb" {
|
||||||
logger.Info().Msg("USB audio just disabled, automatic switch audio output source to HDMI")
|
logger.Info().Msg("USB audio disabled, switching output to HDMI")
|
||||||
config.AudioOutputSource = "hdmi"
|
config.AudioOutputSource = "hdmi"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update USB gadget configuration
|
||||||
if err := gadget.UpdateGadgetConfig(); err != nil {
|
if err := gadget.UpdateGadgetConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to write gadget config: %w", err)
|
return fmt.Errorf("failed to update gadget config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restart audio if needed
|
||||||
if err := startAudio(); err != nil {
|
if err := startAudio(); err != nil {
|
||||||
logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration")
|
logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue