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)}
+ />
+
+