diff --git a/.devcontainer/install_audio_deps.sh b/.devcontainer/install_audio_deps.sh new file mode 100755 index 00000000..bfc46120 --- /dev/null +++ b/.devcontainer/install_audio_deps.sh @@ -0,0 +1,88 @@ +#!/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 +function use_sudo() { + if [ "$UID" -eq 0 ] || [ -z "$(which sudo 2>/dev/null)" ]; then + "$@" + else + sudo -E "$@" + fi +} + +# Accept version parameters or use defaults +ALSA_VERSION="${1:-1.2.14}" +OPUS_VERSION="${2:-1.5.2}" +SPEEXDSP_VERSION="${3:-1.2.1}" + +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 +[ -f speexdsp-${SPEEXDSP_VERSION}.tar.gz ] || wget -N https://ftp.osuosl.org/pub/xiph/releases/speex/speexdsp-${SPEEXDSP_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 +[ -d speexdsp-${SPEEXDSP_VERSION} ] || tar xf speexdsp-${SPEEXDSP_VERSION}.tar.gz + +# ARM Cortex-A7 optimization flags with NEON support +OPTIM_CFLAGS="-O2 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard -DNDEBUG" + +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) . + # Minimal ALSA configuration for direct hw: device access with SpeexDSP resampling + CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR \ + --enable-static=yes --enable-shared=no \ + --with-pcm-plugins=linear,copy \ + --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 .. + +# Build SpeexDSP +cd speexdsp-${SPEEXDSP_VERSION} +if [ ! -f .built ]; then + chown -R $(whoami):$(whoami) . + # NEON-optimized high-quality resampler + CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR \ + --enable-static=yes --enable-shared=no \ + --enable-neon \ + --disable-examples + make -j$(nproc) + touch .built +fi +cd .. + +echo "ALSA, Opus, and SpeexDSP built in $AUDIO_LIBS_DIR" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..72cdff65 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,115 @@ +# JetKVM Copilot Instructions + +## Architecture Overview + +JetKVM is a high-performance KVM-over-IP solution with **bidirectional architecture**: a Go backend running on ARM hardware and a React/TypeScript frontend served by the device. The system provides real-time video streaming, HID input, virtual media mounting, and **2-way audio support** via WebRTC. + +### Core Components + +- **Backend**: Go application (`main.go`, `web.go`, `webrtc.go`) handling WebRTC, HTTP APIs, hardware interfaces +- **Frontend**: React/TypeScript SPA (`ui/src/`) with Vite build system, served as embedded static files +- **Hardware Layer**: CGO bridge (`internal/native/`) to ARM SoC for video capture, USB gadget, display control +- **Audio System**: In-process CGO implementation (`internal/audio/`) with ALSA/Opus for USB Audio Gadget streaming +- **USB Gadget**: Configurable emulation (`internal/usbgadget/`) - keyboard, mouse, mass storage, **audio device** + +## Development Workflows + +### Quick Development Commands +```bash +# Full development deployment (most common) +./dev_deploy.sh -r + +# UI-only changes (faster iteration) +cd ui && ./dev_device.sh + +# Backend-only changes (skip UI build) +./dev_deploy.sh -r --skip-ui-build + +# Build for release +make build_release +``` + +### Critical Build Dependencies +- **Audio libraries**: `make build_audio_deps` builds ALSA and Opus static libs for ARM +- **Native CGO**: `make build_native` compiles hardware interface layer +- **Cross-compilation**: ARM7 with buildkit toolchain at `/opt/jetkvm-native-buildkit` + +### Testing +```bash +# Run Go tests on device +./dev_deploy.sh -r --run-go-tests + +# UI linting +cd ui && npm run lint +``` + +## Project-Specific Patterns + +### Configuration Management +- **Single config file**: `/userdata/kvm_config.json` persisted across updates +- **Config struct**: `config.go` with JSON tags, atomic operations for thread safety +- **Migration pattern**: Use `ensureConfigLoaded()` before config access + +### WebRTC Architecture +- **Session management**: `webrtc.go` handles peer connections with connection pooling +- **Media tracks**: Separate video and **stereo audio tracks** in independent MediaStreams +- **Data channels**: RPC (ordered), HID (unordered), upload, terminal, serial channels +- **SDP munging**: Frontend modifies SDP for Opus stereo parameters (`OPUS_STEREO_PARAMS`) + +### Audio Implementation Details +- **USB Audio Gadget**: UAC1 device presenting as stereo speakers + microphone +- **Bidirectional streaming**: Output (device→browser) and input (browser→device) +- **CGO integration**: `internal/audio/cgo_source.go` bridges Go↔C for ALSA operations +- **Frame-based processing**: 20ms Opus frames (960 samples @ 48kHz) for low latency + +### Frontend State Management +- **Zustand stores**: `hooks/stores.ts` - RTC, UI, Settings, Video state with persistence +- **JSON-RPC communication**: `useJsonRpc` hook for backend API calls +- **Localization**: Paraglide.js with compile-time validation - all UI strings use `m.key()` pattern + +### USB Gadget Configuration +- **Configfs-based**: Dynamic reconfiguration via `/sys/kernel/config/usb_gadget/` +- **Transaction pattern**: `WithTransaction()` ensures atomic gadget changes +- **Device composition**: Keyboard + mouse + mass storage + **audio** with individual toggle support + +## Integration Patterns + +### Backend API Structure +- **JSON-RPC over WebSocket**: Primary communication via data channels +- **HTTP endpoints**: `/api/*` for REST operations, `/static/*` for UI assets +- **Configuration endpoints**: `rpc*` functions in `jsonrpc.go` with parameter validation + +### Cross-Component Communication +- **Video pipeline**: `native.go` → WebRTC track → browser via H.264 streaming +- **HID input**: Browser → WebRTC data channel → `internal/usbgadget/hid_*.go` → Linux gadget +- **Audio pipeline**: ALSA capture → Opus encode → WebRTC → browser speakers (and reverse for mic) +- **Virtual media**: HTTP upload → storage → NBD server → USB mass storage gadget + +### Error Handling Patterns +- **Graceful degradation**: Audio/video failures don't crash core functionality +- **Session isolation**: Per-connection logging with `connectionID` context +- **Recovery mechanisms**: USB gadget timeouts, audio restart on device changes + +## Key Files for Common Tasks + +### Adding new RPC endpoints +1. Add handler function in `jsonrpc.go` +2. Register in `rpcHandlers` map +3. Add frontend hook in `useJsonRpc` + +### USB device modifications +- `internal/usbgadget/config.go` - gadget composition and attributes +- `internal/usbgadget/hid_*.go` - HID device implementations + +### Audio feature development +- `audio.go` - high-level audio management and session integration +- `internal/audio/` - CGO sources, relays, and C audio processing + +### Frontend feature development +- `ui/src/routes/` - page components +- `ui/src/components/` - reusable UI components +- `ui/localization/messages/en.json` - localizable strings + +--- + +*This codebase uses advanced patterns like CGO, USB gadgets, and real-time media streaming. Focus on understanding the session lifecycle and cross-compilation requirements for effective development.* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1691153c..b5967418 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,14 @@ node_modules #internal/native/include #internal/native/lib -ui/reports \ No newline at end of file +ui/reports + +# Log files +*.log +app.log +deploy.log + +# Development files +.devcontainer.json +utilities/ +core diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9d3a4b62..22dbb906 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -31,6 +31,21 @@ If you're using Windows, we strongly recommend using **WSL (Windows Subsystem fo This ensures compatibility with shell scripts and build tools used in the project. +#### Using DevPod + +**For Apple Silicon (M1/M2/M3/M4) Mac users:** You must set the Docker platform to `linux/amd64` before starting the DevPod container, as the JetKVM build system requires x86_64 architecture: + +```bash +export DOCKER_DEFAULT_PLATFORM=linux/amd64 +devpod up . --id kvm --provider docker --devcontainer-path .devcontainer/docker/devcontainer.json +``` + +After the container starts, you'll need to manually install build dependencies: + +```bash +bash .devcontainer/install-deps.sh +``` + ### Project Setup 1. **Clone the repository:** diff --git a/Dockerfile.build b/Dockerfile.build index db433b2d..b588da1a 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -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" ] \ No newline at end of file +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/Makefile b/Makefile index 0390bd06..cb170b76 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,10 @@ BIN_DIR := $(shell pwd)/bin TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u) +# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs +build_audio_deps: + bash .devcontainer/install_audio_deps.sh + build_native: @if [ "$(SKIP_NATIVE_IF_EXISTS)" = "1" ] && [ -f "internal/native/cgo/lib/libjknative.a" ]; then \ echo "libjknative.a already exists, skipping native build..."; \ @@ -136,4 +140,32 @@ 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 \ No newline at end of file + rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256 +# 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 && npm run lint + +# Run UI linting with auto-fix +lint-ui-fix: + @echo "Running UI lint with auto-fix..." + @cd ui && npm ci && npm run lint:fix + +# Legacy alias for UI linting (for backward compatibility) +ui-lint: lint-ui diff --git a/audio.go b/audio.go new file mode 100644 index 00000000..1f6c4163 --- /dev/null +++ b/audio.go @@ -0,0 +1,464 @@ +package kvm + +import ( + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + "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 + inputSourceMutex sync.Mutex // Prevents concurrent WebRTC packets from racing during lazy connect + write + outputSource atomic.Pointer[audio.AudioSource] + inputSource atomic.Pointer[audio.AudioSource] + outputRelay atomic.Pointer[audio.OutputRelay] + inputRelay atomic.Pointer[audio.InputRelay] + audioInitialized bool + activeConnections atomic.Int32 + audioLogger zerolog.Logger + currentAudioTrack *webrtc.TrackLocalStaticSample + currentInputTrack atomic.Pointer[string] + audioOutputEnabled atomic.Bool + audioInputEnabled atomic.Bool +) + +func getAlsaDevice(source string) string { + if source == "hdmi" { + return "hw:0,0" // TC358743 HDMI audio + } + return "hw:1,0" // USB Audio Gadget +} + +func initAudio() { + audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger() + + ensureConfigLoaded() + audioOutputEnabled.Store(config.AudioOutputEnabled) + audioInputEnabled.Store(config.AudioInputAutoEnable) + + audioLogger.Debug().Msg("Audio subsystem initialized") + audioInitialized = true +} + +func getAudioConfig() audio.AudioConfig { + cfg := audio.DefaultAudioConfig() + + if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 { + cfg.Bitrate = uint16(config.AudioBitrate) + } else if config.AudioBitrate != 0 { + audioLogger.Warn().Int("bitrate", config.AudioBitrate).Msg("Invalid audio bitrate, using default") + } + + if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 { + cfg.Complexity = uint8(config.AudioComplexity) + } else if config.AudioComplexity != 0 { + audioLogger.Warn().Int("complexity", config.AudioComplexity).Msg("Invalid audio complexity, using default") + } + + if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 { + cfg.BufferPeriods = uint8(config.AudioBufferPeriods) + } else if config.AudioBufferPeriods != 0 { + audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Msg("Invalid buffer periods, using default") + } + + if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 { + cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc) + } else if config.AudioPacketLossPerc != 0 { + audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Msg("Invalid packet loss percentage, using default") + } + + cfg.DTXEnabled = config.AudioDTXEnabled + cfg.FECEnabled = config.AudioFECEnabled + + return cfg +} + +func startAudio() error { + audioMutex.Lock() + defer audioMutex.Unlock() + + if !audioInitialized { + audioLogger.Warn().Msg("Audio not initialized, skipping start") + return nil + } + + if activeConnections.Load() <= 0 { + audioLogger.Debug().Msg("No active connections, skipping audio start") + return nil + } + + ensureConfigLoaded() + + var outputErr, inputErr error + + // Start output audio if enabled and track is available + if audioOutputEnabled.Load() && currentAudioTrack != nil { + outputErr = startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource)) + } + + // Start input audio if enabled and USB audio device is configured + if audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio { + inputErr = startInputAudioUnderMutex(getAlsaDevice("usb")) + } + + // Return combined errors if any + if outputErr != nil && inputErr != nil { + return fmt.Errorf("audio start failed - output: %w, input: %v", outputErr, inputErr) + } + return firstError(outputErr, inputErr) +} + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} + +func startOutputAudioUnderMutex(alsaOutputDevice string) error { + oldRelay := outputRelay.Swap(nil) + oldSource := outputSource.Swap(nil) + + if oldRelay != nil { + oldRelay.Stop() + } + if oldSource != nil { + (*oldSource).Disconnect() + } + + newSource := audio.NewCgoOutputSource(alsaOutputDevice, getAudioConfig()) + newRelay := audio.NewOutputRelay(&newSource, currentAudioTrack) + + if err := newRelay.Start(); err != nil { + audioLogger.Error().Err(err).Str("alsaOutputDevice", alsaOutputDevice).Msg("Failed to start audio output relay") + return err + } + + outputSource.Swap(&newSource) + outputRelay.Swap(newRelay) + return nil +} + +func startInputAudioUnderMutex(alsaPlaybackDevice string) error { + oldRelay := inputRelay.Swap(nil) + oldSource := inputSource.Swap(nil) + + if oldRelay != nil { + oldRelay.Stop() + } + if oldSource != nil { + (*oldSource).Disconnect() + } + + newSource := audio.NewCgoInputSource(alsaPlaybackDevice, getAudioConfig()) + newRelay := audio.NewInputRelay(&newSource) + + if err := newRelay.Start(); err != nil { + audioLogger.Error().Err(err).Str("alsaPlaybackDevice", alsaPlaybackDevice).Msg("Failed to start input relay") + return err + } + + inputSource.Swap(&newSource) + inputRelay.Swap(newRelay) + return nil +} + +func stopOutputAudio() { + audioMutex.Lock() + oldRelay := outputRelay.Swap(nil) + oldSource := outputSource.Swap(nil) + audioMutex.Unlock() + + if oldRelay != nil { + oldRelay.Stop() + } + if oldSource != nil { + (*oldSource).Disconnect() + } +} + +func stopInputAudio() { + audioMutex.Lock() + oldRelay := inputRelay.Swap(nil) + oldSource := inputSource.Swap(nil) + audioMutex.Unlock() + + if oldRelay != nil { + oldRelay.Stop() + } + if oldSource != nil { + (*oldSource).Disconnect() + } +} + +func stopAudio() { + stopOutputAudio() + stopInputAudio() +} + +func onWebRTCConnect() { + count := activeConnections.Add(1) + if count == 1 { + if err := startAudio(); err != nil { + audioLogger.Error().Err(err).Msg("Failed to start audio") + } + } +} + +func onWebRTCDisconnect() { + count := activeConnections.Add(-1) + if count <= 0 { + // Stop audio immediately to release HDMI audio device which shares hardware with video device + stopAudio() + } +} + +func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) { + audioMutex.Lock() + defer audioMutex.Unlock() + + outRelay := outputRelay.Swap(nil) + outSource := outputSource.Swap(nil) + if outRelay != nil { + outRelay.Stop() + } + if outSource != nil { + (*outSource).Disconnect() + } + + currentAudioTrack = audioTrack + + if audioInitialized && activeConnections.Load() > 0 && audioOutputEnabled.Load() && currentAudioTrack != nil { + if err := startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource)); err != nil { + audioLogger.Error().Err(err).Msg("Failed to start output audio after track change") + } + } +} + +func setPendingInputTrack(track *webrtc.TrackRemote) { + trackID := track.ID() + currentInputTrack.Store(&trackID) + go handleInputTrackForSession(track) +} + +// SetAudioOutputEnabled blocks up to 5 seconds when enabling. +// Returns error if audio fails to start within timeout. +func SetAudioOutputEnabled(enabled bool) error { + if audioOutputEnabled.Swap(enabled) == enabled { + return nil + } + + if enabled && activeConnections.Load() > 0 { + // Start audio synchronously with timeout to provide immediate feedback + done := make(chan error, 1) + go func() { + done <- startAudio() + }() + + select { + case err := <-done: + if err != nil { + audioLogger.Error().Err(err).Msg("Failed to start output audio after enable") + audioOutputEnabled.Store(false) // Revert state on failure + return fmt.Errorf("failed to start audio output: %w", err) + } + return nil + case <-time.After(5 * time.Second): + audioLogger.Error().Msg("Audio output start timed out after 5 seconds") + audioOutputEnabled.Store(false) // Revert state on timeout + return fmt.Errorf("audio output start timed out after 5 seconds") + } + } + stopOutputAudio() + return nil +} + +// SetAudioInputEnabled blocks up to 5 seconds when enabling. +// Returns error if audio fails to start within timeout. +func SetAudioInputEnabled(enabled bool) error { + if audioInputEnabled.Swap(enabled) == enabled { + return nil + } + + if enabled && activeConnections.Load() > 0 { + // Start audio synchronously with timeout to provide immediate feedback + done := make(chan error, 1) + go func() { + done <- startAudio() + }() + + select { + case err := <-done: + if err != nil { + audioLogger.Error().Err(err).Msg("Failed to start input audio after enable") + audioInputEnabled.Store(false) // Revert state on failure + return fmt.Errorf("failed to start audio input: %w", err) + } + return nil + case <-time.After(5 * time.Second): + audioLogger.Error().Msg("Audio input start timed out after 5 seconds") + audioInputEnabled.Store(false) // Revert state on timeout + return fmt.Errorf("audio input start timed out after 5 seconds") + } + } + stopInputAudio() + return nil +} + +// SetAudioOutputSource switches between HDMI and USB audio capture. +// Config is saved synchronously, audio restarts asynchronously. +func SetAudioOutputSource(source string) error { + if source != "hdmi" && source != "usb" { + return fmt.Errorf("invalid audio source: %s (must be 'hdmi' or 'usb')", source) + } + + ensureConfigLoaded() + if config.AudioOutputSource == source { + return nil + } + + config.AudioOutputSource = source + + // Save config synchronously before starting async audio operations + if err := SaveConfig(); err != nil { + audioLogger.Error().Err(err).Msg("Failed to save config after audio source change") + return err + } + + // Stop audio immediately (synchronous to release hardware) + stopOutputAudio() + + // Restart audio with timeout + done := make(chan error, 1) + go func() { + done <- startAudio() + }() + + select { + case err := <-done: + if err != nil { + audioLogger.Error().Err(err).Str("source", source).Msg("Failed to start audio after source change") + return fmt.Errorf("failed to start audio after source change: %w", err) + } + return nil + case <-time.After(5 * time.Second): + audioLogger.Error().Str("source", source).Msg("Audio restart timed out after source change") + return fmt.Errorf("audio restart timed out after 5 seconds") + } +} + +// RestartAudioOutput stops and restarts the audio output capture. +// Blocks up to 5 seconds waiting for audio to restart. +// Returns error if restart fails or times out. +func RestartAudioOutput() error { + audioMutex.Lock() + hasActiveOutput := audioOutputEnabled.Load() && currentAudioTrack != nil && outputSource.Load() != nil + audioMutex.Unlock() + + if !hasActiveOutput { + return nil + } + + audioLogger.Info().Msg("Restarting audio output") + stopOutputAudio() + + // Restart with timeout + done := make(chan error, 1) + go func() { + done <- startAudio() + }() + + select { + case err := <-done: + if err != nil { + audioLogger.Error().Err(err).Msg("Failed to restart audio output") + return fmt.Errorf("failed to restart audio output: %w", err) + } + return nil + case <-time.After(5 * time.Second): + audioLogger.Error().Msg("Audio output restart timed out") + return fmt.Errorf("audio output restart timed out after 5 seconds") + } +} + +func handleInputTrackForSession(track *webrtc.TrackRemote) { + myTrackID := track.ID() + + trackLogger := audioLogger.With(). + Str("codec", track.Codec().MimeType). + Str("track_id", myTrackID). + Logger() + + trackLogger.Debug().Msg("starting input track handler") + + for { + // Check if we've been superseded by another track + currentTrackID := currentInputTrack.Load() + if currentTrackID != nil && *currentTrackID != myTrackID { + trackLogger.Debug(). + Str("current_track_id", *currentTrackID). + Msg("input track handler exiting - superseded") + return + } + + // Read RTP packet + rtpPacket, _, err := track.ReadRTP() + if err != nil { + if err == io.EOF { + trackLogger.Debug().Msg("input track ended") + return + } + trackLogger.Warn().Err(err).Msg("failed to read RTP packet") + continue + } + + // Skip empty payloads + if len(rtpPacket.Payload) == 0 { + continue + } + + // Skip if input is disabled + if !audioInputEnabled.Load() { + continue + } + + // Process the audio packet + if err := processInputPacket(rtpPacket.Payload); err != nil { + trackLogger.Warn().Err(err).Msg("failed to process audio packet") + } + } +} + +func processInputPacket(opusData []byte) error { + inputSourceMutex.Lock() + defer inputSourceMutex.Unlock() + + source := inputSource.Load() + if source == nil || *source == nil { + return nil + } + + // Lazy connect on first use + if !(*source).IsConnected() { + if err := (*source).Connect(); err != nil { + return err + } + } + + // Write opus data, disconnect on error + if err := (*source).WriteMessage(0, opusData); err != nil { + (*source).Disconnect() + return err + } + + return nil +} diff --git a/config.go b/config.go index a06febd5..c68e2295 100644 --- a/config.go +++ b/config.go @@ -10,6 +10,7 @@ import ( "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/native" "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/internal/usbgadget" "github.com/prometheus/client_golang/prometheus" @@ -95,7 +96,7 @@ type Config struct { IncludePreRelease bool `json:"include_pre_release"` HashedPassword string `json:"hashed_password"` LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + LocalAuthMode string `json:"localAuthMode"` // Uses camelCase for backwards compatibility with existing configs LocalLoopbackOnly bool `json:"local_loopback_only"` WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` @@ -113,6 +114,15 @@ 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"` + AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb" + AudioBitrate int `json:"audio_bitrate"` // kbps (64-256) + AudioComplexity int `json:"audio_complexity"` // 0-10 + AudioDTXEnabled bool `json:"audio_dtx_enabled"` + AudioFECEnabled bool `json:"audio_fec_enabled"` + AudioBufferPeriods int `json:"audio_buffer_periods"` // 2-24 + AudioPacketLossPerc int `json:"audio_packet_loss_perc"` // 0-100 NativeMaxRestart uint `json:"native_max_restart_attempts"` } @@ -147,8 +157,8 @@ func (c *Config) SetDisplayRotation(rotation string) error { const configPath = "/userdata/kvm_config.json" -// it's a temporary solution to avoid sharing the same pointer -// we should migrate to a proper config solution in the future +// Default configuration structs used to create independent copies in getDefaultConfig(). +// These are package-level variables to avoid repeated allocations. var ( defaultJigglerConfig = JigglerConfig{ InactivityLimitSeconds: 60, @@ -168,6 +178,7 @@ var ( RelativeMouse: true, Keyboard: true, MassStorage: true, + Audio: true, } ) @@ -181,6 +192,7 @@ func getDefaultConfig() Config { KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", KeyboardLayout: "en-US", + EdidString: native.DefaultEDID, DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes @@ -195,8 +207,17 @@ func getDefaultConfig() Config { _ = confparser.SetDefaultsAndValidate(c) return c }(), - DefaultLogLevel: "INFO", - VideoQualityFactor: 1.0, + DefaultLogLevel: "INFO", + VideoQualityFactor: 1.0, + AudioInputAutoEnable: false, + AudioOutputEnabled: true, + AudioOutputSource: "usb", + AudioBitrate: 192, + AudioComplexity: 8, + AudioDTXEnabled: true, + AudioFECEnabled: true, + AudioBufferPeriods: 12, + AudioPacketLossPerc: 20, } } @@ -267,6 +288,17 @@ func LoadConfig() { loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig } + // Apply audio defaults for new configs + if loadedConfig.AudioBitrate == 0 { + defaults := getDefaultConfig() + loadedConfig.AudioBitrate = defaults.AudioBitrate + loadedConfig.AudioComplexity = defaults.AudioComplexity + loadedConfig.AudioDTXEnabled = defaults.AudioDTXEnabled + loadedConfig.AudioFECEnabled = defaults.AudioFECEnabled + loadedConfig.AudioBufferPeriods = defaults.AudioBufferPeriods + loadedConfig.AudioPacketLossPerc = defaults.AudioPacketLossPerc + } + // fixup old keyboard layout value if loadedConfig.KeyboardLayout == "en_US" { loadedConfig.KeyboardLayout = "en-US" diff --git a/internal/audio/c/audio.c b/internal/audio/c/audio.c new file mode 100644 index 00000000..8ea3f8da --- /dev/null +++ b/internal/audio/c/audio.c @@ -0,0 +1,1181 @@ +/* + * JetKVM Audio Processing Module + * + * Bidirectional audio processing optimized for ARM NEON SIMD: + * - OUTPUT PATH: TC358743 HDMI or USB Gadget audio → Client speakers + * Pipeline: ALSA hw:0,0 or hw:1,0 capture → SpeexDSP resample → Opus encode (192kbps, 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 + * - SpeexDSP high-quality resampling (SPEEX_RESAMPLER_QUALITY_DESKTOP) + * - Opus in-band FEC for packet loss resilience + * - S16_LE stereo, 20ms frames at 48kHz (hardware rate auto-negotiated) + * - Direct hardware access with userspace resampling (no ALSA plugin layer) + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ARM NEON SIMD optimizations (Cortex-A7 accelerates buffer operations, with scalar fallback) +#include + +// TC358743 V4L2 control IDs for audio +#ifndef V4L2_CID_USER_TC35874X_BASE +#define V4L2_CID_USER_TC35874X_BASE (V4L2_CID_USER_BASE + 0x10a0) +#endif +#define TC35874X_CID_AUDIO_SAMPLING_RATE (V4L2_CID_USER_TC35874X_BASE + 0) + +// 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) + +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 + +static const char *alsa_capture_device = NULL; +static const char *alsa_playback_device = NULL; +static bool capture_channels_swapped = false; + +static OpusEncoder *encoder = NULL; +static OpusDecoder *decoder = NULL; +static SpeexResamplerState *capture_resampler = NULL; + +// Audio format - RFC 7587 requires Opus RTP clock rate (not sample rate) to be 48kHz +// The Opus codec itself supports multiple sample rates (8/12/16/24/48 kHz), but the +// RTP timestamp clock must always increment at 48kHz for WebRTC compatibility +static const uint32_t opus_sample_rate = 48000; // RFC 7587: Opus RTP timestamp clock rate (not codec sample rate) +static uint32_t hardware_sample_rate = 48000; // Hardware-negotiated rate (can be 44.1k, 48k, 96k, etc.) +static uint8_t capture_channels = 2; // OUTPUT: Audio source (HDMI or USB) → client (stereo by default) +static uint8_t playback_channels = 1; // INPUT: Client mono mic → device (always mono for USB audio gadget) +static const uint16_t opus_frame_size = 960; // 20ms frames at 48kHz (fixed) +static uint16_t hardware_frame_size = 960; // 20ms frames at hardware rate + +// Maximum hardware frame size: 192kHz @ 20ms = 3840 samples/channel +// This is the upper bound for hardware buffer allocation (highest sample rate we support) +#define MAX_HARDWARE_FRAME_SIZE 3840 + +// Audio initialization error codes +#define ERR_ALSA_OPEN_FAILED -1 +#define ERR_ALSA_CONFIG_FAILED -2 +#define ERR_RESAMPLER_INIT_FAILED -3 +#define ERR_CODEC_INIT_FAILED -4 + +static uint32_t opus_bitrate = 192000; +static uint8_t opus_complexity = 8; +static uint16_t max_packet_size = 1500; + +// Opus encoder configuration constants (see opus_defines.h for full enum values) +#define OPUS_VBR 1 // Variable bitrate mode enabled +#define OPUS_VBR_CONSTRAINT 1 // Constrained VBR maintains bitrate ceiling +#define OPUS_SIGNAL_TYPE 3002 // OPUS_SIGNAL_MUSIC (optimized for music/audio content) +#define OPUS_BANDWIDTH 1104 // OPUS_BANDWIDTH_FULLBAND (0-20kHz frequency range) +#define OPUS_LSB_DEPTH 16 // 16-bit PCM sample depth (S16_LE format) + +static uint8_t opus_dtx_enabled = 1; +static uint8_t opus_fec_enabled = 1; +static uint8_t opus_packet_loss_perc = 20; // Default packet loss compensation percentage +static uint8_t buffer_period_count = 24; + +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; + +static atomic_int capture_stop_requested = 0; +static atomic_int playback_stop_requested = 0; + +// Mutexes protect handle lifecycle and codec operations, NOT the ALSA I/O itself. +// The mutex is temporarily released during snd_pcm_readi/writei to prevent blocking. +// Race conditions are detected via handle pointer comparison after reacquiring the lock. +static pthread_mutex_t capture_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_mutex_t playback_mutex = PTHREAD_MUTEX_INITIALIZER; + +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, + uint8_t dtx_enabled, uint8_t fec_enabled, uint8_t buf_periods, uint8_t pkt_loss_perc); +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, + uint8_t buf_periods); + + +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, + uint8_t dtx_enabled, uint8_t fec_enabled, uint8_t buf_periods, uint8_t pkt_loss_perc) { + opus_bitrate = (bitrate >= 64000 && bitrate <= 256000) ? bitrate : 192000; + opus_complexity = (complexity <= 10) ? complexity : 5; + capture_channels = (ch == 1 || ch == 2) ? ch : 2; + max_packet_size = max_pkt > 0 ? max_pkt : 1500; + sleep_microseconds = sleep_us > 0 ? sleep_us : 1000; + sleep_milliseconds = sleep_microseconds / 1000; + max_attempts_global = max_attempts > 0 ? max_attempts : 5; + max_backoff_us_global = max_backoff > 0 ? max_backoff : 500000; + opus_dtx_enabled = dtx_enabled ? 1 : 0; + opus_fec_enabled = fec_enabled ? 1 : 0; + buffer_period_count = (buf_periods >= 2 && buf_periods <= 24) ? buf_periods : 12; + opus_packet_loss_perc = (pkt_loss_perc <= 100) ? pkt_loss_perc : 20; + + // Note: sr and fs parameters ignored - RFC 7587 requires fixed 48kHz RTP clock rate + // Hardware sample rate conversion is handled by SpeexDSP resampler +} + +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, + uint8_t buf_periods) { + playback_channels = (ch == 1 || ch == 2) ? ch : 2; + max_packet_size = max_pkt > 0 ? max_pkt : 1500; + sleep_microseconds = sleep_us > 0 ? sleep_us : 1000; + sleep_milliseconds = sleep_microseconds / 1000; + max_attempts_global = max_attempts > 0 ? max_attempts : 5; + max_backoff_us_global = max_backoff > 0 ? max_backoff : 500000; + buffer_period_count = (buf_periods >= 2 && buf_periods <= 24) ? buf_periods : 12; + + // Note: sr and fs parameters ignored - decoder always operates at 48kHz (RFC 7587) + // Playback device configured at 48kHz, no resampling needed for output +} + +/** + * Initialize ALSA device names from environment variables + * Must be called before jetkvm_audio_capture_init or jetkvm_audio_playback_init + * + * Device mapping (set via ALSA_CAPTURE_DEVICE/ALSA_PLAYBACK_DEVICE): + * hw:0,0 = TC358743 HDMI audio (direct hardware access, SpeexDSP resampling) + * hw:1,0 = USB Audio Gadget (direct hardware access, SpeexDSP resampling) + */ +static void init_alsa_devices_from_env(void) { + alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE"); + if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') { + alsa_capture_device = "hw:1,0"; + } + + alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE"); + if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') { + alsa_playback_device = "hw:1,0"; + } +} + +// SIMD-OPTIMIZED BUFFER OPERATIONS (ARM NEON) + +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; + +// ALSA UTILITY FUNCTIONS + +/** + * Query TC358743 HDMI receiver for detected audio sample rate + * Reads the hardware-detected sample rate from V4L2 control + * @return detected sample rate (44100, 48000, etc.) or 0 if detection fails + */ +static unsigned int get_hdmi_audio_sample_rate(void) { + // TC358743 is a V4L2 subdevice at /dev/v4l-subdev2 + int fd = open("/dev/v4l-subdev2", O_RDWR); + if (fd < 0) { + // Distinguish between different failure modes for better diagnostics + if (errno == ENOENT) { + fprintf(stdout, "INFO: TC358743 device not found (USB audio mode or device not present)\n"); + } else if (errno == EACCES || errno == EPERM) { + fprintf(stderr, "ERROR: Permission denied accessing TC358743 (/dev/v4l-subdev2)\n"); + fprintf(stderr, " Check device permissions or run with appropriate privileges\n"); + } else { + fprintf(stderr, "WARNING: Could not open /dev/v4l-subdev2: %s (errno=%d)\n", strerror(errno), errno); + fprintf(stderr, " HDMI audio sample rate detection unavailable, will use 48kHz default\n"); + } + fflush(stderr); + fflush(stdout); + return 0; + } + + // Use extended controls API for custom V4L2 controls + struct v4l2_ext_control ext_ctrl = {0}; + ext_ctrl.id = TC35874X_CID_AUDIO_SAMPLING_RATE; + + struct v4l2_ext_controls ext_ctrls = {0}; + ext_ctrls.ctrl_class = V4L2_CTRL_CLASS_USER; + ext_ctrls.count = 1; + ext_ctrls.controls = &ext_ctrl; + + if (ioctl(fd, VIDIOC_G_EXT_CTRLS, &ext_ctrls) == -1) { + // Provide specific error messages based on errno + if (errno == EINVAL) { + fprintf(stderr, "ERROR: TC358743 sample rate control not supported (driver version mismatch?)\n"); + fprintf(stderr, " Ensure kernel driver supports audio_sampling_rate control\n"); + } else { + fprintf(stderr, "WARNING: TC358743 ioctl failed: %s (errno=%d)\n", strerror(errno), errno); + fprintf(stderr, " Will use 48kHz default sample rate\n"); + } + fflush(stderr); + close(fd); + return 0; + } + + close(fd); + + unsigned int detected_rate = (unsigned int)ext_ctrl.value; + static unsigned int last_logged_rate = 0; // Track last logged rate to suppress duplicate messages + + if (detected_rate == 0) { + if (last_logged_rate != 0) { + fprintf(stdout, "INFO: TC358743 reports 0 Hz (no HDMI signal or audio not detected yet)\n"); + fprintf(stdout, " Will use 48kHz default and resample if needed when signal detected\n"); + fflush(stdout); + last_logged_rate = 0; + } + return 0; + } + + // Validate detected rate is reasonable (log warning only on rate changes) + if (detected_rate < 8000 || detected_rate > 192000) { + if (detected_rate != last_logged_rate) { + fprintf(stderr, "WARNING: TC358743 reported unusual sample rate: %u Hz (expected 32k-192k)\n", detected_rate); + fprintf(stderr, " Using detected rate anyway, but audio may not work correctly\n"); + fflush(stderr); + last_logged_rate = detected_rate; + } + } + + // Log rate changes and update tracking state to suppress duplicate logging + if (detected_rate != last_logged_rate) { + fprintf(stdout, "INFO: TC358743 detected HDMI audio sample rate: %u Hz\n", detected_rate); + fflush(stdout); + last_logged_rate = detected_rate; + } + + return detected_rate; +} + +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) { + // Validate that we can switch to blocking mode + err = snd_pcm_nonblock(*handle, 0); + if (err < 0) { + fprintf(stderr, "ERROR: Failed to set blocking mode on %s: %s\n", + device, snd_strerror(err)); + fflush(stderr); + snd_pcm_close(*handle); + *handle = NULL; + return err; + } + return 0; + } + + attempt++; + + // Apply sleep strategy based on error type + if (err == -EPERM || err == -EACCES) { + precise_sleep_us(backoff_us >> 1); // Shorter wait for permission errors + } else { + precise_sleep_us(backoff_us); + // Exponential backoff for retry-worthy errors + if (err == -EBUSY || err == -EAGAIN || err == -ENODEV || err == -ENOENT) { + backoff_us = (backoff_us < 50000) ? (backoff_us << 1) : 50000; + } + } + } + return err; +} + +/** + * Swap stereo channels (L<->R) using ARM NEON SIMD + * Processes 4 frames (8 samples) at a time for optimal performance + * @param buffer Interleaved stereo buffer (L,R,L,R,...) + * @param num_frames Number of stereo frames to swap + */ +static inline void swap_stereo_channels(int16_t *buffer, uint16_t num_frames) { + uint16_t i; + // Process in chunks of 4 frames (8 samples, 128 bits) + for (i = 0; i + 3 < num_frames; i += 4) { + int16x8_t vec = vld1q_s16(&buffer[i * 2]); + int16x8_t swapped = vrev32q_s16(vec); + vst1q_s16(&buffer[i * 2], swapped); + } + + // Handle remaining frames with scalar code + for (; i < num_frames; i++) { + int16_t temp = buffer[i * 2]; + buffer[i * 2] = buffer[i * 2 + 1]; + buffer[i * 2 + 1] = temp; + } +} + +/** + * Handle ALSA I/O errors with recovery attempts + * @param handle Pointer to PCM handle to use for recovery operations + * @param valid_handle Pointer to the valid handle to check against (for race detection) + * @param stop_flag Pointer to atomic stop flag + * @param pcm_rc Error code from ALSA I/O operation + * @param recovery_attempts Pointer to uint8_t recovery attempt counter + * @param sleep_ms Milliseconds to sleep during recovery + * @param max_attempts Maximum recovery attempts allowed + * @return Return codes: + * 1 = Retry operation (error was recovered) + * 0 = Skip this frame and continue + * -1 = Fatal error, abort operation + * + * IMPORTANT: This function NEVER unlocks the mutex. The caller is always + * responsible for unlocking after checking the return value. This ensures + * consistent mutex ownership semantics. + */ +static int handle_alsa_error(snd_pcm_t *handle, snd_pcm_t **valid_handle, + atomic_int *stop_flag, + int pcm_rc, uint8_t *recovery_attempts, + uint32_t sleep_ms, uint8_t max_attempts) { + int err; + + if (pcm_rc == -EPIPE) { + // Buffer underrun/overrun + (*recovery_attempts)++; + if (*recovery_attempts > max_attempts || handle != *valid_handle) { + return -1; + } + err = snd_pcm_prepare(handle); + if (err < 0) { + if (handle != *valid_handle) { + return -1; + } + snd_pcm_drop(handle); + err = snd_pcm_prepare(handle); + if (err < 0 || handle != *valid_handle) { + return -1; + } + } + return 1; // Retry + } else if (pcm_rc == -EAGAIN) { + // Resource temporarily unavailable + if (handle != *valid_handle) { + return -1; + } + snd_pcm_wait(handle, sleep_ms); + return 1; // Retry + } else if (pcm_rc == -ESTRPIPE) { + // Suspended, need to resume + (*recovery_attempts)++; + if (*recovery_attempts > max_attempts || handle != *valid_handle) { + return -1; + } + uint8_t resume_attempts = 0; + while ((err = snd_pcm_resume(handle)) == -EAGAIN && resume_attempts < 10) { + if (*stop_flag || handle != *valid_handle) { + return -1; + } + snd_pcm_wait(handle, sleep_ms); + resume_attempts++; + } + if (err < 0) { + if (handle != *valid_handle) { + return -1; + } + err = snd_pcm_prepare(handle); + if (err < 0 || handle != *valid_handle) { + return -1; + } + } + return 0; // Skip frame after suspend recovery + } else if (pcm_rc == -ENODEV) { + // Device was removed + return -1; + } else if (pcm_rc == -EIO) { + // I/O error + (*recovery_attempts)++; + if (*recovery_attempts <= max_attempts && handle == *valid_handle) { + snd_pcm_drop(handle); + if (handle != *valid_handle) { + return -1; + } + err = snd_pcm_prepare(handle); + if (err >= 0 && handle == *valid_handle) { + return 1; // Retry + } + } + return -1; + } else { + // Other errors + (*recovery_attempts)++; + if (*recovery_attempts <= 1 && pcm_rc == -EINTR) { + return 1; // Retry on first interrupt + } else if (*recovery_attempts <= 1 && pcm_rc == -EBUSY && handle == *valid_handle) { + snd_pcm_wait(handle, 1); + return 1; // Retry on first busy + } + return -1; + } +} + +/** + * Configure ALSA device (S16_LE @ hardware-negotiated rate with optimized buffering) + * @param handle ALSA PCM handle + * @param device_name Device name for logging + * @param num_channels Number of channels (1=mono, 2=stereo) + * @param preferred_rate Preferred sample rate (0 = use default 48kHz) + * @param actual_rate_out Pointer to store the actual hardware-negotiated rate + * @param actual_frame_size_out Pointer to store the actual frame size at hardware rate + * @param channels_swapped_out Pointer to store whether channels are swapped (NULL to ignore) + * @return 0 on success, negative error code on failure + */ +static int configure_alsa_device(snd_pcm_t *handle, const char *device_name, uint8_t num_channels, + unsigned int preferred_rate, unsigned int *actual_rate_out, uint16_t *actual_frame_size_out, + bool *channels_swapped_out) { + snd_pcm_hw_params_t *params; + snd_pcm_sw_params_t *sw_params; + int err; + + 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) { + fprintf(stderr, "ERROR: %s: Failed to set access mode: %s\n", device_name, snd_strerror(err)); + fflush(stderr); + return err; + } + + err = snd_pcm_hw_params_set_format(handle, params, SND_PCM_FORMAT_S16_LE); + if (err < 0) { + fprintf(stderr, "ERROR: %s: Failed to set format S16_LE: %s\n", device_name, snd_strerror(err)); + fflush(stderr); + return err; + } + + err = snd_pcm_hw_params_set_channels(handle, params, num_channels); + if (err < 0) { + fprintf(stderr, "ERROR: %s: Failed to set %u channels: %s\n", device_name, num_channels, snd_strerror(err)); + fflush(stderr); + return err; + } + + // Disable ALSA resampling - we handle it with SpeexDSP + err = snd_pcm_hw_params_set_rate_resample(handle, params, 0); + if (err < 0) { + fprintf(stderr, "ERROR: %s: Failed to disable ALSA resampling: %s\n", device_name, snd_strerror(err)); + fflush(stderr); + return err; + } + + // Use preferred rate if specified, otherwise default to 48kHz + unsigned int requested_rate = (preferred_rate > 0) ? preferred_rate : opus_sample_rate; + err = snd_pcm_hw_params_set_rate_near(handle, params, &requested_rate, 0); + if (err < 0) return err; + + // Calculate frame size for this hardware rate (20ms) + uint16_t hw_frame_size = requested_rate / 50; + + snd_pcm_uframes_t period_size = hw_frame_size; + 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 * buffer_period_count; + 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; + + unsigned int negotiated_rate = 0; + err = snd_pcm_hw_params_get_rate(params, &negotiated_rate, 0); + if (err < 0) return err; + + fprintf(stdout, "INFO: %s: Hardware negotiated %u Hz (Opus uses %u Hz with SpeexDSP resampling)\n", + device_name, negotiated_rate, opus_sample_rate); + fflush(stdout); + + 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; + + err = snd_pcm_prepare(handle); + if (err < 0) return err; + + if (num_channels == 2 && channels_swapped_out) { + snd_pcm_chmap_t *chmap = snd_pcm_get_chmap(handle); + if (chmap != NULL) { + if (chmap->channels != 2) { + fprintf(stderr, "WARN: %s: Expected 2 channels but channel map has %u\n", + device_name, chmap->channels); + fflush(stderr); + } else if (chmap->pos[0] == SND_CHMAP_UNKNOWN || chmap->pos[1] == SND_CHMAP_UNKNOWN) { + fprintf(stderr, "WARN: %s: Channel map positions are unknown, cannot detect swap\n", + device_name); + fflush(stderr); + } else { + bool is_swapped = (chmap->pos[0] == SND_CHMAP_FR && chmap->pos[1] == SND_CHMAP_FL); + if (is_swapped) { + fprintf(stdout, "INFO: %s: Hardware reports swapped channel map (R,L instead of L,R)\n", + device_name); + fflush(stdout); + } + *channels_swapped_out = is_swapped; + } + free(chmap); + } + } + + if (actual_rate_out) *actual_rate_out = negotiated_rate; + if (actual_frame_size_out) { + // Calculate actual frame size based on negotiated rate (20ms frames) + *actual_frame_size_out = negotiated_rate / 50; + } + + return 0; +} + +// AUDIO OUTPUT PATH FUNCTIONS (TC358743 HDMI Audio → Client Speakers) + +/** + * Initialize OUTPUT path (HDMI or USB Gadget audio capture → Opus encoder) + * Opens ALSA capture device from ALSA_CAPTURE_DEVICE env (default: hw:1,0, set to hw:0,0 for HDMI) + * and creates Opus encoder with optimized settings + * @return 0 on success, -EBUSY if initializing, or: + * ERR_ALSA_OPEN_FAILED (-1), ERR_ALSA_CONFIG_FAILED (-2), + * ERR_RESAMPLER_INIT_FAILED (-3), ERR_CODEC_INIT_FAILED (-4) + */ +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 != NULL || pcm_capture_handle != NULL) { + capture_initialized = 0; + atomic_store(&capture_stop_requested, 1); + + if (pcm_capture_handle) { + snd_pcm_drop(pcm_capture_handle); + } + + pthread_mutex_lock(&capture_mutex); + + if (encoder) { + opus_encoder_destroy(encoder); + encoder = NULL; + } + if (pcm_capture_handle) { + snd_pcm_close(pcm_capture_handle); + pcm_capture_handle = NULL; + } + + pthread_mutex_unlock(&capture_mutex); + + atomic_store(&capture_stop_requested, 0); + } + + 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); + atomic_store(&capture_stop_requested, 0); + capture_initializing = 0; + return ERR_ALSA_OPEN_FAILED; + } + + // Query TC358743 for detected HDMI audio sample rate + unsigned int preferred_rate = get_hdmi_audio_sample_rate(); + if (preferred_rate > 0) { + fprintf(stdout, "INFO: Using TC358743 detected sample rate: %u Hz\n", preferred_rate); + } else { + fprintf(stdout, "INFO: TC358743 sample rate not detected, using default 48kHz\n"); + preferred_rate = 0; // Will default to 48kHz + } + fflush(stdout); + + unsigned int actual_rate = 0; + uint16_t actual_frame_size = 0; + bool channels_swapped = false; + err = configure_alsa_device(pcm_capture_handle, "capture", capture_channels, preferred_rate, &actual_rate, &actual_frame_size, &channels_swapped); + if (err < 0) { + snd_pcm_t *handle = pcm_capture_handle; + pcm_capture_handle = NULL; + if (handle) { + snd_pcm_close(handle); + } + atomic_store(&capture_stop_requested, 0); + capture_initializing = 0; + return ERR_ALSA_CONFIG_FAILED; + } + + capture_channels_swapped = channels_swapped; + hardware_sample_rate = actual_rate; + hardware_frame_size = actual_frame_size; + if (hardware_frame_size > MAX_HARDWARE_FRAME_SIZE) { + fprintf(stderr, "ERROR: capture: Hardware frame size %u exceeds buffer capacity %u\n", + hardware_frame_size, MAX_HARDWARE_FRAME_SIZE); + fflush(stderr); + snd_pcm_t *handle = pcm_capture_handle; + pcm_capture_handle = NULL; + if (handle) { + snd_pcm_close(handle); + } + atomic_store(&capture_stop_requested, 0); + capture_initializing = 0; + return ERR_CODEC_INIT_FAILED; + } + + // Clean up any existing resampler before creating new one (prevents memory leak on re-init) + if (capture_resampler) { + speex_resampler_destroy(capture_resampler); + capture_resampler = NULL; + } + + // Initialize Speex resampler if hardware rate != 48kHz + if (hardware_sample_rate != opus_sample_rate) { + int speex_err = 0; + capture_resampler = speex_resampler_init(capture_channels, hardware_sample_rate, + opus_sample_rate, SPEEX_RESAMPLER_QUALITY_DESKTOP, + &speex_err); + if (!capture_resampler || speex_err != 0) { + fprintf(stderr, "ERROR: capture: Failed to create SpeexDSP resampler (%u Hz → %u Hz): %d\n", + hardware_sample_rate, opus_sample_rate, speex_err); + fflush(stderr); + snd_pcm_t *handle = pcm_capture_handle; + pcm_capture_handle = NULL; + if (handle) { + snd_pcm_close(handle); + } + atomic_store(&capture_stop_requested, 0); + capture_initializing = 0; + return ERR_RESAMPLER_INIT_FAILED; + } + } + + fprintf(stdout, "INFO: capture: Initializing Opus encoder %sat (%u Hz → %u Hz), %u channels, frame size %u\n", + hardware_sample_rate == opus_sample_rate ? "" : "SpeexDSP resampled ", + hardware_sample_rate, opus_sample_rate, + capture_channels, opus_frame_size); + fflush(stdout); + + int opus_err = 0; + encoder = opus_encoder_create(opus_sample_rate, capture_channels, OPUS_APPLICATION_AUDIO, &opus_err); + if (!encoder || opus_err != OPUS_OK) { + if (capture_resampler) { + speex_resampler_destroy(capture_resampler); + capture_resampler = NULL; + } + if (pcm_capture_handle) { + snd_pcm_t *handle = pcm_capture_handle; + pcm_capture_handle = NULL; + if (handle) { + snd_pcm_close(handle); + } + } + atomic_store(&capture_stop_requested, 0); + capture_initializing = 0; + return ERR_CODEC_INIT_FAILED; + } + + // Critical settings that must succeed for WebRTC compliance + #define OPUS_CTL_CRITICAL(call, desc) do { \ + int _err = call; \ + if (_err != OPUS_OK) { \ + fprintf(stderr, "ERROR: capture: Failed to set " desc ": %s\n", opus_strerror(_err)); \ + fflush(stderr); \ + opus_encoder_destroy(encoder); \ + encoder = NULL; \ + if (capture_resampler) { \ + speex_resampler_destroy(capture_resampler); \ + capture_resampler = NULL; \ + } \ + snd_pcm_t *handle = pcm_capture_handle; \ + pcm_capture_handle = NULL; \ + if (handle) { \ + snd_pcm_close(handle); \ + } \ + atomic_store(&capture_stop_requested, 0); \ + capture_initializing = 0; \ + return ERR_CODEC_INIT_FAILED; \ + } \ + } while(0) + + // Non-critical settings that can fail without breaking functionality + #define OPUS_CTL_WARN(call, desc) do { \ + int _err = call; \ + if (_err != OPUS_OK) { \ + fprintf(stderr, "WARN: capture: Failed to set " desc ": %s (non-critical, continuing)\n", opus_strerror(_err)); \ + fflush(stderr); \ + } \ + } while(0) + + // Critical: Bitrate, VBR mode, FEC are required for proper WebRTC operation + OPUS_CTL_CRITICAL(opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate)), "bitrate"); + OPUS_CTL_CRITICAL(opus_encoder_ctl(encoder, OPUS_SET_VBR(OPUS_VBR)), "VBR mode"); + OPUS_CTL_CRITICAL(opus_encoder_ctl(encoder, OPUS_SET_VBR_CONSTRAINT(OPUS_VBR_CONSTRAINT)), "VBR constraint"); + OPUS_CTL_CRITICAL(opus_encoder_ctl(encoder, OPUS_SET_INBAND_FEC(opus_fec_enabled)), "FEC"); + + // Non-critical: These optimize quality/performance but aren't required + OPUS_CTL_WARN(opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity)), "complexity"); + OPUS_CTL_WARN(opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_TYPE)), "signal type"); + OPUS_CTL_WARN(opus_encoder_ctl(encoder, OPUS_SET_BANDWIDTH(OPUS_BANDWIDTH)), "bandwidth"); + OPUS_CTL_WARN(opus_encoder_ctl(encoder, OPUS_SET_DTX(opus_dtx_enabled)), "DTX"); + OPUS_CTL_WARN(opus_encoder_ctl(encoder, OPUS_SET_LSB_DEPTH(OPUS_LSB_DEPTH)), "LSB depth"); + OPUS_CTL_WARN(opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(opus_packet_loss_perc)), "packet loss percentage"); + + #undef OPUS_CTL_CRITICAL + #undef OPUS_CTL_WARN + + capture_initialized = 1; + atomic_store(&capture_stop_requested, 0); + capture_initializing = 0; + return 0; +} + +__attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf) { + // Two buffers: hardware buffer + resampled buffer (at 48kHz) + static short CACHE_ALIGN pcm_hw_buffer[MAX_HARDWARE_FRAME_SIZE * 2]; // Max hardware rate * stereo + static short CACHE_ALIGN pcm_opus_buffer[960 * 2]; // 48kHz @ 20ms * 2 channels + static uint16_t sample_rate_check_counter = 0; + unsigned char * __restrict__ out = (unsigned char*)opus_buf; + int32_t pcm_rc, nb_bytes; + uint8_t recovery_attempts = 0; + const uint8_t max_recovery_attempts = 3; + + if (__builtin_expect(atomic_load(&capture_stop_requested), 0)) { + return -1; + } + + SIMD_PREFETCH(out, 1, 0); + SIMD_PREFETCH(pcm_hw_buffer, 0, 0); + SIMD_PREFETCH(pcm_hw_buffer + 64, 0, 1); + + pthread_mutex_lock(&capture_mutex); + + if (__builtin_expect(!capture_initialized || !pcm_capture_handle || !encoder || !opus_buf, 0)) { + pthread_mutex_unlock(&capture_mutex); + return -1; + } + +retry_read: + if (__builtin_expect(atomic_load(&capture_stop_requested), 0)) { + pthread_mutex_unlock(&capture_mutex); + return -1; + } + + snd_pcm_t *handle = pcm_capture_handle; + + pthread_mutex_unlock(&capture_mutex); + pcm_rc = snd_pcm_readi(handle, pcm_hw_buffer, hardware_frame_size); + pthread_mutex_lock(&capture_mutex); + + if (handle != pcm_capture_handle || atomic_load(&capture_stop_requested)) { + pthread_mutex_unlock(&capture_mutex); + return -1; + } + + if (__builtin_expect(pcm_rc < 0, 0)) { + int err_result = handle_alsa_error(handle, &pcm_capture_handle, &capture_stop_requested, + pcm_rc, &recovery_attempts, + sleep_milliseconds, max_recovery_attempts); + if (err_result == 1) { + // Recovery successful, retry (mutex still held) + goto retry_read; + } else { + // Fatal error or skip frame (err_result == -1 or 0) + pthread_mutex_unlock(&capture_mutex); + return (err_result == 0) ? 0 : -1; + } + } + + // Periodic sample rate change detection (every 50 frames = ~1 second) + if (__builtin_expect(++sample_rate_check_counter >= 50, 0)) { + sample_rate_check_counter = 0; + unsigned int current_rate = get_hdmi_audio_sample_rate(); + if (current_rate != 0 && current_rate != hardware_sample_rate) { + fprintf(stderr, "ERROR: capture: HDMI sample rate changed from %u to %u Hz\n", + hardware_sample_rate, current_rate); + fprintf(stderr, " Triggering reconnection for automatic reconfiguration\n"); + fflush(stderr); + pthread_mutex_unlock(&capture_mutex); + return -1; + } + } + + if (__builtin_expect(pcm_rc < hardware_frame_size, 0)) { + uint32_t remaining_samples = (hardware_frame_size - pcm_rc) * capture_channels; + simd_clear_samples_s16(&pcm_hw_buffer[pcm_rc * capture_channels], remaining_samples); + } + + if (capture_channels_swapped) { + swap_stereo_channels(pcm_hw_buffer, hardware_frame_size); + } + + short *pcm_to_encode; + if (capture_resampler) { + spx_uint32_t in_len = hardware_frame_size; + spx_uint32_t out_len = opus_frame_size; + int res_err = speex_resampler_process_interleaved_int(capture_resampler, + pcm_hw_buffer, &in_len, + pcm_opus_buffer, &out_len); + if (res_err != 0 || out_len != opus_frame_size) { + fprintf(stderr, "ERROR: capture: Resampling failed (err=%d, out_len=%u, expected=%u)\n", + res_err, out_len, opus_frame_size); + fflush(stderr); + pthread_mutex_unlock(&capture_mutex); + return -1; + } + pcm_to_encode = pcm_opus_buffer; + } else { + pcm_to_encode = pcm_hw_buffer; + } + + OpusEncoder *enc = encoder; + if (!enc) { + pthread_mutex_unlock(&capture_mutex); + return -1; + } + + nb_bytes = opus_encode(enc, pcm_to_encode, opus_frame_size, out, max_packet_size); + + if (__builtin_expect(nb_bytes < 0, 0)) { + fprintf(stderr, "ERROR: capture: Opus encoding failed: %s\n", opus_strerror(nb_bytes)); + fflush(stderr); + } + + pthread_mutex_unlock(&capture_mutex); + return nb_bytes; +} + +// AUDIO INPUT PATH FUNCTIONS (Client Microphone → Device Speakers) + +/** + * Initialize INPUT path (Opus decoder → device speakers) + * Opens ALSA playback device from ALSA_PLAYBACK_DEVICE env (default: hw:1,0) + * and creates Opus decoder. Returns immediately on device open failure (no fallback). + * @return 0 on success, -EBUSY if initializing, or: + * ERR_ALSA_OPEN_FAILED (-1), ERR_ALSA_CONFIG_FAILED (-2), ERR_CODEC_INIT_FAILED (-4) + */ +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 != NULL || pcm_playback_handle != NULL) { + playback_initialized = 0; + atomic_store(&playback_stop_requested, 1); + __sync_synchronize(); + + if (pcm_playback_handle) { + snd_pcm_drop(pcm_playback_handle); + } + + pthread_mutex_lock(&playback_mutex); + + if (decoder) { + opus_decoder_destroy(decoder); + decoder = NULL; + } + if (pcm_playback_handle) { + snd_pcm_close(pcm_playback_handle); + pcm_playback_handle = NULL; + } + + pthread_mutex_unlock(&playback_mutex); + + atomic_store(&playback_stop_requested, 0); + } + + 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); + atomic_store(&playback_stop_requested, 0); + playback_initializing = 0; + return ERR_ALSA_OPEN_FAILED; + } + + unsigned int actual_rate = 0; + uint16_t actual_frame_size = 0; + err = configure_alsa_device(pcm_playback_handle, "playback", playback_channels, 0, &actual_rate, &actual_frame_size, NULL); + if (err < 0) { + snd_pcm_t *handle = pcm_playback_handle; + pcm_playback_handle = NULL; + if (handle) { + snd_pcm_close(handle); + } + atomic_store(&playback_stop_requested, 0); + playback_initializing = 0; + return ERR_ALSA_CONFIG_FAILED; + } + + fprintf(stdout, "INFO: playback: Initializing Opus decoder at %u Hz, %u channels, frame size %u\n", + actual_rate, playback_channels, actual_frame_size); + fflush(stdout); + + int opus_err = 0; + decoder = opus_decoder_create(actual_rate, playback_channels, &opus_err); + if (!decoder || opus_err != OPUS_OK) { + snd_pcm_t *handle = pcm_playback_handle; + pcm_playback_handle = NULL; + if (handle) { + snd_pcm_close(handle); + } + atomic_store(&playback_stop_requested, 0); + playback_initializing = 0; + return ERR_CODEC_INIT_FAILED; + } + + playback_initialized = 1; + atomic_store(&playback_stop_requested, 0); + playback_initializing = 0; + return 0; +} + +__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; + uint8_t recovery_attempts = 0; + const uint8_t max_recovery_attempts = 3; + + // Validate inputs before acquiring mutex to reduce lock contention + if (__builtin_expect(!opus_buf || opus_size <= 0 || opus_size > max_packet_size, 0)) { + return -1; + } + + if (__builtin_expect(atomic_load(&playback_stop_requested), 0)) { + return -1; + } + + SIMD_PREFETCH(in, 0, 0); + + pthread_mutex_lock(&playback_mutex); + + if (__builtin_expect(!playback_initialized || !pcm_playback_handle || !decoder, 0)) { + pthread_mutex_unlock(&playback_mutex); + return -1; + } + + OpusDecoder *dec = decoder; + if (!dec) { + pthread_mutex_unlock(&playback_mutex); + return -1; + } + + pcm_frames = opus_decode(dec, in, opus_size, pcm_buffer, opus_frame_size, 0); + + if (__builtin_expect(pcm_frames < 0, 0)) { + // Initial decode failed, try Forward Error Correction from previous packets + fprintf(stderr, "WARN: playback: Opus decode failed (%d), attempting FEC recovery\n", pcm_frames); + fflush(stderr); + + pcm_frames = opus_decode(dec, NULL, 0, pcm_buffer, opus_frame_size, 1); + + if (pcm_frames < 0) { + fprintf(stderr, "ERROR: playback: FEC recovery also failed (%d), dropping frame\n", pcm_frames); + fflush(stderr); + pthread_mutex_unlock(&playback_mutex); + return -1; + } + + if (pcm_frames > 0) { + fprintf(stdout, "INFO: playback: FEC recovered %d frames\n", pcm_frames); + fflush(stdout); + } else { + fprintf(stderr, "WARN: playback: FEC returned 0 frames (silence)\n"); + fflush(stderr); + } + } + +retry_write: + if (__builtin_expect(atomic_load(&playback_stop_requested), 0)) { + pthread_mutex_unlock(&playback_mutex); + return -1; + } + + snd_pcm_t *handle = pcm_playback_handle; + + pthread_mutex_unlock(&playback_mutex); + pcm_rc = snd_pcm_writei(handle, pcm_buffer, pcm_frames); + pthread_mutex_lock(&playback_mutex); + + if (handle != pcm_playback_handle || atomic_load(&playback_stop_requested)) { + pthread_mutex_unlock(&playback_mutex); + return -1; + } + if (__builtin_expect(pcm_rc < 0, 0)) { + int err_result = handle_alsa_error(handle, &pcm_playback_handle, &playback_stop_requested, + pcm_rc, &recovery_attempts, + sleep_milliseconds, max_recovery_attempts); + if (err_result == 1) { + // Recovery successful, retry (mutex still held) + goto retry_write; + } else { + // Fatal error or skip frame (err_result == -1 or 0) + pthread_mutex_unlock(&playback_mutex); + return (err_result == 0) ? 0 : -2; + } + } + pthread_mutex_unlock(&playback_mutex); + return pcm_frames; +} + +// CLEANUP FUNCTIONS + +/** + * Close audio stream (shared cleanup logic for capture and playback) + * @param stop_requested Pointer to stop flag + * @param initializing Pointer to initializing flag + * @param initialized Pointer to initialized flag + * @param mutex Mutex to protect cleanup + * @param pcm_handle Pointer to PCM handle + * @param codec Pointer to codec (encoder or decoder) + * @param destroy_codec Function to destroy the codec + */ +typedef void (*codec_destroy_fn)(void*); + +static void close_audio_stream(atomic_int *stop_requested, volatile int *initializing, + volatile int *initialized, pthread_mutex_t *mutex, + snd_pcm_t **pcm_handle, void **codec, + codec_destroy_fn destroy_codec) { + atomic_store(stop_requested, 1); + + while (*initializing) { + sched_yield(); + } + + if (__sync_bool_compare_and_swap(initialized, 1, 0) == 0) { + atomic_store(stop_requested, 0); + return; + } + + struct timespec short_delay = { .tv_sec = 0, .tv_nsec = 5000000 }; + nanosleep(&short_delay, NULL); + + pthread_mutex_lock(mutex); + + snd_pcm_t *handle_to_close = *pcm_handle; + void *codec_to_destroy = *codec; + *pcm_handle = NULL; + *codec = NULL; + + // Clean up resampler inside mutex to prevent race with encoding thread + if (mutex == &capture_mutex && capture_resampler) { + SpeexResamplerState *res = capture_resampler; + capture_resampler = NULL; + speex_resampler_destroy(res); + } + + pthread_mutex_unlock(mutex); + + if (handle_to_close) { + snd_pcm_drop(handle_to_close); + snd_pcm_close(handle_to_close); + } + + if (codec_to_destroy) { + destroy_codec(codec_to_destroy); + } + + atomic_store(stop_requested, 0); +} + +void jetkvm_audio_playback_close() { + close_audio_stream(&playback_stop_requested, &playback_initializing, + &playback_initialized, &playback_mutex, + &pcm_playback_handle, (void**)&decoder, + (codec_destroy_fn)opus_decoder_destroy); +} + +void jetkvm_audio_capture_close() { + close_audio_stream(&capture_stop_requested, &capture_initializing, + &capture_initialized, &capture_mutex, + &pcm_capture_handle, (void**)&encoder, + (codec_destroy_fn)opus_encoder_destroy); +} diff --git a/internal/audio/cgo_source.go b/internal/audio/cgo_source.go new file mode 100644 index 00000000..0fb130c3 --- /dev/null +++ b/internal/audio/cgo_source.go @@ -0,0 +1,250 @@ +//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 -I/opt/jetkvm-audio-libs/speexdsp-1.2.1/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 /opt/jetkvm-audio-libs/speexdsp-1.2.1/libspeexdsp/.libs/libspeexdsp.a -lm -ldl -lpthread + +#include +#include "c/audio.c" +*/ +import "C" +import ( + "fmt" + "os" + "sync" + "unsafe" + + "github.com/jetkvm/kvm/internal/logging" + "github.com/rs/zerolog" +) + +const ( + ipcMaxFrameSize = 1500 +) + +type CgoSource struct { + outputDevice bool + alsaDevice string + connected bool + mu sync.Mutex + logger zerolog.Logger + opusBuf []byte + config AudioConfig +} + +var _ AudioSource = (*CgoSource)(nil) + +func NewCgoOutputSource(alsaDevice string, cfg AudioConfig) AudioSource { + logger := logging.GetDefaultLogger().With(). + Str("component", "audio-output-cgo"). + Str("alsa_device", alsaDevice). + Logger() + + return &CgoSource{ + outputDevice: true, + alsaDevice: alsaDevice, + logger: logger, + opusBuf: make([]byte, ipcMaxFrameSize), + config: cfg, + } +} + +func NewCgoInputSource(alsaDevice string, cfg AudioConfig) AudioSource { + logger := logging.GetDefaultLogger().With(). + Str("component", "audio-input-cgo"). + Str("alsa_device", alsaDevice). + Logger() + + return &CgoSource{ + outputDevice: false, + alsaDevice: alsaDevice, + logger: logger, + opusBuf: make([]byte, ipcMaxFrameSize), + config: cfg, + } +} + +func (c *CgoSource) Connect() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected { + return nil + } + + if c.outputDevice { + return c.connectOutput() + } + return c.connectInput() +} + +func (c *CgoSource) connectOutput() error { + os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice) + + const sampleRate = 48000 + const frameSize = uint16(sampleRate * 20 / 1000) // 20ms frames + + c.logger.Debug(). + Uint16("bitrate_kbps", c.config.Bitrate). + Uint8("complexity", c.config.Complexity). + Bool("dtx", c.config.DTXEnabled). + Bool("fec", c.config.FECEnabled). + Uint8("buffer_periods", c.config.BufferPeriods). + Uint32("sample_rate", sampleRate). + Uint16("frame_size", uint16(frameSize)). + Uint8("packet_loss_perc", c.config.PacketLossPerc). + Msg("Initializing audio capture") + + C.update_audio_constants( + C.uint(uint32(c.config.Bitrate)*1000), + C.uchar(c.config.Complexity), + C.uint(sampleRate), + C.uchar(2), + C.ushort(frameSize), + C.ushort(1500), + C.uint(1000), + C.uchar(5), + C.uint(500000), + boolToUchar(c.config.DTXEnabled), + boolToUchar(c.config.FECEnabled), + C.uchar(c.config.BufferPeriods), + C.uchar(c.config.PacketLossPerc), + ) + + 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) + } + + c.connected = true + return nil +} + +func (c *CgoSource) connectInput() error { + os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice) + + // USB Audio Gadget uses fixed 48kHz sample rate + const inputSampleRate = 48000 + const frameSize = uint16(inputSampleRate * 20 / 1000) // 20ms frames + + C.update_audio_decoder_constants( + C.uint(inputSampleRate), + C.uchar(1), // Mono for USB audio gadget + C.ushort(uint16(frameSize)), + C.ushort(1500), + C.uint(1000), + C.uchar(5), + C.uint(500000), + C.uchar(c.config.BufferPeriods), + ) + + 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 + return nil +} + +func boolToUchar(b bool) C.uchar { + if b { + return C.uchar(1) + } + return C.uchar(0) +} + +func (c *CgoSource) Disconnect() { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.connected { + return + } + + if c.outputDevice { + C.jetkvm_audio_capture_close() + os.Unsetenv("ALSA_CAPTURE_DEVICE") + } else { + C.jetkvm_audio_playback_close() + os.Unsetenv("ALSA_PLAYBACK_DEVICE") + } + + c.connected = false +} + +func (c *CgoSource) IsConnected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.connected +} + +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.outputDevice { + return 0, nil, fmt.Errorf("ReadMessage only supported for output direction") + } + + // Hold mutex during C call to prevent race condition with Disconnect(). + // Lock order is consistent (c.mu -> capture_mutex) in all code paths, + // so this cannot deadlock. The C layer's capture_mutex protects ALSA/codec + // state, while c.mu protects the connection lifecycle. + 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 { + return 0, nil, nil + } + + if int(opusSize) > len(c.opusBuf) { + return 0, nil, fmt.Errorf("opus packet too large: %d > %d", opusSize, len(c.opusBuf)) + } + + return ipcMsgTypeOpus, c.opusBuf[:opusSize], nil +} + +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.outputDevice { + return fmt.Errorf("WriteMessage only supported for input direction") + } + + if msgType != ipcMsgTypeOpus { + return nil + } + + if len(payload) == 0 { + return nil + } + + if len(payload) > 1500 { + return fmt.Errorf("opus packet too large: %d bytes (max 1500)", len(payload)) + } + + // Hold mutex during C call to prevent race condition with Disconnect(). + // Lock order is consistent (c.mu -> playback_mutex) in all code paths. + 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 +} diff --git a/internal/audio/cgo_source_stub.go b/internal/audio/cgo_source_stub.go new file mode 100644 index 00000000..22cf499b --- /dev/null +++ b/internal/audio/cgo_source_stub.go @@ -0,0 +1,37 @@ +//go:build !linux || (!arm && !arm64) + +package audio + +// Stub implementations for non-ARM Linux platforms + +type CgoSource struct{} + +var _ AudioSource = (*CgoSource)(nil) + +func NewCgoOutputSource(alsaDevice string, audioConfig AudioConfig) AudioSource { + panic("audio CGO source not supported on this platform") +} + +func NewCgoInputSource(alsaDevice string, audioConfig AudioConfig) AudioSource { + panic("audio CGO source not supported on this platform") +} + +func (c *CgoSource) Connect() error { + panic("audio CGO source not supported on this platform") +} + +func (c *CgoSource) Disconnect() { + panic("audio CGO source not supported on this platform") +} + +func (c *CgoSource) IsConnected() bool { + panic("audio CGO source not supported on this platform") +} + +func (c *CgoSource) ReadMessage() (uint8, []byte, error) { + panic("audio CGO source not supported on this platform") +} + +func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error { + panic("audio CGO source not supported on this platform") +} diff --git a/internal/audio/relay.go b/internal/audio/relay.go new file mode 100644 index 00000000..8b42de62 --- /dev/null +++ b/internal/audio/relay.go @@ -0,0 +1,180 @@ +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" +) + +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{} + + framesRelayed atomic.Uint32 + framesDropped atomic.Uint32 +} + +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, + }, + } +} + +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 +} + +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") +} + +func (r *OutputRelay) relayLoop() { + defer close(r.stopped) + + const maxRetries = 10 + const maxConsecutiveWriteFailures = 50 // Allow some WebRTC write failures before reconnecting + retryDelay := 1 * time.Second + consecutiveFailures := 0 + consecutiveWriteFailures := 0 + + for r.running.Load() { + if !(*r.source).IsConnected() { + if err := (*r.source).Connect(); err != nil { + if consecutiveFailures++; consecutiveFailures >= maxRetries { + r.logger.Error().Int("failures", consecutiveFailures).Msg("Max retries exceeded, stopping relay") + return + } + r.logger.Debug().Err(err).Int("failures", consecutiveFailures).Msg("Connection failed, retrying") + time.Sleep(retryDelay) + retryDelay = min(retryDelay*2, 30*time.Second) + continue + } + consecutiveFailures = 0 + retryDelay = 1 * time.Second + } + + msgType, payload, err := (*r.source).ReadMessage() + if err != nil { + if !r.running.Load() { + break + } + if consecutiveFailures++; consecutiveFailures >= maxRetries { + r.logger.Error().Int("failures", consecutiveFailures).Msg("Max read retries exceeded, stopping relay") + return + } + r.logger.Warn().Err(err).Int("failures", consecutiveFailures).Msg("Read error, reconnecting") + (*r.source).Disconnect() + time.Sleep(retryDelay) + retryDelay = min(retryDelay*2, 30*time.Second) + continue + } + + consecutiveFailures = 0 + retryDelay = 1 * time.Second + + if msgType == ipcMsgTypeOpus && len(payload) > 0 { + r.sample.Data = payload + if err := r.audioTrack.WriteSample(r.sample); err != nil { + r.framesDropped.Add(1) + consecutiveWriteFailures++ + + // Log warning on first failure and every 10th failure + if consecutiveWriteFailures == 1 || consecutiveWriteFailures%10 == 0 { + r.logger.Warn(). + Err(err). + Int("consecutive_failures", consecutiveWriteFailures). + Msg("Failed to write sample to WebRTC") + } + + if consecutiveWriteFailures >= maxConsecutiveWriteFailures { + r.logger.Error(). + Int("failures", consecutiveWriteFailures). + Msg("Too many consecutive WebRTC write failures, reconnecting source") + (*r.source).Disconnect() + consecutiveWriteFailures = 0 + consecutiveFailures = 0 + } + } else { + r.framesRelayed.Add(1) + consecutiveWriteFailures = 0 + } + } + } +} + +type InputRelay struct { + source *AudioSource + ctx context.Context + cancel context.CancelFunc + logger zerolog.Logger + running atomic.Bool +} + +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, + } +} + +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 +} + +func (r *InputRelay) Stop() { + if !r.running.Swap(false) { + return + } + + r.cancel() + r.logger.Debug().Msg("input relay stopped") +} diff --git a/internal/audio/source.go b/internal/audio/source.go new file mode 100644 index 00000000..fcc19e62 --- /dev/null +++ b/internal/audio/source.go @@ -0,0 +1,33 @@ +package audio + +const ( + ipcMsgTypeOpus = 0 +) + +type AudioConfig struct { + Bitrate uint16 + Complexity uint8 + BufferPeriods uint8 + DTXEnabled bool + FECEnabled bool + PacketLossPerc uint8 +} + +func DefaultAudioConfig() AudioConfig { + return AudioConfig{ + Bitrate: 192, + Complexity: 8, + BufferPeriods: 12, + DTXEnabled: true, + FECEnabled: true, + PacketLossPerc: 0, + } +} + +type AudioSource interface { + ReadMessage() (msgType uint8, payload []byte, err error) + WriteMessage(msgType uint8, payload []byte) error + IsConnected() bool + Connect() error + Disconnect() +} diff --git a/internal/native/cgo/video.c b/internal/native/cgo/video.c index 9107c70e..e362684c 100644 --- a/internal/native/cgo/video.c +++ b/internal/native/cgo/video.c @@ -769,20 +769,19 @@ uint8_t video_get_streaming_status() { void video_restart_streaming() { uint8_t streaming_status = video_get_streaming_status(); - if (streaming_status == 0) - { - log_info("will not restart video streaming because it's stopped"); - return; - } - if (streaming_status == 2) { + if (streaming_status == 0 && !detected_signal) { + return; + } + + if (streaming_status != 0) { video_stop_streaming(); } if (!wait_for_streaming_stopped()) { return; } - + video_start_streaming(); } @@ -802,7 +801,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) diff --git a/internal/native/video.go b/internal/native/video.go index 176511c6..4f738959 100644 --- a/internal/native/video.go +++ b/internal/native/video.go @@ -8,7 +8,6 @@ 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" var extraLockTimeout = 5 * time.Second @@ -153,10 +152,6 @@ func (n *Native) VideoSetEDID(edid string) error { n.videoLock.Lock() defer n.videoLock.Unlock() - if edid == "" { - edid = DefaultEDID - } - return n.useExtraLock(func() error { return videoSetEDID(edid) }) @@ -170,6 +165,11 @@ func (n *Native) VideoGetEDID() (string, error) { return videoGetEDID() } +// GetDefaultEDID returns the default EDID constant. +func (n *Native) GetDefaultEDID() string { + return DefaultEDID +} + // VideoLogStatus gets the log status for the video stream. func (n *Native) VideoLogStatus() (string, error) { n.videoLock.Lock() diff --git a/internal/tzdata/tzdata.go b/internal/tzdata/tzdata.go index 368c7205..1d58ae76 100644 --- a/internal/tzdata/tzdata.go +++ b/internal/tzdata/tzdata.go @@ -1,6 +1,8 @@ // Code generated by "go run gen.go". DO NOT EDIT. +// //go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go package tzdata + var TimeZones = []string{ "Africa/Abidjan", "Africa/Accra", diff --git a/internal/usbgadget/changeset_resolver.go b/internal/usbgadget/changeset_resolver.go index 67812e0d..c06fac96 100644 --- a/internal/usbgadget/changeset_resolver.go +++ b/internal/usbgadget/changeset_resolver.go @@ -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) diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 6d1bd391..5eca71cd 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -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": "1", // Playback: mono (1 channel for microphone) + "p_srate": "48000", // Playback: 48kHz sample rate + "p_ssize": "2", // Playback: 16-bit (2 bytes) + "p_volume_present": "1", // Playback: enable volume control + "c_chmask": "3", // Capture: stereo (2 channels for HDMI audio) + "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 } @@ -115,6 +134,13 @@ func (u *UsbGadget) SetGadgetDevices(devices *Devices) { u.enabledDevices = *devices } +func (u *UsbGadget) GetGadgetDevices() Devices { + u.configLock.Lock() + defer u.configLock.Unlock() + + return u.enabledDevices +} + // GetConfigPath returns the path to the config item. func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) { item, ok := u.configMap[itemKey] @@ -182,6 +208,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 +220,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 } diff --git a/internal/usbgadget/config_tx.go b/internal/usbgadget/config_tx.go index df8a3d1b..d5591b17 100644 --- a/internal/usbgadget/config_tx.go +++ b/internal/usbgadget/config_tx.go @@ -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 { diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index f01ae09d..7dc8c926 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -19,6 +19,16 @@ type Devices struct { RelativeMouse bool `json:"relative_mouse"` Keyboard bool `json:"keyboard"` MassStorage bool `json:"mass_storage"` + Audio bool `json:"audio"` +} + +// Equals checks if two Devices structs are equal. +func (d Devices) Equals(other Devices) bool { + return d.AbsoluteMouse == other.AbsoluteMouse && + d.RelativeMouse == other.RelativeMouse && + d.Keyboard == other.Keyboard && + d.MassStorage == other.MassStorage && + d.Audio == other.Audio } // Config is a struct that represents the customizations for a USB gadget. @@ -39,6 +49,7 @@ var defaultUsbGadgetDevices = Devices{ RelativeMouse: true, Keyboard: true, MassStorage: true, + Audio: true, } type KeysDownState struct { @@ -188,3 +199,52 @@ func (u *UsbGadget) Close() error { return nil } + +// CloseHidFiles closes all open HID files +func (u *UsbGadget) CloseHidFiles() { + u.log.Debug().Msg("closing HID files") + + closeFile := func(file **os.File, name string) { + if *file != nil { + if err := (*file).Close(); err != nil { + u.log.Debug().Err(err).Msgf("failed to close %s HID file", name) + } + *file = nil + } + } + + closeFile(&u.keyboardHidFile, "keyboard") + closeFile(&u.absMouseHidFile, "absolute mouse") + closeFile(&u.relMouseHidFile, "relative mouse") +} + +// PreOpenHidFiles opens all HID files to reduce input latency +func (u *UsbGadget) PreOpenHidFiles() { + // Small delay for USB gadget reconfiguration to complete + time.Sleep(100 * time.Millisecond) + + openHidFile := func(file **os.File, path string, name string) { + if *file == nil { + f, err := os.OpenFile(path, os.O_RDWR, 0666) + if err != nil { + u.log.Debug().Err(err).Msgf("failed to pre-open %s HID file", name) + } else { + *file = f + } + } + } + + 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 { + openHidFile(&u.absMouseHidFile, "/dev/hidg1", "absolute mouse") + } + + if u.enabledDevices.RelativeMouse { + openHidFile(&u.relMouseHidFile, "/dev/hidg2", "relative mouse") + } +} diff --git a/jsonrpc.go b/jsonrpc.go index b401ac59..cb3e49b2 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -19,6 +19,7 @@ import ( "go.bug.st/serial" "github.com/jetkvm/kvm/internal/hidrpc" + "github.com/jetkvm/kvm/internal/native" "github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/utils" ) @@ -123,7 +124,6 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { Interface("id", request.ID).Logger() scopedLogger.Trace().Msg("Received RPC request") - t := time.Now() handler, ok := rpcHandlers[request.Method] if !ok { @@ -155,7 +155,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { return } - scopedLogger.Trace().Dur("duration", time.Since(t)).Interface("result", result).Msg("RPC handler returned") + scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned") response := JSONRPCResponse{ JSONRPC: "2.0", @@ -209,15 +209,16 @@ func rpcSetAutoUpdateState(enabled bool) (bool, error) { } func rpcGetEDID() (string, error) { - resp, err := nativeInstance.VideoGetEDID() - if err != nil { - return "", err - } - return resp, nil + return config.EdidString, nil +} + +func rpcGetDefaultEDID() (string, error) { + return native.DefaultEDID, nil } func rpcSetEDID(edid string) error { if edid == "" { + edid = native.DefaultEDID logger.Info().Msg("Restoring EDID to default") } else { logger.Info().Str("edid", edid).Msg("Setting EDID") @@ -227,12 +228,26 @@ func rpcSetEDID(edid string) error { return err } - // Save EDID to config, allowing it to be restored on reboot. config.EdidString = edid - _ = SaveConfig() + if err := SaveConfig(); err != nil { + logger.Error().Err(err).Msg("Failed to save config after EDID change") + return err + } return nil } +func rpcRefreshHdmiConnection() error { + currentEDID, err := nativeInstance.VideoGetEDID() + if err != nil { + return err + } + if currentEDID == "" { + // Use the default EDID from the native package + currentEDID = native.DefaultEDID + } + return nativeInstance.VideoSetEDID(currentEDID) +} + func rpcGetVideoLogStatus() (string, error) { return nativeInstance.VideoLogStatus() } @@ -436,7 +451,7 @@ type RPCHandler struct { Params []string } -// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls +// call the handler but recover from a panic to ensure our RPC goroutine doesn't collapse on malformed calls func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) { // Use defer to recover from a panic defer func() { @@ -606,6 +621,9 @@ func rpcGetMassStorageMode() (string, error) { } func rpcIsUpdatePending() (bool, error) { + if otaState == nil { + return false, nil + } return otaState.IsUpdatePending(), nil } @@ -628,9 +646,12 @@ func rpcGetUsbConfig() (usbgadget.Config, error) { func rpcSetUsbConfig(usbConfig usbgadget.Config) error { LoadConfig() + wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio + config.UsbConfig = &usbConfig gadget.SetGadgetConfig(config.UsbConfig) - return updateUsbRelatedConfig() + + return updateUsbRelatedConfig(wasUsbAudioEnabled) } func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) { @@ -842,23 +863,60 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) { return *config.UsbDevices, nil } -func updateUsbRelatedConfig() error { - if err := gadget.UpdateGadgetConfig(); err != nil { - return fmt.Errorf("failed to write gadget config: %w", err) +func updateUsbRelatedConfig(wasUsbAudioEnabled bool) error { + ensureConfigLoaded() + nowHasUsbAudio := config.UsbDevices != nil && config.UsbDevices.Audio + + // Stop audio before reconfiguring USB gadget + stopInputAudio() + if config.AudioOutputSource == "usb" { + stopOutputAudio() } + + // Auto-switch to HDMI when USB audio disabled + if wasUsbAudioEnabled && !nowHasUsbAudio && config.AudioOutputSource == "usb" { + logger.Info().Msg("USB audio disabled, switching output to HDMI") + config.AudioOutputSource = "hdmi" + } + + // Update USB gadget configuration + if err := gadget.UpdateGadgetConfig(); err != nil { + return fmt.Errorf("failed to update gadget config: %w", err) + } + + // Save configuration if err := SaveConfig(); err != nil { return fmt.Errorf("failed to save config: %w", err) } + + // Restart audio if needed + 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 { + wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio + currentDevices := gadget.GetGadgetDevices() + + // Skip reconfiguration if devices haven't changed to avoid HID disruption + if currentDevices.Equals(usbDevices) { + logger.Debug().Msg("USB devices unchanged, skipping gadget reconfiguration") + return nil + } + config.UsbDevices = &usbDevices gadget.SetGadgetDevices(config.UsbDevices) - return updateUsbRelatedConfig() + + return updateUsbRelatedConfig(wasUsbAudioEnabled) } func rpcSetUsbDeviceState(device string, enabled bool) error { + wasUsbAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio + currentDevices := gadget.GetGadgetDevices() + switch device { case "absoluteMouse": config.UsbDevices.AbsoluteMouse = enabled @@ -868,11 +926,117 @@ 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) } + + // Skip reconfiguration if devices haven't changed to avoid HID disruption + if currentDevices.Equals(*config.UsbDevices) { + logger.Debug().Msg("USB device state unchanged, skipping gadget reconfiguration") + return nil + } + gadget.SetGadgetDevices(config.UsbDevices) - return updateUsbRelatedConfig() + return updateUsbRelatedConfig(wasUsbAudioEnabled) +} + +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 rpcGetAudioOutputSource() (string, error) { + ensureConfigLoaded() + if config.AudioOutputSource == "" { + return "usb", nil + } + return config.AudioOutputSource, nil +} + +func rpcSetAudioOutputSource(source string) error { + return SetAudioOutputSource(source) +} + +type AudioConfigResponse struct { + Bitrate int `json:"bitrate"` + Complexity int `json:"complexity"` + DTXEnabled bool `json:"dtx_enabled"` + FECEnabled bool `json:"fec_enabled"` + BufferPeriods int `json:"buffer_periods"` + PacketLossPerc int `json:"packet_loss_perc"` +} + +func rpcGetAudioConfig() (AudioConfigResponse, error) { + ensureConfigLoaded() + cfg := getAudioConfig() + return AudioConfigResponse{ + Bitrate: int(cfg.Bitrate), + Complexity: int(cfg.Complexity), + DTXEnabled: cfg.DTXEnabled, + FECEnabled: cfg.FECEnabled, + BufferPeriods: int(cfg.BufferPeriods), + PacketLossPerc: int(cfg.PacketLossPerc), + }, nil +} + +func rpcSetAudioConfig(bitrate int, complexity int, dtxEnabled bool, fecEnabled bool, bufferPeriods int, packetLossPerc int) error { + ensureConfigLoaded() + + if bitrate < 64 || bitrate > 256 { + return fmt.Errorf("bitrate must be between 64 and 256 kbps") + } + if complexity < 0 || complexity > 10 { + return fmt.Errorf("complexity must be between 0 and 10") + } + if bufferPeriods < 2 || bufferPeriods > 24 { + return fmt.Errorf("buffer periods must be between 2 and 24") + } + if packetLossPerc < 0 || packetLossPerc > 100 { + return fmt.Errorf("packet loss percentage must be between 0 and 100") + } + + config.AudioBitrate = bitrate + config.AudioComplexity = complexity + config.AudioDTXEnabled = dtxEnabled + config.AudioFECEnabled = fecEnabled + config.AudioBufferPeriods = bufferPeriods + config.AudioPacketLossPerc = packetLossPerc + + return SaveConfig() +} + +func rpcRestartAudioOutput() error { + return RestartAudioOutput() +} + +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 { @@ -1117,97 +1281,110 @@ 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}, - "checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}}, - "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, - "tryUpdate": {Func: rpcTryUpdate}, - "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}}, - "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"}}, - "getFailSafeLogs": {Func: rpcGetFailsafeLogs}, - "getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}}, - "checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses}, + "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}, + "getDefaultEDID": {Func: rpcGetDefaultEDID}, + "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}, + "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel, Params: []string{"channel"}}, + "checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}}, + "tryUpdate": {Func: rpcTryUpdate}, + "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}}, + "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"}}, + "getAudioOutputSource": {Func: rpcGetAudioOutputSource}, + "setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}}, + "refreshHdmiConnection": {Func: rpcRefreshHdmiConnection}, + "getAudioConfig": {Func: rpcGetAudioConfig}, + "setAudioConfig": {Func: rpcSetAudioConfig, Params: []string{"bitrate", "complexity", "dtxEnabled", "fecEnabled", "bufferPeriods", "packetLossPerc"}}, + "restartAudioOutput": {Func: rpcRestartAudioOutput}, + "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"}}, + "getFailSafeLogs": {Func: rpcGetFailsafeLogs}, + "getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}}, + "checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses}, } diff --git a/main.go b/main.go index 88d2dec7..86d45b75 100644 --- a/main.go +++ b/main.go @@ -58,6 +58,9 @@ func Main() { initNative(systemVersionLocal, appVersionLocal) initDisplay() + initAudio() + defer stopAudio() + http.DefaultClient.Timeout = 1 * time.Minute err = rootcerts.UpdateDefaultTransport() @@ -104,6 +107,7 @@ func Main() { if err := initImagesFolder(); err != nil { logger.Warn().Err(err).Msg("failed to init images folder") } + initJiggler() // start video sleep mode timer @@ -170,6 +174,7 @@ func Main() { <-sigs logger.Log().Msg("JetKVM Shutting Down") + //if fuseServer != nil { // err := setMassStorageImage(" ") // if err != nil { diff --git a/scripts/build_utils.sh b/scripts/build_utils.sh index d70b29ae..f924b23a 100644 --- a/scripts/build_utils.sh +++ b/scripts/build_utils.sh @@ -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 -} \ No newline at end of file +} diff --git a/scripts/dev_deploy.sh b/scripts/dev_deploy.sh index 96e7cf60..8c8e8aba 100755 --- a/scripts/dev_deploy.sh +++ b/scripts/dev_deploy.sh @@ -196,6 +196,11 @@ EOF exit 0 fi +# Always clear Go build caches to prevent stale CGO builds +msg_info "▶ Clearing Go build caches" +go clean -cache -modcache -testcache -fuzzcache +msg_info "✓ Build caches cleared" + # Build the development version on the host # When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag # check if static/index.html exists @@ -260,18 +265,20 @@ fi if [ "$INSTALL_APP" = true ] then msg_info "▶ Building release binary" + # Build audio dependencies and release binary + do_make build_audio_deps do_make build_release \ SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE} - # Copy the binary to the remote host as if we were the OTA updater. + # Deploy as OTA update and reboot sshdev "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app - - # Reboot the device, the new app will be deployed by the startup process. sshdev "reboot" else msg_info "▶ Building development binary" + # Build audio dependencies and development binary + do_make build_audio_deps do_make build_dev \ SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index dc7d5801..1d94afc5 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -47,6 +47,7 @@ "access_tls_self_signed": "Selvsigneret", "access_tls_updated": "TLS-indstillingerne er blevet opdateret", "access_update_tls_settings": "Opdater TLS-indstillinger", + "action_bar_audio": "Audio", "action_bar_connection_stats": "Forbindelsesstatistik", "action_bar_extension": "Udvidelse", "action_bar_fullscreen": "Fuldskærm", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}", "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}", - "advanced_error_version_update": "Kunne ikke starte versionsopdatering: {error}", "advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)", "advanced_loopback_only_title": "Kun loopback-tilstand", "advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "Opdater SSH-nøgle", "advanced_usb_emulation_description": "Styr USB-emuleringstilstanden", "advanced_usb_emulation_title": "USB-emulering", - "advanced_version_update_app_label": "App-version", - "advanced_version_update_button": "Opdatering til version", - "advanced_version_update_description": "Installer en specifik version fra GitHub-udgivelser", - "advanced_version_update_github_link": "JetKVM-udgivelsesside", - "advanced_version_update_helper": "Find tilgængelige versioner på", - "advanced_version_update_reset_config_description": "Nulstil konfigurationen efter opdateringen", - "advanced_version_update_reset_config_label": "Nulstil konfiguration", - "advanced_version_update_system_label": "Systemversion", - "advanced_version_update_target_app": "Kun i appen", - "advanced_version_update_target_both": "Både app og system", - "advanced_version_update_target_label": "Hvad skal opdateres", - "advanced_version_update_target_system": "Kun systemet", - "advanced_version_update_title": "Opdatering til specifik version", "already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.", "already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.", "already_adopted_return_to_dashboard": "Tilbage til dashboardet", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "Nulstil", "atx_power_control_send_action_error": "Kunne ikke sende ATX-strømfunktion {action} : {error}", "atx_power_control_short_power_button": "Kort tryk", + "audio_https_only": "Kun HTTPS", + "audio_input_auto_enable_disabled": "Automatisk aktivering af mikrofon deaktiveret", + "audio_input_auto_enable_enabled": "Automatisk aktivering af mikrofon aktiveret", + "audio_input_failed_disable": "Kunne ikke deaktivere lydindgang: {error}", + "audio_input_failed_enable": "Kunne ikke aktivere lydindgang: {error}", + "audio_microphone_description": "Mikrofonindgang til mål", + "audio_microphone_title": "Mikrofon", + "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_popover_description": "Hurtige lydkontroller til højttalere og mikrofon", + "audio_popover_title": "Lyd", + "audio_settings_applied": "Lydindstillinger anvendt", + "audio_settings_apply_button": "Anvend indstillinger", + "audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)", + "audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk", + "audio_settings_bitrate_description": "Lydkodningsbitrate (højere = bedre kvalitet, mere båndbredde)", + "audio_settings_bitrate_title": "Opus Bitrate", + "audio_settings_buffer_description": "ALSA bufferstørrelse (højere = mere stabil, mere latens)", + "audio_settings_buffer_title": "Bufferperioder", + "audio_settings_complexity_description": "Encoder-kompleksitet (0-10, højere = bedre kvalitet, mere CPU)", + "audio_settings_complexity_title": "Opus Kompleksitet", + "audio_settings_config_updated": "Lydkonfiguration opdateret", + "audio_settings_description": "Konfigurer lydindgangs- og lydudgangsindstillinger for din JetKVM-enhed", + "audio_settings_dtx_description": "Spar båndbredde under stilhed", + "audio_settings_dtx_title": "DTX (Diskontinuerlig Transmission)", + "audio_settings_fec_description": "Forbedre lydkvaliteten på tabende forbindelser", + "audio_settings_fec_title": "FEC (Fremadrettet Fejlkorrektion)", + "audio_settings_hdmi_label": "HDMI", + "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. Lyd starter om 30-60 sekunder.", + "audio_settings_output_source_title": "Lydudgangskilde", + "audio_settings_output_title": "Lydudgang", + "audio_settings_packet_loss_description": "FEC overhead-procent (højere = bedre gendannelse, mere båndbredde)", + "audio_settings_packet_loss_title": "Pakketabskompensation", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Samplingsrate", + "audio_settings_title": "Lyd", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Lyd fra mål til højttalere", + "audio_speakers_title": "Højttalere", "auth_authentication_mode": "Vælg venligst en godkendelsestilstand", "auth_authentication_mode_error": "Der opstod en fejl under indstilling af godkendelsestilstanden", "auth_authentication_mode_invalid": "Ugyldig godkendelsestilstand", @@ -193,7 +224,6 @@ "connection_stats_remote_ip_address": "Fjern IP-adresse", "connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere fjern-IP-adresse", "connection_stats_remote_ip_address_copy_success": "Fjern IP-adresse { ip } kopieret til udklipsholder", - "connection_stats_remote_ip_address_description": "IP-adressen på den eksterne enhed.", "connection_stats_round_trip_time": "Rundturstid", "connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.", "connection_stats_sidebar": "Forbindelsesstatistik", @@ -259,7 +289,6 @@ "general_auto_update_description": "Opdater automatisk enheden til den nyeste version", "general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}", "general_auto_update_title": "Automatisk opdatering", - "general_check_for_stable_updates": "Nedgradering", "general_check_for_updates": "Tjek for opdateringer", "general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer", "general_reboot_description": "Vil du fortsætte med at genstarte systemet?", @@ -280,13 +309,9 @@ "general_update_checking_title": "Søger efter opdateringer…", "general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!", "general_update_completed_title": "Opdatering gennemført", - "general_update_downgrade_available_description": "En nedgradering er tilgængelig for at vende tilbage til en tidligere version.", - "general_update_downgrade_available_title": "Nedgradering tilgængelig", - "general_update_downgrade_button": "Nedgrader nu", "general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.", "general_update_error_details": "Fejldetaljer: {errorMessage}", "general_update_error_title": "Opdateringsfejl", - "general_update_keep_current_button": "Behold den aktuelle version", "general_update_later_button": "Opdater senere", "general_update_now_button": "Opdater nu", "general_update_rebooting": "Genstarter for at fuldføre opdateringen…", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "Systemet er opdateret", "general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.", "general_update_updating_title": "Opdatering af din enhed", - "general_update_will_disable_auto_update_description": "Du er ved at ændre din enhedsversion manuelt. Automatisk opdatering vil blive deaktiveret, når opdateringen er fuldført, for at forhindre utilsigtede opdateringer.", "getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}", "hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}", "hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}", @@ -817,6 +841,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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.", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "Kun tastatur", "usb_device_restore_default": "Gendan til standard", "usb_device_title": "USB-enhed", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index ef20ed9a..665fce25 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -47,6 +47,7 @@ "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", "action_bar_extension": "Erweiterung", "action_bar_fullscreen": "Vollbild", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}", "advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}", "advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}", - "advanced_error_version_update": "Versionsaktualisierung konnte nicht initiiert werden: {error}", "advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).", "advanced_loopback_only_title": "Nur-Loopback-Modus", "advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren", "advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus", "advanced_usb_emulation_title": "USB-Emulation", - "advanced_version_update_app_label": "App-Version", - "advanced_version_update_button": "Aktualisierung auf Version", - "advanced_version_update_description": "Installieren Sie eine bestimmte Version aus den GitHub-Releases.", - "advanced_version_update_github_link": "JetKVM-Releases-Seite", - "advanced_version_update_helper": "Finden Sie verfügbare Versionen auf der", - "advanced_version_update_reset_config_description": "Konfiguration nach dem Update zurücksetzen", - "advanced_version_update_reset_config_label": "Konfiguration zurücksetzen", - "advanced_version_update_system_label": "Systemversion", - "advanced_version_update_target_app": "Nur App", - "advanced_version_update_target_both": "Sowohl App als auch System", - "advanced_version_update_target_label": "Was sollte aktualisiert werden?", - "advanced_version_update_target_system": "System nur", - "advanced_version_update_title": "Aktualisierung auf eine bestimmte Version", "already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.", "already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.", "already_adopted_return_to_dashboard": "Zurück zum Dashboard", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "Reset-Taste", "atx_power_control_send_action_error": "ATX-Stromversorgungsaktion {action} konnte nicht gesendet werden: {error}", "atx_power_control_short_power_button": "Kurzes Drücken", + "audio_https_only": "Nur HTTPS", + "audio_input_auto_enable_disabled": "Automatische Mikrofonaktivierung deaktiviert", + "audio_input_auto_enable_enabled": "Automatische Mikrofonaktivierung aktiviert", + "audio_input_failed_disable": "Fehler beim Deaktivieren des Audioeingangs: {error}", + "audio_input_failed_enable": "Fehler beim Aktivieren des Audioeingangs: {error}", + "audio_microphone_description": "Mikrofoneingang zum Ziel", + "audio_microphone_title": "Mikrofon", + "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_popover_description": "Schnelle Audiosteuerung für Lautsprecher und Mikrofon", + "audio_popover_title": "Audio", + "audio_settings_applied": "Audioeinstellungen angewendet", + "audio_settings_apply_button": "Einstellungen anwenden", + "audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)", + "audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren", + "audio_settings_bitrate_description": "Audio-Codierungsbitrate (höher = bessere Qualität, mehr Bandbreite)", + "audio_settings_bitrate_title": "Opus Bitrate", + "audio_settings_buffer_description": "ALSA-Puffergröße (höher = stabiler, mehr Latenz)", + "audio_settings_buffer_title": "Pufferperioden", + "audio_settings_complexity_description": "Encoder-Komplexität (0-10, höher = bessere Qualität, mehr CPU)", + "audio_settings_complexity_title": "Opus Komplexität", + "audio_settings_config_updated": "Audiokonfiguration aktualisiert", + "audio_settings_description": "Konfigurieren Sie Audio-Eingangs- und Ausgangseinstellungen für Ihr JetKVM-Gerät", + "audio_settings_dtx_description": "Bandbreite während Stille sparen", + "audio_settings_dtx_title": "DTX (Discontinuous Transmission)", + "audio_settings_fec_description": "Audioqualität bei verlustbehafteten Verbindungen verbessern", + "audio_settings_fec_title": "FEC (Forward Error Correction)", + "audio_settings_hdmi_label": "HDMI", + "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 aktualisiert. Audio startet in 30-60 Sekunden.", + "audio_settings_output_source_title": "Audioausgabequelle", + "audio_settings_output_title": "Audioausgang", + "audio_settings_packet_loss_description": "FEC-Overhead-Prozentsatz (höher = bessere Wiederherstellung, mehr Bandbreite)", + "audio_settings_packet_loss_title": "Paketverlust-Kompensation", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Abtastrate", + "audio_settings_title": "Audio", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Audio vom Ziel zu Lautsprechern", + "audio_speakers_title": "Lautsprecher", "auth_authentication_mode": "Bitte wählen Sie einen Authentifizierungsmodus", "auth_authentication_mode_error": "Beim Einstellen des Authentifizierungsmodus ist ein Fehler aufgetreten", "auth_authentication_mode_invalid": "Ungültiger Authentifizierungsmodus", @@ -193,7 +224,6 @@ "connection_stats_remote_ip_address": "Remote-IP-Adresse", "connection_stats_remote_ip_address_copy_error": "Fehler beim Kopieren der Remote-IP-Adresse", "connection_stats_remote_ip_address_copy_success": "Remote-IP-Adresse { ip } in die Zwischenablage kopiert", - "connection_stats_remote_ip_address_description": "Die IP-Adresse des Remote-Geräts.", "connection_stats_round_trip_time": "Round-Trip-Zeit", "connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.", "connection_stats_sidebar": "Verbindungsstatistiken", @@ -259,7 +289,6 @@ "general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version", "general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}", "general_auto_update_title": "Automatische Aktualisierung", - "general_check_for_stable_updates": "Herabstufung", "general_check_for_updates": "Nach Updates suchen", "general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren", "general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?", @@ -280,13 +309,9 @@ "general_update_checking_title": "Suche nach Updates…", "general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!", "general_update_completed_title": "Update erfolgreich abgeschlossen", - "general_update_downgrade_available_description": "Es besteht die Möglichkeit, auf eine frühere Version zurückzukehren.", - "general_update_downgrade_available_title": "Downgrade verfügbar", - "general_update_downgrade_button": "Jetzt downgraden", "general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.", "general_update_error_details": "Fehlerdetails: {errorMessage}", "general_update_error_title": "Aktualisierungsfehler", - "general_update_keep_current_button": "Aktuelle Version beibehalten", "general_update_later_button": "Später", "general_update_now_button": "Jetzt aktualisieren", "general_update_rebooting": "Neustart zum Abschließen des Updates …", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "Das System ist auf dem neuesten Stand", "general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.", "general_update_updating_title": "Aktualisieren Ihres Geräts", - "general_update_will_disable_auto_update_description": "Sie sind im Begriff, die Version Ihres Geräts manuell zu ändern. Die automatische Aktualisierung wird nach Abschluss der Aktualisierung deaktiviert, um versehentliche Updates zu verhindern.", "getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}", "hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}", "hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}", @@ -817,6 +841,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "Nur Tastatur", "usb_device_restore_default": "Auf Standard zurücksetzen", "usb_device_title": "USB-Gerät", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 3730fe3a..aa3f22c3 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -47,6 +47,7 @@ "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", "action_bar_extension": "Extension", "action_bar_fullscreen": "Fullscreen", @@ -134,6 +135,53 @@ "atx_power_control_reset_button": "Reset", "atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}", "atx_power_control_short_power_button": "Short Press", + "audio_https_only": "HTTPS only", + "audio_input_auto_enable_disabled": "Auto-enable microphone disabled", + "audio_input_auto_enable_enabled": "Auto-enable microphone enabled", + "audio_input_failed_disable": "Failed to disable audio input: {error}", + "audio_input_failed_enable": "Failed to enable audio input: {error}", + "audio_microphone_description": "Microphone input to target", + "audio_microphone_title": "Microphone", + "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_popover_description": "Quick audio controls for speakers and microphone", + "audio_popover_title": "Audio", + "audio_settings_applied": "Audio settings applied", + "audio_settings_apply_button": "Apply Settings", + "audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)", + "audio_settings_auto_enable_microphone_title": "Auto-enable Microphone", + "audio_settings_bitrate_description": "Audio encoding bitrate (higher = better quality, more bandwidth)", + "audio_settings_bitrate_title": "Opus Bitrate", + "audio_settings_buffer_description": "ALSA buffer size (higher = more stable, more latency)", + "audio_settings_buffer_title": "Buffer Periods", + "audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)", + "audio_settings_complexity_title": "Opus Complexity", + "audio_settings_config_updated": "Audio configuration updated", + "audio_settings_default_lan_suffix": " (default - LAN)", + "audio_settings_default_suffix": " (default)", + "audio_settings_description": "Configure audio input and output settings for your JetKVM device", + "audio_settings_dtx_description": "Save bandwidth during silence", + "audio_settings_dtx_title": "DTX (Discontinuous Transmission)", + "audio_settings_fec_description": "Improve audio quality on lossy connections", + "audio_settings_fec_title": "FEC (Forward Error Correction)", + "audio_settings_hdmi_label": "HDMI", + "audio_settings_no_compensation_suffix": " (no compensation)", + "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. Audio will start in 30-60 seconds.", + "audio_settings_output_source_title": "Audio Output Source", + "audio_settings_output_title": "Audio Output", + "audio_settings_packet_loss_description": "FEC overhead percentage (higher = better recovery, more bandwidth)", + "audio_settings_packet_loss_title": "Packet Loss Compensation", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Sample Rate", + "audio_settings_title": "Audio", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Audio from target to speakers", + "audio_speakers_title": "Speakers", "auth_authentication_mode": "Please select an authentication mode", "auth_authentication_mode_error": "An error occurred while setting the authentication mode", "auth_authentication_mode_invalid": "Invalid authentication mode", @@ -817,6 +865,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", @@ -826,6 +876,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", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index 32d54d9c..eb405855 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -47,6 +47,7 @@ "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", "action_bar_extension": "Extensión", "action_bar_fullscreen": "Pantalla completa", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}", "advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}", "advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}", - "advanced_error_version_update": "Error al iniciar la actualización de versión: {error}", "advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)", "advanced_loopback_only_title": "Modo de solo bucle invertido", "advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "Actualizar clave SSH", "advanced_usb_emulation_description": "Controlar el estado de emulación USB", "advanced_usb_emulation_title": "Emulación USB", - "advanced_version_update_app_label": "Versión de la aplicación", - "advanced_version_update_button": "Actualización a la versión", - "advanced_version_update_description": "Instala una versión específica desde las versiones de GitHub.", - "advanced_version_update_github_link": "Página de lanzamientos de JetKVM", - "advanced_version_update_helper": "Encuentra las versiones disponibles en el", - "advanced_version_update_reset_config_description": "Restablecer la configuración después de la actualización", - "advanced_version_update_reset_config_label": "Restablecer configuración", - "advanced_version_update_system_label": "Versión del sistema", - "advanced_version_update_target_app": "Solo aplicación", - "advanced_version_update_target_both": "Tanto la aplicación como el sistema", - "advanced_version_update_target_label": "Qué actualizar", - "advanced_version_update_target_system": "Solo sistema", - "advanced_version_update_title": "Actualización a una versión específica", "already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.", "already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.", "already_adopted_return_to_dashboard": "Regresar al panel de control", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "Reiniciar", "atx_power_control_send_action_error": "No se pudo enviar la acción de alimentación ATX {action} : {error}", "atx_power_control_short_power_button": "Prensa corta", + "audio_https_only": "Solo HTTPS", + "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_input_failed_disable": "Error al desactivar la entrada de audio: {error}", + "audio_input_failed_enable": "Error al activar la entrada de audio: {error}", + "audio_microphone_description": "Entrada de micrófono al objetivo", + "audio_microphone_title": "Micrófono", + "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_popover_description": "Controles de audio rápidos para altavoces y micrófono", + "audio_popover_title": "Audio", + "audio_settings_applied": "Configuración de audio aplicada", + "audio_settings_apply_button": "Aplicar configuración", + "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)", + "audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente", + "audio_settings_bitrate_description": "Tasa de bits de codificación de audio (mayor = mejor calidad, más ancho de banda)", + "audio_settings_bitrate_title": "Bitrate Opus", + "audio_settings_buffer_description": "Tamaño del buffer ALSA (mayor = más estable, más latencia)", + "audio_settings_buffer_title": "Períodos de Buffer", + "audio_settings_complexity_description": "Complejidad del codificador (0-10, mayor = mejor calidad, más CPU)", + "audio_settings_complexity_title": "Complejidad Opus", + "audio_settings_config_updated": "Configuración de audio actualizada", + "audio_settings_description": "Configure los ajustes de entrada y salida de audio para su dispositivo JetKVM", + "audio_settings_dtx_description": "Ahorrar ancho de banda durante el silencio", + "audio_settings_dtx_title": "DTX (Transmisión Discontinua)", + "audio_settings_fec_description": "Mejorar la calidad de audio en conexiones con pérdida", + "audio_settings_fec_title": "FEC (Corrección de Errores)", + "audio_settings_hdmi_label": "HDMI", + "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. El audio comenzará en 30-60 segundos.", + "audio_settings_output_source_title": "Fuente de salida de audio", + "audio_settings_output_title": "Salida de audio", + "audio_settings_packet_loss_description": "Porcentaje de sobrecarga FEC (mayor = mejor recuperación, más ancho de banda)", + "audio_settings_packet_loss_title": "Compensación de Pérdida de Paquetes", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Tasa de Muestreo", + "audio_settings_title": "Audio", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Audio del objetivo a los altavoces", + "audio_speakers_title": "Altavoces", "auth_authentication_mode": "Por favor seleccione un modo de autenticación", "auth_authentication_mode_error": "Se produjo un error al configurar el modo de autenticación", "auth_authentication_mode_invalid": "Modo de autenticación no válido", @@ -193,7 +224,6 @@ "connection_stats_remote_ip_address": "Dirección IP remota", "connection_stats_remote_ip_address_copy_error": "No se pudo copiar la dirección IP remota", "connection_stats_remote_ip_address_copy_success": "Dirección IP remota { ip } copiada al portapapeles", - "connection_stats_remote_ip_address_description": "La dirección IP del dispositivo remoto.", "connection_stats_round_trip_time": "Tiempo de ida y vuelta", "connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.", "connection_stats_sidebar": "Estadísticas de conexión", @@ -259,7 +289,6 @@ "general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión", "general_auto_update_error": "No se pudo configurar la actualización automática: {error}", "general_auto_update_title": "Actualización automática", - "general_check_for_stable_updates": "Degradar", "general_check_for_updates": "Buscar actualizaciones", "general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias", "general_reboot_description": "¿Desea continuar con el reinicio del sistema?", @@ -280,13 +309,9 @@ "general_update_checking_title": "Buscando actualizaciones…", "general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!", "general_update_completed_title": "Actualización completada con éxito", - "general_update_downgrade_available_description": "Es posible realizar una reversión a una versión anterior.", - "general_update_downgrade_available_title": "Opción de cambio a una versión inferior disponible", - "general_update_downgrade_button": "Revierte ahora", "general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.", "general_update_error_details": "Detalles del error: {errorMessage}", "general_update_error_title": "Error de actualización", - "general_update_keep_current_button": "Mantener la versión actual", "general_update_later_button": "Posponer", "general_update_now_button": "Actualizar ahora", "general_update_rebooting": "Reiniciando para completar la actualización…", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "El sistema está actualizado", "general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.", "general_update_updating_title": "Actualizar su dispositivo", - "general_update_will_disable_auto_update_description": "Estás a punto de cambiar manualmente la versión de tu dispositivo. La actualización automática se desactivará una vez completada la actualización para evitar actualizaciones accidentales.", "getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}", "hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}", "hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}", @@ -817,6 +841,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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.", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "Sólo teclado", "usb_device_restore_default": "Restaurar a valores predeterminados", "usb_device_title": "Dispositivo USB", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index e13a44b9..43e6ae37 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -47,6 +47,7 @@ "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", "action_bar_extension": "Extension", "action_bar_fullscreen": "Plein écran", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}", "advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}", "advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}", - "advanced_error_version_update": "Échec de la mise à jour de version : {error}", "advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)", "advanced_loopback_only_title": "Mode de bouclage uniquement", "advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "Mettre à jour la clé SSH", "advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB", "advanced_usb_emulation_title": "Émulation USB", - "advanced_version_update_app_label": "Version de l'application", - "advanced_version_update_button": "Mise à jour vers la version", - "advanced_version_update_description": "Installer une version spécifique à partir des versions GitHub", - "advanced_version_update_github_link": "page des versions de JetKVM", - "advanced_version_update_helper": "Trouvez les versions disponibles sur le", - "advanced_version_update_reset_config_description": "Réinitialiser la configuration après la mise à jour", - "advanced_version_update_reset_config_label": "Réinitialiser la configuration", - "advanced_version_update_system_label": "Version du système", - "advanced_version_update_target_app": "Application uniquement", - "advanced_version_update_target_both": "L'application et le système", - "advanced_version_update_target_label": "Que mettre à jour", - "advanced_version_update_target_system": "Système uniquement", - "advanced_version_update_title": "Mise à jour vers une version spécifique", "already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.", "already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.", "already_adopted_return_to_dashboard": "Retour au tableau de bord", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "Réinitialiser", "atx_power_control_send_action_error": "Échec de l'envoi de l'action d'alimentation ATX {action} : {error}", "atx_power_control_short_power_button": "Appui court", + "audio_https_only": "HTTPS uniquement", + "audio_input_auto_enable_disabled": "Activation automatique du microphone désactivée", + "audio_input_auto_enable_enabled": "Activation automatique du microphone 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_microphone_description": "Entrée microphone vers la cible", + "audio_microphone_title": "Microphone", + "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_popover_description": "Contrôles audio rapides pour haut-parleurs et microphone", + "audio_popover_title": "Audio", + "audio_settings_applied": "Paramètres audio appliqués", + "audio_settings_apply_button": "Appliquer les paramètres", + "audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)", + "audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone", + "audio_settings_bitrate_description": "Débit d'encodage audio (plus élevé = meilleure qualité, plus de bande passante)", + "audio_settings_bitrate_title": "Débit Opus", + "audio_settings_buffer_description": "Taille du tampon ALSA (plus élevé = plus stable, plus de latence)", + "audio_settings_buffer_title": "Périodes de Tampon", + "audio_settings_complexity_description": "Complexité de l'encodeur (0-10, plus élevé = meilleure qualité, plus de CPU)", + "audio_settings_complexity_title": "Complexité Opus", + "audio_settings_config_updated": "Configuration audio mise à jour", + "audio_settings_description": "Configurez les paramètres d'entrée et de sortie audio pour votre appareil JetKVM", + "audio_settings_dtx_description": "Économiser la bande passante pendant le silence", + "audio_settings_dtx_title": "DTX (Transmission Discontinue)", + "audio_settings_fec_description": "Améliorer la qualité audio sur les connexions avec perte", + "audio_settings_fec_title": "FEC (Correction d'Erreur)", + "audio_settings_hdmi_label": "HDMI", + "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. L'audio démarrera dans 30 à 60 secondes.", + "audio_settings_output_source_title": "Source de sortie audio", + "audio_settings_output_title": "Sortie audio", + "audio_settings_packet_loss_description": "Pourcentage de surcharge FEC (plus élevé = meilleure récupération, plus de bande passante)", + "audio_settings_packet_loss_title": "Compensation de Perte de Paquets", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Fréquence d'Échantillonnage", + "audio_settings_title": "Audio", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Audio de la cible vers les haut-parleurs", + "audio_speakers_title": "Haut-parleurs", "auth_authentication_mode": "Veuillez sélectionner un mode d'authentification", "auth_authentication_mode_error": "Une erreur s'est produite lors de la définition du mode d'authentification", "auth_authentication_mode_invalid": "Mode d'authentification non valide", @@ -193,7 +224,6 @@ "connection_stats_remote_ip_address": "Adresse IP distante", "connection_stats_remote_ip_address_copy_error": "Échec de la copie de l'adresse IP distante", "connection_stats_remote_ip_address_copy_success": "Adresse IP distante { ip } copiée dans le presse-papiers", - "connection_stats_remote_ip_address_description": "L'adresse IP du périphérique distant.", "connection_stats_round_trip_time": "Temps de trajet aller-retour", "connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.", "connection_stats_sidebar": "Statistiques de connexion", @@ -259,7 +289,6 @@ "general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version", "general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}", "general_auto_update_title": "Mise à jour automatique", - "general_check_for_stable_updates": "Rétrograder", "general_check_for_updates": "Vérifier les mises à jour", "general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences", "general_reboot_description": "Voulez-vous procéder au redémarrage du système ?", @@ -280,13 +309,9 @@ "general_update_checking_title": "Vérification des mises à jour…", "general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !", "general_update_completed_title": "Mise à jour terminée avec succès", - "general_update_downgrade_available_description": "Il est possible de revenir à une version antérieure.", - "general_update_downgrade_available_title": "Rétrogradation possible", - "general_update_downgrade_button": "Rétrograder maintenant", "general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.", "general_update_error_details": "Détails de l'erreur : {errorMessage}", "general_update_error_title": "Erreur de mise à jour", - "general_update_keep_current_button": "Conserver la version actuelle", "general_update_later_button": "Faire plus tard", "general_update_now_button": "Mettre à jour maintenant", "general_update_rebooting": "Redémarrage pour terminer la mise à jour…", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "Le système est à jour", "general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.", "general_update_updating_title": "Mise à jour de votre appareil", - "general_update_will_disable_auto_update_description": "Vous allez modifier manuellement la version de votre appareil. La mise à jour automatique sera désactivée une fois la mise à jour terminée afin d'éviter toute mise à jour accidentelle.", "getting_remote_session_description": "Obtention d'{attempt} description de session à distance", "hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}", "hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}", @@ -817,6 +841,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and 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", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 928ba99f..cd7c79bd 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -47,6 +47,7 @@ "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", "action_bar_extension": "Estensione", "action_bar_fullscreen": "A schermo intero", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}", "advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}", "advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}", - "advanced_error_version_update": "Impossibile avviare l'aggiornamento della versione: {error}", "advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)", "advanced_loopback_only_title": "Modalità solo loopback", "advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "Aggiorna la chiave SSH", "advanced_usb_emulation_description": "Controlla lo stato di emulazione USB", "advanced_usb_emulation_title": "Emulazione USB", - "advanced_version_update_app_label": "Versione dell'app", - "advanced_version_update_button": "Aggiorna alla versione", - "advanced_version_update_description": "Installa una versione specifica dalle versioni di GitHub", - "advanced_version_update_github_link": "Pagina delle versioni di JetKVM", - "advanced_version_update_helper": "Trova le versioni disponibili su", - "advanced_version_update_reset_config_description": "Ripristina la configurazione dopo l'aggiornamento", - "advanced_version_update_reset_config_label": "Reimposta configurazione", - "advanced_version_update_system_label": "Versione del sistema", - "advanced_version_update_target_app": "Solo app", - "advanced_version_update_target_both": "Sia l'app che il sistema", - "advanced_version_update_target_label": "Cosa aggiornare", - "advanced_version_update_target_system": "Solo sistema", - "advanced_version_update_title": "Aggiorna alla versione specifica", "already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.", "already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.", "already_adopted_return_to_dashboard": "Torna alla dashboard", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "Reset", "atx_power_control_send_action_error": "Impossibile inviare l'azione di alimentazione ATX {action} : {error}", "atx_power_control_short_power_button": "Pressione breve", + "audio_https_only": "Solo HTTPS", + "audio_input_auto_enable_disabled": "Abilitazione automatica microfono disabilitata", + "audio_input_auto_enable_enabled": "Abilitazione automatica microfono abilitata", + "audio_input_failed_disable": "Impossibile disabilitare l'ingresso audio: {error}", + "audio_input_failed_enable": "Impossibile abilitare l'ingresso audio: {error}", + "audio_microphone_description": "Ingresso microfono al target", + "audio_microphone_title": "Microfono", + "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_popover_description": "Controlli audio rapidi per altoparlanti e microfono", + "audio_popover_title": "Audio", + "audio_settings_applied": "Impostazioni audio applicate", + "audio_settings_apply_button": "Applica impostazioni", + "audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)", + "audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono", + "audio_settings_bitrate_description": "Bitrate di codifica audio (più alto = migliore qualità, più banda)", + "audio_settings_bitrate_title": "Bitrate Opus", + "audio_settings_buffer_description": "Dimensione buffer ALSA (più alto = più stabile, più latenza)", + "audio_settings_buffer_title": "Periodi Buffer", + "audio_settings_complexity_description": "Complessità dell'encoder (0-10, più alto = migliore qualità, più CPU)", + "audio_settings_complexity_title": "Complessità Opus", + "audio_settings_config_updated": "Configurazione audio aggiornata", + "audio_settings_description": "Configura le impostazioni di ingresso e uscita audio per il tuo dispositivo JetKVM", + "audio_settings_dtx_description": "Risparmia banda durante il silenzio", + "audio_settings_dtx_title": "DTX (Trasmissione Discontinua)", + "audio_settings_fec_description": "Migliora la qualità audio su connessioni con perdita", + "audio_settings_fec_title": "FEC (Correzione Errori)", + "audio_settings_hdmi_label": "HDMI", + "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. L'audio inizierà tra 30-60 secondi.", + "audio_settings_output_source_title": "Sorgente di uscita audio", + "audio_settings_output_title": "Uscita audio", + "audio_settings_packet_loss_description": "Percentuale overhead FEC (più alto = migliore recupero, più banda)", + "audio_settings_packet_loss_title": "Compensazione Perdita Pacchetti", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Frequenza di Campionamento", + "audio_settings_title": "Audio", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Audio dal target agli altoparlanti", + "audio_speakers_title": "Altoparlanti", "auth_authentication_mode": "Seleziona una modalità di autenticazione", "auth_authentication_mode_error": "Si è verificato un errore durante l'impostazione della modalità di autenticazione", "auth_authentication_mode_invalid": "Modalità di autenticazione non valida", @@ -193,7 +224,6 @@ "connection_stats_remote_ip_address": "Indirizzo IP remoto", "connection_stats_remote_ip_address_copy_error": "Impossibile copiare l'indirizzo IP remoto", "connection_stats_remote_ip_address_copy_success": "Indirizzo IP remoto { ip } copiato negli appunti", - "connection_stats_remote_ip_address_description": "L'indirizzo IP del dispositivo remoto.", "connection_stats_round_trip_time": "Tempo di andata e ritorno", "connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.", "connection_stats_sidebar": "Statistiche di connessione", @@ -259,7 +289,6 @@ "general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione", "general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}", "general_auto_update_title": "Aggiornamento automatico", - "general_check_for_stable_updates": "Declassare", "general_check_for_updates": "Verifica aggiornamenti", "general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze", "general_reboot_description": "Vuoi procedere con il riavvio del sistema?", @@ -280,13 +309,9 @@ "general_update_checking_title": "Controllo degli aggiornamenti…", "general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!", "general_update_completed_title": "Aggiornamento completato con successo", - "general_update_downgrade_available_description": "È possibile effettuare il downgrade per tornare a una versione precedente.", - "general_update_downgrade_available_title": "Downgrade disponibile", - "general_update_downgrade_button": "Effettua il downgrade ora", "general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.", "general_update_error_details": "Dettagli errore: {errorMessage}", "general_update_error_title": "Errore di aggiornamento", - "general_update_keep_current_button": "Mantieni la versione corrente", "general_update_later_button": "Fallo più tardi", "general_update_now_button": "Aggiorna ora", "general_update_rebooting": "Riavvio per completare l'aggiornamento…", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "Il sistema è aggiornato", "general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.", "general_update_updating_title": "Aggiornamento del dispositivo", - "general_update_will_disable_auto_update_description": "Stai per modificare manualmente la versione del tuo dispositivo. L'aggiornamento automatico verrà disattivato al termine dell'aggiornamento per evitare aggiornamenti accidentali.", "getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}", "hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}", "hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}", @@ -817,6 +841,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "Solo tastiera", "usb_device_restore_default": "Ripristina impostazioni predefinite", "usb_device_title": "Dispositivo USB", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 4c80dc61..0457389c 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -47,6 +47,7 @@ "access_tls_self_signed": "Selvsignert", "access_tls_updated": "TLS-innstillingene er oppdatert", "access_update_tls_settings": "Oppdater TLS-innstillinger", + "action_bar_audio": "Audio", "action_bar_connection_stats": "Tilkoblingsstatistikk", "action_bar_extension": "Forlengelse", "action_bar_fullscreen": "Fullskjerm", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}", "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}", - "advanced_error_version_update": "Kunne ikke starte versjonsoppdatering: {error}", "advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)", "advanced_loopback_only_title": "Kun lokal tilgang", "advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "Oppdater SSH-nøkkel", "advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden", "advanced_usb_emulation_title": "USB-emulering", - "advanced_version_update_app_label": "Appversjon", - "advanced_version_update_button": "Oppdater til versjon", - "advanced_version_update_description": "Installer en spesifikk versjon fra GitHub-utgivelser", - "advanced_version_update_github_link": "JetKVM-utgivelsesside", - "advanced_version_update_helper": "Finn tilgjengelige versjoner på", - "advanced_version_update_reset_config_description": "Tilbakestill konfigurasjonen etter oppdateringen", - "advanced_version_update_reset_config_label": "Tilbakestill konfigurasjon", - "advanced_version_update_system_label": "Systemversjon", - "advanced_version_update_target_app": "Kun app", - "advanced_version_update_target_both": "Både app og system", - "advanced_version_update_target_label": "Hva som skal oppdateres", - "advanced_version_update_target_system": "Kun systemet", - "advanced_version_update_title": "Oppdatering til spesifikk versjon", "already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.", "already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.", "already_adopted_return_to_dashboard": "Gå tilbake til dashbordet", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "Tilbakestill", "atx_power_control_send_action_error": "Kunne ikke sende ATX-strømhandling {action} : {error}", "atx_power_control_short_power_button": "Kort trykk", + "audio_https_only": "Kun HTTPS", + "audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon deaktivert", + "audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktivert", + "audio_input_failed_disable": "Kunne ikke deaktivere lydinngang: {error}", + "audio_input_failed_enable": "Kunne ikke aktivere lydinngang: {error}", + "audio_microphone_description": "Mikrofoninngang til mål", + "audio_microphone_title": "Mikrofon", + "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_popover_description": "Raske lydkontroller for høyttalere og mikrofon", + "audio_popover_title": "Lyd", + "audio_settings_applied": "Lydinnstillinger brukt", + "audio_settings_apply_button": "Bruk innstillinger", + "audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)", + "audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk", + "audio_settings_bitrate_description": "Lydkodingsbitrate (høyere = bedre kvalitet, mer båndbredde)", + "audio_settings_bitrate_title": "Opus Bitrate", + "audio_settings_buffer_description": "ALSA bufferstørrelse (høyere = mer stabil, mer latens)", + "audio_settings_buffer_title": "Bufferperioder", + "audio_settings_complexity_description": "Encoder-kompleksitet (0-10, høyere = bedre kvalitet, mer CPU)", + "audio_settings_complexity_title": "Opus Kompleksitet", + "audio_settings_config_updated": "Lydkonfigurasjon oppdatert", + "audio_settings_description": "Konfigurer lydinngangs- og lydutgangsinnstillinger for JetKVM-enheten din", + "audio_settings_dtx_description": "Spar båndbredde under stillhet", + "audio_settings_dtx_title": "DTX (Diskontinuerlig Overføring)", + "audio_settings_fec_description": "Forbedre lydkvaliteten på tapende tilkoblinger", + "audio_settings_fec_title": "FEC (Fremadrettet Feilkorreksjon)", + "audio_settings_hdmi_label": "HDMI", + "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. Lyd starter om 30-60 sekunder.", + "audio_settings_output_source_title": "Lydutgangskilde", + "audio_settings_output_title": "Lydutgang", + "audio_settings_packet_loss_description": "FEC overhead-prosent (høyere = bedre gjenoppretting, mer båndbredde)", + "audio_settings_packet_loss_title": "Pakketapskompensasjon", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Samplingsrate", + "audio_settings_title": "Lyd", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Lyd fra mål til høyttalere", + "audio_speakers_title": "Høyttalere", "auth_authentication_mode": "Vennligst velg en autentiseringsmodus", "auth_authentication_mode_error": "Det oppsto en feil under angivelse av autentiseringsmodus", "auth_authentication_mode_invalid": "Ugyldig autentiseringsmodus", @@ -193,7 +224,6 @@ "connection_stats_remote_ip_address": "Ekstern IP-adresse", "connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere den eksterne IP-adressen", "connection_stats_remote_ip_address_copy_success": "Ekstern IP-adresse { ip } kopiert til utklippstavlen", - "connection_stats_remote_ip_address_description": "IP-adressen til den eksterne enheten.", "connection_stats_round_trip_time": "Tur-retur-tid", "connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.", "connection_stats_sidebar": "Tilkoblingsstatistikk", @@ -259,7 +289,6 @@ "general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen", "general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}", "general_auto_update_title": "Automatisk oppdatering", - "general_check_for_stable_updates": "Nedgrader", "general_check_for_updates": "Se etter oppdateringer", "general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser", "general_reboot_description": "Vil du fortsette med å starte systemet på nytt?", @@ -280,13 +309,9 @@ "general_update_checking_title": "Ser etter oppdateringer …", "general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!", "general_update_completed_title": "Oppdatering fullført", - "general_update_downgrade_available_description": "En nedgradering er tilgjengelig for å gå tilbake til en tidligere versjon.", - "general_update_downgrade_available_title": "Nedgradering tilgjengelig", - "general_update_downgrade_button": "Nedgrader nå", "general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.", "general_update_error_details": "Feildetaljer: {errorMessage}", "general_update_error_title": "Oppdateringsfeil", - "general_update_keep_current_button": "Behold gjeldende versjon", "general_update_later_button": "Oppdater senere", "general_update_now_button": "Oppdater nå", "general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "Alt er oppdatert", "general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.", "general_update_updating_title": "Oppdaterer enheten din", - "general_update_will_disable_auto_update_description": "Du er i ferd med å endre enhetsversjonen manuelt. Automatisk oppdatering vil bli deaktivert etter at oppdateringen er fullført for å forhindre utilsiktede oppdateringer.", "getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}", "hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}", "hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}", @@ -817,6 +841,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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.", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "Kun tastatur", "usb_device_restore_default": "Gjenopprett til standard", "usb_device_title": "USB-enhet", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index c51e5770..f2af9932 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -47,7 +47,7 @@ "access_tls_self_signed": "Självsignerad", "access_tls_updated": "TLS-inställningarna har uppdaterats", "access_update_tls_settings": "Uppdatera TLS-inställningar", - "action_bar_connection_stats": "Anslutningsstatistik", + "action_bar_audio": "Audio", "action_bar_extension": "Förlängning", "action_bar_fullscreen": "Helskärm", "action_bar_settings": "Inställningar", @@ -74,7 +74,6 @@ "advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}", "advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}", - "advanced_error_version_update": "Misslyckades med att initiera versionsuppdatering: {error}", "advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)", "advanced_loopback_only_title": "Loopback-läge", "advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:", @@ -101,19 +100,6 @@ "advanced_update_ssh_key_button": "Uppdatera SSH-nyckel", "advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen", "advanced_usb_emulation_title": "USB-emulering", - "advanced_version_update_app_label": "Appversion", - "advanced_version_update_button": "Uppdatera till version", - "advanced_version_update_description": "Installera en specifik version från GitHub-utgåvor", - "advanced_version_update_github_link": "JetKVM-utgåvorsida", - "advanced_version_update_helper": "Hitta tillgängliga versioner på", - "advanced_version_update_reset_config_description": "Återställ konfigurationen efter uppdateringen", - "advanced_version_update_reset_config_label": "Återställ konfigurationen", - "advanced_version_update_system_label": "Systemversion", - "advanced_version_update_target_app": "Endast app", - "advanced_version_update_target_both": "Både app och system", - "advanced_version_update_target_label": "Vad som ska uppdateras", - "advanced_version_update_target_system": "Endast systemet", - "advanced_version_update_title": "Uppdatera till specifik version", "already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.", "already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.", "already_adopted_return_to_dashboard": "Återgå till instrumentpanelen", @@ -134,6 +120,50 @@ "atx_power_control_reset_button": "Starta om", "atx_power_control_send_action_error": "Misslyckades med att skicka ATX-strömåtgärd {action} : {error}", "atx_power_control_short_power_button": "Kort tryck", + "audio_https_only": "Endast HTTPS", + "audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon inaktiverad", + "audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon 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_microphone_description": "Mikrofoningång till mål", + "audio_microphone_title": "Mikrofon", + "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_popover_description": "Snabba ljudkontroller för högtalare och mikrofon", + "audio_popover_title": "Ljud", + "audio_settings_applied": "Ljudinställningar tillämpade", + "audio_settings_apply_button": "Tillämpa inställningar", + "audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)", + "audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt", + "audio_settings_bitrate_description": "Ljudkodningsbitrate (högre = bättre kvalitet, mer bandbredd)", + "audio_settings_bitrate_title": "Opus Bitrate", + "audio_settings_buffer_description": "ALSA bufferstorlek (högre = mer stabil, mer latens)", + "audio_settings_buffer_title": "Bufferperioder", + "audio_settings_complexity_description": "Encoder-komplexitet (0-10, högre = bättre kvalitet, mer CPU)", + "audio_settings_complexity_title": "Opus Komplexitet", + "audio_settings_config_updated": "Ljudkonfiguration uppdaterad", + "audio_settings_description": "Konfigurera ljudinmatnings- och ljudutgångsinställningar för din JetKVM-enhet", + "audio_settings_dtx_description": "Spara bandbredd under tystnad", + "audio_settings_dtx_title": "DTX (Diskontinuerlig Överföring)", + "audio_settings_fec_description": "Förbättra ljudkvaliteten på förlustdrabbade anslutningar", + "audio_settings_fec_title": "FEC (Framåtriktad Felkorrigering)", + "audio_settings_hdmi_label": "HDMI", + "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 uppdaterad. Ljud startar om 30-60 sekunder.", + "audio_settings_output_source_title": "Ljudutgångskälla", + "audio_settings_output_title": "Ljudutgång", + "audio_settings_packet_loss_description": "FEC overhead-procent (högre = bättre återställning, mer bandbredd)", + "audio_settings_packet_loss_title": "Paketförlustkompensation", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "Samplingsfrekvens", + "audio_settings_title": "Ljud", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "Ljud från mål till högtalare", + "audio_speakers_title": "Högtalare", "auth_authentication_mode": "Välj ett autentiseringsläge", "auth_authentication_mode_error": "Ett fel uppstod när autentiseringsläget ställdes in", "auth_authentication_mode_invalid": "Ogiltigt autentiseringsläge", @@ -193,7 +223,6 @@ "connection_stats_remote_ip_address": "Fjärr-IP-adress", "connection_stats_remote_ip_address_copy_error": "Misslyckades med att kopiera fjärr-IP-adressen", "connection_stats_remote_ip_address_copy_success": "Fjärr-IP-adress { ip } kopierad till urklipp", - "connection_stats_remote_ip_address_description": "IP-adressen för den fjärranslutna enheten.", "connection_stats_round_trip_time": "Tur- och returtid", "connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.", "connection_stats_sidebar": "Anslutningsstatistik", @@ -259,7 +288,6 @@ "general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen", "general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}", "general_auto_update_title": "Automatisk uppdatering", - "general_check_for_stable_updates": "Nedvärdera", "general_check_for_updates": "Kontrollera efter uppdateringar", "general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar", "general_reboot_description": "Vill du fortsätta med att starta om systemet?", @@ -280,13 +308,9 @@ "general_update_checking_title": "Söker efter uppdateringar…", "general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!", "general_update_completed_title": "Uppdateringen är slutförd", - "general_update_downgrade_available_description": "En nedgradering är tillgänglig för att återgå till en tidigare version.", - "general_update_downgrade_available_title": "Nedgradering tillgänglig", - "general_update_downgrade_button": "Nedgradera nu", "general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.", "general_update_error_details": "Felinformation: {errorMessage}", "general_update_error_title": "Uppdateringsfel", - "general_update_keep_current_button": "Behåll aktuell version", "general_update_later_button": "Gör det senare", "general_update_now_button": "Uppdatera nu", "general_update_rebooting": "Startar om för att slutföra uppdateringen…", @@ -302,7 +326,6 @@ "general_update_up_to_date_title": "Systemet är uppdaterat", "general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.", "general_update_updating_title": "Uppdaterar din enhet", - "general_update_will_disable_auto_update_description": "Du håller på att ändra din enhetsversion manuellt. Automatisk uppdatering inaktiveras efter att uppdateringen är klar för att förhindra oavsiktliga uppdateringar.", "getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}", "hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}", "hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}", @@ -817,6 +840,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": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "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.", @@ -826,6 +851,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "Endast tangentbord", "usb_device_restore_default": "Återställ till standard", "usb_device_title": "USB-enhet", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 7ed0d860..c7c73572 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -47,6 +47,7 @@ "access_tls_self_signed": "自签名", "access_tls_updated": "TLS 设置更新成功", "access_update_tls_settings": "更新 TLS 设置", + "action_bar_audio": "Audio", "action_bar_connection_stats": "连接统计", "action_bar_extension": "扩展", "action_bar_fullscreen": "全屏", @@ -74,7 +75,6 @@ "advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}", "advanced_error_usb_emulation_disable": "无法禁用 USB 仿真: {error}", "advanced_error_usb_emulation_enable": "无法启用 USB 仿真: {error}", - "advanced_error_version_update": "版本更新失败: {error}", "advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机(127.0.0.1)", "advanced_loopback_only_title": "仅环回模式", "advanced_loopback_warning_before": "在启用此功能之前,请确保您已:", @@ -101,19 +101,6 @@ "advanced_update_ssh_key_button": "更新 SSH 密钥", "advanced_usb_emulation_description": "控制 USB 仿真状态", "advanced_usb_emulation_title": "USB 仿真", - "advanced_version_update_app_label": "应用版本", - "advanced_version_update_button": "更新至版本", - "advanced_version_update_description": "从 GitHub 发布页面安装特定版本", - "advanced_version_update_github_link": "JetKVM 发布页面", - "advanced_version_update_helper": "在以下平台查找可用版本", - "advanced_version_update_reset_config_description": "更新后重置配置", - "advanced_version_update_reset_config_label": "重置配置", - "advanced_version_update_system_label": "系统版本", - "advanced_version_update_target_app": "仅限应用内购买", - "advanced_version_update_target_both": "应用程序和系统", - "advanced_version_update_target_label": "需要更新什么", - "advanced_version_update_target_system": "仅系统", - "advanced_version_update_title": "更新至特定版本", "already_adopted_new_owner": "如果您是新用户,请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误,请联系我们的支持团队寻求帮助。", "already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。", "already_adopted_return_to_dashboard": "返回仪表板", @@ -134,6 +121,50 @@ "atx_power_control_reset_button": "重置", "atx_power_control_send_action_error": "无法发送 ATX 电源操作 {action} : {error}", "atx_power_control_short_power_button": "短按", + "audio_https_only": "仅限 HTTPS", + "audio_input_auto_enable_disabled": "自动启用麦克风已禁用", + "audio_input_auto_enable_enabled": "自动启用麦克风已启用", + "audio_input_failed_disable": "禁用音频输入失败:{error}", + "audio_input_failed_enable": "启用音频输入失败:{error}", + "audio_microphone_description": "麦克风输入到目标设备", + "audio_microphone_title": "麦克风", + "audio_output_disabled": "音频输出已禁用", + "audio_output_enabled": "音频输出已启用", + "audio_output_failed_disable": "禁用音频输出失败:{error}", + "audio_output_failed_enable": "启用音频输出失败:{error}", + "audio_popover_description": "扬声器和麦克风的快速音频控制", + "audio_popover_title": "音频", + "audio_settings_applied": "音频设置已应用", + "audio_settings_apply_button": "应用设置", + "audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)", + "audio_settings_auto_enable_microphone_title": "自动启用麦克风", + "audio_settings_bitrate_description": "音频编码比特率(越高 = 质量越好,带宽越大)", + "audio_settings_bitrate_title": "Opus 比特率", + "audio_settings_buffer_description": "ALSA 缓冲大小(越高 = 越稳定,延迟越高)", + "audio_settings_buffer_title": "缓冲周期", + "audio_settings_complexity_description": "编码器复杂度(0-10,越高 = 质量越好,CPU 使用越多)", + "audio_settings_complexity_title": "Opus 复杂度", + "audio_settings_config_updated": "音频配置已更新", + "audio_settings_description": "配置 JetKVM 设备的音频输入和输出设置", + "audio_settings_dtx_description": "在静音时节省带宽", + "audio_settings_dtx_title": "DTX(不连续传输)", + "audio_settings_fec_description": "改善有损连接上的音频质量", + "audio_settings_fec_title": "FEC(前向纠错)", + "audio_settings_hdmi_label": "HDMI", + "audio_settings_output_description": "启用或禁用来自远程计算机的音频", + "audio_settings_output_source_description": "选择音频捕获设备(HDMI 或 USB)", + "audio_settings_output_source_failed": "设置音频输出源失败:{error}", + "audio_settings_output_source_success": "音频输出源已更新。音频将在30-60秒内启动。", + "audio_settings_output_source_title": "音频输出源", + "audio_settings_output_title": "音频输出", + "audio_settings_packet_loss_description": "FEC 开销百分比(越高 = 恢复越好,带宽越大)", + "audio_settings_packet_loss_title": "丢包补偿", + "audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)", + "audio_settings_sample_rate_title": "采样率", + "audio_settings_title": "音频", + "audio_settings_usb_label": "USB", + "audio_speakers_description": "从目标设备到扬声器的音频", + "audio_speakers_title": "扬声器", "auth_authentication_mode": "请选择身份验证方式", "auth_authentication_mode_error": "设置身份验证模式时发生错误", "auth_authentication_mode_invalid": "身份验证模式无效", @@ -190,10 +221,9 @@ "connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。", "connection_stats_playback_delay": "播放延迟", "connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。", - "connection_stats_remote_ip_address": "远程 IP 地址", + "connection_stats_remote_ip_address": "远程IP地址", "connection_stats_remote_ip_address_copy_error": "复制远程 IP 地址失败", "connection_stats_remote_ip_address_copy_success": "远程 IP 地址{ ip }已复制到剪贴板", - "connection_stats_remote_ip_address_description": "远程设备的IP地址。", "connection_stats_round_trip_time": "往返时间", "connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。", "connection_stats_sidebar": "连接统计", @@ -259,7 +289,6 @@ "general_auto_update_description": "自动将设备更新到最新版本", "general_auto_update_error": "无法设置自动更新: {error}", "general_auto_update_title": "自动更新", - "general_check_for_stable_updates": "降级", "general_check_for_updates": "检查更新", "general_page_description": "配置设备设置并更新首选项", "general_reboot_description": "您想继续重新启动系统吗?", @@ -280,13 +309,9 @@ "general_update_checking_title": "正在检查更新…", "general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!", "general_update_completed_title": "更新已成功完成", - "general_update_downgrade_available_description": "可以降级到以前的版本。", - "general_update_downgrade_available_title": "可降级", - "general_update_downgrade_button": "立即降级", "general_update_error_description": "更新您的设备时出错。请稍后重试。", "general_update_error_details": "错误详细信息: {errorMessage}", "general_update_error_title": "更新错误", - "general_update_keep_current_button": "保持当前版本", "general_update_later_button": "稍后再说", "general_update_now_button": "立即更新", "general_update_rebooting": "重新启动以完成更新…", @@ -302,7 +327,6 @@ "general_update_up_to_date_title": "系统已更新", "general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。", "general_update_updating_title": "更新您的设备", - "general_update_will_disable_auto_update_description": "您即将手动更改设备版本。更新完成后,自动更新功能将被禁用,以防止意外更新。", "getting_remote_session_description": "获取远程会话描述尝试 {attempt}", "hardware_backlight_settings_error": "无法设置背光设置: {error}", "hardware_backlight_settings_get_error": "无法获取背光设置: {error}", @@ -817,6 +841,8 @@ "usb_device_description": "在目标计算机上仿真的 USB 设备", "usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)", "usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)", + "usb_device_enable_audio_description": "Enable bidirectional audio", + "usb_device_enable_audio_title": "Enable USB Audio", "usb_device_enable_keyboard_description": "启用键盘", "usb_device_enable_keyboard_title": "启用键盘", "usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题", @@ -826,6 +852,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": "Keyboard, Mouse, Mass Storage and Audio", "usb_device_keyboard_only": "仅限键盘", "usb_device_restore_default": "恢复默认设置", "usb_device_title": "USB 设备", diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 3a6a283d..ed9dc72d 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -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)} /> + + + + + + + ); +} diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index f8d84571..802e8737 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -85,7 +85,7 @@ export default function SettingsGeneralRoute() {
diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index f82b489b..8ce727dd 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -6,6 +6,7 @@ import { LuMouse, LuKeyboard, LuVideo, + LuVolume2, LuCpu, LuShieldCheck, LuWrench, @@ -178,6 +179,17 @@ export default function SettingsRoute() {
+ (isActive ? "active" : "")} + > +
+ +

Audio

+
+
+
+
cx(isActive ? "active" : "", { diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index f1e68a0d..231b48b1 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -12,13 +12,7 @@ import Fieldset from "@components/Fieldset"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; -const defaultEdid = - "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; -const edids = [ - { - value: defaultEdid, - label: m.video_edid_jetkvm_default(), - }, +const otherEdids = [ { value: "00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096", @@ -53,6 +47,8 @@ export default function SettingsVideoRoute() { const [customEdidValue, setCustomEdidValue] = useState(null); const [edid, setEdid] = useState(null); const [edidLoading, setEdidLoading] = useState(true); + const [defaultEdid, setDefaultEdid] = useState(""); + const [edids, setEdids] = useState<{value: string, label: string}[]>([]); const { debugMode } = useSettingsStore(); // Video enhancement settings from store const { @@ -70,28 +66,42 @@ export default function SettingsVideoRoute() { setStreamQuality(String(resp.result)); }); - send("getEDID", {}, (resp: JsonRpcResponse) => { - setEdidLoading(false); + send("getDefaultEDID", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(m.video_failed_get_edid({ error: resp.error.data || m.unknown_error() })); return; } - const receivedEdid = resp.result as string; + const fetchedDefaultEdid = resp.result as string; + setDefaultEdid(fetchedDefaultEdid); - const matchingEdid = edids.find( - x => x.value.toLowerCase() === receivedEdid.toLowerCase(), - ); + const allEdids = [ + { value: fetchedDefaultEdid, label: m.video_edid_jetkvm_default() }, + ...otherEdids + ]; + setEdids(allEdids); - if (matchingEdid) { - // EDID is stored in uppercase in the UI - setEdid(matchingEdid.value.toUpperCase()); - // Reset custom EDID value - setCustomEdidValue(null); - } else { - setEdid("custom"); - setCustomEdidValue(receivedEdid); - } + send("getEDID", {}, (resp: JsonRpcResponse) => { + setEdidLoading(false); + if ("error" in resp) { + notifications.error(m.video_failed_get_edid({ error: resp.error.data || m.unknown_error() })); + return; + } + + const receivedEdid = resp.result as string; + + const matchingEdid = allEdids.find( + x => x.value.toLowerCase() === receivedEdid.toLowerCase(), + ); + + if (matchingEdid) { + setEdid(matchingEdid.value.toUpperCase()); + setCustomEdidValue(null); + } else { + setEdid("custom"); + setCustomEdidValue(receivedEdid); + } + }); }); }, [send]); diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 043125c0..4ec4f276 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -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, @@ -53,6 +54,7 @@ import { } from "@components/VideoOverlay"; import { FeatureFlagProvider } from "@providers/FeatureFlagProvider"; import { m } from "@localizations/messages.js"; +import { isSecureContext } from "@/utils"; import { doRpcHidHandshake } from "@hooks/useHidRpc"; export type AuthMode = "password" | "noPassword" | null; @@ -115,6 +117,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 { @@ -125,6 +128,8 @@ export default function KvmIdRoute() { isTurnServerInUse, setTurnServerInUse, rpcDataChannel, setTransceiver, + setAudioTransceiver, + audioTransceiver, setRpcHidChannel, setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableChannel, @@ -177,6 +182,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); @@ -439,6 +468,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; @@ -481,11 +533,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}`); @@ -539,6 +596,9 @@ export default function KvmIdRoute() { setRpcHidUnreliableChannel, setRpcHidProtocolVersion, setTransceiver, + setAudioTransceiver, + audioInputAutoEnable, + setMicrophoneEnabled, ]); useEffect(() => { @@ -548,6 +608,66 @@ export default function KvmIdRoute() { } }, [peerConnectionState, cleanupAndStopReconnecting]); + const microphoneRequestInProgress = useRef(false); + useEffect(() => { + if (!audioTransceiver || !peerConnection) return; + + if (microphoneEnabled) { + if (microphoneRequestInProgress.current) return; + + const currentTrack = audioTransceiver.sender.track; + if (currentTrack) { + currentTrack.stop(); + } + + const requestMicrophone = () => { + microphoneRequestInProgress.current = true; + navigator.mediaDevices?.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + channelCount: 1, + } + }).then((stream) => { + microphoneRequestInProgress.current = false; + const audioTrack = stream.getAudioTracks()[0]; + if (audioTrack && audioTransceiver.sender) { + const handleTrackEnded = () => { + console.warn('Microphone track ended unexpectedly, attempting to restart...'); + if (audioTransceiver.sender.track === audioTrack) { + audioTransceiver.sender.replaceTrack(null); + setTimeout(requestMicrophone, 500); + } + }; + + audioTrack.addEventListener('ended', handleTrackEnded, { once: true }); + audioTransceiver.sender.replaceTrack(audioTrack); + } + }).catch((err) => { + microphoneRequestInProgress.current = false; + console.error('Failed to get microphone:', err); + setMicrophoneEnabled(false); + }); + }; + + requestMicrophone(); + } else { + microphoneRequestInProgress.current = false; + if (audioTransceiver.sender.track) { + audioTransceiver.sender.track.stop(); + audioTransceiver.sender.replaceTrack(null); + } + } + + // Cleanup on unmount or when dependencies change + return () => { + if (audioTransceiver?.sender.track) { + audioTransceiver.sender.track.stop(); + } + }; + }, [microphoneEnabled, audioTransceiver, peerConnection, setMicrophoneEnabled]); + // Cleanup effect const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore(); @@ -721,6 +841,7 @@ export default function KvmIdRoute() { const { send } = useJsonRpc(onJsonRpcRequest); + useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; console.log("Requesting video state"); @@ -732,6 +853,46 @@ export default function KvmIdRoute() { }); }, [rpcDataChannel?.readyState, send, setHdmiState]); + const [audioInputAutoEnableLoaded, setAudioInputAutoEnableLoaded] = useState(false); + useEffect(() => { + if (rpcDataChannel?.readyState !== "open") return; + send("getAudioInputAutoEnable", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setAudioInputAutoEnable(resp.result as boolean); + setAudioInputAutoEnableLoaded(true); + }); + }, [rpcDataChannel?.readyState, send, setAudioInputAutoEnable]); + + const autoEnableAppliedRef = useRef(false); + const audioInputAutoEnableValueRef = useRef(audioInputAutoEnable); + + useEffect(() => { + audioInputAutoEnableValueRef.current = audioInputAutoEnable; + }, [audioInputAutoEnable]); + + useEffect(() => { + if (!audioTransceiver || !peerConnection || microphoneEnabled) return; + if (!audioInputAutoEnableLoaded || autoEnableAppliedRef.current) return; + + if (audioInputAutoEnableValueRef.current && isSecureContext()) { + autoEnableAppliedRef.current = true; + send("setAudioInputEnabled", { enabled: true }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.error("Failed to auto-enable audio input:", resp.error); + } else { + setMicrophoneEnabled(true); + } + }); + } + }, [audioTransceiver, peerConnection, audioInputAutoEnableLoaded, microphoneEnabled, setMicrophoneEnabled, send]); + + useEffect(() => { + if (!peerConnection) { + autoEnableAppliedRef.current = false; + setAudioInputAutoEnableLoaded(false); + } + }, [peerConnection]); + const [needLedState, setNeedLedState] = useState(true); // request keyboard led state from the device diff --git a/ui/src/routes/login-local.tsx b/ui/src/routes/login-local.tsx index 16481568..139faf1c 100644 --- a/ui/src/routes/login-local.tsx +++ b/ui/src/routes/login-local.tsx @@ -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); diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index 15bd73f1..58b0a92f 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -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 ( { return new Promise(resolve => setTimeout(resolve, ms)); } + +export function isSecureContext(): boolean { + return window.location.protocol === "https:" || window.location.hostname === "localhost"; +} diff --git a/usb.go b/usb.go index af57692f..a8f5b066 100644 --- a/usb.go +++ b/usb.go @@ -9,7 +9,6 @@ import ( var gadget *usbgadget.UsbGadget -// initUsbGadget initializes the USB gadget. // call it only after the config is loaded. func initUsbGadget() { gadget = usbgadget.NewUsbGadget( @@ -44,7 +43,6 @@ func initUsbGadget() { } }) - // open the keyboard hid file to listen for keyboard events if err := gadget.OpenKeyboardHidFile(); err != nil { usbLogger.Error().Err(err).Msg("failed to open keyboard hid file") } @@ -109,8 +107,23 @@ func checkUSBState() { return } + oldState := usbState usbState = newState - usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed") + usbLogger.Info().Str("from", oldState).Str("to", newState).Msg("USB state changed") + + if oldState == "configured" && newState != "configured" { + usbLogger.Info().Msg("USB deconfigured, closing HID files") + gadget.CloseHidFiles() + } + + if newState == "configured" && oldState != "configured" { + usbLogger.Info().Msg("USB configured, reopening HID files") + gadget.CloseHidFiles() + gadget.PreOpenHidFiles() + if err := gadget.OpenKeyboardHidFile(); err != nil { + usbLogger.Error().Err(err).Msg("failed to reopen keyboard hid file") + } + } requestDisplayUpdate(true, "usb_state_changed") triggerUSBStateUpdate() diff --git a/webrtc.go b/webrtc.go index 10c43ddf..5913d58f 100644 --- a/webrtc.go +++ b/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 @@ -327,6 +328,40 @@ func newSession(config SessionConfig) (*Session, error) { } } }() + + session.AudioTrack, err = webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, + "audio", + "kvm-audio", + ) + if err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to create AudioTrack (non-fatal)") + session.AudioTrack = nil + } else { + _, err = peerConnection.AddTransceiverFromTrack(session.AudioTrack, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }) + if err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to add AudioTrack transceiver (non-fatal)") + session.AudioTrack = nil + } else { + setAudioTrack(session.AudioTrack) + + peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + scopedLogger.Info(). + Str("codec", track.Codec().MimeType). + Str("track_id", track.ID()). + Msg("Received incoming audio track from browser") + + // Store track for connection when audio starts + // OnTrack fires during SDP exchange, before ICE connection completes + setPendingInputTrack(track) + }) + + scopedLogger.Info().Msg("Audio tracks configured successfully") + } + } + var isConnected bool peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) { @@ -357,6 +392,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() @@ -403,10 +440,12 @@ func onActiveSessionsChanged() { func onFirstSessionConnected() { notifyFailsafeMode(currentSession) _ = nativeInstance.VideoStart() + onWebRTCConnect() stopVideoSleepModeTicker() } func onLastSessionDisconnected() { _ = nativeInstance.VideoStop() + onWebRTCDisconnect() startVideoSleepModeTicker() }