From e79c6f730e414973b607b862fe208723d943e454 Mon Sep 17 00:00:00 2001 From: Alex P Date: Mon, 17 Nov 2025 20:45:34 +0200 Subject: [PATCH] Add audio output source switching and improve shutdown handling - Add HDMI/USB audio source selection in settings UI - Add stop flags for graceful audio shutdown - Use immediate PCM drop instead of drain for faster switching - Add HDMI connection refresh RPC method - Add GetDefaultEDID helper method --- audio.go | 49 +++++++++++++++- config.go | 2 + internal/audio/c/audio.c | 62 +++++++++++++------- internal/native/video.go | 5 ++ jsonrpc.go | 26 ++++++++ ui/src/hooks/stores.ts | 4 ++ ui/src/routes/devices.$id.settings.audio.tsx | 37 +++++++++++- 7 files changed, 160 insertions(+), 25 deletions(-) diff --git a/audio.go b/audio.go index 9bf83330..00b8c764 100644 --- a/audio.go +++ b/audio.go @@ -51,7 +51,11 @@ func startAudio() error { // Start output audio if not running, enabled, and we have a track if outputSource == nil && audioOutputEnabled.Load() && currentAudioTrack != nil { - alsaDevice := "hw:1,0" // USB audio + ensureConfigLoaded() + alsaDevice := "hw:1,0" // USB audio (default) + if config.AudioOutputSource == "hdmi" { + alsaDevice = "hw:0,0" // HDMI audio + } outputSource = audio.NewCgoOutputSource(alsaDevice) outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack) @@ -153,7 +157,11 @@ func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) { audioMutex.Lock() if currentAudioTrack != nil && audioOutputEnabled.Load() { - alsaDevice := "hw:1,0" + ensureConfigLoaded() + alsaDevice := "hw:1,0" // USB audio (default) + if config.AudioOutputSource == "hdmi" { + alsaDevice = "hw:0,0" // HDMI audio + } newSource := audio.NewCgoOutputSource(alsaDevice) newRelay := audio.NewOutputRelay(newSource, currentAudioTrack) outputSource = newSource @@ -213,6 +221,43 @@ func SetAudioInputEnabled(enabled bool) error { return nil } +// SetAudioOutputSource switches between HDMI and USB audio sources +func SetAudioOutputSource(source string) error { + if source != "hdmi" && source != "usb" { + return nil + } + + ensureConfigLoaded() + if config.AudioOutputSource == source { + return nil + } + + config.AudioOutputSource = source + + stopOutputAudio() + + if audioOutputEnabled.Load() && activeConnections.Load() > 0 && currentAudioTrack != nil { + alsaDevice := "hw:1,0" // USB + if source == "hdmi" { + alsaDevice = "hw:0,0" // HDMI + } + + newSource := audio.NewCgoOutputSource(alsaDevice) + newRelay := audio.NewOutputRelay(newSource, currentAudioTrack) + + audioMutex.Lock() + outputSource = newSource + outputRelay = newRelay + audioMutex.Unlock() + + if err := newRelay.Start(); err != nil { + audioLogger.Error().Err(err).Str("source", source).Msg("Failed to start audio relay with new source") + } + } + + return SaveConfig() +} + // handleInputTrackForSession runs for the entire WebRTC session lifetime // It continuously reads from the track and sends to whatever relay is currently active func handleInputTrackForSession(track *webrtc.TrackRemote) { diff --git a/config.go b/config.go index 219675fb..792d8605 100644 --- a/config.go +++ b/config.go @@ -109,6 +109,7 @@ type Config struct { VideoQualityFactor float64 `json:"video_quality_factor"` AudioInputAutoEnable bool `json:"audio_input_auto_enable"` AudioOutputEnabled bool `json:"audio_output_enabled"` + AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb" } func (c *Config) GetDisplayRotation() uint16 { @@ -184,6 +185,7 @@ func getDefaultConfig() Config { VideoQualityFactor: 1.0, AudioInputAutoEnable: false, AudioOutputEnabled: true, + AudioOutputSource: "usb", } } diff --git a/internal/audio/c/audio.c b/internal/audio/c/audio.c index 822825c0..a8a5a9e9 100644 --- a/internal/audio/c/audio.c +++ b/internal/audio/c/audio.c @@ -73,6 +73,9 @@ static uint32_t sleep_milliseconds = 1; static uint8_t max_attempts_global = 5; static uint32_t max_backoff_us_global = 500000; +static volatile int capture_stop_requested = 0; +static volatile int playback_stop_requested = 0; + int jetkvm_audio_capture_init(); void jetkvm_audio_capture_close(); int jetkvm_audio_read_encode(void *opus_buf); @@ -399,10 +402,13 @@ __attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf) uint8_t recovery_attempts = 0; const uint8_t max_recovery_attempts = 3; - // Prefetch for write (out) and read (pcm_buffer) - RV1106 has small L1 cache - SIMD_PREFETCH(out, 1, 0); // Write, immediate use - SIMD_PREFETCH(pcm_buffer, 0, 0); // Read, immediate use - SIMD_PREFETCH(pcm_buffer + 64, 0, 1); // Prefetch next cache line + if (__builtin_expect(capture_stop_requested, 0)) { + return -1; + } + + SIMD_PREFETCH(out, 1, 0); + SIMD_PREFETCH(pcm_buffer, 0, 0); + SIMD_PREFETCH(pcm_buffer + 64, 0, 1); if (__builtin_expect(!capture_initialized || !pcm_capture_handle || !encoder || !opus_buf, 0)) { TRACE_LOG("[AUDIO_OUTPUT] jetkvm_audio_read_encode: Failed safety checks - capture_initialized=%d, pcm_capture_handle=%p, encoder=%p, opus_buf=%p\n", @@ -411,7 +417,10 @@ __attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf) } retry_read: - // Read 960 frames (20ms) from ALSA capture device + if (__builtin_expect(capture_stop_requested, 0)) { + return -1; + } + pcm_rc = snd_pcm_readi(pcm_capture_handle, pcm_buffer, frame_size); if (__builtin_expect(pcm_rc < 0, 0)) { @@ -428,7 +437,6 @@ retry_read: } goto retry_read; } else if (pcm_rc == -EAGAIN) { - // Wait for data to be available snd_pcm_wait(pcm_capture_handle, sleep_milliseconds); goto retry_read; } else if (pcm_rc == -ESTRPIPE) { @@ -438,6 +446,7 @@ retry_read: } uint8_t resume_attempts = 0; while ((err = snd_pcm_resume(pcm_capture_handle)) == -EAGAIN && resume_attempts < 10) { + if (capture_stop_requested) return -1; snd_pcm_wait(pcm_capture_handle, sleep_milliseconds); resume_attempts++; } @@ -558,7 +567,10 @@ __attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf, uint8_t recovery_attempts = 0; const uint8_t max_recovery_attempts = 3; - // Prefetch input buffer - locality 0 for immediate use + if (__builtin_expect(playback_stop_requested, 0)) { + return -1; + } + SIMD_PREFETCH(in, 0, 0); if (__builtin_expect(!playback_initialized || !pcm_playback_handle || !decoder || !opus_buf || opus_size <= 0, 0)) { @@ -592,7 +604,10 @@ __attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf, TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus decode successful - decoded %d PCM frames\n", pcm_frames); retry_write: - // Write decoded PCM to ALSA playback device + if (__builtin_expect(playback_stop_requested, 0)) { + return -1; + } + pcm_rc = snd_pcm_writei(pcm_playback_handle, pcm_buffer, pcm_frames); if (__builtin_expect(pcm_rc < 0, 0)) { TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: ALSA write failed with error %d (%s), attempt %d/%d\n", @@ -626,6 +641,7 @@ retry_write: TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspended, attempting resume (attempt %d)\n", recovery_attempts); uint8_t resume_attempts = 0; while ((err = snd_pcm_resume(pcm_playback_handle)) == -EAGAIN && resume_attempts < 10) { + if (playback_stop_requested) return -1; snd_pcm_wait(pcm_playback_handle, sleep_milliseconds); resume_attempts++; } @@ -681,15 +697,16 @@ retry_write: // CLEANUP FUNCTIONS -/** - * Close INPUT path (thread-safe with drain) - */ void jetkvm_audio_playback_close() { + playback_stop_requested = 1; + __sync_synchronize(); + while (playback_initializing) { sched_yield(); } if (__sync_bool_compare_and_swap(&playback_initialized, 1, 0) == 0) { + playback_stop_requested = 0; return; } @@ -698,31 +715,36 @@ void jetkvm_audio_playback_close() { decoder = NULL; } if (pcm_playback_handle) { - snd_pcm_drain(pcm_playback_handle); + snd_pcm_drop(pcm_playback_handle); snd_pcm_close(pcm_playback_handle); pcm_playback_handle = NULL; } + + playback_stop_requested = 0; } -/** - * Close OUTPUT path (thread-safe with drain) - */ void jetkvm_audio_capture_close() { + capture_stop_requested = 1; + __sync_synchronize(); + while (capture_initializing) { sched_yield(); } if (__sync_bool_compare_and_swap(&capture_initialized, 1, 0) == 0) { + capture_stop_requested = 0; return; } + if (pcm_capture_handle) { + snd_pcm_drop(pcm_capture_handle); + snd_pcm_close(pcm_capture_handle); + pcm_capture_handle = NULL; + } if (encoder) { opus_encoder_destroy(encoder); encoder = NULL; } - if (pcm_capture_handle) { - snd_pcm_drain(pcm_capture_handle); - snd_pcm_close(pcm_capture_handle); - pcm_capture_handle = NULL; - } + + capture_stop_requested = 0; } diff --git a/internal/native/video.go b/internal/native/video.go index b8068360..7cf6f501 100644 --- a/internal/native/video.go +++ b/internal/native/video.go @@ -131,6 +131,11 @@ func (n *Native) VideoGetEDID() (string, error) { return videoGetEDID() } +// GetDefaultEDID returns the default EDID constant. +func (n *Native) GetDefaultEDID() string { + return DefaultEDID +} + // VideoLogStatus gets the log status for the video stream. func (n *Native) VideoLogStatus() (string, error) { n.videoLock.Lock() diff --git a/jsonrpc.go b/jsonrpc.go index 871a8705..bfc1c2bf 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -232,6 +232,17 @@ func rpcSetEDID(edid string) error { return nil } +func rpcRefreshHdmiConnection() error { + currentEDID, err := nativeInstance.VideoGetEDID() + if err != nil { + return err + } + if currentEDID == "" { + currentEDID = nativeInstance.GetDefaultEDID() + } + return nativeInstance.VideoSetEDID(currentEDID) +} + func rpcGetVideoLogStatus() (string, error) { return nativeInstance.VideoLogStatus() } @@ -977,6 +988,18 @@ func rpcSetAudioInputEnabled(enabled bool) error { return SetAudioInputEnabled(enabled) } +func rpcGetAudioOutputSource() (string, error) { + ensureConfigLoaded() + if config.AudioOutputSource == "" { + return "usb", nil + } + return config.AudioOutputSource, nil +} + +func rpcSetAudioOutputSource(source string) error { + return SetAudioOutputSource(source) +} + func rpcGetAudioInputAutoEnable() (bool, error) { ensureConfigLoaded() return config.AudioInputAutoEnable, nil @@ -1314,6 +1337,9 @@ var rpcHandlers = map[string]RPCHandler{ "setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}}, "getAudioInputEnabled": {Func: rpcGetAudioInputEnabled}, "setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}}, + "getAudioOutputSource": {Func: rpcGetAudioOutputSource}, + "setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}}, + "refreshHdmiConnection": {Func: rpcRefreshHdmiConnection}, "getAudioInputAutoEnable": {Func: rpcGetAudioInputAutoEnable}, "setAudioInputAutoEnable": {Func: rpcSetAudioInputAutoEnable, Params: []string{"enabled"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 137eacc7..649405b4 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -385,6 +385,8 @@ export interface SettingsState { // Audio settings audioOutputEnabled: boolean; setAudioOutputEnabled: (enabled: boolean) => void; + audioOutputSource: string; + setAudioOutputSource: (source: string) => void; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; audioInputAutoEnable: boolean; @@ -441,6 +443,8 @@ export const useSettingsStore = create( audioOutputEnabled: true, setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }), + audioOutputSource: "usb", + setAudioOutputSource: (source: string) => set({ audioOutputSource: source }), microphoneEnabled: false, setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }), audioInputAutoEnable: false, diff --git a/ui/src/routes/devices.$id.settings.audio.tsx b/ui/src/routes/devices.$id.settings.audio.tsx index 6c1f6811..2ceaafc2 100644 --- a/ui/src/routes/devices.$id.settings.audio.tsx +++ b/ui/src/routes/devices.$id.settings.audio.tsx @@ -4,7 +4,7 @@ import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { useSettingsStore } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -// import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; import Checkbox from "@components/Checkbox"; import { m } from "@localizations/messages.js"; @@ -12,7 +12,7 @@ import notifications from "../notifications"; export default function SettingsAudioRoute() { const { send } = useJsonRpc(); - const { setAudioOutputEnabled, setAudioInputAutoEnable, audioOutputEnabled, audioInputAutoEnable } = useSettingsStore(); + const { setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource, audioOutputEnabled, audioInputAutoEnable, audioOutputSource } = useSettingsStore(); useEffect(() => { send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { @@ -24,7 +24,12 @@ export default function SettingsAudioRoute() { if ("error" in resp) return; setAudioInputAutoEnable(resp.result as boolean); }); - }, [send, setAudioOutputEnabled, setAudioInputAutoEnable]); + + send("getAudioOutputSource", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setAudioOutputSource(resp.result as string); + }); + }, [send, setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource]); const handleAudioOutputEnabledChange = (enabled: boolean) => { send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { @@ -41,6 +46,17 @@ export default function SettingsAudioRoute() { }); }; + const handleAudioOutputSourceChange = (source: string) => { + send("setAudioOutputSource", { source }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(m.audio_settings_output_source_failed({ error: String(resp.error.data || m.unknown_error()) })); + return; + } + setAudioOutputSource(source); + notifications.success(m.audio_settings_output_source_success()); + }); + }; + const handleAudioInputAutoEnableChange = (enabled: boolean) => { send("setAudioInputAutoEnable", { enabled }, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -72,6 +88,21 @@ export default function SettingsAudioRoute() { /> + + handleAudioOutputSourceChange(e.target.value)} + /> + +