This commit is contained in:
Alex 2025-11-10 17:09:31 +00:00 committed by GitHub
commit db620b70d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 3129 additions and 147 deletions

View File

@ -5,7 +5,7 @@ function sudo() {
if [ "$UID" -eq 0 ]; then
"$@"
else
${SUDO_PATH} "$@"
${SUDO_PATH} -E "$@"
fi
}
@ -17,7 +17,7 @@ sudo apt-get install -y --no-install-recommends \
iputils-ping \
build-essential \
device-tree-compiler \
gperf g++-multilib gcc-multilib \
gperf \
libnl-3-dev libdbus-1-dev libelf-dev libmpc-dev dwarves \
bc openssl flex bison libssl-dev python3 python-is-python3 texinfo kmod cmake \
wget zstd \
@ -31,7 +31,35 @@ pushd "${BUILDKIT_TMPDIR}" > /dev/null
wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSION}/buildkit.tar.zst && \
sudo mkdir -p /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="zstd -d --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
rm buildkit.tar.zst
popd
# Install audio dependencies (ALSA and Opus) for JetKVM
echo "Installing JetKVM audio dependencies..."
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
AUDIO_DEPS_SCRIPT="${PROJECT_ROOT}/install_audio_deps.sh"
if [ -f "${AUDIO_DEPS_SCRIPT}" ]; then
echo "Running audio dependencies installation..."
# Pre-create audio libs directory with proper permissions
sudo mkdir -p /opt/jetkvm-audio-libs
sudo chmod 777 /opt/jetkvm-audio-libs
# Run installation script (now it can write without sudo)
bash "${AUDIO_DEPS_SCRIPT}"
echo "Audio dependencies installation completed."
if [ -d "/opt/jetkvm-audio-libs" ]; then
echo "Audio libraries installed in /opt/jetkvm-audio-libs"
# Set recursive permissions for all subdirectories and files
sudo chmod -R 777 /opt/jetkvm-audio-libs
echo "Permissions set to allow all users access to audio libraries"
else
echo "Error: /opt/jetkvm-audio-libs directory not found after installation."
exit 1
fi
else
echo "Warning: Audio dependencies script not found at ${AUDIO_DEPS_SCRIPT}"
echo "Skipping audio dependencies installation."
fi
rm -rf "${BUILDKIT_TMPDIR}"

View File

@ -0,0 +1,74 @@
#!/bin/bash
# .devcontainer/install_audio_deps.sh
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
set -e
# Sudo wrapper function
SUDO_PATH=$(which sudo 2>/dev/null || echo "")
function use_sudo() {
if [ "$UID" -eq 0 ]; then
"$@"
elif [ -n "$SUDO_PATH" ]; then
${SUDO_PATH} -E "$@"
else
"$@"
fi
}
# Accept version parameters or use defaults
ALSA_VERSION="${1:-1.2.14}"
OPUS_VERSION="${2:-1.5.2}"
AUDIO_LIBS_DIR="/opt/jetkvm-audio-libs"
BUILDKIT_PATH="/opt/jetkvm-native-buildkit"
BUILDKIT_FLAVOR="arm-rockchip830-linux-uclibcgnueabihf"
CROSS_PREFIX="$BUILDKIT_PATH/bin/$BUILDKIT_FLAVOR"
# Create directory with proper permissions
use_sudo mkdir -p "$AUDIO_LIBS_DIR"
use_sudo chmod 777 "$AUDIO_LIBS_DIR"
cd "$AUDIO_LIBS_DIR"
# Download sources
[ -f alsa-lib-${ALSA_VERSION}.tar.bz2 ] || wget -N https://www.alsa-project.org/files/pub/lib/alsa-lib-${ALSA_VERSION}.tar.bz2
[ -f opus-${OPUS_VERSION}.tar.gz ] || wget -N https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz
# Extract
[ -d alsa-lib-${ALSA_VERSION} ] || tar xf alsa-lib-${ALSA_VERSION}.tar.bz2
[ -d opus-${OPUS_VERSION} ] || tar xf opus-${OPUS_VERSION}.tar.gz
# Optimization flags for ARM Cortex-A7 with NEON (simplified to avoid FD_SETSIZE issues)
OPTIM_CFLAGS="-O2 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard"
export CC="${CROSS_PREFIX}-gcc"
export CFLAGS="$OPTIM_CFLAGS"
export CXXFLAGS="$OPTIM_CFLAGS"
# Build ALSA
cd alsa-lib-${ALSA_VERSION}
if [ ! -f .built ]; then
chown -R $(whoami):$(whoami) .
# Use minimal ALSA configuration to avoid FD_SETSIZE issues in devcontainer
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR \
--enable-static=yes --enable-shared=no \
--with-pcm-plugins=rate,linear \
--disable-seq --disable-rawmidi --disable-ucm \
--disable-python --disable-old-symbols \
--disable-topology --disable-hwdep --disable-mixer \
--disable-alisp --disable-aload --disable-resmgr
make -j$(nproc)
touch .built
fi
cd ..
# Build Opus
cd opus-${OPUS_VERSION}
if [ ! -f .built ]; then
chown -R $(whoami):$(whoami) .
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR --enable-static=yes --enable-shared=no --enable-fixed-point
make -j$(nproc)
touch .built
fi
cd ..
echo "ALSA and Opus built in $AUDIO_LIBS_DIR"

View File

@ -6,6 +6,8 @@ ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
COPY install-deps.sh /install-deps.sh
COPY install_audio_deps.sh /install_audio_deps.sh
RUN /install-deps.sh
# Create build directory

102
Makefile
View File

@ -1,10 +1,52 @@
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE := $(shell date -u +%FT%T%z)
BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD)
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
build_audio_deps:
bash .devcontainer/install_audio_deps.sh $(ALSA_VERSION) $(OPUS_VERSION)
# Prepare everything needed for local development (toolchain + audio deps + Go tools)
dev_env: build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Installing Go development tools..."
go install golang.org/x/tools/cmd/goimports@latest
@echo "Development environment ready."
JETKVM_HOME ?= $(HOME)/.jetkvm
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
BUILDKIT_FLAVOR ?= arm-rockchip830-linux-uclibcgnueabihf
AUDIO_LIBS_DIR ?= /opt/jetkvm-audio-libs
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8
# Audio library versions
ALSA_VERSION ?= 1.2.14
OPUS_VERSION ?= 1.5.2
# Set PKG_CONFIG_PATH globally for all targets that use CGO with audio libraries
export PKG_CONFIG_PATH := $(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/utils:$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)
# Common command to clean Go cache with verbose output for all Go builds
CLEAN_GO_CACHE := @echo "Cleaning Go cache..."; go clean -cache -v
# Optimization flags for ARM Cortex-A7 with NEON SIMD
OPTIM_CFLAGS := -O3 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard -ftree-vectorize -ffast-math -funroll-loops -mvectorize-with-neon-quad -marm -D__ARM_NEON
# Cross-compilation environment for ARM - exported globally
export GOOS := linux
export GOARCH := arm
export GOARM := 7
export CC := $(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc
export CGO_ENABLED := 1
export CGO_CFLAGS := $(OPTIM_CFLAGS) -I$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/include -I$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/sysroot/usr/include
export CGO_LDFLAGS := -L$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/lib -L$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/sysroot/usr/lib -lrockit -lrockchip_mpp -lrga -lpthread -lm -ldl
# Audio-specific flags (only used for audio C binaries, NOT for main Go app)
AUDIO_CFLAGS := $(CGO_CFLAGS) -I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt
AUDIO_LDFLAGS := $(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs/libasound.a $(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs/libopus.a -lm -ldl -lpthread
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
@ -55,22 +97,26 @@ build_native:
./scripts/build_cgo.sh; \
fi
build_dev: build_native
build_dev: build_native build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Building..."
$(GO_CMD) build \
go build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/jetkvm_app -v cmd/main.go
build_test2json:
$(CLEAN_GO_CACHE)
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_gotestsum:
$(CLEAN_GO_CACHE)
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_test2json build_gotestsum
build_dev_test: build_audio_deps build_test2json build_gotestsum
$(CLEAN_GO_CACHE)
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@ -80,7 +126,7 @@ build_dev_test: build_test2json build_gotestsum
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
$(GO_CMD) test -v \
go test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_BUILD_ARGS) \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
@ -117,9 +163,10 @@ dev_release: frontend build_dev
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
build_release: frontend build_native
build_release: frontend build_native build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Building release..."
$(GO_CMD) build \
go build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/jetkvm_app cmd/main.go
@ -134,3 +181,38 @@ release:
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
# Run both Go and UI linting
lint: lint-go lint-ui
@echo "All linting completed successfully!"
# Run golangci-lint locally with the same configuration as CI
lint-go: build_audio_deps
@echo "Running golangci-lint..."
@mkdir -p static && touch static/.gitkeep
golangci-lint run --verbose
# Run both Go and UI linting with auto-fix
lint-fix: lint-go-fix lint-ui-fix
@echo "All linting with auto-fix completed successfully!"
# Run golangci-lint with auto-fix
lint-go-fix: build_audio_deps
@echo "Running golangci-lint with auto-fix..."
@mkdir -p static && touch static/.gitkeep
golangci-lint run --fix --verbose
# Run UI linting locally (mirrors GitHub workflow ui-lint.yml)
lint-ui:
@echo "Running UI lint..."
@cd ui && npm ci
@cd ui && npm run lint
# Run UI linting with auto-fix
lint-ui-fix:
@echo "Running UI lint with auto-fix..."
@cd ui && npm ci
@cd ui && npm run lint:fix
# Legacy alias for UI linting (for backward compatibility)
ui-lint: lint-ui

260
audio.go Normal file
View File

@ -0,0 +1,260 @@
package kvm
import (
"io"
"sync"
"sync/atomic"
"github.com/jetkvm/kvm/internal/audio"
"github.com/jetkvm/kvm/internal/logging"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
)
var (
audioMutex sync.Mutex
outputSource audio.AudioSource
inputSource atomic.Pointer[audio.AudioSource]
outputRelay *audio.OutputRelay
inputRelay *audio.InputRelay
audioInitialized bool
activeConnections atomic.Int32
audioLogger zerolog.Logger
currentAudioTrack *webrtc.TrackLocalStaticSample
currentInputTrack atomic.Pointer[string]
audioOutputEnabled atomic.Bool
audioInputEnabled atomic.Bool
)
func initAudio() {
audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger()
ensureConfigLoaded()
audioOutputEnabled.Store(config.AudioOutputEnabled)
audioInputEnabled.Store(true)
audioLogger.Debug().Msg("Audio subsystem initialized")
audioInitialized = true
}
// startAudio starts audio sources and relays (skips already running ones)
func startAudio() error {
audioMutex.Lock()
defer audioMutex.Unlock()
if !audioInitialized {
audioLogger.Warn().Msg("Audio not initialized, skipping start")
return nil
}
// Start output audio if not running and enabled
if outputSource == nil && audioOutputEnabled.Load() {
alsaDevice := "hw:1,0" // USB audio
outputSource = audio.NewCgoOutputSource(alsaDevice)
if currentAudioTrack != nil {
outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack)
if err := outputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start audio output relay")
}
}
}
// Start input audio if not running, USB audio enabled, and input enabled
ensureConfigLoaded()
if inputSource.Load() == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
alsaPlaybackDevice := "hw:1,0" // USB speakers
// Create CGO audio source
source := audio.NewCgoInputSource(alsaPlaybackDevice)
inputSource.Store(&source)
inputRelay = audio.NewInputRelay(source)
if err := inputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start input relay")
}
}
return nil
}
// stopOutputAudio stops output audio (device → browser)
func stopOutputAudio() {
audioMutex.Lock()
outRelay := outputRelay
outSource := outputSource
outputRelay = nil
outputSource = nil
audioMutex.Unlock()
// Disconnect outside mutex to avoid blocking during CGO calls
if outRelay != nil {
outRelay.Stop()
}
if outSource != nil {
outSource.Disconnect()
}
}
// stopInputAudio stops input audio (browser → device)
func stopInputAudio() {
audioMutex.Lock()
inRelay := inputRelay
inputRelay = nil
audioMutex.Unlock()
// Atomically swap and disconnect outside mutex
inSource := inputSource.Swap(nil)
// Disconnect outside mutex to avoid blocking during CGO calls
if inRelay != nil {
inRelay.Stop()
}
if inSource != nil {
(*inSource).Disconnect()
}
}
func stopAudio() {
stopOutputAudio()
stopInputAudio()
}
func onWebRTCConnect() {
count := activeConnections.Add(1)
if count == 1 {
if err := startAudio(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start audio")
}
}
}
func onWebRTCDisconnect() {
count := activeConnections.Add(-1)
if count == 0 {
// Stop audio immediately to release HDMI audio device which shares hardware with video device
stopAudio()
}
}
func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
audioMutex.Lock()
defer audioMutex.Unlock()
currentAudioTrack = audioTrack
if outputRelay != nil {
outputRelay.Stop()
outputRelay = nil
}
if outputSource != nil {
outputRelay = audio.NewOutputRelay(outputSource, audioTrack)
if err := outputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start output relay")
}
}
}
func setPendingInputTrack(track *webrtc.TrackRemote) {
trackID := track.ID()
currentInputTrack.Store(&trackID)
go handleInputTrackForSession(track)
}
// SetAudioOutputEnabled enables or disables audio output
func SetAudioOutputEnabled(enabled bool) error {
if audioOutputEnabled.Swap(enabled) == enabled {
return nil // Already in desired state
}
if enabled {
if activeConnections.Load() > 0 {
return startAudio()
}
} else {
stopOutputAudio()
}
return nil
}
// SetAudioInputEnabled enables or disables audio input
func SetAudioInputEnabled(enabled bool) error {
if audioInputEnabled.Swap(enabled) == enabled {
return nil // Already in desired state
}
if enabled {
if activeConnections.Load() > 0 {
return startAudio()
}
} else {
stopInputAudio()
}
return nil
}
// 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) {
myTrackID := track.ID()
audioLogger.Debug().
Str("codec", track.Codec().MimeType).
Str("track_id", myTrackID).
Msg("starting session-lifetime track handler")
for {
// Check if we've been superseded by a new track
currentTrackID := currentInputTrack.Load()
if currentTrackID != nil && *currentTrackID != myTrackID {
audioLogger.Debug().
Str("my_track_id", myTrackID).
Str("current_track_id", *currentTrackID).
Msg("audio track handler exiting - superseded by new track")
return
}
// Read RTP packet (must always read to keep track alive)
rtpPacket, _, err := track.ReadRTP()
if err != nil {
if err == io.EOF {
audioLogger.Debug().Str("track_id", myTrackID).Msg("audio track ended")
return
}
audioLogger.Warn().Err(err).Str("track_id", myTrackID).Msg("failed to read RTP packet")
continue
}
// Extract Opus payload
opusData := rtpPacket.Payload
if len(opusData) == 0 {
continue
}
// Only send if input is enabled
if !audioInputEnabled.Load() {
continue // Drop frame but keep reading
}
// Lock-free source access (hot path optimization)
source := inputSource.Load()
if source == nil {
continue // No relay, drop frame but keep reading
}
if !(*source).IsConnected() {
if err := (*source).Connect(); err != nil {
continue
}
}
if err := (*source).WriteMessage(0, opusData); err != nil {
(*source).Disconnect()
}
}
}

