mirror of https://github.com/jetkvm/kvm.git
Compare commits
319 Commits
810abe1f43
...
b165137f79
| Author | SHA1 | Date |
|---|---|---|
|
|
b165137f79 | |
|
|
36f06a064a | |
|
|
336a75812f | |
|
|
ce4ea10551 | |
|
|
3448663afa | |
|
|
925b14da1e | |
|
|
d0e6bb1ac6 | |
|
|
645b178d0d | |
|
|
14b741c3dd | |
|
|
de74ae1a12 | |
|
|
2ea65c7a96 | |
|
|
516a953f41 | |
|
|
eeacceb667 | |
|
|
802166ba23 | |
|
|
4bc60c3f1b | |
|
|
e7e6d7cb9d | |
|
|
65bbcf85ad | |
|
|
cd7a098f76 | |
|
|
ef86af8afc | |
|
|
b2a57a64e9 | |
|
|
be2bb518ed | |
|
|
0f43a84551 | |
|
|
557b3bf3e6 | |
|
|
450836daba | |
|
|
68b1bc54ce | |
|
|
54cbd98781 | |
|
|
47782856f3 | |
|
|
f75f5eb58d | |
|
|
c96c3e215a | |
|
|
a20c13d7f3 | |
|
|
9a82df662f | |
|
|
55f40cb729 | |
|
|
50b938d13e | |
|
|
374fc310d3 | |
|
|
a81544070b | |
|
|
0951f150bf | |
|
|
71553bcef7 | |
|
|
532e83e514 | |
|
|
bb5634be58 | |
|
|
1bca60ae6b | |
|
|
dd09cbcdc3 | |
|
|
035ba4c35f | |
|
|
00b8da45d9 | |
|
|
9abb2aa026 | |
|
|
0f16e0b11a | |
|
|
9d7fd878a1 | |
|
|
58fad71112 | |
|
|
9c72db913b | |
|
|
19fe908426 | |
|
|
04dd37f58f | |
|
|
5158c89103 | |
|
|
141e2f9099 | |
|
|
7872ddc8fc | |
|
|
67447e4e5e | |
|
|
c8e220334d | |
|
|
178c7486cc | |
|
|
257993ec20 | |
|
|
56c02f1067 | |
|
|
4c12783107 | |
|
|
6ccd9fdf19 | |
|
|
74f73d9496 | |
|
|
ef5c25efcf | |
|
|
bdcac6a468 | |
|
|
160a925f40 | |
|
|
70ef7193fd | |
|
|
35b5dbd034 | |
|
|
7dc57bcdf3 | |
|
|
05b347fe74 | |
|
|
e989cad633 | |
|
|
fc38830af1 | |
|
|
76b80da157 | |
|
|
01719e01dd | |
|
|
753c613708 | |
|
|
f6dd605ea6 | |
|
|
680607e82e | |
|
|
6c6a1def28 | |
|
|
dcce0fefb7 | |
|
|
630571da25 | |
|
|
d311dee4c6 | |
|
|
7060f9e8d6 | |
|
|
b63af01d73 | |
|
|
4dec696c4a | |
|
|
093f2bbe22 | |
|
|
dec0b9d3db | |
|
|
f2ad918dfd | |
|
|
a84f63c0c4 | |
|
|
439f57c3c8 | |
|
|
b6d093f399 | |
|
|
cd87aa499c | |
|
|
274854b198 | |
|
|
6ee79b79c3 | |
|
|
8b86124be1 | |
|
|
f2edfa66f0 | |
|
|
432303e228 | |
|
|
1dbc6c9d06 | |
|
|
3e24a3c186 | |
|
|
17c3c4be9a | |
|
|
140a803ccf | |
|
|
eca3c52513 | |
|
|
55bcfb5a22 | |
|
|
0027001390 | |
|
|
caa0a60ebb | |
|
|
a5fb3bf30c | |
|
|
26e71806cb | |
|
|
2f7bf55f22 | |
|
|
8a3f1b6c32 | |
|
|
7ffb9e1d59 | |
|
|
647eca4292 | |
|
|
a8b58b5d34 | |
|
|
b23cc50d6c | |
|
|
1f88dab95f | |
|
|
0944c886e5 | |
|
|
5e257b3144 | |
|
|
fb98c4edcb | |
|
|
e894470ca8 | |
|
|
996016b0da | |
|
|
7ab4a0e41d | |
|
|
ebb79600b0 | |
|
|
b040b8feaf | |
|
|
ca38ebee0c | |
|
|
cca1fe720d | |
|
|
9d6bd997d9 | |
|
|
e29694921b | |
|
|
c8630e7c7f | |
|
|
b6858ab155 | |
|
|
0eaad6ba16 | |
|
|
557aa5891a | |
|
|
49d62f8eb0 | |
|
|
9e4392127e | |
|
|
15baf9323b | |
|
|
0e76023c39 | |
|
|
5da357ba01 | |
|
|
eab0261344 | |
|
|
e0b6e612c0 | |
|
|
f48c3fe25a | |
|
|
d4c10aef87 | |
|
|
2a81497d34 | |
|
|
8cff7d600b | |
|
|
eca1e6a80d | |
|
|
02acee0c75 | |
|
|
0f2aa9abe4 | |
|
|
5d4f4d8e10 | |
|
|
a5d1ef1225 | |
|
|
bda92b4a62 | |
|
|
3c6184d0e8 | |
|
|
2bc7e50391 | |
|
|
f71d18039b | |
|
|
00e5148eef | |
|
|
0ebfc762f7 | |
|
|
845eadec18 | |
|
|
aa21b4b459 | |
|
|
89e68f5cdb | |
|
|
f873b50469 | |
|
|
0893eb88ac | |
|
|
8cf0b639af | |
|
|
6f10010d71 | |
|
|
1d1658db15 | |
|
|
91f9dba4c6 | |
|
|
219c972e33 | |
|
|
df58e04846 | |
|
|
323d2587b7 | |
|
|
a6913bf33b | |
|
|
6890f17a54 | |
|
|
a2a87b46b8 | |
|
|
96a6a0f8f9 | |
|
|
bfdbbdc557 | |
|
|
e3b4bb2002 | |
|
|
7d39a2741e | |
|
|
e27f1cfa59 | |
|
|
b267348084 | |
|
|
5a0dce9984 | |
|
|
d3e2b2dff2 | |
|
|
947b4f9528 | |
|
|
8a189ba1b9 | |
|
|
d3bbe1bf0a | |
|
|
158437352c | |
|
|
e45bec4a9c | |
|
|
2c2f2d416b | |
|
|
0a38451c95 | |
|
|
d9072673c0 | |
|
|
9cb976ab8d | |
|
|
fcd07b2b59 | |
|
|
1a0c7a84bc | |
|
|
463f34e40b | |
|
|
4075057c2b | |
|
|
c1cc8dd832 | |
|
|
c40459664f | |
|
|
cdf6731639 | |
|
|
6f15fdf965 | |
|
|
b63404c26b | |
|
|
b497444d6d | |
|
|
476a245598 | |
|
|
5dc04321a1 | |
|
|
a3702dadd9 | |
|
|
2568660149 | |
|
|
ca365f1acd | |
|
|
5c55da0787 | |
|
|
260f62efc3 | |
|
|
a741f05829 | |
|
|
a557987629 | |
|
|
5353c1cab2 | |
|
|
370178e43b | |
|
|
9f1dd28ad6 | |
|
|
2ab90e76e0 | |
|
|
1b7198aec2 | |
|
|
f9781f170c | |
|
|
d7b67e5012 | |
|
|
8110be6cc6 | |
|
|
950ca2bd99 | |
|
|
dfbf9249b9 | |
|
|
f51f6da2de | |
|
|
fd7608384a | |
|
|
6adcc26ff2 | |
|
|
858859e317 | |
|
|
9c0aff4489 | |
|
|
0d4176cf98 | |
|
|
fe4571956d | |
|
|
f9adb4382d | |
|
|
758bbbfff6 | |
|
|
3efe2f2a1d | |
|
|
ece36ce5fd | |
|
|
cdf0b20bc7 | |
|
|
25363cef90 | |
|
|
e3e7b898b5 | |
|
|
9dda569523 | |
|
|
6355dd87be | |
|
|
cb20956445 | |
|
|
50e04192bf | |
|
|
dc2db8ed2d | |
|
|
8fb0b9f9c6 | |
|
|
e8d12bae4b | |
|
|
6a68e23d12 | |
|
|
b1f85db7de | |
|
|
e4ed2b8fad | |
|
|
fff2d2b791 | |
|
|
6898a6ef1b | |
|
|
34f8829e8a | |
|
|
60a6e6c5c5 | |
|
|
c5216920b3 | |
|
|
9e343b3cc7 | |
|
|
35a666ed31 | |
|
|
7ec583ed6a | |
|
|
d1c192bf8b | |
|
|
c89d678963 | |
|
|
6f02870c90 | |
|
|
1a0377bbdf | |
|
|
f24443e072 | |
|
|
2afe2ca539 | |
|
|
bc53523fbb | |
|
|
44a35aa5c2 | |
|
|
3a28105f56 | |
|
|
a9a1082bcc | |
|
|
e0f7b1d930 | |
|
|
5188717bb9 | |
|
|
70e49a1cac | |
|
|
9d40263eed | |
|
|
0651faeceb | |
|
|
199cca83ed | |
|
|
a3b2b46f49 | |
|
|
f729675a3f | |
|
|
785a68d923 | |
|
|
57b7bafcc1 | |
|
|
88679cda2f | |
|
|
76174f4486 | |
|
|
27a999c58a | |
|
|
ddc2f90016 | |
|
|
692f7ddb2d | |
|
|
38ad145863 | |
|
|
879ea5e472 | |
|
|
2082b1a671 | |
|
|
5e28a6c429 | |
|
|
0e1c896aa2 | |
|
|
0ed84257f6 | |
|
|
32055f5762 | |
|
|
97bcb3c1ea | |
|
|
6ecb829334 | |
|
|
e360348829 | |
|
|
1e1677b35a | |
|
|
3c1e9b8dc2 | |
|
|
62d4ec2f89 | |
|
|
aeb7a12c72 | |
|
|
671d875890 | |
|
|
7129bd5521 | |
|
|
bd4fbef6dc | |
|
|
b3373e56de | |
|
|
73e8897fc3 | |
|
|
de0077a351 | |
|
|
4875c243d3 | |
|
|
071129a9ec | |
|
|
dee8a0b5a1 | |
|
|
a976ce1da9 | |
|
|
d5295d0e4b | |
|
|
423d5775e3 | |
|
|
7e83015932 | |
|
|
629cdf59a7 | |
|
|
767311ec04 | |
|
|
c51bdc50b5 | |
|
|
1f2c46230c | |
|
|
4688f9e6ca | |
|
|
a9a92c52ab | |
|
|
4b693b4279 | |
|
|
5f905e7eee | |
|
|
94ca3fa3f4 | |
|
|
3c1f96d49c | |
|
|
a208715cc6 | |
|
|
638d08cdc5 | |
|
|
520c218598 | |
|
|
3158ca59f7 | |
|
|
612dca3fca | |
|
|
3444607021 | |
|
|
3dc196bab5 | |
|
|
575abb75f0 | |
|
|
09ac8c5e37 | |
|
|
4f47d62079 | |
|
|
28a8fa05cc | |
|
|
c529c903d0 | |
|
|
9d12dd1e54 | |
|
|
cc83e4193f | |
|
|
466271d935 |
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -14,4 +14,4 @@ node_modules
|
|||
#internal/native/include
|
||||
#internal/native/lib
|
||||
|
||||
ui/reports
|
||||
ui/reports
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -21,4 +23,4 @@ RUN go mod download && go mod verify
|
|||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
|
|
|
|||
104
Makefile
104
Makefile
|
|
@ -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
|
||||
|
|
@ -133,4 +180,39 @@ release:
|
|||
@echo "Uploading 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 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 == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
|
||||
alsaPlaybackDevice := "hw:1,0" // USB speakers
|
||||
|
||||
// Create CGO audio source
|
||||
inputSource = audio.NewCgoInputSource(alsaPlaybackDevice)
|
||||
|
||||
inputRelay = audio.NewInputRelay(inputSource)
|
||||
if err := inputRelay.Start(); err != nil {
|
||||
audioLogger.Error().Err(err).Msg("Failed to start input relay")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopOutputLocked stops output audio (assumes mutex is held)
|
||||
func stopOutputLocked() {
|
||||
if outputRelay != nil {
|
||||
outputRelay.Stop()
|
||||
outputRelay = nil
|
||||
}
|
||||
if outputSource != nil {
|
||||
outputSource.Disconnect()
|
||||
outputSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
// stopInputLocked stops input audio (assumes mutex is held)
|
||||
func stopInputLocked() {
|
||||
if inputRelay != nil {
|
||||
inputRelay.Stop()
|
||||
inputRelay = nil
|
||||
}
|
||||
if inputSource != nil {
|
||||
inputSource.Disconnect()
|
||||
inputSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
// stopAudioLocked stops all audio (assumes mutex is held)
|
||||
func stopAudioLocked() {
|
||||
stopOutputLocked()
|
||||
stopInputLocked()
|
||||
}
|
||||
|
||||
// stopAudio stops all audio
|
||||
func stopAudio() {
|
||||
audioMutex.Lock()
|
||||
defer audioMutex.Unlock()
|
||||
stopAudioLocked()
|
||||
}
|
||||
|
||||
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 {
|
||||
audioMutex.Lock()
|
||||
stopOutputLocked()
|
||||
audioMutex.Unlock()
|
||||
}
|
||||
|
||||
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 {
|
||||
audioMutex.Lock()
|
||||
stopInputLocked()
|
||||
audioMutex.Unlock()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Get source in single mutex operation (hot path optimization)
|
||||
audioMutex.Lock()
|
||||
source := inputSource
|
||||
audioMutex.Unlock()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -177,8 +180,10 @@ func getDefaultConfig() Config {
|
|||
_ = confparser.SetDefaultsAndValidate(c)
|
||||
return c
|
||||
}(),
|
||||
DefaultLogLevel: "INFO",
|
||||
VideoQualityFactor: 1.0,
|
||||
DefaultLogLevel: "INFO",
|
||||
VideoQualityFactor: 1.0,
|
||||
AudioInputAutoEnable: false,
|
||||
AudioOutputEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(¶ms);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
u.txLock.Lock()
|
||||
defer u.txLock.Unlock()
|
||||
return u.WithTransactionTimeout(fn, 60*time.Second)
|
||||
}
|
||||
|
||||
err := u.newUsbGadgetTransaction(false)
|
||||
if err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to create transaction")
|
||||
return err
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
u.log.Error().Err(err).Msg("transaction failed")
|
||||
return err
|
||||
}
|
||||
result := u.tx.Commit()
|
||||
u.tx = nil
|
||||
// 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()
|
||||
|
||||
return result
|
||||
// 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")
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
|
||||
if err := fn(); err != nil {
|
||||
u.log.Error().Err(err).Msg("transaction failed")
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
|
||||
result := u.tx.Commit()
|
||||
u.tx = nil
|
||||
done <- 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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
243
jsonrpc.go
243
jsonrpc.go
|
|
@ -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,42 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
|||
return *config.UsbDevices, nil
|
||||
}
|
||||
|
||||
func updateUsbRelatedConfig() error {
|
||||
func updateUsbRelatedConfig(wasAudioEnabled bool) error {
|
||||
ensureConfigLoaded()
|
||||
|
||||
// Stop input audio before USB reconfiguration (input uses USB)
|
||||
audioMutex.Lock()
|
||||
stopInputLocked()
|
||||
audioMutex.Unlock()
|
||||
|
||||
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 +936,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 {
|
||||
|
|
@ -1161,91 +1216,97 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro
|
|||
}
|
||||
|
||||
var rpcHandlers = map[string]RPCHandler{
|
||||
"ping": {Func: rpcPing},
|
||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||
"getCloudState": {Func: rpcGetCloudState},
|
||||
"getNetworkState": {Func: rpcGetNetworkState},
|
||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
"getVideoState": {Func: rpcGetVideoState},
|
||||
"getUSBState": {Func: rpcGetUSBState},
|
||||
"unmountImage": {Func: rpcUnmountImage},
|
||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||
"getJigglerState": {Func: rpcGetJigglerState},
|
||||
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||
"getTimezones": {Func: rpcGetTimezones},
|
||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
|
||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||
"getEDID": {Func: rpcGetEDID},
|
||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
|
||||
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
|
||||
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
|
||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||
"tryUpdate": {Func: rpcTryUpdate},
|
||||
"getDevModeState": {Func: rpcGetDevModeState},
|
||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||
"getTLSState": {Func: rpcGetTLSState},
|
||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
||||
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
||||
"getActiveExtension": {Func: rpcGetActiveExtension},
|
||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||
"getATXState": {Func: rpcGetATXState},
|
||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||
"getUsbDevices": {Func: rpcGetUsbDevices},
|
||||
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
|
||||
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
|
||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
||||
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||
"ping": {Func: rpcPing},
|
||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||
"getCloudState": {Func: rpcGetCloudState},
|
||||
"getNetworkState": {Func: rpcGetNetworkState},
|
||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
"getVideoState": {Func: rpcGetVideoState},
|
||||
"getUSBState": {Func: rpcGetUSBState},
|
||||
"unmountImage": {Func: rpcUnmountImage},
|
||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||
"getJigglerState": {Func: rpcGetJigglerState},
|
||||
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||
"getTimezones": {Func: rpcGetTimezones},
|
||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
|
||||
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
|
||||
"getEDID": {Func: rpcGetEDID},
|
||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
|
||||
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
|
||||
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
|
||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||
"tryUpdate": {Func: rpcTryUpdate},
|
||||
"getDevModeState": {Func: rpcGetDevModeState},
|
||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||
"getTLSState": {Func: rpcGetTLSState},
|
||||
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
|
||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||
"resetConfig": {Func: rpcResetConfig},
|
||||
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
|
||||
"getDisplayRotation": {Func: rpcGetDisplayRotation},
|
||||
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
|
||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
||||
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
||||
"getActiveExtension": {Func: rpcGetActiveExtension},
|
||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||
"getATXState": {Func: rpcGetATXState},
|
||||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||
"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"}},
|
||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||
}
|
||||
|
|
|
|||
6
main.go
6
main.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
@ -103,4 +104,4 @@ function do_make() {
|
|||
make "$@"
|
||||
set +x
|
||||
fi
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 设备",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
addStreamToVideoElm(e.streams[0]);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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 settings = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
settings.setAudioOutputEnabled(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
settings.setAudioInputAutoEnable(resp.result as boolean);
|
||||
});
|
||||
}, [send, settings]);
|
||||
|
||||
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;
|
||||
}
|
||||
settings.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;
|
||||
}
|
||||
settings.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={settings.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={settings.audioInputAutoEnable || false}
|
||||
onChange={(e) => handleAudioInputAutoEnableChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import notifications from "@/notifications";
|
|||
import { m } from "@localizations/messages.js";
|
||||
|
||||
const defaultEdid =
|
||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
||||
"00ffffffffffff002a8b01000100000001230104800000782ec9a05747982712484c00000000d1c081c0a9c0b3000101010101010101083a801871382d40582c450000000000001e011d007251d01e206e28550000000000001e000000fc004a65744b564d2048444d490a20000000fd00187801ff1d000a20202020202001e102032e7229097f070d07070f0707509005040302011f132220111214061507831f000068030c0010003021e2050700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047";
|
||||
const edids = [
|
||||
{
|
||||
value: defaultEdid,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
setMediaStream(event.streams[0]);
|
||||
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,40 @@ export default function KvmIdRoute() {
|
|||
}
|
||||
}, [peerConnectionState, cleanupAndStopReconnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioTransceiver || !peerConnection) return;
|
||||
|
||||
if (microphoneEnabled) {
|
||||
navigator.mediaDevices?.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 2,
|
||||
}
|
||||
}).then((stream) => {
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (audioTrack && audioTransceiver.sender) {
|
||||
audioTransceiver.sender.replaceTrack(audioTrack);
|
||||
}
|
||||
}).catch(() => {
|
||||
setMicrophoneEnabled(false);
|
||||
});
|
||||
} else {
|
||||
if (audioTransceiver.sender.track) {
|
||||
audioTransceiver.sender.track.stop();
|
||||
audioTransceiver.sender.replaceTrack(null);
|
||||
}
|
||||
}
|
||||
}, [microphoneEnabled, audioTransceiver, peerConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioTransceiver || !peerConnection || !audioInputAutoEnable || microphoneEnabled) return;
|
||||
if (isSecureContext()) {
|
||||
setMicrophoneEnabled(true);
|
||||
}
|
||||
}, [audioInputAutoEnable, audioTransceiver, peerConnection, microphoneEnabled]);
|
||||
|
||||
// Cleanup effect
|
||||
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
|
||||
|
||||
|
|
@ -700,6 +794,7 @@ export default function KvmIdRoute() {
|
|||
|
||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
console.log("Requesting video state");
|
||||
|
|
@ -711,6 +806,15 @@ export default function KvmIdRoute() {
|
|||
});
|
||||
}, [rpcDataChannel?.readyState, send, setHdmiState]);
|
||||
|
||||
// Load audio input auto-enable preference from backend
|
||||
useEffect(() => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => {
|
||||
if ("error" in resp) return;
|
||||
setAudioInputAutoEnable(resp.result as boolean);
|
||||
});
|
||||
}, [rpcDataChannel?.readyState, send, setAudioInputAutoEnable]);
|
||||
|
||||
const [needLedState, setNeedLedState] = useState(true);
|
||||
|
||||
// request keyboard led state from the device
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
24
web.go
24
web.go
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path/filepath"
|
||||
|
|
@ -184,6 +185,8 @@ func setupRouter() *gin.Engine {
|
|||
protected.PUT("/auth/password-local", handleUpdatePassword)
|
||||
protected.DELETE("/auth/local-password", handleDeletePassword)
|
||||
protected.POST("/storage/upload", handleUploadHttp)
|
||||
|
||||
protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket)
|
||||
}
|
||||
|
||||
// Catch-all route for SPA
|
||||
|
|
@ -341,7 +344,6 @@ func handleWebRTCSignalWsMessages(
|
|||
|
||||
l.Trace().Msg("sending ping frame")
|
||||
err := wsCon.Ping(runCtx)
|
||||
|
||||
if err != nil {
|
||||
l.Warn().Str("error", err.Error()).Msg("websocket ping error")
|
||||
cancelRun()
|
||||
|
|
@ -807,3 +809,23 @@ func handleSetup(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"})
|
||||
}
|
||||
|
||||
func handleSendWOLMagicPacket(c *gin.Context) {
|
||||
inputMacAddr := c.Param("mac-addr")
|
||||
macAddr, err := net.ParseMAC(inputMacAddr)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("sendWol", inputMacAddr).Msg("Invalid mac address provided")
|
||||
c.String(http.StatusBadRequest, "Invalid mac address provided")
|
||||
return
|
||||
}
|
||||
|
||||
macAddrString := macAddr.String()
|
||||
err = rpcSendWOLMagicPacket(macAddrString)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("sendWOL", macAddrString).Msg("Failed to send WOL magic packet")
|
||||
c.String(http.StatusInternalServerError, "Failed to send WOL to %s: %v", macAddrString, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, "WOL sent to %s ", macAddr)
|
||||
}
|
||||
|
|
|
|||
38
webrtc.go
38
webrtc.go
|
|
@ -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,39 @@ 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)")
|
||||
} 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 +387,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 +432,12 @@ func onActiveSessionsChanged() {
|
|||
|
||||
func onFirstSessionConnected() {
|
||||
_ = nativeInstance.VideoStart()
|
||||
onWebRTCConnect()
|
||||
stopVideoSleepModeTicker()
|
||||
}
|
||||
|
||||
func onLastSessionDisconnected() {
|
||||
_ = nativeInstance.VideoStop()
|
||||
onWebRTCDisconnect()
|
||||
startVideoSleepModeTicker()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue