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
This commit is contained in:
Alex P 2025-11-17 20:45:34 +02:00
parent 9e69a0cb97
commit e79c6f730e
7 changed files with 160 additions and 25 deletions

View File

@ -51,7 +51,11 @@ func startAudio() error {
// Start output audio if not running, enabled, and we have a track // 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 {
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) outputSource = audio.NewCgoOutputSource(alsaDevice)
outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack) outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack)
@ -153,7 +157,11 @@ func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
audioMutex.Lock() audioMutex.Lock()
if currentAudioTrack != nil && audioOutputEnabled.Load() { 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) newSource := audio.NewCgoOutputSource(alsaDevice)
newRelay := audio.NewOutputRelay(newSource, currentAudioTrack) newRelay := audio.NewOutputRelay(newSource, currentAudioTrack)
outputSource = newSource outputSource = newSource
@ -213,6 +221,43 @@ func SetAudioInputEnabled(enabled bool) error {
return nil 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 // handleInputTrackForSession runs for the entire WebRTC session lifetime
// It continuously reads from the track and sends to whatever relay is currently active // It continuously reads from the track and sends to whatever relay is currently active
func handleInputTrackForSession(track *webrtc.TrackRemote) { func handleInputTrackForSession(track *webrtc.TrackRemote) {

View File

@ -109,6 +109,7 @@ type Config struct {
VideoQualityFactor float64 `json:"video_quality_factor"` VideoQualityFactor float64 `json:"video_quality_factor"`
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"
} }
func (c *Config) GetDisplayRotation() uint16 { func (c *Config) GetDisplayRotation() uint16 {
@ -184,6 +185,7 @@ func getDefaultConfig() Config {
VideoQualityFactor: 1.0, VideoQualityFactor: 1.0,
AudioInputAutoEnable: false, AudioInputAutoEnable: false,
AudioOutputEnabled: true, AudioOutputEnabled: true,
AudioOutputSource: "usb",
} }
} }

View File

@ -73,6 +73,9 @@ static uint32_t sleep_milliseconds = 1;
static uint8_t max_attempts_global = 5; static uint8_t max_attempts_global = 5;
static uint32_t max_backoff_us_global = 500000; 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(); int jetkvm_audio_capture_init();
void jetkvm_audio_capture_close(); void jetkvm_audio_capture_close();
int jetkvm_audio_read_encode(void *opus_buf); 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; uint8_t recovery_attempts = 0;
const uint8_t max_recovery_attempts = 3; const uint8_t max_recovery_attempts = 3;
// Prefetch for write (out) and read (pcm_buffer) - RV1106 has small L1 cache if (__builtin_expect(capture_stop_requested, 0)) {
SIMD_PREFETCH(out, 1, 0); // Write, immediate use return -1;
SIMD_PREFETCH(pcm_buffer, 0, 0); // Read, immediate use }
SIMD_PREFETCH(pcm_buffer + 64, 0, 1); // Prefetch next cache line
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)) { 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", 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: 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); pcm_rc = snd_pcm_readi(pcm_capture_handle, pcm_buffer, frame_size);
if (__builtin_expect(pcm_rc < 0, 0)) { if (__builtin_expect(pcm_rc < 0, 0)) {
@ -428,7 +437,6 @@ retry_read:
} }
goto retry_read; goto retry_read;
} else if (pcm_rc == -EAGAIN) { } else if (pcm_rc == -EAGAIN) {
// Wait for data to be available
snd_pcm_wait(pcm_capture_handle, sleep_milliseconds); snd_pcm_wait(pcm_capture_handle, sleep_milliseconds);
goto retry_read; goto retry_read;
} else if (pcm_rc == -ESTRPIPE) { } else if (pcm_rc == -ESTRPIPE) {
@ -438,6 +446,7 @@ retry_read:
} }
uint8_t resume_attempts = 0; uint8_t resume_attempts = 0;
while ((err = snd_pcm_resume(pcm_capture_handle)) == -EAGAIN && resume_attempts < 10) { 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); snd_pcm_wait(pcm_capture_handle, sleep_milliseconds);
resume_attempts++; resume_attempts++;
} }
@ -558,7 +567,10 @@ __attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf,
uint8_t recovery_attempts = 0; uint8_t recovery_attempts = 0;
const uint8_t max_recovery_attempts = 3; 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); SIMD_PREFETCH(in, 0, 0);
if (__builtin_expect(!playback_initialized || !pcm_playback_handle || !decoder || !opus_buf || opus_size <= 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); TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus decode successful - decoded %d PCM frames\n", pcm_frames);
retry_write: 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); pcm_rc = snd_pcm_writei(pcm_playback_handle, pcm_buffer, pcm_frames);
if (__builtin_expect(pcm_rc < 0, 0)) { 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", 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); TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspended, attempting resume (attempt %d)\n", recovery_attempts);
uint8_t resume_attempts = 0; uint8_t resume_attempts = 0;
while ((err = snd_pcm_resume(pcm_playback_handle)) == -EAGAIN && resume_attempts < 10) { 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); snd_pcm_wait(pcm_playback_handle, sleep_milliseconds);
resume_attempts++; resume_attempts++;
} }
@ -681,15 +697,16 @@ retry_write:
// CLEANUP FUNCTIONS // CLEANUP FUNCTIONS
/**
* Close INPUT path (thread-safe with drain)
*/
void jetkvm_audio_playback_close() { void jetkvm_audio_playback_close() {
playback_stop_requested = 1;
__sync_synchronize();
while (playback_initializing) { while (playback_initializing) {
sched_yield(); sched_yield();
} }
if (__sync_bool_compare_and_swap(&playback_initialized, 1, 0) == 0) { if (__sync_bool_compare_and_swap(&playback_initialized, 1, 0) == 0) {
playback_stop_requested = 0;
return; return;
} }
@ -698,31 +715,36 @@ void jetkvm_audio_playback_close() {
decoder = NULL; decoder = NULL;
} }
if (pcm_playback_handle) { if (pcm_playback_handle) {
snd_pcm_drain(pcm_playback_handle); snd_pcm_drop(pcm_playback_handle);
snd_pcm_close(pcm_playback_handle); snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL; pcm_playback_handle = NULL;
} }
playback_stop_requested = 0;
} }
/**
* Close OUTPUT path (thread-safe with drain)
*/
void jetkvm_audio_capture_close() { void jetkvm_audio_capture_close() {
capture_stop_requested = 1;
__sync_synchronize();
while (capture_initializing) { while (capture_initializing) {
sched_yield(); sched_yield();
} }
if (__sync_bool_compare_and_swap(&capture_initialized, 1, 0) == 0) { if (__sync_bool_compare_and_swap(&capture_initialized, 1, 0) == 0) {
capture_stop_requested = 0;
return; return;
} }
if (pcm_capture_handle) {
snd_pcm_drop(pcm_capture_handle);
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
if (encoder) { if (encoder) {
opus_encoder_destroy(encoder); opus_encoder_destroy(encoder);
encoder = NULL; encoder = NULL;
} }
if (pcm_capture_handle) {
snd_pcm_drain(pcm_capture_handle); capture_stop_requested = 0;
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
} }

View File

@ -131,6 +131,11 @@ func (n *Native) VideoGetEDID() (string, error) {
return videoGetEDID() return videoGetEDID()
} }
// GetDefaultEDID returns the default EDID constant.
func (n *Native) GetDefaultEDID() string {
return DefaultEDID
}
// VideoLogStatus gets the log status for the video stream. // VideoLogStatus gets the log status for the video stream.
func (n *Native) VideoLogStatus() (string, error) { func (n *Native) VideoLogStatus() (string, error) {
n.videoLock.Lock() n.videoLock.Lock()

View File

@ -232,6 +232,17 @@ func rpcSetEDID(edid string) error {
return nil 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) { func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus() return nativeInstance.VideoLogStatus()
} }
@ -977,6 +988,18 @@ func rpcSetAudioInputEnabled(enabled bool) error {
return SetAudioInputEnabled(enabled) 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) { func rpcGetAudioInputAutoEnable() (bool, error) {
ensureConfigLoaded() ensureConfigLoaded()
return config.AudioInputAutoEnable, nil return config.AudioInputAutoEnable, nil
@ -1314,6 +1337,9 @@ var rpcHandlers = map[string]RPCHandler{
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}}, "setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled}, "getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}}, "setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
"getAudioOutputSource": {Func: rpcGetAudioOutputSource},
"setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}},
"refreshHdmiConnection": {Func: rpcRefreshHdmiConnection},
"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

@ -385,6 +385,8 @@ export interface SettingsState {
// Audio settings // Audio settings
audioOutputEnabled: boolean; audioOutputEnabled: boolean;
setAudioOutputEnabled: (enabled: boolean) => void; setAudioOutputEnabled: (enabled: boolean) => void;
audioOutputSource: string;
setAudioOutputSource: (source: string) => void;
microphoneEnabled: boolean; microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void; setMicrophoneEnabled: (enabled: boolean) => void;
audioInputAutoEnable: boolean; audioInputAutoEnable: boolean;
@ -441,6 +443,8 @@ export const useSettingsStore = create(
audioOutputEnabled: true, audioOutputEnabled: true,
setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }), setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }),
audioOutputSource: "usb",
setAudioOutputSource: (source: string) => set({ audioOutputSource: source }),
microphoneEnabled: false, microphoneEnabled: false,
setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }), setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }),
audioInputAutoEnable: false, audioInputAutoEnable: false,

View File

@ -4,7 +4,7 @@ import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
// import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import Checkbox from "@components/Checkbox"; import Checkbox from "@components/Checkbox";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
@ -12,7 +12,7 @@ import notifications from "../notifications";
export default function SettingsAudioRoute() { export default function SettingsAudioRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { setAudioOutputEnabled, setAudioInputAutoEnable, audioOutputEnabled, audioInputAutoEnable } = useSettingsStore(); const { setAudioOutputEnabled, setAudioInputAutoEnable, setAudioOutputSource, audioOutputEnabled, audioInputAutoEnable, audioOutputSource } = useSettingsStore();
useEffect(() => { useEffect(() => {
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
@ -24,7 +24,12 @@ export default function SettingsAudioRoute() {
if ("error" in resp) return; if ("error" in resp) return;
setAudioInputAutoEnable(resp.result as boolean); 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) => { const handleAudioOutputEnabledChange = (enabled: boolean) => {
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { 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) => { const handleAudioInputAutoEnableChange = (enabled: boolean) => {
send("setAudioInputAutoEnable", { enabled }, (resp: JsonRpcResponse) => { send("setAudioInputAutoEnable", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
@ -72,6 +88,21 @@ export default function SettingsAudioRoute() {
/> />
</SettingsItem> </SettingsItem>
<SettingsItem
title={m.audio_settings_output_source_title()}
description={m.audio_settings_output_source_description()}
>
<SelectMenuBasic
size="SM"
value={audioOutputSource || "usb"}
options={[
{ value: "usb", label: m.audio_settings_usb_label() },
{ value: "hdmi", label: m.audio_settings_hdmi_label() },
]}
onChange={(e) => handleAudioOutputSourceChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem <SettingsItem
title={m.audio_settings_auto_enable_microphone_title()} title={m.audio_settings_auto_enable_microphone_title()}
description={m.audio_settings_auto_enable_microphone_description()} description={m.audio_settings_auto_enable_microphone_description()}