View File

@ -107,6 +107,8 @@ type Config struct {
DefaultLogLevel string `json:"default_log_level"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
VideoQualityFactor float64 `json:"video_quality_factor"`
AudioInputAutoEnable bool `json:"audio_input_auto_enable"`
AudioOutputEnabled bool `json:"audio_output_enabled"`
}
func (c *Config) GetDisplayRotation() uint16 {
@ -151,6 +153,7 @@ var (
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
}
)
@ -179,6 +182,8 @@ func getDefaultConfig() Config {
}(),
DefaultLogLevel: "INFO",
VideoQualityFactor: 1.0,
AudioInputAutoEnable: false,
AudioOutputEnabled: true,
}
}

728
internal/audio/c/audio.c Normal file
View File

@ -0,0 +1,728 @@
/*
* JetKVM Audio Processing Module
*
* Bidirectional audio processing optimized for ARM NEON SIMD:
* TODO: Remove USB Gadget audio once new system image release is made available
* - OUTPUT PATH: TC358743 HDMI or USB Gadget audio Client speakers
* Pipeline: ALSA hw:0,0 or hw:1,0 capture Opus encode (128kbps, FEC enabled)
*
* - INPUT PATH: Client microphone Device speakers
* Pipeline: Opus decode (with FEC) ALSA hw:1,0 playback
*
* Key features:
* - ARM NEON SIMD optimization for all audio operations
* - Opus in-band FEC for packet loss resilience
* - S16_LE @ 48kHz stereo, 20ms frames (960 samples)
*/
#include <alsa/asoundlib.h>
#include <opus.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sched.h>
#include <time.h>
#include <signal.h>
// ARM NEON SIMD support (always available on JetKVM's ARM Cortex-A7)
#include <arm_neon.h>
// RV1106 (Cortex-A7) has 64-byte cache lines
#define CACHE_LINE_SIZE 64
#define SIMD_ALIGN __attribute__((aligned(16)))
#define CACHE_ALIGN __attribute__((aligned(CACHE_LINE_SIZE)))
#define SIMD_PREFETCH(addr, rw, locality) __builtin_prefetch(addr, rw, locality)
// Compile-time trace logging - disabled for production (zero overhead)
#define TRACE_LOG(...) ((void)0)
// ALSA device handles
static snd_pcm_t *pcm_capture_handle = NULL; // OUTPUT: TC358743 HDMI audio → client
static snd_pcm_t *pcm_playback_handle = NULL; // INPUT: Client microphone → device speakers
// ALSA device names
static const char *alsa_capture_device = NULL;
static const char *alsa_playback_device = NULL;
// Opus codec instances
static OpusEncoder *encoder = NULL;
static OpusDecoder *decoder = NULL;
// Audio format (S16_LE @ 48kHz stereo)
static uint32_t sample_rate = 48000;
static uint8_t channels = 2;
static uint16_t frame_size = 960; // 20ms frames at 48kHz
static uint32_t opus_bitrate = 128000;
static uint8_t opus_complexity = 5; // Higher complexity for better quality
static uint16_t max_packet_size = 1500;
// Opus encoder constants (hardcoded for production)
#define OPUS_VBR 1 // VBR enabled
#define OPUS_VBR_CONSTRAINT 1 // Constrained VBR (prevents bitrate starvation at low volumes)
#define OPUS_SIGNAL_TYPE 3002 // OPUS_SIGNAL_MUSIC (better transient handling)
#define OPUS_BANDWIDTH 1104 // OPUS_BANDWIDTH_SUPERWIDEBAND (16kHz)
#define OPUS_DTX 1 // DTX enabled (bandwidth optimization)
#define OPUS_LSB_DEPTH 16 // 16-bit depth
// ALSA retry configuration
static uint32_t sleep_microseconds = 1000;
static uint32_t sleep_milliseconds = 1;
static uint8_t max_attempts_global = 5;
static uint32_t max_backoff_us_global = 500000;
int jetkvm_audio_capture_init();
void jetkvm_audio_capture_close();
int jetkvm_audio_read_encode(void *opus_buf);
int jetkvm_audio_playback_init();
void jetkvm_audio_playback_close();
int jetkvm_audio_decode_write(void *opus_buf, int opus_size);
void update_audio_constants(uint32_t bitrate, uint8_t complexity,
uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff);
void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff);
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity);
/**
* Sync encoder configuration from Go to C
*/
void update_audio_constants(uint32_t bitrate, uint8_t complexity,
uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff) {
opus_bitrate = bitrate;
opus_complexity = complexity;
sample_rate = sr;
channels = ch;
frame_size = fs;
max_packet_size = max_pkt;
sleep_microseconds = sleep_us;
sleep_milliseconds = sleep_us / 1000; // Precompute for snd_pcm_wait
max_attempts_global = max_attempts;
max_backoff_us_global = max_backoff;
}
/**
* Sync decoder configuration from Go to C (no encoder-only params)
*/
void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff) {
sample_rate = sr;
channels = ch;
frame_size = fs;
max_packet_size = max_pkt;
sleep_microseconds = sleep_us;
sleep_milliseconds = sleep_us / 1000; // Precompute for snd_pcm_wait
max_attempts_global = max_attempts;
max_backoff_us_global = max_backoff;
}
/**
* Initialize ALSA device names from environment variables
* Must be called before jetkvm_audio_capture_init or jetkvm_audio_playback_init
*/
static void init_alsa_devices_from_env(void) {
// Always read from environment to support device switching
alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE");
if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') {
alsa_capture_device = "hw:1,0"; // Default to USB gadget
}
alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE");
if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') {
alsa_playback_device = "hw:1,0"; // Default to USB gadget
}
}
// SIMD-OPTIMIZED BUFFER OPERATIONS (ARM NEON)
/**
* Clear audio buffer using NEON (16 samples/iteration with 2x unrolling)
*/
static inline void simd_clear_samples_s16(short * __restrict__ buffer, uint32_t samples) {
const int16x8_t zero = vdupq_n_s16(0);
uint32_t i = 0;
// Process 16 samples at a time (2x unrolled for better pipeline utilization)
uint32_t simd_samples = samples & ~15U;
for (; i < simd_samples; i += 16) {
vst1q_s16(&buffer[i], zero);
vst1q_s16(&buffer[i + 8], zero);
}
// Handle remaining 8 samples
if (i + 8 <= samples) {
vst1q_s16(&buffer[i], zero);
i += 8;
}
// Scalar: remaining samples
for (; i < samples; i++) {
buffer[i] = 0;
}
}
// INITIALIZATION STATE TRACKING
static volatile sig_atomic_t capture_initializing = 0;
static volatile sig_atomic_t capture_initialized = 0;
static volatile sig_atomic_t playback_initializing = 0;
static volatile sig_atomic_t playback_initialized = 0;
/**
* Update Opus encoder settings at runtime (does NOT modify FEC or hardcoded settings)
*
* NOTE: Currently unused but kept for potential future runtime configuration updates.
* In the current CGO implementation, encoder params are set once via update_audio_constants()
* before initialization. This function would be useful if we add runtime bitrate/complexity
* adjustment without restarting the encoder.
*
* @return 0 on success, -1 if not initialized, >0 if some settings failed
*/
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity) {
if (!encoder || !capture_initialized) {
return -1;
}
// Update runtime-configurable parameters
opus_bitrate = bitrate;
opus_complexity = complexity;
// Apply settings to encoder
int result = 0;
result |= opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
result |= opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity));
return result;
}
// ALSA UTILITY FUNCTIONS
/**
* Open ALSA device with exponential backoff retry
* @return 0 on success, negative error code on failure
*/
// Helper: High-precision sleep using nanosleep (better than usleep)
static inline void precise_sleep_us(uint32_t microseconds) {
struct timespec ts = {
.tv_sec = microseconds / 1000000,
.tv_nsec = (microseconds % 1000000) * 1000
};
nanosleep(&ts, NULL);
}
static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream_t stream) {
uint8_t attempt = 0;
int err;
uint32_t backoff_us = sleep_microseconds;
while (attempt < max_attempts_global) {
err = snd_pcm_open(handle, device, stream, SND_PCM_NONBLOCK);
if (err >= 0) {
snd_pcm_nonblock(*handle, 0);
return 0;
}
attempt++;
// Exponential backoff with bit shift (faster than multiplication)
if (err == -EBUSY || err == -EAGAIN) {
precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global;
} else if (err == -ENODEV || err == -ENOENT) {
precise_sleep_us(backoff_us << 1);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global;
} else if (err == -EPERM || err == -EACCES) {
precise_sleep_us(backoff_us >> 1);
} else {
precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global;
}
}
return err;
}
/**
* Configure ALSA device (S16_LE @ 48kHz stereo with optimized buffering)
* @param handle ALSA PCM handle
* @param device_name Unused (for debugging only)
* @return 0 on success, negative error code on failure
*/
static int configure_alsa_device(snd_pcm_t *handle, const char *device_name) {
snd_pcm_hw_params_t *params;
snd_pcm_sw_params_t *sw_params;
int err;
if (!handle) return -1;
snd_pcm_hw_params_alloca(&params);
snd_pcm_sw_params_alloca(&sw_params);
err = snd_pcm_hw_params_any(handle, params);
if (err < 0) return err;
err = snd_pcm_hw_params_set_access(handle, params, SND_PCM_ACCESS_RW_INTERLEAVED);
if (err < 0) return err;
err = snd_pcm_hw_params_set_format(handle, params, SND_PCM_FORMAT_S16_LE);
if (err < 0) return err;
err = snd_pcm_hw_params_set_channels(handle, params, channels);
if (err < 0) return err;
err = snd_pcm_hw_params_set_rate(handle, params, sample_rate, 0);
if (err < 0) {
unsigned int rate = sample_rate;
err = snd_pcm_hw_params_set_rate_near(handle, params, &rate, 0);
if (err < 0) return err;
}
snd_pcm_uframes_t period_size = frame_size; // Optimized: use full frame as period
if (period_size < 64) period_size = 64;
err = snd_pcm_hw_params_set_period_size_near(handle, params, &period_size, 0);
if (err < 0) return err;
snd_pcm_uframes_t buffer_size = period_size * 4; // 4 periods = 80ms buffer for stability
err = snd_pcm_hw_params_set_buffer_size_near(handle, params, &buffer_size);
if (err < 0) return err;
err = snd_pcm_hw_params(handle, params);
if (err < 0) return err;
err = snd_pcm_sw_params_current(handle, sw_params);
if (err < 0) return err;
err = snd_pcm_sw_params_set_start_threshold(handle, sw_params, period_size);
if (err < 0) return err;
err = snd_pcm_sw_params_set_avail_min(handle, sw_params, period_size);
if (err < 0) return err;
err = snd_pcm_sw_params(handle, sw_params);
if (err < 0) return err;
return snd_pcm_prepare(handle);
}
// AUDIO OUTPUT PATH FUNCTIONS (TC358743 HDMI Audio → Client Speakers)
/**
* Initialize OUTPUT path (TC358743 HDMI capture Opus encoder)
* Opens hw:0,0 (TC358743) and creates Opus encoder with optimized settings
* @return 0 on success, -EBUSY if initializing, -1/-2/-3 on errors
*/
int jetkvm_audio_capture_init() {
int err;
init_alsa_devices_from_env();
if (__sync_bool_compare_and_swap(&capture_initializing, 0, 1) == 0) {
return -EBUSY;
}
if (capture_initialized) {
capture_initializing = 0;
return 0;
}
if (encoder) {
opus_encoder_destroy(encoder);
encoder = NULL;
}
if (pcm_capture_handle) {
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
err = safe_alsa_open(&pcm_capture_handle, alsa_capture_device, SND_PCM_STREAM_CAPTURE);
if (err < 0) {
fprintf(stderr, "Failed to open ALSA capture device %s: %s\n",
alsa_capture_device, snd_strerror(err));
fflush(stderr);
capture_initializing = 0;
return -1;
}
err = configure_alsa_device(pcm_capture_handle, "capture");
if (err < 0) {
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
capture_initializing = 0;
return -2;
}
int opus_err = 0;
encoder = opus_encoder_create(sample_rate, channels, OPUS_APPLICATION_AUDIO, &opus_err);
if (!encoder || opus_err != OPUS_OK) {
if (pcm_capture_handle) {
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
capture_initializing = 0;
return -3;
}
// Configure encoder with optimized settings
opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity));
opus_encoder_ctl(encoder, OPUS_SET_VBR(OPUS_VBR));
opus_encoder_ctl(encoder, OPUS_SET_VBR_CONSTRAINT(OPUS_VBR_CONSTRAINT));
opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_TYPE));
opus_encoder_ctl(encoder, OPUS_SET_BANDWIDTH(OPUS_BANDWIDTH));
opus_encoder_ctl(encoder, OPUS_SET_DTX(OPUS_DTX));
opus_encoder_ctl(encoder, OPUS_SET_LSB_DEPTH(OPUS_LSB_DEPTH));
opus_encoder_ctl(encoder, OPUS_SET_INBAND_FEC(1));
opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(20));
capture_initialized = 1;
capture_initializing = 0;
return 0;
}
/**
* Read HDMI audio, encode to Opus (OUTPUT path hot function)
* @param opus_buf Output buffer for encoded Opus packet
* @return >0 = Opus packet size in bytes, -1 = error
*/
__attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf) {
static short CACHE_ALIGN pcm_buffer[960 * 2]; // Cache-aligned
unsigned char * __restrict__ out = (unsigned char*)opus_buf;
int32_t pcm_rc, nb_bytes;
int32_t err = 0;
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_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",
capture_initialized, pcm_capture_handle, encoder, opus_buf);
return -1;
}
retry_read:
// Read 960 frames (20ms) from ALSA capture device
pcm_rc = snd_pcm_readi(pcm_capture_handle, pcm_buffer, frame_size);
if (__builtin_expect(pcm_rc < 0, 0)) {
if (pcm_rc == -EPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
return -1;
}
err = snd_pcm_prepare(pcm_capture_handle);
if (err < 0) {
snd_pcm_drop(pcm_capture_handle);
err = snd_pcm_prepare(pcm_capture_handle);
if (err < 0) return -1;
}
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) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
return -1;
}
uint8_t resume_attempts = 0;
while ((err = snd_pcm_resume(pcm_capture_handle)) == -EAGAIN && resume_attempts < 10) {
snd_pcm_wait(pcm_capture_handle, sleep_milliseconds);
resume_attempts++;
}
if (err < 0) {
err = snd_pcm_prepare(pcm_capture_handle);
if (err < 0) return -1;
}
return 0;
} else if (pcm_rc == -ENODEV) {
return -1;
} else if (pcm_rc == -EIO) {
recovery_attempts++;
if (recovery_attempts <= max_recovery_attempts) {
snd_pcm_drop(pcm_capture_handle);
err = snd_pcm_prepare(pcm_capture_handle);
if (err >= 0) {
goto retry_read;
}
}
return -1;
} else {
recovery_attempts++;
if (recovery_attempts <= 1 && pcm_rc == -EINTR) {
goto retry_read;
} else if (recovery_attempts <= 1 && pcm_rc == -EBUSY) {
snd_pcm_wait(pcm_capture_handle, 1); // Wait 1ms for device
goto retry_read;
}
return -1;
}
}
// Zero-pad if we got a short read
if (__builtin_expect(pcm_rc < frame_size, 0)) {
uint32_t remaining_samples = (frame_size - pcm_rc) * channels;
simd_clear_samples_s16(&pcm_buffer[pcm_rc * channels], remaining_samples);
}
nb_bytes = opus_encode(encoder, pcm_buffer, frame_size, out, max_packet_size);
return nb_bytes;
}
// AUDIO INPUT PATH FUNCTIONS (Client Microphone → Device Speakers)
/**
* Initialize INPUT path (Opus decoder device speakers)
* Opens hw:1,0 (USB gadget) or "default" and creates Opus decoder
* @return 0 on success, -EBUSY if initializing, -1/-2 on errors
*/
int jetkvm_audio_playback_init() {
int err;
init_alsa_devices_from_env();
if (__sync_bool_compare_and_swap(&playback_initializing, 0, 1) == 0) {
return -EBUSY;
}
if (playback_initialized) {
playback_initializing = 0;
return 0;
}
if (decoder) {
opus_decoder_destroy(decoder);
decoder = NULL;
}
if (pcm_playback_handle) {
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
}
err = safe_alsa_open(&pcm_playback_handle, alsa_playback_device, SND_PCM_STREAM_PLAYBACK);
if (err < 0) {
fprintf(stderr, "Failed to open ALSA playback device %s: %s\n",
alsa_playback_device, snd_strerror(err));
fflush(stderr);
err = safe_alsa_open(&pcm_playback_handle, "default", SND_PCM_STREAM_PLAYBACK);
if (err < 0) {
playback_initializing = 0;
return -1;
}
}
err = configure_alsa_device(pcm_playback_handle, "playback");
if (err < 0) {
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
playback_initializing = 0;
return -1;
}
int opus_err = 0;
decoder = opus_decoder_create(sample_rate, channels, &opus_err);
if (!decoder || opus_err != OPUS_OK) {
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
playback_initializing = 0;
return -2;
}
playback_initialized = 1;
playback_initializing = 0;
return 0;
}
/**
* Decode Opus, write to device speakers (INPUT path hot function)
* Processing pipeline: Opus decode (with FEC) ALSA playback with error recovery
* @param opus_buf Encoded Opus packet from client
* @param opus_size Size of Opus packet in bytes
* @return >0 = PCM frames written, 0 = frame skipped, -1/-2 = error
*/
__attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf, int32_t opus_size) {
static short CACHE_ALIGN pcm_buffer[960 * 2]; // Cache-aligned
unsigned char * __restrict__ in = (unsigned char*)opus_buf;
int32_t pcm_frames, pcm_rc, err = 0;
uint8_t recovery_attempts = 0;
const uint8_t max_recovery_attempts = 3;
// Prefetch input buffer - locality 0 for immediate use
SIMD_PREFETCH(in, 0, 0);
if (__builtin_expect(!playback_initialized || !pcm_playback_handle || !decoder || !opus_buf || opus_size <= 0, 0)) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Failed safety checks - playback_initialized=%d, pcm_playback_handle=%p, decoder=%p, opus_buf=%p, opus_size=%d\n",
playback_initialized, pcm_playback_handle, decoder, opus_buf, opus_size);
return -1;
}
if (opus_size > max_packet_size) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus packet too large - size=%d, max=%d\n", opus_size, max_packet_size);
return -1;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Processing Opus packet - size=%d bytes\n", opus_size);
// Decode Opus packet to PCM (FEC automatically applied if embedded in packet)
// decode_fec=0 means normal decode (FEC data is used automatically when present)
pcm_frames = opus_decode(decoder, in, opus_size, pcm_buffer, frame_size, 0);
if (__builtin_expect(pcm_frames < 0, 0)) {
// Decode failed - attempt packet loss concealment using FEC from previous packet
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus decode failed with error %d, attempting packet loss concealment\n", pcm_frames);
// decode_fec=1 means use FEC data from the NEXT packet to reconstruct THIS lost packet
pcm_frames = opus_decode(decoder, NULL, 0, pcm_buffer, frame_size, 1);
if (pcm_frames < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Packet loss concealment also failed with error %d\n", pcm_frames);
return -1;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Packet loss concealment succeeded, recovered %d frames\n", pcm_frames);
} else
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
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",
pcm_rc, snd_strerror(pcm_rc), recovery_attempts + 1, max_recovery_attempts);
if (pcm_rc == -EPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Buffer underrun recovery failed after %d attempts\n", max_recovery_attempts);
return -2;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Buffer underrun detected, attempting recovery (attempt %d)\n", recovery_attempts);
err = snd_pcm_prepare(pcm_playback_handle);
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: snd_pcm_prepare failed (%s), trying drop+prepare\n", snd_strerror(err));
snd_pcm_drop(pcm_playback_handle);
err = snd_pcm_prepare(pcm_playback_handle);
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: drop+prepare recovery failed (%s)\n", snd_strerror(err));
return -2;
}
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Buffer underrun recovery successful, retrying write\n");
goto retry_write;
} else if (pcm_rc == -ESTRPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspend recovery failed after %d attempts\n", max_recovery_attempts);
return -2;
}
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) {
snd_pcm_wait(pcm_playback_handle, sleep_milliseconds);
resume_attempts++;
}
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device resume failed (%s), trying prepare fallback\n", snd_strerror(err));
err = snd_pcm_prepare(pcm_playback_handle);
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Prepare fallback failed (%s)\n", snd_strerror(err));
return -2;
}
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspend recovery successful, skipping frame\n");
return 0;
} else if (pcm_rc == -ENODEV) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device disconnected (ENODEV) - critical error\n");
return -2;
} else if (pcm_rc == -EIO) {
recovery_attempts++;
if (recovery_attempts <= max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: I/O error detected, attempting recovery\n");
snd_pcm_drop(pcm_playback_handle);
err = snd_pcm_prepare(pcm_playback_handle);
if (err >= 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: I/O error recovery successful, retrying write\n");
goto retry_write;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: I/O error recovery failed (%s)\n", snd_strerror(err));
}
return -2;
} else if (pcm_rc == -EAGAIN) {
recovery_attempts++;
if (recovery_attempts <= max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device not ready (EAGAIN), waiting and retrying\n");
snd_pcm_wait(pcm_playback_handle, 1); // Wait 1ms
goto retry_write;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device not ready recovery failed after %d attempts\n", max_recovery_attempts);
return -2;
} else {
recovery_attempts++;
if (recovery_attempts <= 1 && (pcm_rc == -EINTR || pcm_rc == -EBUSY)) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Transient error %d (%s), retrying once\n", pcm_rc, snd_strerror(pcm_rc));
snd_pcm_wait(pcm_playback_handle, 1); // Wait 1ms
goto retry_write;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Unrecoverable error %d (%s)\n", pcm_rc, snd_strerror(pcm_rc));
return -2;
}
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Successfully wrote %d PCM frames to device\n", pcm_frames);
return pcm_frames;
}
// CLEANUP FUNCTIONS
/**
* Close INPUT path (thread-safe with drain)
*/
void jetkvm_audio_playback_close() {
while (playback_initializing) {
sched_yield();
}
if (__sync_bool_compare_and_swap(&playback_initialized, 1, 0) == 0) {
return;
}
if (decoder) {
opus_decoder_destroy(decoder);
decoder = NULL;
}
if (pcm_playback_handle) {
snd_pcm_drain(pcm_playback_handle);
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
}
}
/**
* Close OUTPUT path (thread-safe with drain)
*/
void jetkvm_audio_capture_close() {
while (capture_initializing) {
sched_yield();
}
if (__sync_bool_compare_and_swap(&capture_initialized, 1, 0) == 0) {
return;
}
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;
}
}

View File

@ -0,0 +1,101 @@
/*
* JetKVM Audio Common Utilities
*
* Shared functions for audio processing
*/
#include "audio_common.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <time.h>
// GLOBAL STATE FOR SIGNAL HANDLER
// Pointer to the running flag that will be set to 0 on shutdown
static volatile sig_atomic_t *g_running_ptr = NULL;
// SIGNAL HANDLERS
static void signal_handler(int signo) {
if (signo == SIGTERM || signo == SIGINT) {
printf("Audio server: Received signal %d, shutting down...\n", signo);
if (g_running_ptr != NULL) {
*g_running_ptr = 0;
}
}
}
void audio_common_setup_signal_handlers(volatile sig_atomic_t *running) {
g_running_ptr = running;
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
// Ignore SIGPIPE (write to closed socket should return error, not crash)
signal(SIGPIPE, SIG_IGN);
}
int32_t audio_common_parse_env_int(const char *name, int32_t default_value) {
const char *str = getenv(name);
if (str == NULL || str[0] == '\0') {
return default_value;
}
return (int32_t)atoi(str);
}
const char* audio_common_parse_env_string(const char *name, const char *default_value) {
const char *str = getenv(name);
if (str == NULL || str[0] == '\0') {
return default_value;
}
return str;
}
// COMMON CONFIGURATION
void audio_common_load_config(audio_config_t *config, int is_output) {
// ALSA device configuration
if (is_output) {
config->alsa_device = audio_common_parse_env_string("ALSA_CAPTURE_DEVICE", "hw:1,0");
} else {
config->alsa_device = audio_common_parse_env_string("ALSA_PLAYBACK_DEVICE", "hw:1,0");
}
// Common Opus configuration
config->opus_bitrate = audio_common_parse_env_int("OPUS_BITRATE", 128000);
config->opus_complexity = audio_common_parse_env_int("OPUS_COMPLEXITY", 2);
// Audio format
config->sample_rate = audio_common_parse_env_int("AUDIO_SAMPLE_RATE", 48000);
config->channels = audio_common_parse_env_int("AUDIO_CHANNELS", 2);
config->frame_size = audio_common_parse_env_int("AUDIO_FRAME_SIZE", 960);
// Log configuration
printf("Audio %s Server Configuration:\n", is_output ? "Output" : "Input");
printf(" ALSA Device: %s\n", config->alsa_device);
printf(" Sample Rate: %d Hz\n", config->sample_rate);
printf(" Channels: %d\n", config->channels);
printf(" Frame Size: %d samples\n", config->frame_size);
if (is_output) {
printf(" Opus Bitrate: %d bps\n", config->opus_bitrate);
printf(" Opus Complexity: %d\n", config->opus_complexity);
}
}
void audio_common_print_startup(const char *server_name) {
printf("JetKVM %s Starting...\n", server_name);
}
void audio_common_print_shutdown(const char *server_name) {
printf("Shutting down %s...\n", server_name);
}

View File

@ -0,0 +1,135 @@
/*
* JetKVM Audio Common Utilities
*
* Shared functions used by both audio input and output servers
*/
#ifndef JETKVM_AUDIO_COMMON_H
#define JETKVM_AUDIO_COMMON_H
#include <signal.h>
#include <stdint.h>
// SHARED CONSTANTS
// Audio processing parameters
#define AUDIO_MAX_PACKET_SIZE 1500 // Maximum Opus packet size
#define AUDIO_SLEEP_MICROSECONDS 1000 // Default sleep time in microseconds
#define AUDIO_MAX_ATTEMPTS 5 // Maximum retry attempts
#define AUDIO_MAX_BACKOFF_US 500000 // Maximum backoff in microseconds
// Error handling
#define AUDIO_MAX_CONSECUTIVE_ERRORS 10 // Maximum consecutive errors before giving up
// Performance monitoring
#define AUDIO_TRACE_MASK 0x3FF // Log every 1024th frame (bit mask for efficiency)
// SIGNAL HANDLERS
/**
* Setup signal handlers for graceful shutdown.
* Handles SIGTERM and SIGINT by setting the running flag to 0.
* Ignores SIGPIPE to prevent crashes on broken pipe writes.
*
* @param running Pointer to the volatile running flag to set on shutdown
*/
void audio_common_setup_signal_handlers(volatile sig_atomic_t *running);
/**
* Parse integer from environment variable.
* Returns default_value if variable is not set or empty.
*
* @param name Environment variable name
* @param default_value Default value if not set
* @return Parsed integer value or default
*/
int32_t audio_common_parse_env_int(const char *name, int32_t default_value);
/**
* Parse string from environment variable.
* Returns default_value if variable is not set or empty.
*
* @param name Environment variable name
* @param default_value Default value if not set
* @return Environment variable value or default (not duplicated)
*/
const char* audio_common_parse_env_string(const char *name, const char *default_value);
// COMMON CONFIGURATION
/**
* Common audio configuration structure
*/
typedef struct {
const char *alsa_device; // ALSA device path
int opus_bitrate; // Opus bitrate
int opus_complexity; // Opus complexity
int sample_rate; // Sample rate
int channels; // Number of channels
int frame_size; // Frame size in samples
} audio_config_t;
/**
* Load common audio configuration from environment
* @param config Output configuration
* @param is_output true for output server, false for input
*/
void audio_common_load_config(audio_config_t *config, int is_output);
/**
* Print server startup message
* @param server_name Name of the server (e.g., "Audio Output Server")
*/
void audio_common_print_startup(const char *server_name);
/**
* Print server shutdown message
* @param server_name Name of the server
*/
void audio_common_print_shutdown(const char *server_name);
// ERROR TRACKING
/**
* Error tracking state for audio processing loops
*/
typedef struct {
uint8_t consecutive_errors; // Current consecutive error count
uint32_t frame_count; // Total frames processed
} audio_error_tracker_t;
/**
* Initialize error tracker
*/
static inline void audio_error_tracker_init(audio_error_tracker_t *tracker) {
tracker->consecutive_errors = 0;
tracker->frame_count = 0;
}
/**
* Record an error and check if we should give up
* Returns 1 if too many errors, 0 to continue
*/
static inline uint8_t audio_error_tracker_record_error(audio_error_tracker_t *tracker) {
tracker->consecutive_errors++;
return (tracker->consecutive_errors >= AUDIO_MAX_CONSECUTIVE_ERRORS) ? 1 : 0;
}
/**
* Record success and increment frame count
*/
static inline void audio_error_tracker_record_success(audio_error_tracker_t *tracker) {
tracker->consecutive_errors = 0;
tracker->frame_count++;
}
/**
* Check if we should log trace info for this frame
*/
static inline uint8_t audio_error_tracker_should_trace(audio_error_tracker_t *tracker) {
return ((tracker->frame_count & AUDIO_TRACE_MASK) == 1) ? 1 : 0;
}
#endif // JETKVM_AUDIO_COMMON_H

View File

@ -0,0 +1,212 @@
//go:build linux && (arm || arm64)
package audio
/*
#cgo CFLAGS: -O3 -ffast-math -I/opt/jetkvm-audio-libs/alsa-lib-1.2.14/include -I/opt/jetkvm-audio-libs/opus-1.5.2/include
#cgo LDFLAGS: /opt/jetkvm-audio-libs/alsa-lib-1.2.14/src/.libs/libasound.a /opt/jetkvm-audio-libs/opus-1.5.2/.libs/libopus.a -lm -ldl -lpthread
#include <stdlib.h>
#include "c/audio.c"
*/
import "C"
import (
"fmt"
"os"
"sync"
"unsafe"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
const (
ipcMaxFrameSize = 1024 // Max Opus frame size: 128kbps @ 20ms = ~600 bytes
)
// CgoSource implements AudioSource via direct CGO calls to C audio functions (in-process)
type CgoSource struct {
direction string // "output" or "input"
alsaDevice string
initialized bool
connected bool
mu sync.Mutex
logger zerolog.Logger
opusBuf []byte // Reusable buffer for Opus packets
}
// NewCgoOutputSource creates a new CGO audio source for output (HDMI/USB → browser)
func NewCgoOutputSource(alsaDevice string) *CgoSource {
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger()
return &CgoSource{
direction: "output",
alsaDevice: alsaDevice,
logger: logger,
opusBuf: make([]byte, ipcMaxFrameSize),
}
}
// NewCgoInputSource creates a new CGO audio source for input (browser → USB speakers)
func NewCgoInputSource(alsaDevice string) *CgoSource {
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger()
return &CgoSource{
direction: "input",
alsaDevice: alsaDevice,
logger: logger,
opusBuf: make([]byte, ipcMaxFrameSize),
}
}
// Connect initializes the C audio subsystem
func (c *CgoSource) Connect() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return nil
}
// Set ALSA device via environment for C code to read via init_alsa_devices_from_env()
if c.direction == "output" {
// Set capture device for output path via environment variable
os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice)
// Initialize constants
C.update_audio_constants(
C.uint(128000), // bitrate
C.uchar(5), // complexity
C.uint(48000), // sample_rate
C.uchar(2), // channels
C.ushort(960), // frame_size
C.ushort(1500), // max_packet_size
C.uint(1000), // sleep_us
C.uchar(5), // max_attempts
C.uint(500000), // max_backoff_us
)
// Initialize capture (HDMI/USB → browser)
rc := C.jetkvm_audio_capture_init()
if rc != 0 {
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture")
return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc)
}
} else {
// Set playback device for input path via environment variable
os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice)
// Initialize decoder constants
C.update_audio_decoder_constants(
C.uint(48000), // sample_rate
C.uchar(2), // channels
C.ushort(960), // frame_size
C.ushort(1500), // max_packet_size
C.uint(1000), // sleep_us
C.uchar(5), // max_attempts
C.uint(500000), // max_backoff_us
)
// Initialize playback (browser → USB speakers)
rc := C.jetkvm_audio_playback_init()
if rc != 0 {
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback")
return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc)
}
}
c.connected = true
c.initialized = true
return nil
}
// Disconnect closes the C audio subsystem
func (c *CgoSource) Disconnect() {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return
}
if c.direction == "output" {
C.jetkvm_audio_capture_close()
} else {
C.jetkvm_audio_playback_close()
}
c.connected = false
}
// IsConnected returns true if currently connected
func (c *CgoSource) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// ReadMessage reads the next audio frame from C audio subsystem
// For output path: reads HDMI/USB audio and encodes to Opus
// For input path: not used (input uses WriteMessage instead)
// Returns message type (0 = Opus), payload data, and error
func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return 0, nil, fmt.Errorf("not connected")
}
if c.direction != "output" {
return 0, nil, fmt.Errorf("ReadMessage only supported for output direction")
}
// Call C function to read HDMI/USB audio and encode to Opus
// Returns Opus packet size (>0) or error (<0)
opusSize := C.jetkvm_audio_read_encode(unsafe.Pointer(&c.opusBuf[0]))
if opusSize < 0 {
return 0, nil, fmt.Errorf("jetkvm_audio_read_encode failed: %d", opusSize)
}
if opusSize == 0 {
// No data available (silence/DTX)
return 0, nil, nil
}
// Return slice of opusBuf - caller must use immediately
return ipcMsgTypeOpus, c.opusBuf[:opusSize], nil
}
// WriteMessage writes an Opus packet to the C audio subsystem for playback
// Only used for input path (browser → USB speakers)
func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return fmt.Errorf("not connected")
}
if c.direction != "input" {
return fmt.Errorf("WriteMessage only supported for input direction")
}
if msgType != ipcMsgTypeOpus {
// Ignore non-Opus messages
return nil
}
if len(payload) == 0 {
return nil
}
// Call C function to decode Opus and write to USB speakers
rc := C.jetkvm_audio_decode_write(unsafe.Pointer(&payload[0]), C.int(len(payload)))
if rc < 0 {
return fmt.Errorf("jetkvm_audio_decode_write failed: %d", rc)
}
return nil
}

158
internal/audio/relay.go Normal file
View File

@ -0,0 +1,158 @@
package audio
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/rs/zerolog"
)
// OutputRelay forwards audio from AudioSource (CGO) to WebRTC (browser)
type OutputRelay struct {
source AudioSource
audioTrack *webrtc.TrackLocalStaticSample
ctx context.Context
cancel context.CancelFunc
logger zerolog.Logger
running atomic.Bool
sample media.Sample
stopped chan struct{}
// Stats (Uint32: overflows after 2.7 years @ 50fps, faster atomics on 32-bit ARM)
framesRelayed atomic.Uint32
framesDropped atomic.Uint32
}
// NewOutputRelay creates a relay for output audio (device → browser)
func NewOutputRelay(source AudioSource, audioTrack *webrtc.TrackLocalStaticSample) *OutputRelay {
ctx, cancel := context.WithCancel(context.Background())
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-relay").Logger()
return &OutputRelay{
source: source,
audioTrack: audioTrack,
ctx: ctx,
cancel: cancel,
logger: logger,
stopped: make(chan struct{}),
sample: media.Sample{
Duration: 20 * time.Millisecond,
},
}
}
// Start begins relaying audio frames
func (r *OutputRelay) Start() error {
if r.running.Swap(true) {
return fmt.Errorf("output relay already running")
}
go r.relayLoop()
r.logger.Debug().Msg("output relay started")
return nil
}
// Stop stops the relay and waits for goroutine to exit
func (r *OutputRelay) Stop() {
if !r.running.Swap(false) {
return
}
r.cancel()
<-r.stopped
r.logger.Debug().
Uint32("frames_relayed", r.framesRelayed.Load()).
Uint32("frames_dropped", r.framesDropped.Load()).
Msg("output relay stopped")
}
// relayLoop continuously reads from audio source and writes to WebRTC
func (r *OutputRelay) relayLoop() {
defer close(r.stopped)
const reconnectDelay = 1 * time.Second
for r.running.Load() {
// Ensure connected
if !r.source.IsConnected() {
if err := r.source.Connect(); err != nil {
r.logger.Debug().Err(err).Msg("failed to connect, will retry")
time.Sleep(reconnectDelay)
continue
}
}
// Read message from audio source
msgType, payload, err := r.source.ReadMessage()
if err != nil {
// Connection error - reconnect
if r.running.Load() {
r.logger.Warn().Err(err).Msg("read error, reconnecting")
r.source.Disconnect()
time.Sleep(reconnectDelay)
}
continue
}
// Handle message
if msgType == ipcMsgTypeOpus && len(payload) > 0 {
// Reuse sample struct (zero-allocation hot path)
r.sample.Data = payload
if err := r.audioTrack.WriteSample(r.sample); err != nil {
r.framesDropped.Add(1)
r.logger.Warn().Err(err).Msg("failed to write sample to WebRTC")
} else {
r.framesRelayed.Add(1)
}
}
}
}
// InputRelay forwards audio from WebRTC (browser microphone) to AudioSource (USB audio)
type InputRelay struct {
source AudioSource
ctx context.Context
cancel context.CancelFunc
logger zerolog.Logger
running atomic.Bool
}
// NewInputRelay creates a relay for input audio (browser → device)
func NewInputRelay(source AudioSource) *InputRelay {
ctx, cancel := context.WithCancel(context.Background())
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-relay").Logger()
return &InputRelay{
source: source,
ctx: ctx,
cancel: cancel,
logger: logger,
}
}
// Start begins relaying audio frames
func (r *InputRelay) Start() error {
if r.running.Swap(true) {
return fmt.Errorf("input relay already running")
}
r.logger.Debug().Msg("input relay started")
return nil
}
// Stop stops the relay
func (r *InputRelay) Stop() {
if !r.running.Swap(false) {
return
}
r.cancel()
r.logger.Debug().Msg("input relay stopped")
}

28
internal/audio/source.go Normal file
View File

@ -0,0 +1,28 @@
package audio
// IPC message types
const (
ipcMsgTypeOpus = 0 // Message type for Opus audio data
)
// AudioSource provides audio frames via CGO (in-process) C audio functions
type AudioSource interface {
// ReadMessage reads the next audio message
// Returns message type, payload data, and error
// Blocks until data is available or error occurs
// Used for output path (device → browser)
ReadMessage() (msgType uint8, payload []byte, err error)
// WriteMessage writes an audio message
// Used for input path (browser → device)
WriteMessage(msgType uint8, payload []byte) error
// IsConnected returns true if the source is connected and ready
IsConnected() bool
// Connect initializes the C audio subsystem
Connect() error
// Disconnect closes the connection and releases resources
Disconnect()
}

View File

@ -752,7 +752,6 @@ void *run_detect_format(void *arg)
while (!should_exit)
{
ensure_sleep_mode_disabled();
memset(&dv_timings, 0, sizeof(dv_timings));
if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0)

View File

@ -8,8 +8,8 @@ import (
const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
// DefaultEDID is the default EDID for the video stream.
const DefaultEDID = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
// DefaultEDID is the default EDID (identifies as "JetKVM HDMI" with full TC358743 audio/video capabilities).
const DefaultEDID = "00ffffffffffff002a8b01000100000001230104800000782ec9a05747982712484c00000000d1c081c0a9c0b3000101010101010101083a801871382d40582c450000000000001e011d007251d01e206e28550000000000001e000000fc004a65746b564d2048444d490a20000000fd00187801ff1d000a20202020202001e102032e7229097f070d07070f0707509005040302011f132220111214061507831f000068030c0010003021e2050700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047"
var extraLockTimeout = 5 * time.Second

View File

@ -1,7 +1,9 @@
package usbgadget
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag"
@ -114,7 +116,20 @@ func (c *ChangeSetResolver) resolveChanges(initial bool) error {
}
func (c *ChangeSetResolver) applyChanges() error {
return c.applyChangesWithTimeout(45 * time.Second)
}
func (c *ChangeSetResolver) applyChangesWithTimeout(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, change := range c.resolvedChanges {
select {
case <-ctx.Done():
return fmt.Errorf("USB gadget reconfiguration timed out after %v: %w", timeout, ctx.Err())
default:
}
change.ResetActionResolution()
action := change.Action()
actionStr := FileChangeResolvedActionString[action]
@ -126,7 +141,7 @@ func (c *ChangeSetResolver) applyChanges() error {
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change)
err := c.applyChangeWithTimeout(ctx, change)
if err != nil {
if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
@ -139,6 +154,20 @@ func (c *ChangeSetResolver) applyChanges() error {
return nil
}
func (c *ChangeSetResolver) applyChangeWithTimeout(ctx context.Context, change *FileChange) error {
done := make(chan error, 1)
go func() {
done <- c.changeset.applyChange(change)
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("change application timed out for %s: %w", change.String(), ctx.Err())
}
}
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange)

View File

@ -59,6 +59,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
// mass storage
"mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config,
// audio (UAC1 - USB Audio Class 1)
"audio": {
order: 4000,
device: "uac1.usb0",
path: []string{"functions", "uac1.usb0"},
configPath: []string{"uac1.usb0"},
attrs: gadgetAttributes{
"p_chmask": "3", // Playback: stereo (2 channels)
"p_srate": "48000", // Playback: 48kHz sample rate
"p_ssize": "2", // Playback: 16-bit (2 bytes)
"p_volume_present": "0", // Playback: no volume control
"c_chmask": "3", // Capture: stereo (2 channels)
"c_srate": "48000", // Capture: 48kHz sample rate
"c_ssize": "2", // Capture: 16-bit (2 bytes)
"c_volume_present": "0", // Capture: no volume control
},
},
}
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
@ -73,6 +90,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
case "audio":
return u.enabledDevices.Audio
default:
return true
}
@ -182,6 +201,9 @@ func (u *UsbGadget) Init() error {
return u.logError("unable to initialize USB stack", err)
}
// Pre-open HID files to reduce input latency
u.PreOpenHidFiles()
return nil
}
@ -191,11 +213,17 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig()
// Close HID files before reconfiguration to prevent "file already closed" errors
u.CloseHidFiles()
err := u.configureUsbGadget(true)
if err != nil {
return u.logError("unable to update gadget config", err)
}
// Reopen HID files after reconfiguration
u.PreOpenHidFiles()
return nil
}

View File

@ -1,10 +1,12 @@
package usbgadget
import (
"context"
"fmt"
"path"
"path/filepath"
"sort"
"time"
"github.com/rs/zerolog"
)
@ -52,22 +54,49 @@ func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
}
func (u *UsbGadget) WithTransaction(fn func() error) error {
return u.WithTransactionTimeout(fn, 60*time.Second)
}
// WithTransactionTimeout executes a USB gadget transaction with a specified timeout
// to prevent indefinite blocking during USB reconfiguration operations
func (u *UsbGadget) WithTransactionTimeout(fn func() error, timeout time.Duration) error {
// Create a context with timeout for the entire transaction
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Channel to signal when the transaction is complete
done := make(chan error, 1)
// Execute the transaction in a goroutine
go func() {
u.txLock.Lock()
defer u.txLock.Unlock()
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
return err
done <- err
return
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
return err
done <- err
return
}
result := u.tx.Commit()
u.tx = nil
done <- result
}()
return result
// Wait for either completion or timeout
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("USB gadget transaction timed out after %v: %w", timeout, ctx.Err())
}
}
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {

View File

@ -19,6 +19,7 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"`
Audio bool `json:"audio"`
}
// Config is a struct that represents the customizations for a USB gadget.
@ -39,6 +40,7 @@ var defaultUsbGadgetDevices = Devices{
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
}
type KeysDownState struct {
@ -188,3 +190,63 @@ func (u *UsbGadget) Close() error {
return nil
}
// CloseHidFiles closes all open HID files
func (u *UsbGadget) CloseHidFiles() {
u.log.Debug().Msg("closing HID files")
// Close keyboard HID file
if u.keyboardHidFile != nil {
if err := u.keyboardHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close keyboard HID file")
}
u.keyboardHidFile = nil
}
// Close absolute mouse HID file
if u.absMouseHidFile != nil {
if err := u.absMouseHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close absolute mouse HID file")
}
u.absMouseHidFile = nil
}
// Close relative mouse HID file
if u.relMouseHidFile != nil {
if err := u.relMouseHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close relative mouse HID file")
}
u.relMouseHidFile = nil
}
}
// PreOpenHidFiles opens all HID files to reduce input latency
func (u *UsbGadget) PreOpenHidFiles() {
// Add a small delay to allow USB gadget reconfiguration to complete
// This prevents "no such device or address" errors when trying to open HID files
time.Sleep(100 * time.Millisecond)
if u.enabledDevices.Keyboard {
if err := u.openKeyboardHidFile(); err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file")
}
}
if u.enabledDevices.AbsoluteMouse {
if u.absMouseHidFile == nil {
var err error
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
if err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open absolute mouse HID file")
}
}
}
if u.enabledDevices.RelativeMouse {
if u.relMouseHidFile == nil {
var err error
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
if err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open relative mouse HID file")
}
}
}
}

View File

@ -678,7 +678,8 @@ func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
LoadConfig()
config.UsbConfig = &usbConfig
gadget.SetGadgetConfig(config.UsbConfig)
return updateUsbRelatedConfig()
wasAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
return updateUsbRelatedConfig(wasAudioEnabled)
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
@ -890,23 +891,51 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func updateUsbRelatedConfig() error {
func updateUsbRelatedConfig(wasAudioEnabled bool) error {
ensureConfigLoaded()
audioMutex.Lock()
inRelay := inputRelay
inSource := inputSource
inputRelay = nil
inputSource = nil
audioMutex.Unlock()
if inRelay != nil {
inRelay.Stop()
}
if inSource != nil {
inSource.Disconnect()
}
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
// Restart audio if USB audio is enabled with active connections
if activeConnections.Load() > 0 && config.UsbDevices != nil && config.UsbDevices.Audio {
if err := startAudio(); err != nil {
logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration")
}
}
return nil
}
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
wasAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
return updateUsbRelatedConfig(wasAudioEnabled)
}
func rpcSetUsbDeviceState(device string, enabled bool) error {
wasAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
switch device {
case "absoluteMouse":
config.UsbDevices.AbsoluteMouse = enabled
@ -916,11 +945,46 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
config.UsbDevices.Keyboard = enabled
case "massStorage":
config.UsbDevices.MassStorage = enabled
case "audio":
config.UsbDevices.Audio = enabled
default:
return fmt.Errorf("invalid device: %s", device)
}
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
return updateUsbRelatedConfig(wasAudioEnabled)
}
func rpcGetAudioOutputEnabled() (bool, error) {
ensureConfigLoaded()
return config.AudioOutputEnabled, nil
}
func rpcSetAudioOutputEnabled(enabled bool) error {
ensureConfigLoaded()
config.AudioOutputEnabled = enabled
if err := SaveConfig(); err != nil {
return err
}
return SetAudioOutputEnabled(enabled)
}
func rpcGetAudioInputEnabled() (bool, error) {
return audioInputEnabled.Load(), nil
}
func rpcSetAudioInputEnabled(enabled bool) error {
return SetAudioInputEnabled(enabled)
}
func rpcGetAudioInputAutoEnable() (bool, error) {
ensureConfigLoaded()
return config.AudioInputAutoEnable, nil
}
func rpcSetAudioInputAutoEnable(enabled bool) error {
ensureConfigLoaded()
config.AudioInputAutoEnable = enabled
return SaveConfig()
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
@ -1241,6 +1305,12 @@ var rpcHandlers = map[string]RPCHandler{
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"getAudioOutputEnabled": {Func: rpcGetAudioOutputEnabled},
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
"getAudioInputAutoEnable": {Func: rpcGetAudioInputAutoEnable},
"setAudioInputAutoEnable": {Func: rpcSetAudioInputAutoEnable, Params: []string{"enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},

View File

@ -36,6 +36,7 @@ func Main() {
initDisplay()
initNative(systemVersionLocal, appVersionLocal)
initAudio()
http.DefaultClient.Timeout = 1 * time.Minute
@ -132,7 +133,10 @@ func Main() {
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Log().Msg("JetKVM Shutting Down")
logger.Info().Msg("JetKVM Shutting Down")
stopAudio()
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {

View File

@ -41,6 +41,7 @@ BUILD_IN_DOCKER=${BUILD_IN_DOCKER:-false}
function prepare_docker_build_context() {
msg_info "▶ Preparing docker build context ..."
cp .devcontainer/install-deps.sh \
.devcontainer/install_audio_deps.sh \
go.mod \
go.sum \
Dockerfile.build \

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Selvsigneret",
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
"access_update_tls_settings": "Opdater TLS-indstillinger",
"action_bar_audio": "Lyd",
"action_bar_connection_stats": "Forbindelsesstatistik",
"audio_disable": "Deaktiver",
"audio_enable": "Aktiver",
"audio_input_description": "Aktiver mikrofonindgang til målet",
"audio_input_disabled": "Lydindgang deaktiveret",
"audio_input_enabled": "Lydindgang aktiveret",
"audio_input_failed_disable": "Kunne ikke deaktivere lydindgang: {error}",
"audio_input_failed_enable": "Kunne ikke aktivere lydindgang: {error}",
"audio_input_title": "Lydindgang (Mikrofon)",
"audio_input_auto_enable_disabled": "Automatisk aktivering af mikrofon deaktiveret",
"audio_input_auto_enable_enabled": "Automatisk aktivering af mikrofon aktiveret",
"audio_output_description": "Aktiver lyd fra mål til højttalere",
"audio_output_disabled": "Lydudgang deaktiveret",
"audio_output_enabled": "Lydudgang aktiveret",
"audio_output_failed_disable": "Kunne ikke deaktivere lydudgang: {error}",
"audio_output_failed_enable": "Kunne ikke aktivere lydudgang: {error}",
"audio_output_title": "Lydudgang",
"audio_popover_title": "Lyd",
"audio_popover_description": "Hurtige lydkontroller til højttalere og mikrofon",
"audio_speakers_title": "Højttalere",
"audio_speakers_description": "Lyd fra mål til højttalere",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofonindgang til mål",
"audio_https_only": "Kun HTTPS",
"audio_settings_description": "Konfigurer lydindgangs- og lydudgangsindstillinger for din JetKVM-enhed",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Aktiver eller deaktiver mikrofon lyd til fjerncomputeren",
"audio_settings_input_title": "Lydindgang",
"audio_settings_output_description": "Aktiver eller deaktiver lyd fra fjerncomputeren",
"audio_settings_output_source_description": "Vælg lydoptagelsesenheden (HDMI eller USB)",
"audio_settings_output_source_failed": "Kunne ikke indstille lydudgangskilde: {error}",
"audio_settings_output_source_success": "Lydudgangskilde opdateret med succes",
"audio_settings_output_source_title": "Lydudgangskilde",
"audio_settings_output_title": "Lydudgang",
"audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)",
"action_bar_extension": "Udvidelse",
"action_bar_fullscreen": "Fuldskærm",
"action_bar_settings": "Indstillinger",
@ -790,6 +828,8 @@
"usb_device_description": "USB-enheder, der skal emuleres på målcomputeren",
"usb_device_enable_absolute_mouse_description": "Aktivér absolut mus (markør)",
"usb_device_enable_absolute_mouse_title": "Aktivér absolut mus (markør)",
"usb_device_enable_audio_description": "Aktiver tovejs lyd",
"usb_device_enable_audio_title": "Aktiver USB-lyd",
"usb_device_enable_keyboard_description": "Aktivér tastatur",
"usb_device_enable_keyboard_title": "Aktivér tastatur",
"usb_device_enable_mass_storage_description": "Nogle gange skal det muligvis deaktiveres for at forhindre problemer med bestemte enheder.",
@ -799,6 +839,7 @@
"usb_device_failed_load": "Kunne ikke indlæse USB-enheder: {error}",
"usb_device_failed_set": "Kunne ikke indstille USB-enheder: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastatur, mus, masselager og lyd",
"usb_device_keyboard_only": "Kun tastatur",
"usb_device_restore_default": "Gendan til standard",
"usb_device_title": "USB-enhed",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Selbstsigniert",
"access_tls_updated": "TLS-Einstellungen erfolgreich aktualisiert",
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Verbindungsstatistiken",
"audio_disable": "Deaktivieren",
"audio_enable": "Aktivieren",
"audio_input_description": "Mikrofoneingang zum Ziel aktivieren",
"audio_input_disabled": "Audioeingang deaktiviert",
"audio_input_enabled": "Audioeingang aktiviert",
"audio_input_failed_disable": "Fehler beim Deaktivieren des Audioeingangs: {error}",
"audio_input_failed_enable": "Fehler beim Aktivieren des Audioeingangs: {error}",
"audio_input_title": "Audioeingang (Mikrofon)",
"audio_input_auto_enable_disabled": "Automatische Mikrofonaktivierung deaktiviert",
"audio_input_auto_enable_enabled": "Automatische Mikrofonaktivierung aktiviert",
"audio_output_description": "Audio vom Ziel zu Lautsprechern aktivieren",
"audio_output_disabled": "Audioausgang deaktiviert",
"audio_output_enabled": "Audioausgang aktiviert",
"audio_output_failed_disable": "Fehler beim Deaktivieren des Audioausgangs: {error}",
"audio_output_failed_enable": "Fehler beim Aktivieren des Audioausgangs: {error}",
"audio_output_title": "Audioausgang",
"audio_popover_title": "Audio",
"audio_popover_description": "Schnelle Audiosteuerung für Lautsprecher und Mikrofon",
"audio_speakers_title": "Lautsprecher",
"audio_speakers_description": "Audio vom Ziel zu Lautsprechern",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofoneingang zum Ziel",
"audio_https_only": "Nur HTTPS",
"audio_settings_description": "Konfigurieren Sie Audio-Eingangs- und Ausgangseinstellungen für Ihr JetKVM-Gerät",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Mikrofonaudio zum entfernten Computer aktivieren oder deaktivieren",
"audio_settings_input_title": "Audioeingang",
"audio_settings_output_description": "Audio vom entfernten Computer aktivieren oder deaktivieren",
"audio_settings_output_source_description": "Wählen Sie das Audioaufnahmegerät (HDMI oder USB)",
"audio_settings_output_source_failed": "Fehler beim Festlegen der Audioausgabequelle: {error}",
"audio_settings_output_source_success": "Audioausgabequelle erfolgreich aktualisiert",
"audio_settings_output_source_title": "Audioausgabequelle",
"audio_settings_output_title": "Audioausgang",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren",
"audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)",
"action_bar_extension": "Erweiterung",
"action_bar_fullscreen": "Vollbild",
"action_bar_settings": "Einstellungen",
@ -790,6 +828,8 @@
"usb_device_description": "USB-Geräte zum Emulieren auf dem Zielcomputer",
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
"usb_device_enable_absolute_mouse_title": "Absolute Maus (Zeiger) aktivieren",
"usb_device_enable_audio_description": "Bidirektionales Audio aktivieren",
"usb_device_enable_audio_title": "USB-Audio aktivieren",
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
"usb_device_enable_keyboard_title": "Tastatur aktivieren",
"usb_device_enable_mass_storage_description": "Manchmal muss es möglicherweise deaktiviert werden, um Probleme mit bestimmten Geräten zu vermeiden",
@ -799,6 +839,7 @@
"usb_device_failed_load": "USB-Geräte konnten nicht geladen werden: {error}",
"usb_device_failed_set": "Fehler beim Festlegen der USB-Geräte: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, Maus und Massenspeicher",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastatur, Maus, Massenspeicher und Audio",
"usb_device_keyboard_only": "Nur Tastatur",
"usb_device_restore_default": "Auf Standard zurücksetzen",
"usb_device_title": "USB-Gerät",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Self-signed",
"access_tls_updated": "TLS settings updated successfully",
"access_update_tls_settings": "Update TLS Settings",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Connection Stats",
"audio_disable": "Disable",
"audio_enable": "Enable",
"audio_input_description": "Enable microphone input to target",
"audio_input_disabled": "Audio input disabled",
"audio_input_enabled": "Audio input enabled",
"audio_input_failed_disable": "Failed to disable audio input: {error}",
"audio_input_failed_enable": "Failed to enable audio input: {error}",
"audio_input_title": "Audio Input (Microphone)",
"audio_input_auto_enable_disabled": "Auto-enable microphone disabled",
"audio_input_auto_enable_enabled": "Auto-enable microphone enabled",
"audio_output_description": "Enable audio from target to speakers",
"audio_output_disabled": "Audio output disabled",
"audio_output_enabled": "Audio output enabled",
"audio_output_failed_disable": "Failed to disable audio output: {error}",
"audio_output_failed_enable": "Failed to enable audio output: {error}",
"audio_output_title": "Audio Output",
"audio_popover_title": "Audio",
"audio_popover_description": "Quick audio controls for speakers and microphone",
"audio_speakers_title": "Speakers",
"audio_speakers_description": "Audio from target to speakers",
"audio_microphone_title": "Microphone",
"audio_microphone_description": "Microphone input to target",
"audio_https_only": "HTTPS only",
"audio_settings_description": "Configure audio input and output settings for your JetKVM device",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Enable or disable microphone audio to the remote computer",
"audio_settings_input_title": "Audio Input",
"audio_settings_output_description": "Enable or disable audio from the remote computer",
"audio_settings_output_source_description": "Select the audio capture device (HDMI or USB)",
"audio_settings_output_source_failed": "Failed to set audio output source: {error}",
"audio_settings_output_source_success": "Audio output source updated successfully",
"audio_settings_output_source_title": "Audio Output Source",
"audio_settings_output_title": "Audio Output",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Auto-enable Microphone",
"audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen",
"action_bar_settings": "Settings",
@ -790,6 +828,8 @@
"usb_device_description": "USB devices to emulate on the target computer",
"usb_device_enable_absolute_mouse_description": "Enable Absolute Mouse (Pointer)",
"usb_device_enable_absolute_mouse_title": "Enable Absolute Mouse (Pointer)",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Enable Keyboard",
"usb_device_enable_keyboard_title": "Enable Keyboard",
"usb_device_enable_mass_storage_description": "Sometimes it might need to be disabled to prevent issues with certain devices",
@ -799,6 +839,7 @@
"usb_device_failed_load": "Failed to load USB devices: {error}",
"usb_device_failed_set": "Failed to set USB devices: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Keyboard Only",
"usb_device_restore_default": "Restore to Default",
"usb_device_title": "USB Device",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Autofirmado",
"access_tls_updated": "La configuración de TLS se actualizó correctamente",
"access_update_tls_settings": "Actualizar la configuración de TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Estadísticas de conexión",
"audio_disable": "Desactivar",
"audio_enable": "Activar",
"audio_input_description": "Habilitar entrada de micrófono al objetivo",
"audio_input_disabled": "Entrada de audio desactivada",
"audio_input_enabled": "Entrada de audio activada",
"audio_input_failed_disable": "Error al desactivar la entrada de audio: {error}",
"audio_input_failed_enable": "Error al activar la entrada de audio: {error}",
"audio_input_title": "Entrada de audio (Micrófono)",
"audio_input_auto_enable_disabled": "Habilitación automática de micrófono desactivada",
"audio_input_auto_enable_enabled": "Habilitación automática de micrófono activada",
"audio_output_description": "Habilitar audio del objetivo a los altavoces",
"audio_output_disabled": "Salida de audio desactivada",
"audio_output_enabled": "Salida de audio activada",
"audio_output_failed_disable": "Error al desactivar la salida de audio: {error}",
"audio_output_failed_enable": "Error al activar la salida de audio: {error}",
"audio_output_title": "Salida de audio",
"audio_popover_title": "Audio",
"audio_popover_description": "Controles de audio rápidos para altavoces y micrófono",
"audio_speakers_title": "Altavoces",
"audio_speakers_description": "Audio del objetivo a los altavoces",
"audio_microphone_title": "Micrófono",
"audio_microphone_description": "Entrada de micrófono al objetivo",
"audio_https_only": "Solo HTTPS",
"audio_settings_description": "Configure los ajustes de entrada y salida de audio para su dispositivo JetKVM",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Habilitar o deshabilitar el audio del micrófono a la computadora remota",
"audio_settings_input_title": "Entrada de audio",
"audio_settings_output_description": "Habilitar o deshabilitar el audio de la computadora remota",
"audio_settings_output_source_description": "Seleccione el dispositivo de captura de audio (HDMI o USB)",
"audio_settings_output_source_failed": "Error al configurar la fuente de salida de audio: {error}",
"audio_settings_output_source_success": "Fuente de salida de audio actualizada correctamente",
"audio_settings_output_source_title": "Fuente de salida de audio",
"audio_settings_output_title": "Salida de audio",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente",
"audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)",
"action_bar_extension": "Extensión",
"action_bar_fullscreen": "Pantalla completa",
"action_bar_settings": "Ajustes",
@ -790,6 +828,8 @@
"usb_device_description": "Dispositivos USB para emular en la computadora de destino",
"usb_device_enable_absolute_mouse_description": "Habilitar el puntero absoluto del ratón",
"usb_device_enable_absolute_mouse_title": "Habilitar el puntero absoluto del ratón",
"usb_device_enable_audio_description": "Habilitar audio bidireccional",
"usb_device_enable_audio_title": "Habilitar audio USB",
"usb_device_enable_keyboard_description": "Habilitar el teclado",
"usb_device_enable_keyboard_title": "Habilitar el teclado",
"usb_device_enable_mass_storage_description": "A veces puede ser necesario desactivarlo para evitar problemas con ciertos dispositivos.",
@ -799,6 +839,7 @@
"usb_device_failed_load": "No se pudieron cargar los dispositivos USB: {error}",
"usb_device_failed_set": "No se pudieron configurar los dispositivos USB: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Teclado, ratón y almacenamiento masivo",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Teclado, ratón, almacenamiento masivo y audio",
"usb_device_keyboard_only": "Sólo teclado",
"usb_device_restore_default": "Restaurar a valores predeterminados",
"usb_device_title": "Dispositivo USB",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Auto-signé",
"access_tls_updated": "Les paramètres TLS ont été mis à jour avec succès",
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Statistiques de connexion",
"audio_disable": "Désactiver",
"audio_enable": "Activer",
"audio_input_description": "Activer l'entrée microphone vers la cible",
"audio_input_disabled": "Entrée audio désactivée",
"audio_input_enabled": "Entrée audio activée",
"audio_input_failed_disable": "Échec de la désactivation de l'entrée audio : {error}",
"audio_input_failed_enable": "Échec de l'activation de l'entrée audio : {error}",
"audio_input_title": "Entrée audio (Microphone)",
"audio_input_auto_enable_disabled": "Activation automatique du microphone désactivée",
"audio_input_auto_enable_enabled": "Activation automatique du microphone activée",
"audio_output_description": "Activer l'audio de la cible vers les haut-parleurs",
"audio_output_disabled": "Sortie audio désactivée",
"audio_output_enabled": "Sortie audio activée",
"audio_output_failed_disable": "Échec de la désactivation de la sortie audio : {error}",
"audio_output_failed_enable": "Échec de l'activation de la sortie audio : {error}",
"audio_output_title": "Sortie audio",
"audio_popover_title": "Audio",
"audio_popover_description": "Contrôles audio rapides pour haut-parleurs et microphone",
"audio_speakers_title": "Haut-parleurs",
"audio_speakers_description": "Audio de la cible vers les haut-parleurs",
"audio_microphone_title": "Microphone",
"audio_microphone_description": "Entrée microphone vers la cible",
"audio_https_only": "HTTPS uniquement",
"audio_settings_description": "Configurez les paramètres d'entrée et de sortie audio pour votre appareil JetKVM",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Activer ou désactiver l'audio du microphone vers l'ordinateur distant",
"audio_settings_input_title": "Entrée audio",
"audio_settings_output_description": "Activer ou désactiver l'audio de l'ordinateur distant",
"audio_settings_output_source_description": "Sélectionnez le périphérique de capture audio (HDMI ou USB)",
"audio_settings_output_source_failed": "Échec de la configuration de la source de sortie audio : {error}",
"audio_settings_output_source_success": "Source de sortie audio mise à jour avec succès",
"audio_settings_output_source_title": "Source de sortie audio",
"audio_settings_output_title": "Sortie audio",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone",
"audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Plein écran",
"action_bar_settings": "Paramètres",
@ -790,6 +828,8 @@
"usb_device_description": "Périphériques USB à émuler sur l'ordinateur cible",
"usb_device_enable_absolute_mouse_description": "Activer la souris absolue (pointeur)",
"usb_device_enable_absolute_mouse_title": "Activer la souris absolue (pointeur)",
"usb_device_enable_audio_description": "Activer l'audio bidirectionnel",
"usb_device_enable_audio_title": "Activer l'audio USB",
"usb_device_enable_keyboard_description": "Activer le clavier",
"usb_device_enable_keyboard_title": "Activer le clavier",
"usb_device_enable_mass_storage_description": "Parfois, il peut être nécessaire de le désactiver pour éviter des problèmes avec certains appareils",
@ -799,6 +839,7 @@
"usb_device_failed_load": "Échec du chargement des périphériques USB : {error}",
"usb_device_failed_set": "Échec de la configuration des périphériques USB : {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Clavier, souris et stockage de masse",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Clavier, souris, stockage de masse et audio",
"usb_device_keyboard_only": "Clavier uniquement",
"usb_device_restore_default": "Restaurer les paramètres par défaut",
"usb_device_title": "périphérique USB",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Autofirmato",
"access_tls_updated": "Impostazioni TLS aggiornate correttamente",
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Statistiche di connessione",
"audio_disable": "Disabilita",
"audio_enable": "Abilita",
"audio_input_description": "Abilita l'ingresso del microfono al target",
"audio_input_disabled": "Ingresso audio disabilitato",
"audio_input_enabled": "Ingresso audio abilitato",
"audio_input_failed_disable": "Impossibile disabilitare l'ingresso audio: {error}",
"audio_input_failed_enable": "Impossibile abilitare l'ingresso audio: {error}",
"audio_input_title": "Ingresso audio (Microfono)",
"audio_input_auto_enable_disabled": "Abilitazione automatica microfono disabilitata",
"audio_input_auto_enable_enabled": "Abilitazione automatica microfono abilitata",
"audio_output_description": "Abilita l'audio dal target agli altoparlanti",
"audio_output_disabled": "Uscita audio disabilitata",
"audio_output_enabled": "Uscita audio abilitata",
"audio_output_failed_disable": "Impossibile disabilitare l'uscita audio: {error}",
"audio_output_failed_enable": "Impossibile abilitare l'uscita audio: {error}",
"audio_output_title": "Uscita audio",
"audio_popover_title": "Audio",
"audio_popover_description": "Controlli audio rapidi per altoparlanti e microfono",
"audio_speakers_title": "Altoparlanti",
"audio_speakers_description": "Audio dal target agli altoparlanti",
"audio_microphone_title": "Microfono",
"audio_microphone_description": "Ingresso microfono al target",
"audio_https_only": "Solo HTTPS",
"audio_settings_description": "Configura le impostazioni di ingresso e uscita audio per il tuo dispositivo JetKVM",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Abilita o disabilita l'audio del microfono al computer remoto",
"audio_settings_input_title": "Ingresso audio",
"audio_settings_output_description": "Abilita o disabilita l'audio dal computer remoto",
"audio_settings_output_source_description": "Seleziona il dispositivo di acquisizione audio (HDMI o USB)",
"audio_settings_output_source_failed": "Impossibile impostare la sorgente di uscita audio: {error}",
"audio_settings_output_source_success": "Sorgente di uscita audio aggiornata con successo",
"audio_settings_output_source_title": "Sorgente di uscita audio",
"audio_settings_output_title": "Uscita audio",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono",
"audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)",
"action_bar_extension": "Estensione",
"action_bar_fullscreen": "A schermo intero",
"action_bar_settings": "Impostazioni",
@ -790,6 +828,8 @@
"usb_device_description": "Dispositivi USB da emulare sul computer di destinazione",
"usb_device_enable_absolute_mouse_description": "Abilita mouse assoluto (puntatore)",
"usb_device_enable_absolute_mouse_title": "Abilita mouse assoluto (puntatore)",
"usb_device_enable_audio_description": "Abilita audio bidirezionale",
"usb_device_enable_audio_title": "Abilita audio USB",
"usb_device_enable_keyboard_description": "Abilita tastiera",
"usb_device_enable_keyboard_title": "Abilita tastiera",
"usb_device_enable_mass_storage_description": "A volte potrebbe essere necessario disattivarlo per evitare problemi con determinati dispositivi",
@ -799,6 +839,7 @@
"usb_device_failed_load": "Impossibile caricare i dispositivi USB: {error}",
"usb_device_failed_set": "Impossibile impostare i dispositivi USB: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastiera, mouse e memoria di massa",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastiera, mouse, archiviazione di massa e audio",
"usb_device_keyboard_only": "Solo tastiera",
"usb_device_restore_default": "Ripristina impostazioni predefinite",
"usb_device_title": "Dispositivo USB",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Selvsignert",
"access_tls_updated": "TLS-innstillingene er oppdatert",
"access_update_tls_settings": "Oppdater TLS-innstillinger",
"action_bar_audio": "Lyd",
"action_bar_connection_stats": "Tilkoblingsstatistikk",
"audio_disable": "Deaktiver",
"audio_enable": "Aktiver",
"audio_input_description": "Aktiver mikrofoninngang til målet",
"audio_input_disabled": "Lydinngang deaktivert",
"audio_input_enabled": "Lydinngang aktivert",
"audio_input_failed_disable": "Kunne ikke deaktivere lydinngang: {error}",
"audio_input_failed_enable": "Kunne ikke aktivere lydinngang: {error}",
"audio_input_title": "Lydinngang (Mikrofon)",
"audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon deaktivert",
"audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktivert",
"audio_output_description": "Aktiver lyd fra mål til høyttalere",
"audio_output_disabled": "Lydutgang deaktivert",
"audio_output_enabled": "Lydutgang aktivert",
"audio_output_failed_disable": "Kunne ikke deaktivere lydutgang: {error}",
"audio_output_failed_enable": "Kunne ikke aktivere lydutgang: {error}",
"audio_output_title": "Lydutgang",
"audio_popover_title": "Lyd",
"audio_popover_description": "Raske lydkontroller for høyttalere og mikrofon",
"audio_speakers_title": "Høyttalere",
"audio_speakers_description": "Lyd fra mål til høyttalere",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofoninngang til mål",
"audio_https_only": "Kun HTTPS",
"audio_settings_description": "Konfigurer lydinngangs- og lydutgangsinnstillinger for JetKVM-enheten din",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Aktiver eller deaktiver mikrofonlyd til den eksterne datamaskinen",
"audio_settings_input_title": "Lydinngang",
"audio_settings_output_description": "Aktiver eller deaktiver lyd fra den eksterne datamaskinen",
"audio_settings_output_source_description": "Velg lydopptaksenhet (HDMI eller USB)",
"audio_settings_output_source_failed": "Kunne ikke angi lydutgangskilde: {error}",
"audio_settings_output_source_success": "Lydutgangskilde oppdatert vellykket",
"audio_settings_output_source_title": "Lydutgangskilde",
"audio_settings_output_title": "Lydutgang",
"audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)",
"action_bar_extension": "Forlengelse",
"action_bar_fullscreen": "Fullskjerm",
"action_bar_settings": "Innstillinger",
@ -790,6 +828,8 @@
"usb_device_description": "USB-enheter som skal emuleres på måldatamaskinen",
"usb_device_enable_absolute_mouse_description": "Aktiver absolutt mus (peker)",
"usb_device_enable_absolute_mouse_title": "Aktiver absolutt mus (peker)",
"usb_device_enable_audio_description": "Aktiver toveis lyd",
"usb_device_enable_audio_title": "Aktiver USB-lyd",
"usb_device_enable_keyboard_description": "Aktiver tastatur",
"usb_device_enable_keyboard_title": "Aktiver tastatur",
"usb_device_enable_mass_storage_description": "Noen ganger må det kanskje deaktiveres for å forhindre problemer med visse enheter.",
@ -799,6 +839,7 @@
"usb_device_failed_load": "Klarte ikke å laste inn USB-enheter: {error}",
"usb_device_failed_set": "Kunne ikke angi USB-enheter: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastatur, mus, masselagring og lyd",
"usb_device_keyboard_only": "Kun tastatur",
"usb_device_restore_default": "Gjenopprett til standard",
"usb_device_title": "USB-enhet",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "Självsignerad",
"access_tls_updated": "TLS-inställningarna har uppdaterats",
"access_update_tls_settings": "Uppdatera TLS-inställningar",
"action_bar_audio": "Ljud",
"action_bar_connection_stats": "Anslutningsstatistik",
"audio_disable": "Inaktivera",
"audio_enable": "Aktivera",
"audio_input_description": "Aktivera mikrofoningång till målet",
"audio_input_disabled": "Ljudingång inaktiverad",
"audio_input_enabled": "Ljudingång aktiverad",
"audio_input_failed_disable": "Det gick inte att inaktivera ljudingången: {error}",
"audio_input_failed_enable": "Det gick inte att aktivera ljudingången: {error}",
"audio_input_title": "Ljudingång (Mikrofon)",
"audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon inaktiverad",
"audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktiverad",
"audio_output_description": "Aktivera ljud från mål till högtalare",
"audio_output_disabled": "Ljudutgång inaktiverad",
"audio_output_enabled": "Ljudutgång aktiverad",
"audio_output_failed_disable": "Det gick inte att inaktivera ljudutgången: {error}",
"audio_output_failed_enable": "Det gick inte att aktivera ljudutgången: {error}",
"audio_output_title": "Ljudutgång",
"audio_popover_title": "Ljud",
"audio_popover_description": "Snabba ljudkontroller för högtalare och mikrofon",
"audio_speakers_title": "Högtalare",
"audio_speakers_description": "Ljud från mål till högtalare",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofoningång till mål",
"audio_https_only": "Endast HTTPS",
"audio_settings_description": "Konfigurera ljudinmatnings- och ljudutgångsinställningar för din JetKVM-enhet",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "Aktivera eller inaktivera mikrofonljud till fjärrdatorn",
"audio_settings_input_title": "Ljudingång",
"audio_settings_output_description": "Aktivera eller inaktivera ljud från fjärrdatorn",
"audio_settings_output_source_description": "Välj ljudinspelningsenhet (HDMI eller USB)",
"audio_settings_output_source_failed": "Det gick inte att ställa in ljudutgångskälla: {error}",
"audio_settings_output_source_success": "Ljudutgångskälla uppdaterades framgångsrikt",
"audio_settings_output_source_title": "Ljudutgångskälla",
"audio_settings_output_title": "Ljudutgång",
"audio_settings_title": "Ljud",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt",
"audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)",
"action_bar_extension": "Förlängning",
"action_bar_fullscreen": "Helskärm",
"action_bar_settings": "Inställningar",
@ -790,6 +828,8 @@
"usb_device_description": "USB-enheter att emulera på måldatorn",
"usb_device_enable_absolute_mouse_description": "Aktivera absolut mus (pekare)",
"usb_device_enable_absolute_mouse_title": "Aktivera absolut mus (pekare)",
"usb_device_enable_audio_description": "Aktivera dubbelriktad ljud",
"usb_device_enable_audio_title": "Aktivera USB-ljud",
"usb_device_enable_keyboard_description": "Aktivera tangentbord",
"usb_device_enable_keyboard_title": "Aktivera tangentbord",
"usb_device_enable_mass_storage_description": "Ibland kan det behöva inaktiveras för att förhindra problem med vissa enheter.",
@ -799,6 +839,7 @@
"usb_device_failed_load": "Misslyckades med att ladda USB-enheter: {error}",
"usb_device_failed_set": "Misslyckades med att ställa in USB-enheter: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tangentbord, mus och masslagring",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tangentbord, mus, masslagring och ljud",
"usb_device_keyboard_only": "Endast tangentbord",
"usb_device_restore_default": "Återställ till standard",
"usb_device_title": "USB-enhet",

View File

@ -47,7 +47,45 @@
"access_tls_self_signed": "自签名",
"access_tls_updated": "TLS 设置更新成功",
"access_update_tls_settings": "更新 TLS 设置",
"action_bar_audio": "音频",
"action_bar_connection_stats": "连接统计",
"audio_disable": "禁用",
"audio_enable": "启用",
"audio_input_description": "启用麦克风输入到目标设备",
"audio_input_disabled": "音频输入已禁用",
"audio_input_enabled": "音频输入已启用",
"audio_input_failed_disable": "禁用音频输入失败:{error}",
"audio_input_failed_enable": "启用音频输入失败:{error}",
"audio_input_title": "音频输入(麦克风)",
"audio_input_auto_enable_disabled": "自动启用麦克风已禁用",
"audio_input_auto_enable_enabled": "自动启用麦克风已启用",
"audio_output_description": "启用从目标设备到扬声器的音频",
"audio_output_disabled": "音频输出已禁用",
"audio_output_enabled": "音频输出已启用",
"audio_output_failed_disable": "禁用音频输出失败:{error}",
"audio_output_failed_enable": "启用音频输出失败:{error}",
"audio_output_title": "音频输出",
"audio_popover_title": "音频",
"audio_popover_description": "扬声器和麦克风的快速音频控制",
"audio_speakers_title": "扬声器",
"audio_speakers_description": "从目标设备到扬声器的音频",
"audio_microphone_title": "麦克风",
"audio_microphone_description": "麦克风输入到目标设备",
"audio_https_only": "仅限 HTTPS",
"audio_settings_description": "配置 JetKVM 设备的音频输入和输出设置",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_input_description": "启用或禁用到远程计算机的麦克风音频",
"audio_settings_input_title": "音频输入",
"audio_settings_output_description": "启用或禁用来自远程计算机的音频",
"audio_settings_output_source_description": "选择音频捕获设备HDMI 或 USB",
"audio_settings_output_source_failed": "设置音频输出源失败:{error}",
"audio_settings_output_source_success": "音频输出源更新成功",
"audio_settings_output_source_title": "音频输出源",
"audio_settings_output_title": "音频输出",
"audio_settings_title": "音频",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "自动启用麦克风",
"audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)",
"action_bar_extension": "扩展",
"action_bar_fullscreen": "全屏",
"action_bar_settings": "设置",
@ -790,6 +828,8 @@
"usb_device_description": "在目标计算机上仿真的 USB 设备",
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
"usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)",
"usb_device_enable_audio_description": "启用双向音频",
"usb_device_enable_audio_title": "启用 USB 音频",
"usb_device_enable_keyboard_description": "启用键盘",
"usb_device_enable_keyboard_title": "启用键盘",
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
@ -799,6 +839,7 @@
"usb_device_failed_load": "无法加载 USB 设备: {error}",
"usb_device_failed_set": "无法设置 USB 设备: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "键盘、鼠标和大容量存储器",
"usb_device_keyboard_mouse_mass_storage_and_audio": "键盘、鼠标、大容量存储和音频",
"usb_device_keyboard_only": "仅限键盘",
"usb_device_restore_default": "恢复默认设置",
"usb_device_title": "USB 设备",

View File

@ -1,6 +1,6 @@
import { Fragment, useCallback, useRef } from "react";
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal, LuVolume2 } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
@ -19,6 +19,7 @@ import PasteModal from "@components/popovers/PasteModal";
import WakeOnLanModal from "@components/popovers/WakeOnLan/Index";
import MountPopopover from "@components/popovers/MountPopover";
import ExtensionPopover from "@components/popovers/ExtensionPopover";
import AudioPopover from "@components/popovers/AudioPopover";
import { m } from "@localizations/messages.js";
export default function Actionbar({
@ -201,6 +202,36 @@ export default function Actionbar({
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text={m.action_bar_audio()}
LeadingIcon={LuVolume2}
onClick={() => {
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] flex-col overflow-visible!",
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="mx-auto w-full max-w-xl">
<AudioPopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">

View File

@ -1,17 +1,45 @@
import { cx } from "@/cva.config";
import { Link } from "react-router";
import { cva, cx } from "@/cva.config";
import LoadingSpinner from "@components/LoadingSpinner";
const badgeVariants = cva({
base: "ml-2 rounded-full px-2 py-1 text-[10px] font-medium leading-none text-white dark:border",
variants: {
variant: {
error: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50",
info: "bg-blue-500 dark:border-blue-600 dark:bg-blue-700 dark:text-blue-50",
},
},
});
interface SettingsItemProps {
readonly title: string;
readonly description: string | React.ReactNode;
readonly badge?: string;
readonly badgeVariant?: "error" | "info";
readonly badgeLink?: string;
readonly className?: string;
readonly loading?: boolean;
readonly children?: React.ReactNode;
}
export function SettingsItem(props: SettingsItemProps) {
const { title, description, badge, children, className, loading } = props;
const { title, description, badge, badgeVariant = "error", badgeLink, children, className, loading } = props;
const badgeClasses = badgeVariants({ variant: badgeVariant });
const badgeContent = badge && (
badgeLink ? (
<Link to={badgeLink} className={cx(badgeClasses, "hover:opacity-80 transition-opacity cursor-pointer")}>
{badge}
</Link>
) : (
<span className={badgeClasses}>
{badge}
</span>
)
);
return (
<label
@ -24,11 +52,7 @@ export function SettingsItem(props: SettingsItemProps) {
<div className="flex items-center gap-x-2">
<div className="flex items-center text-base font-semibold text-black dark:text-white">
{title}
{badge && (
<span className="ml-2 rounded-full bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border dark:border-red-700 dark:bg-red-800 dark:text-red-50">
{badge}
</span>
)}
{badgeContent}
</div>
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
</div>

View File

@ -24,6 +24,7 @@ export interface UsbDeviceConfig {
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
audio: boolean;
}
const defaultUsbDeviceConfig: UsbDeviceConfig = {
@ -31,17 +32,30 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
audio: true,
};
const usbPresets = [
{
label: m.usb_device_keyboard_mouse_and_mass_storage(),
label: m.usb_device_keyboard_mouse_mass_storage_and_audio(),
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
audio: true,
},
},
{
label: m.usb_device_keyboard_mouse_and_mass_storage(),
value: "keyboard_mouse_and_mass_storage",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
audio: false,
},
},
{
@ -52,6 +66,7 @@ const usbPresets = [
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
audio: false,
},
},
{
@ -219,6 +234,17 @@ export function UsbDeviceSetting() {
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title={m.usb_device_enable_audio_title()}
description={m.usb_device_enable_audio_description()}
>
<Checkbox
checked={usbDeviceConfig.audio}
onChange={onUsbConfigItemChange("audio")}
/>
</SettingsItem>
</div>
</div>
<div className="mt-6 flex gap-x-2">
<Button

View File

@ -22,16 +22,19 @@ import {
import { keys } from "@/keyboardMappings";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const audioElementsRef = useRef<HTMLAudioElement[]>([]);
const { mediaStream, peerConnectionState } = useRTCStore();
const [isPlaying, setIsPlaying] = useState(false);
const [audioAutoplayBlocked, setAudioAutoplayBlocked] = useState(false);
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isPointerLockPossible = isSecureContext();
// Store hooks
const settings = useSettingsStore();
@ -334,13 +337,34 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
if (e.track.kind === "video") {
addStreamToVideoElm(e.streams[0]);
} else if (e.track.kind === "audio") {
const audioElm = document.createElement("audio");
audioElm.srcObject = e.streams[0];
audioElm.style.display = "none";
document.body.appendChild(audioElm);
audioElementsRef.current.push(audioElm);
audioElm.play().then(() => {
setAudioAutoplayBlocked(false);
}).catch(() => {
console.debug("[Audio] Autoplay blocked, will be started by user interaction");
setAudioAutoplayBlocked(true);
});
}
},
{ signal },
);
return () => {
abortController.abort();
audioElementsRef.current.forEach((audioElm) => {
audioElm.srcObject = null;
audioElm.remove();
});
audioElementsRef.current = [];
setAudioAutoplayBlocked(false);
};
},
[addStreamToVideoElm, peerConnection],
@ -454,11 +478,12 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false;
if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false;
return true;
}, [hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
if (!isPlaying) return true;
if (audioAutoplayBlocked) return true;
return false;
}, [audioAutoplayBlocked, hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
const showPointerLockBar = useMemo(() => {
if (settings.mouseMode !== "relative") return false;
@ -519,7 +544,6 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted
playsInline
disablePictureInPicture
controlsList="nofullscreen"
@ -551,6 +575,11 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
audioElementsRef.current.forEach(audioElm => {
audioElm.play().then(() => {
setAudioAutoplayBlocked(false);
}).catch(() => undefined);
});
}}
/>
</div>

View File

@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from "react";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useSettingsStore } from "@/hooks/stores";
import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import Checkbox from "@components/Checkbox";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export default function AudioPopover() {
const { send } = useJsonRpc();
const { microphoneEnabled, setMicrophoneEnabled } = useSettingsStore();
const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true);
const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [micLoading, setMicLoading] = useState(false);
const isHttps = isSecureContext();
useEffect(() => {
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to load audio output enabled:", resp.error);
} else {
setAudioOutputEnabled(resp.result as boolean);
}
});
send("getUsbDevices", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
} else {
const usbDevices = resp.result as { audio: boolean };
setUsbAudioEnabled(usbDevices.audio || false);
}
});
}, [send]);
const handleAudioOutputEnabledToggle = useCallback((enabled: boolean) => {
setLoading(true);
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
setLoading(false);
if ("error" in resp) {
const errorMsg = enabled
? m.audio_output_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
: m.audio_output_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
notifications.error(errorMsg);
} else {
setAudioOutputEnabled(enabled);
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
notifications.success(successMsg);
}
});
}, [send]);
const handleMicrophoneToggle = useCallback((enabled: boolean) => {
setMicLoading(true);
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
setMicLoading(false);
if ("error" in resp) {
const errorMsg = enabled
? m.audio_input_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
: m.audio_input_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
notifications.error(errorMsg);
} else {
setMicrophoneEnabled(enabled);
}
});
}, [send, setMicrophoneEnabled]);
return (
<GridCard>
<div className="space-y-4 p-4 py-3">
<div className="space-y-4">
<SettingsPageHeader
title={m.audio_popover_title()}
description={m.audio_popover_description()}
/>
<div className="space-y-3">
<SettingsItem
loading={loading}
title={m.audio_speakers_title()}
description={m.audio_speakers_description()}
>
<Checkbox
checked={audioOutputEnabled}
onChange={(e) => handleAudioOutputEnabledToggle(e.target.checked)}
/>
</SettingsItem>
{usbAudioEnabled && (
<>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem
loading={micLoading}
title={m.audio_microphone_title()}
description={m.audio_microphone_description()}
badge={!isHttps ? m.audio_https_only() : undefined}
badgeVariant="info"
badgeLink={!isHttps ? "settings/access" : undefined}
>
<Checkbox
checked={microphoneEnabled}
disabled={!isHttps}
onChange={(e) => handleMicrophoneToggle(e.target.checked)}
/>
</SettingsItem>
</>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -140,6 +140,9 @@ export interface RTCState {
transceiver: RTCRtpTransceiver | null;
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
audioTransceiver: RTCRtpTransceiver | null;
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
mediaStream: MediaStream | null;
setMediaStream: (stream: MediaStream) => void;
@ -199,6 +202,9 @@ export const useRTCStore = create<RTCState>(set => ({
transceiver: null,
setTransceiver: transceiver => set({ transceiver }),
audioTransceiver: null,
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => set({ audioTransceiver: transceiver }),
peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }),
@ -375,6 +381,16 @@ export interface SettingsState {
videoContrast: number;
setVideoContrast: (value: number) => void;
// Audio settings
audioOutputEnabled: boolean;
setAudioOutputEnabled: (enabled: boolean) => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
audioInputAutoEnable: boolean;
setAudioInputAutoEnable: (enabled: boolean) => void;
resetMicrophoneState: () => void;
}
export const useSettingsStore = create(
@ -422,6 +438,15 @@ export const useSettingsStore = create(
videoContrast: 1.0,
setVideoContrast: (value: number) => set({ videoContrast: value }),
audioOutputEnabled: true,
setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }),
microphoneEnabled: false,
setMicrophoneEnabled: (enabled: boolean) => set({ microphoneEnabled: enabled }),
audioInputAutoEnable: false,
setAudioInputAutoEnable: (enabled: boolean) => set({ audioInputAutoEnable: enabled }),
resetMicrophoneState: () => set({ microphoneEnabled: false }),
}),
{
name: "settings",

View File

@ -41,6 +41,7 @@ const SettingsKeyboardRoute = lazy(() => import("@routes/devices.$id.settings.ke
const SettingsAdvancedRoute = lazy(() => import("@routes/devices.$id.settings.advanced"));
const SettingsHardwareRoute = lazy(() => import("@routes/devices.$id.settings.hardware"));
const SettingsVideoRoute = lazy(() => import("@routes/devices.$id.settings.video"));
const SettingsAudioRoute = lazy(() => import("@routes/devices.$id.settings.audio"));
const SettingsAppearanceRoute = lazy(() => import("@routes/devices.$id.settings.appearance"));
const SettingsGeneralIndexRoute = lazy(() => import("@routes/devices.$id.settings.general._index"));
const SettingsGeneralRebootRoute = lazy(() => import("@routes/devices.$id.settings.general.reboot"));
@ -191,6 +192,10 @@ if (isOnDevice) {
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "audio",
element: <SettingsAudioRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
@ -324,6 +329,10 @@ if (isOnDevice) {
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "audio",
element: <SettingsAudioRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,

View File

@ -0,0 +1,87 @@
import { useEffect } from "react";
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 Checkbox from "@components/Checkbox";
import { m } from "@localizations/messages.js";
import notifications from "../notifications";
export default function SettingsAudioRoute() {
const { send } = useJsonRpc();
const { setAudioOutputEnabled, setAudioInputAutoEnable, audioOutputEnabled, audioInputAutoEnable } = useSettingsStore();
useEffect(() => {
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setAudioOutputEnabled(resp.result as boolean);
});
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setAudioInputAutoEnable(resp.result as boolean);
});
}, [send, setAudioOutputEnabled, setAudioInputAutoEnable]);
const handleAudioOutputEnabledChange = (enabled: boolean) => {
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
const errorMsg = enabled
? m.audio_output_failed_enable({ error: String(resp.error.data || m.unknown_error()) })
: m.audio_output_failed_disable({ error: String(resp.error.data || m.unknown_error()) });
notifications.error(errorMsg);
return;
}
setAudioOutputEnabled(enabled);
const successMsg = enabled ? m.audio_output_enabled() : m.audio_output_disabled();
notifications.success(successMsg);
});
};
const handleAudioInputAutoEnableChange = (enabled: boolean) => {
send("setAudioInputAutoEnable", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(String(resp.error.data || m.unknown_error()));
return;
}
setAudioInputAutoEnable(enabled);
const successMsg = enabled
? m.audio_input_auto_enable_enabled()
: m.audio_input_auto_enable_disabled();
notifications.success(successMsg);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title={m.audio_settings_title()}
description={m.audio_settings_description()}
/>
<div className="space-y-4">
<SettingsItem
title={m.audio_settings_output_title()}
description={m.audio_settings_output_description()}
>
<Checkbox
checked={audioOutputEnabled || false}
onChange={(e) => handleAudioOutputEnabledChange(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title={m.audio_settings_auto_enable_microphone_title()}
description={m.audio_settings_auto_enable_microphone_description()}
>
<Checkbox
checked={audioInputAutoEnable || false}
onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
/>
</SettingsItem>
</div>
</div>
);
}

View File

@ -6,6 +6,7 @@ import {
LuMouse,
LuKeyboard,
LuVideo,
LuVolume2,
LuCpu,
LuShieldCheck,
LuWrench,
@ -169,6 +170,17 @@ export default function SettingsRoute() {
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="audio"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
<LuVolume2 className="h-4 w-4 shrink-0" />
<h1>Audio</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="hardware"

View File

@ -12,7 +12,7 @@ import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
"00ffffffffffff002a8b01000100000001230104800000782ec9a05747982712484c00000000d1c081c0a9c0b3000101010101010101083a801871382d40582c450000000000001e011d007251d01e206e28550000000000001e000000fc004a65744b564d2048444d490a20000000fd00187801ff1d000a20202020202001e102032e7229097f070d07070f0707509005040302011f132220111214061507831f000068030c0010003021e2050700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047";
const edids = [
{
value: defaultEdid,

View File

@ -15,7 +15,7 @@ import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config";
import { CLOUD_API } from "@/ui.config";
import { CLOUD_API, OPUS_STEREO_PARAMS } from "@/ui.config";
import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import {
@ -29,6 +29,7 @@ import {
useNetworkStateStore,
User,
useRTCStore,
useSettingsStore,
useUiStore,
useUpdateStore,
useVideoStore,
@ -51,6 +52,7 @@ import {
} from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { m } from "@localizations/messages.js";
import { isSecureContext } from "@/utils";
export type AuthMode = "password" | "noPassword" | null;
@ -111,6 +113,7 @@ export default function KvmIdRoute() {
const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore();
const { microphoneEnabled, setMicrophoneEnabled, audioInputAutoEnable, setAudioInputAutoEnable } = useSettingsStore();
const [queryParams, setQueryParams] = useSearchParams();
const {
@ -121,6 +124,8 @@ export default function KvmIdRoute() {
isTurnServerInUse, setTurnServerInUse,
rpcDataChannel,
setTransceiver,
setAudioTransceiver,
audioTransceiver,
setRpcHidChannel,
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
@ -172,6 +177,30 @@ export default function KvmIdRoute() {
) {
setLoadingMessage(m.setting_remote_description());
// Enable stereo in remote answer SDP
if (remoteDescription.sdp) {
const opusMatch = remoteDescription.sdp.match(/a=rtpmap:(\d+)\s+opus\/48000\/2/i);
if (!opusMatch) {
console.warn("[SDP] Opus 48kHz stereo not found in answer - stereo may not work");
} else {
const pt = opusMatch[1];
const fmtpRegex = new RegExp(`a=fmtp:${pt}\\s+(.+)`, 'i');
const fmtpMatch = remoteDescription.sdp.match(fmtpRegex);
if (fmtpMatch && !fmtpMatch[1].includes('stereo=')) {
remoteDescription.sdp = remoteDescription.sdp.replace(
fmtpRegex,
`a=fmtp:${pt} ${fmtpMatch[1]};${OPUS_STEREO_PARAMS}`
);
} else if (!fmtpMatch) {
remoteDescription.sdp = remoteDescription.sdp.replace(
opusMatch[0],
`${opusMatch[0]}\r\na=fmtp:${pt} ${OPUS_STEREO_PARAMS}`
);
}
}
}
try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully to: " + remoteDescription.sdp);
@ -434,6 +463,29 @@ export default function KvmIdRoute() {
makingOffer.current = true;
const offer = await pc.createOffer();
// Enable stereo for Opus audio codec
if (offer.sdp) {
const opusMatch = offer.sdp.match(/a=rtpmap:(\d+)\s+opus\/48000\/2/i);
if (!opusMatch) {
console.warn("[SDP] Opus 48kHz stereo not found in offer - stereo may not work");
} else {
const pt = opusMatch[1];
const fmtpRegex = new RegExp(`a=fmtp:${pt}\\s+(.+)`, 'i');
const fmtpMatch = offer.sdp.match(fmtpRegex);
if (fmtpMatch) {
// Modify existing fmtp line
if (!fmtpMatch[1].includes('stereo=')) {
offer.sdp = offer.sdp.replace(fmtpRegex, `a=fmtp:${pt} ${fmtpMatch[1]};${OPUS_STEREO_PARAMS}`);
}
} else {
// Add new fmtp line after rtpmap
offer.sdp = offer.sdp.replace(opusMatch[0], `${opusMatch[0]}\r\na=fmtp:${pt} ${OPUS_STEREO_PARAMS}`);
}
}
}
await pc.setLocalDescription(offer);
const sd = btoa(JSON.stringify(pc.localDescription));
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
@ -476,11 +528,16 @@ export default function KvmIdRoute() {
};
pc.ontrack = function (event) {
if (event.track.kind === "video") {
setMediaStream(event.streams[0]);
}
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
const audioTrans = pc.addTransceiver("audio", { direction: "sendrecv" });
setAudioTransceiver(audioTrans);
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = (ev: Event) => console.error(`Error on DataChannel '${rpcDataChannel.label}': ${ev}`);
@ -532,6 +589,9 @@ export default function KvmIdRoute() {
setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel,
setTransceiver,
setAudioTransceiver,
audioInputAutoEnable,
setMicrophoneEnabled,
]);
useEffect(() => {
@ -541,6 +601,66 @@ export default function KvmIdRoute() {
}
}, [peerConnectionState, cleanupAndStopReconnecting]);
const microphoneRequestInProgress = useRef(false);
useEffect(() => {
if (!audioTransceiver || !peerConnection) return;
if (microphoneEnabled) {
if (microphoneRequestInProgress.current) return;
const currentTrack = audioTransceiver.sender.track;
if (currentTrack) {
currentTrack.stop();
}
const requestMicrophone = () => {
microphoneRequestInProgress.current = true;
navigator.mediaDevices?.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
}
}).then((stream) => {
microphoneRequestInProgress.current = false;
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack && audioTransceiver.sender) {
const handleTrackEnded = () => {
console.warn('Microphone track ended unexpectedly, attempting to restart...');
if (audioTransceiver.sender.track === audioTrack) {
audioTransceiver.sender.replaceTrack(null);
setTimeout(requestMicrophone, 500);
}
};
audioTrack.addEventListener('ended', handleTrackEnded, { once: true });
audioTransceiver.sender.replaceTrack(audioTrack);
}
}).catch((err) => {
microphoneRequestInProgress.current = false;
console.error('Failed to get microphone:', err);
setMicrophoneEnabled(false);
});
};
requestMicrophone();
} else {
microphoneRequestInProgress.current = false;
if (audioTransceiver.sender.track) {
audioTransceiver.sender.track.stop();
audioTransceiver.sender.replaceTrack(null);
}
}
// Cleanup on unmount or when dependencies change
return () => {
if (audioTransceiver?.sender.track) {
audioTransceiver.sender.track.stop();
}
};
}, [microphoneEnabled, audioTransceiver, peerConnection, setMicrophoneEnabled]);
// Cleanup effect
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
@ -700,6 +820,7 @@ export default function KvmIdRoute() {
const { send } = useJsonRpc(onJsonRpcRequest);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
console.log("Requesting video state");
@ -711,6 +832,46 @@ export default function KvmIdRoute() {
});
}, [rpcDataChannel?.readyState, send, setHdmiState]);
const [audioInputAutoEnableLoaded, setAudioInputAutoEnableLoaded] = useState(false);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setAudioInputAutoEnable(resp.result as boolean);
setAudioInputAutoEnableLoaded(true);
});
}, [rpcDataChannel?.readyState, send, setAudioInputAutoEnable]);
const autoEnableAppliedRef = useRef(false);
const audioInputAutoEnableValueRef = useRef(audioInputAutoEnable);
useEffect(() => {
audioInputAutoEnableValueRef.current = audioInputAutoEnable;
}, [audioInputAutoEnable]);
useEffect(() => {
if (!audioTransceiver || !peerConnection || microphoneEnabled) return;
if (!audioInputAutoEnableLoaded || autoEnableAppliedRef.current) return;
if (audioInputAutoEnableValueRef.current && isSecureContext()) {
autoEnableAppliedRef.current = true;
send("setAudioInputEnabled", { enabled: true }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to auto-enable audio input:", resp.error);
} else {
setMicrophoneEnabled(true);
}
});
}
}, [audioTransceiver, peerConnection, audioInputAutoEnableLoaded, microphoneEnabled, setMicrophoneEnabled, send]);
useEffect(() => {
if (!peerConnection) {
autoEnableAppliedRef.current = false;
setAudioInputAutoEnableLoaded(false);
}
}, [peerConnection]);
const [needLedState, setNeedLedState] = useState(true);
// request keyboard led state from the device

View File

@ -16,8 +16,11 @@ import { DeviceStatus } from "@routes/welcome-local";
import { DEVICE_API } from "@/ui.config";
import api from "@/api";
import { m } from "@localizations/messages.js";
import { useSettingsStore } from "@/hooks/stores";
const loader: LoaderFunction = async () => {
useSettingsStore.getState().resetMicrophoneState();
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);

View File

@ -1,13 +1,19 @@
import { useEffect } from "react";
import { useLocation, useSearchParams } from "react-router";
import { m } from "@localizations/messages.js";
import AuthLayout from "@components/AuthLayout";
import { useSettingsStore } from "@/hooks/stores";
export default function LoginRoute() {
const [sq] = useSearchParams();
const location = useLocation();
const deviceId = sq.get("deviceId") || location.state?.deviceId;
useEffect(() => {
useSettingsStore.getState().resetMicrophoneState();
}, []);
if (deviceId) {
return (
<AuthLayout

View File

@ -2,3 +2,6 @@ export const CLOUD_API = import.meta.env.VITE_CLOUD_API;
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
export const DEVICE_API = "";
// Opus codec parameters for stereo audio with error correction
export const OPUS_STEREO_PARAMS = 'stereo=1;sprop-stereo=1;maxaveragebitrate=128000;usedtx=1;useinbandfec=1';

View File

@ -301,3 +301,7 @@ export function deleteCookie(name: string, domain?: string, path = "/") {
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function isSecureContext(): boolean {
return window.location.protocol === "https:" || window.location.hostname === "localhost";
}

View File

@ -22,6 +22,7 @@ import (
type Session struct {
peerConnection *webrtc.PeerConnection
VideoTrack *webrtc.TrackLocalStaticSample
AudioTrack *webrtc.TrackLocalStaticSample
ControlChannel *webrtc.DataChannel
RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
@ -323,6 +324,40 @@ func newSession(config SessionConfig) (*Session, error) {
}
}
}()
session.AudioTrack, err = webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio",
"kvm-audio",
)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to create AudioTrack (non-fatal)")
session.AudioTrack = nil
} else {
_, err = peerConnection.AddTransceiverFromTrack(session.AudioTrack, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendrecv,
})
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to add AudioTrack transceiver (non-fatal)")
session.AudioTrack = nil
} else {
setAudioTrack(session.AudioTrack)
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
scopedLogger.Info().
Str("codec", track.Codec().MimeType).
Str("track_id", track.ID()).
Msg("Received incoming audio track from browser")
// Store track for connection when audio starts
// OnTrack fires during SDP exchange, before ICE connection completes
setPendingInputTrack(track)
})
scopedLogger.Info().Msg("Audio tracks configured successfully")
}
}
var isConnected bool
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
@ -353,6 +388,8 @@ func newSession(config SessionConfig) (*Session, error) {
}
if connectionState == webrtc.ICEConnectionStateClosed {
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media")
// Only clear currentSession if this is actually the current session
// This prevents race condition where old session closes after new one connects
if session == currentSession {
// Cancel any ongoing keyboard report multi when session closes
cancelKeyboardMacro()
@ -396,10 +433,12 @@ func onActiveSessionsChanged() {
func onFirstSessionConnected() {
_ = nativeInstance.VideoStart()
onWebRTCConnect()
stopVideoSleepModeTicker()
}
func onLastSessionDisconnected() {
_ = nativeInstance.VideoStop()
onWebRTCDisconnect()
startVideoSleepModeTicker()
}