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
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) {

View File

@ -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",
}
}

View File

@ -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;
}

View File

@ -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()

View File

@ -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"}},

View File

@ -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,

View File

@ -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() {
/>
</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
title={m.audio_settings_auto_enable_microphone_title()}
description={m.audio_settings_auto_enable_microphone_description()}