mirror of https://github.com/jetkvm/kvm.git
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:
parent
9e69a0cb97
commit
e79c6f730e
49
audio.go
49
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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
26
jsonrpc.go
26
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"}},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
Loading…
Reference in New Issue