From bb5634be58057ad043e9ff3783bad0ab68eddab7 Mon Sep 17 00:00:00 2001 From: Alex P Date: Tue, 7 Oct 2025 13:34:03 +0300 Subject: [PATCH] refactor: Remove subprocess audio infrastructure, use CGO-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all subprocess-based audio code to simplify the audio system and reduce complexity. Audio now uses CGO in-process mode exclusively. Changes: - Remove subprocess mode: Deleted Supervisor, IPCSource, embed.go - Remove audio mode selection from UI (Settings → Audio) - Remove audio mode from backend config (AudioMode field) - Remove JSON-RPC handlers: getAudioMode/setAudioMode - Remove Makefile targets: build_audio_output/input/binaries - Remove standalone C binaries: jetkvm_audio_{input,output}.c - Remove IPC protocol implementation: ipc_protocol.{c,h} - Remove unused IPC functions from audio_common.{c,h} - Simplify audio.go: startAudio() instead of startAudioSubprocesses() - Update all function calls and comments to remove subprocess references - Add constants to cgo_source.go (ipcMaxFrameSize, ipcMsgTypeOpus) - Keep update_opus_encoder_params() for potential future runtime config Benefits: - Simpler codebase: -1,734 lines of code - Better performance: No IPC overhead on embedded hardware - Easier maintenance: Single audio implementation - Smaller binary: No embedded audio subprocess binaries The audio system now works exclusively via CGO direct C function calls, with ALSA device selection (HDMI vs USB) still configurable via settings. --- Makefile | 46 +-- audio.go | 194 ++--------- config.go | 2 - internal/audio/c/audio.c | 29 +- internal/audio/c/audio_common.c | 72 +--- internal/audio/c/audio_common.h | 25 -- internal/audio/c/ipc_protocol.c | 328 ------------------- internal/audio/c/ipc_protocol.h | 211 ------------ internal/audio/c/jetkvm_audio_input.c | 169 ---------- internal/audio/c/jetkvm_audio_output.c | 193 ----------- internal/audio/cgo_source.go | 47 ++- internal/audio/embed.go | 80 ----- internal/audio/ipc_source.go | 185 ----------- internal/audio/relay.go | 4 +- internal/audio/source.go | 7 +- internal/audio/supervisor.go | 187 ----------- jsonrpc.go | 24 +- main.go | 2 +- ui/src/components/popovers/AudioPopover.tsx | 126 ++----- ui/src/hooks/stores.ts | 8 +- ui/src/routes/devices.$id.settings.audio.tsx | 59 +--- webrtc.go | 2 +- 22 files changed, 133 insertions(+), 1867 deletions(-) delete mode 100644 internal/audio/c/ipc_protocol.c delete mode 100644 internal/audio/c/ipc_protocol.h delete mode 100644 internal/audio/c/jetkvm_audio_input.c delete mode 100644 internal/audio/c/jetkvm_audio_output.c delete mode 100644 internal/audio/embed.go delete mode 100644 internal/audio/ipc_source.go delete mode 100644 internal/audio/supervisor.go diff --git a/Makefile b/Makefile index 4f4c9893..f5ad0ef6 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,6 @@ KVM_PKG_NAME := github.com/jetkvm/kvm BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit SKIP_NATIVE_IF_EXISTS ?= 0 -SKIP_AUDIO_BINARIES_IF_EXISTS ?= 0 SKIP_UI_BUILD ?= 0 GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) @@ -92,48 +91,7 @@ build_native: ./scripts/build_cgo.sh; \ fi -# Build audio output C binary (ALSA capture → Opus encode → IPC) -build_audio_output: build_audio_deps - @if [ "$(SKIP_AUDIO_BINARIES_IF_EXISTS)" = "1" ] && [ -f "$(BIN_DIR)/jetkvm_audio_output" ]; then \ - echo "jetkvm_audio_output already exists, skipping build..."; \ - else \ - echo "Building audio output binary (100% static)..."; \ - mkdir -p $(BIN_DIR); \ - $(CC) $(AUDIO_CFLAGS) -static \ - -o $(BIN_DIR)/jetkvm_audio_output \ - internal/audio/c/jetkvm_audio_output.c \ - internal/audio/c/ipc_protocol.c \ - internal/audio/c/audio_common.c \ - internal/audio/c/audio.c \ - $(AUDIO_LDFLAGS); \ - fi - -# Build audio input C binary (IPC → Opus decode → ALSA playback) -build_audio_input: build_audio_deps - @if [ "$(SKIP_AUDIO_BINARIES_IF_EXISTS)" = "1" ] && [ -f "$(BIN_DIR)/jetkvm_audio_input" ]; then \ - echo "jetkvm_audio_input already exists, skipping build..."; \ - else \ - echo "Building audio input binary (100% static)..."; \ - mkdir -p $(BIN_DIR); \ - $(CC) $(AUDIO_CFLAGS) -static \ - -o $(BIN_DIR)/jetkvm_audio_input \ - internal/audio/c/jetkvm_audio_input.c \ - internal/audio/c/ipc_protocol.c \ - internal/audio/c/audio_common.c \ - internal/audio/c/audio.c \ - $(AUDIO_LDFLAGS); \ - fi - -# Build both audio binaries and copy to embed location -build_audio_binaries: build_audio_output build_audio_input - @echo "Audio binaries built successfully" - @echo "Copying binaries to embed location..." - @mkdir -p internal/audio/bin - @cp $(BIN_DIR)/jetkvm_audio_output internal/audio/bin/ - @cp $(BIN_DIR)/jetkvm_audio_input internal/audio/bin/ - @echo "Binaries ready for embedding" - -build_dev: build_native build_audio_deps build_audio_binaries +build_dev: build_native build_audio_deps $(CLEAN_GO_CACHE) @echo "Building..." go build \ @@ -199,7 +157,7 @@ dev_release: frontend build_dev rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256 -build_release: frontend build_native build_audio_deps build_audio_binaries +build_release: frontend build_native build_audio_deps $(CLEAN_GO_CACHE) @echo "Building release..." go build \ diff --git a/audio.go b/audio.go index b8ae629a..6c40146f 100644 --- a/audio.go +++ b/audio.go @@ -1,7 +1,6 @@ package kvm import ( - "fmt" "io" "sync" "sync/atomic" @@ -12,15 +11,8 @@ import ( "github.com/rs/zerolog" ) -const ( - socketPathOutput = "/var/run/audio_output.sock" - socketPathInput = "/var/run/audio_input.sock" -) - var ( audioMutex sync.Mutex - outputSupervisor *audio.Supervisor - inputSupervisor *audio.Supervisor outputSource audio.AudioSource inputSource audio.AudioSource outputRelay *audio.OutputRelay @@ -38,11 +30,6 @@ var ( func initAudio() { audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger() - if err := audio.ExtractEmbeddedBinaries(); err != nil { - audioLogger.Error().Err(err).Msg("Failed to extract audio binaries") - return - } - // Load audio output source from config ensureConfigLoaded() useUSBForAudioOutput = config.AudioOutputSource == "usb" @@ -57,13 +44,13 @@ func initAudio() { audioInitialized = true } -// startAudioSubprocesses starts audio subprocesses and relays (skips already running ones) -func startAudioSubprocesses() error { +// startAudio starts audio sources and relays (skips already running ones) +func startAudio() error { audioMutex.Lock() defer audioMutex.Unlock() if !audioInitialized { - audioLogger.Warn().Msg("Audio not initialized, skipping subprocess start") + audioLogger.Warn().Msg("Audio not initialized, skipping start") return nil } @@ -74,44 +61,8 @@ func startAudioSubprocesses() error { alsaDevice = "hw:1,0" // USB } - ensureConfigLoaded() - audioMode := config.AudioMode - if audioMode == "" { - audioMode = "subprocess" // Default to subprocess - } - - if audioMode == "in-process" { - // In-process CGO mode - outputSource = audio.NewCgoOutputSource(alsaDevice) - audioLogger.Debug(). - Str("mode", "in-process"). - Str("device", alsaDevice). - Msg("Audio output configured for in-process mode") - } else { - // Subprocess mode (default) - outputSupervisor = audio.NewSupervisor( - "audio-output", - audio.GetAudioOutputBinaryPath(), - socketPathOutput, - []string{ - "ALSA_CAPTURE_DEVICE=" + alsaDevice, - "OPUS_BITRATE=128000", - "OPUS_COMPLEXITY=5", - }, - ) - - if err := outputSupervisor.Start(); err != nil { - audioLogger.Error().Err(err).Msg("Failed to start audio output supervisor") - outputSupervisor = nil - return err - } - - outputSource = audio.NewIPCSource("audio-output", socketPathOutput, 0x4A4B4F55) - audioLogger.Debug(). - Str("mode", "subprocess"). - Str("device", alsaDevice). - Msg("Audio output configured for subprocess mode") - } + // Create CGO audio source + outputSource = audio.NewCgoOutputSource(alsaDevice) if currentAudioTrack != nil { outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack) @@ -126,42 +77,8 @@ func startAudioSubprocesses() error { if inputSource == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio { alsaPlaybackDevice := "hw:1,0" // USB speakers - audioMode := config.AudioMode - if audioMode == "" { - audioMode = "subprocess" // Default to subprocess - } - - if audioMode == "in-process" { - // In-process CGO mode - inputSource = audio.NewCgoInputSource(alsaPlaybackDevice) - audioLogger.Debug(). - Str("mode", "in-process"). - Str("device", alsaPlaybackDevice). - Msg("Audio input configured for in-process mode") - } else { - // Subprocess mode (default) - inputSupervisor = audio.NewSupervisor( - "audio-input", - audio.GetAudioInputBinaryPath(), - socketPathInput, - []string{ - "ALSA_PLAYBACK_DEVICE=hw:1,0", - "OPUS_BITRATE=128000", - }, - ) - - if err := inputSupervisor.Start(); err != nil { - audioLogger.Error().Err(err).Msg("Failed to start input supervisor") - inputSupervisor = nil - return err - } - - inputSource = audio.NewIPCSource("audio-input", socketPathInput, 0x4A4B4D49) - audioLogger.Debug(). - Str("mode", "subprocess"). - Str("device", alsaPlaybackDevice). - Msg("Audio input configured for subprocess mode") - } + // Create CGO audio source + inputSource = audio.NewCgoInputSource(alsaPlaybackDevice) inputRelay = audio.NewInputRelay(inputSource) if err := inputRelay.Start(); err != nil { @@ -172,8 +89,8 @@ func startAudioSubprocesses() error { return nil } -// stopOutputSubprocessLocked stops output subprocess (assumes mutex is held) -func stopOutputSubprocessLocked() { +// stopOutputLocked stops output audio (assumes mutex is held) +func stopOutputLocked() { if outputRelay != nil { outputRelay.Stop() outputRelay = nil @@ -182,14 +99,10 @@ func stopOutputSubprocessLocked() { outputSource.Disconnect() outputSource = nil } - if outputSupervisor != nil { - outputSupervisor.Stop() - outputSupervisor = nil - } } -// stopInputSubprocessLocked stops input subprocess (assumes mutex is held) -func stopInputSubprocessLocked() { +// stopInputLocked stops input audio (assumes mutex is held) +func stopInputLocked() { if inputRelay != nil { inputRelay.Stop() inputRelay = nil @@ -198,30 +111,26 @@ func stopInputSubprocessLocked() { inputSource.Disconnect() inputSource = nil } - if inputSupervisor != nil { - inputSupervisor.Stop() - inputSupervisor = nil - } } -// stopAudioSubprocessesLocked stops all audio subprocesses (assumes mutex is held) -func stopAudioSubprocessesLocked() { - stopOutputSubprocessLocked() - stopInputSubprocessLocked() +// stopAudioLocked stops all audio (assumes mutex is held) +func stopAudioLocked() { + stopOutputLocked() + stopInputLocked() } -// stopAudioSubprocesses stops all audio subprocesses -func stopAudioSubprocesses() { +// stopAudio stops all audio +func stopAudio() { audioMutex.Lock() defer audioMutex.Unlock() - stopAudioSubprocessesLocked() + stopAudioLocked() } func onWebRTCConnect() { count := activeConnections.Add(1) if count == 1 { - if err := startAudioSubprocesses(); err != nil { - audioLogger.Error().Err(err).Msg("Failed to start audio subprocesses") + if err := startAudio(); err != nil { + audioLogger.Error().Err(err).Msg("Failed to start audio") } } } @@ -230,7 +139,7 @@ func onWebRTCDisconnect() { count := activeConnections.Add(-1) if count == 0 { // Stop audio immediately to release HDMI audio device which shares hardware with video device - stopAudioSubprocesses() + stopAudio() } } @@ -262,6 +171,11 @@ func SetAudioOutputSource(useUSB bool) error { return nil } + audioLogger.Info(). + Bool("old_usb", useUSBForAudioOutput). + Bool("new_usb", useUSB). + Msg("Switching audio output source") + useUSBForAudioOutput = useUSB ensureConfigLoaded() @@ -275,12 +189,12 @@ func SetAudioOutputSource(useUSB bool) error { return err } - stopOutputSubprocessLocked() + stopOutputLocked() // Restart if there are active connections if activeConnections.Load() > 0 { audioMutex.Unlock() - err := startAudioSubprocesses() + err := startAudio() audioMutex.Lock() if err != nil { audioLogger.Error().Err(err).Msg("Failed to restart audio output") @@ -291,50 +205,6 @@ func SetAudioOutputSource(useUSB bool) error { return nil } -// SetAudioMode switches between subprocess and in-process audio modes -func SetAudioMode(mode string) error { - if mode != "subprocess" && mode != "in-process" { - return fmt.Errorf("invalid audio mode: %s (must be 'subprocess' or 'in-process')", mode) - } - - audioMutex.Lock() - defer audioMutex.Unlock() - - ensureConfigLoaded() - if config.AudioMode == mode { - return nil // Already in desired mode - } - - audioLogger.Info(). - Str("old_mode", config.AudioMode). - Str("new_mode", mode). - Msg("Switching audio mode") - - // Save new mode to config - config.AudioMode = mode - if err := SaveConfig(); err != nil { - audioLogger.Error().Err(err).Msg("Failed to save config") - return err - } - - // Stop all audio (both output and input) - stopAudioSubprocessesLocked() - - // Restart if there are active connections - if activeConnections.Load() > 0 { - audioMutex.Unlock() - err := startAudioSubprocesses() - audioMutex.Lock() - if err != nil { - audioLogger.Error().Err(err).Msg("Failed to restart audio with new mode") - return err - } - } - - audioLogger.Info().Str("mode", mode).Msg("Audio mode switch completed") - return nil -} - func setPendingInputTrack(track *webrtc.TrackRemote) { audioMutex.Lock() defer audioMutex.Unlock() @@ -353,11 +223,11 @@ func SetAudioOutputEnabled(enabled bool) error { if enabled { if activeConnections.Load() > 0 { - return startAudioSubprocesses() + return startAudio() } } else { audioMutex.Lock() - stopOutputSubprocessLocked() + stopOutputLocked() audioMutex.Unlock() } @@ -372,11 +242,11 @@ func SetAudioInputEnabled(enabled bool) error { if enabled { if activeConnections.Load() > 0 { - return startAudioSubprocesses() + return startAudio() } } else { audioMutex.Lock() - stopInputSubprocessLocked() + stopInputLocked() audioMutex.Unlock() } diff --git a/config.go b/config.go index 1f1d42f4..b6273836 100644 --- a/config.go +++ b/config.go @@ -105,7 +105,6 @@ type Config struct { NetworkConfig *network.NetworkConfig `json:"network_config"` DefaultLogLevel string `json:"default_log_level"` AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb" - AudioMode string `json:"audio_mode"` // "subprocess" or "in-process" } func (c *Config) GetDisplayRotation() uint16 { @@ -166,7 +165,6 @@ var defaultConfig = &Config{ NetworkConfig: &network.NetworkConfig{}, DefaultLogLevel: "INFO", AudioOutputSource: "usb", - AudioMode: "subprocess", // Default to subprocess mode for stability } var ( diff --git a/internal/audio/c/audio.c b/internal/audio/c/audio.c index 803737dc..b32b2e8b 100644 --- a/internal/audio/c/audio.c +++ b/internal/audio/c/audio.c @@ -2,8 +2,9 @@ * JetKVM Audio Processing Module * * Bidirectional audio processing optimized for ARM NEON SIMD: - * - OUTPUT PATH: TC358743 HDMI audio → Client speakers - * Pipeline: ALSA hw:0,0 capture → Opus encode (128kbps, FEC enabled) +* TODO: Remove USB Gadget audio once new system image release is made available + * - OUTPUT PATH: TC358743 HDMI or USB Gadget audio → Client speakers + * Pipeline: ALSA hw:0,0 or hw:1,0 capture → Opus encode (128kbps, FEC enabled) * * - INPUT PATH: Client microphone → Device speakers * Pipeline: Opus decode (with FEC) → ALSA hw:1,0 playback @@ -126,17 +127,15 @@ void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16 * Must be called before jetkvm_audio_capture_init or jetkvm_audio_playback_init */ static void init_alsa_devices_from_env(void) { - if (alsa_capture_device == NULL) { - alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE"); - if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') { - alsa_capture_device = "hw:0,0"; // Default to HDMI - } + // Always read from environment to support device switching + alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE"); + if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') { + alsa_capture_device = "hw:1,0"; // Default to USB gadget } - if (alsa_playback_device == NULL) { - alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE"); - if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') { - alsa_playback_device = "hw:1,0"; // Default to USB gadget - } + + alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE"); + if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') { + alsa_playback_device = "hw:1,0"; // Default to USB gadget } } @@ -177,6 +176,12 @@ static volatile sig_atomic_t playback_initialized = 0; /** * Update Opus encoder settings at runtime (does NOT modify FEC or hardcoded settings) + * + * NOTE: Currently unused but kept for potential future runtime configuration updates. + * In the current CGO implementation, encoder params are set once via update_audio_constants() + * before initialization. This function would be useful if we add runtime bitrate/complexity + * adjustment without restarting the encoder. + * * @return 0 on success, -1 if not initialized, >0 if some settings failed */ int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity) { diff --git a/internal/audio/c/audio_common.c b/internal/audio/c/audio_common.c index c0b54a1a..93609624 100644 --- a/internal/audio/c/audio_common.c +++ b/internal/audio/c/audio_common.c @@ -1,22 +1,17 @@ /* * JetKVM Audio Common Utilities * - * Shared functions used by both audio input and output servers + * Shared functions for audio processing */ #include "audio_common.h" -#include "ipc_protocol.h" #include #include #include #include #include -#include #include -// Forward declarations for encoder update (only in output server) -extern int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity); - // GLOBAL STATE FOR SIGNAL HANDLER // Pointer to the running flag that will be set to 0 on shutdown @@ -71,7 +66,7 @@ const char* audio_common_parse_env_string(const char *name, const char *default_ void audio_common_load_config(audio_config_t *config, int is_output) { // ALSA device configuration if (is_output) { - config->alsa_device = audio_common_parse_env_string("ALSA_CAPTURE_DEVICE", "hw:0,0"); + config->alsa_device = audio_common_parse_env_string("ALSA_CAPTURE_DEVICE", "hw:1,0"); } else { config->alsa_device = audio_common_parse_env_string("ALSA_PLAYBACK_DEVICE", "hw:1,0"); } @@ -104,66 +99,3 @@ void audio_common_print_startup(const char *server_name) { void audio_common_print_shutdown(const char *server_name) { printf("Shutting down %s...\n", server_name); } - - -int audio_common_handle_opus_config(const uint8_t *data, uint32_t length, int is_encoder) { - ipc_opus_config_t config; - - if (ipc_parse_opus_config(data, length, &config) != 0) { - fprintf(stderr, "Failed to parse Opus config\n"); - return -1; - } - - if (is_encoder) { - printf("Received Opus config: bitrate=%u, complexity=%u\n", - config.bitrate, config.complexity); - - int result = update_opus_encoder_params( - config.bitrate, - config.complexity - ); - - if (result != 0) { - fprintf(stderr, "Warning: Failed to apply Opus encoder parameters\n"); - } - } else { - printf("Received Opus config (informational): bitrate=%u, complexity=%u\n", - config.bitrate, config.complexity); - } - - return 0; -} - -// IPC MAIN LOOP HELPERS - -int audio_common_server_loop(int server_sock, volatile sig_atomic_t *running, - connection_handler_t handler) { - while (*running) { - printf("Waiting for client connection...\n"); - - int client_sock = accept(server_sock, NULL, NULL); - if (client_sock < 0) { - if (*running) { - fprintf(stderr, "Failed to accept client, retrying...\n"); - struct timespec ts = {1, 0}; // 1 second - nanosleep(&ts, NULL); - continue; - } - break; - } - - printf("Client connected (fd=%d)\n", client_sock); - - // Run handler with this client - handler(client_sock, running); - - // Close client connection - close(client_sock); - - if (*running) { - printf("Client disconnected, waiting for next client...\n"); - } - } - - return 0; -} diff --git a/internal/audio/c/audio_common.h b/internal/audio/c/audio_common.h index 4ce13587..362c1594 100644 --- a/internal/audio/c/audio_common.h +++ b/internal/audio/c/audio_common.h @@ -132,29 +132,4 @@ static inline uint8_t audio_error_tracker_should_trace(audio_error_tracker_t *tr return ((tracker->frame_count & AUDIO_TRACE_MASK) == 1) ? 1 : 0; } - -/** - * Parse Opus config message and optionally apply to encoder. - * @param data Raw message data - * @param length Message length - * @param is_encoder If true, apply config to encoder (output server) - * @return 0 on success, -1 on error - */ -int audio_common_handle_opus_config(const uint8_t *data, uint32_t length, int is_encoder); - -// IPC MAIN LOOP HELPERS - -/** - * Common server accept loop with signal handling. - * Accepts clients and calls handler function for each connection. - * - * @param server_sock Server socket from ipc_create_server - * @param running Pointer to running flag (set to 0 on shutdown) - * @param handler Connection handler function - * @return 0 on clean shutdown, -1 on error - */ -typedef int (*connection_handler_t)(int client_sock, volatile sig_atomic_t *running); -int audio_common_server_loop(int server_sock, volatile sig_atomic_t *running, - connection_handler_t handler); - #endif // JETKVM_AUDIO_COMMON_H diff --git a/internal/audio/c/ipc_protocol.c b/internal/audio/c/ipc_protocol.c deleted file mode 100644 index 0b997e13..00000000 --- a/internal/audio/c/ipc_protocol.c +++ /dev/null @@ -1,328 +0,0 @@ -/* - * JetKVM Audio IPC Protocol Implementation - * - * Implements Unix domain socket communication with exact byte-level - * compatibility with Go implementation in internal/audio/ipc_*.go - */ - -#include "ipc_protocol.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// HELPER FUNCTIONS - -/** - * Read exactly N bytes from socket (loops until complete or error). - * This is critical because read() may return partial data. - */ -int ipc_read_full(int sock, void *buf, size_t len) { - uint8_t *ptr = (uint8_t *)buf; - size_t remaining = len; - - while (remaining > 0) { - ssize_t n = read(sock, ptr, remaining); - - if (n < 0) { - if (errno == EINTR) { - continue; // Interrupted by signal, retry - } - return -1; - } - - if (n == 0) { - return -1; // Connection closed - } - - ptr += n; - remaining -= n; - } - - return 0; // Success -} - - -// MESSAGE READ/WRITE - -/** - * Read a complete IPC message from socket. - * Returns 0 on success, -1 on error. - * Caller MUST free msg->data if non-NULL! - */ -int ipc_read_message(int sock, ipc_message_t *msg, uint32_t expected_magic) { - if (msg == NULL) { - return -1; - } - - // Initialize message - memset(msg, 0, sizeof(ipc_message_t)); - - // 1. Read header (9 bytes) - if (ipc_read_full(sock, &msg->header, IPC_HEADER_SIZE) != 0) { - return -1; - } - - // 2. Convert from little-endian (required on big-endian systems) - msg->header.magic = le32toh(msg->header.magic); - msg->header.length = le32toh(msg->header.length); - - // 3. Validate magic number - if (msg->header.magic != expected_magic) { - fprintf(stderr, "IPC: Invalid magic number: got 0x%08X, expected 0x%08X\n", - msg->header.magic, expected_magic); - return -1; - } - - // 4. Validate length - if (msg->header.length > IPC_MAX_FRAME_SIZE) { - fprintf(stderr, "IPC: Message too large: %u bytes (max %d)\n", - msg->header.length, IPC_MAX_FRAME_SIZE); - return -1; - } - - // 5. Read payload if present - if (msg->header.length > 0) { - msg->data = malloc(msg->header.length); - if (msg->data == NULL) { - fprintf(stderr, "IPC: Failed to allocate %u bytes for payload\n", - msg->header.length); - return -1; - } - - if (ipc_read_full(sock, msg->data, msg->header.length) != 0) { - free(msg->data); - msg->data = NULL; - return -1; - } - } - - return 0; // Success -} - -/** - * Read a complete IPC message using pre-allocated buffer (zero-copy). - */ -int ipc_read_message_zerocopy(int sock, ipc_message_t *msg, uint32_t expected_magic, - uint8_t *buffer, uint32_t buffer_size) { - if (msg == NULL || buffer == NULL) { - return -1; - } - - // Initialize message - memset(msg, 0, sizeof(ipc_message_t)); - - // 1. Read header (9 bytes) - if (ipc_read_full(sock, &msg->header, IPC_HEADER_SIZE) != 0) { - return -1; - } - - // 2. Convert from little-endian - msg->header.magic = le32toh(msg->header.magic); - msg->header.length = le32toh(msg->header.length); - - // 3. Validate magic number - if (msg->header.magic != expected_magic) { - return -1; - } - - // 4. Validate length - if (msg->header.length > IPC_MAX_FRAME_SIZE || msg->header.length > buffer_size) { - return -1; - } - - // 5. Read payload directly into provided buffer (zero-copy) - if (msg->header.length > 0) { - if (ipc_read_full(sock, buffer, msg->header.length) != 0) { - return -1; - } - msg->data = buffer; // Point to provided buffer, no allocation - } - - return 0; // Success -} - -/** - * Write a complete IPC message to socket. - * Uses writev() for atomic header+payload write. - * Returns 0 on success, -1 on error. - */ -int ipc_write_message(int sock, uint32_t magic, uint8_t type, - const uint8_t *data, uint32_t length) { - // Validate length - if (length > IPC_MAX_FRAME_SIZE) { - fprintf(stderr, "IPC: Message too large: %u bytes (max %d)\n", - length, IPC_MAX_FRAME_SIZE); - return -1; - } - - // Prepare header - ipc_header_t header; - header.magic = htole32(magic); - header.type = type; - header.length = htole32(length); - - // Use writev for atomic write (if possible) - struct iovec iov[2]; - iov[0].iov_base = &header; - iov[0].iov_len = IPC_HEADER_SIZE; - iov[1].iov_base = (void *)data; - iov[1].iov_len = length; - - int iovcnt = (length > 0) ? 2 : 1; - size_t total_len = IPC_HEADER_SIZE + length; - - ssize_t written = writev(sock, iov, iovcnt); - - if (written < 0) { - if (errno == EINTR) { - // Retry once on interrupt - written = writev(sock, iov, iovcnt); - } - - if (written < 0) { - perror("IPC: writev failed"); - return -1; - } - } - - if ((size_t)written != total_len) { - fprintf(stderr, "IPC: Partial write: %zd/%zu bytes\n", written, total_len); - return -1; - } - - return 0; // Success -} - - -/** - * Parse Opus configuration from message data (36 bytes, little-endian). - */ -int ipc_parse_opus_config(const uint8_t *data, uint32_t length, ipc_opus_config_t *config) { - if (data == NULL || config == NULL) { - return -1; - } - - if (length != 36) { - fprintf(stderr, "IPC: Invalid Opus config size: %u bytes (expected 36)\n", length); - return -1; - } - - // Parse little-endian uint32 fields - const uint32_t *u32_data = (const uint32_t *)data; - config->sample_rate = le32toh(u32_data[0]); - config->channels = le32toh(u32_data[1]); - config->frame_size = le32toh(u32_data[2]); - config->bitrate = le32toh(u32_data[3]); - config->complexity = le32toh(u32_data[4]); - config->vbr = le32toh(u32_data[5]); - config->signal_type = le32toh(u32_data[6]); - config->bandwidth = le32toh(u32_data[7]); - config->dtx = le32toh(u32_data[8]); - - return 0; // Success -} - -/** - * Parse basic audio configuration from message data (12 bytes, little-endian). - */ -int ipc_parse_config(const uint8_t *data, uint32_t length, ipc_config_t *config) { - if (data == NULL || config == NULL) { - return -1; - } - - if (length != 12) { - fprintf(stderr, "IPC: Invalid config size: %u bytes (expected 12)\n", length); - return -1; - } - - // Parse little-endian uint32 fields - const uint32_t *u32_data = (const uint32_t *)data; - config->sample_rate = le32toh(u32_data[0]); - config->channels = le32toh(u32_data[1]); - config->frame_size = le32toh(u32_data[2]); - - return 0; // Success -} - -/** - * Free message resources. - */ -void ipc_free_message(ipc_message_t *msg) { - if (msg != NULL && msg->data != NULL) { - free(msg->data); - msg->data = NULL; - } -} - -// SOCKET MANAGEMENT - -/** - * Create Unix domain socket server. - */ -int ipc_create_server(const char *socket_path) { - if (socket_path == NULL) { - return -1; - } - - // 1. Create socket - int sock = socket(AF_UNIX, SOCK_STREAM, 0); - if (sock < 0) { - perror("IPC: socket() failed"); - return -1; - } - - // 2. Remove existing socket file (ignore errors) - unlink(socket_path); - - // 3. Bind to path - struct sockaddr_un addr; - memset(&addr, 0, sizeof(addr)); - addr.sun_family = AF_UNIX; - - if (strlen(socket_path) >= sizeof(addr.sun_path)) { - fprintf(stderr, "IPC: Socket path too long: %s\n", socket_path); - close(sock); - return -1; - } - - strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1); - - if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - perror("IPC: bind() failed"); - close(sock); - return -1; - } - - // 4. Listen with backlog=1 (single client) - if (listen(sock, 1) < 0) { - perror("IPC: listen() failed"); - close(sock); - return -1; - } - - printf("IPC: Server listening on %s\n", socket_path); - return sock; -} - -/** - * Accept client connection. - */ -int ipc_accept_client(int server_sock) { - int client_sock = accept(server_sock, NULL, NULL); - - if (client_sock < 0) { - perror("IPC: accept() failed"); - return -1; - } - - printf("IPC: Client connected (fd=%d)\n", client_sock); - return client_sock; -} diff --git a/internal/audio/c/ipc_protocol.h b/internal/audio/c/ipc_protocol.h deleted file mode 100644 index c83dcc53..00000000 --- a/internal/audio/c/ipc_protocol.h +++ /dev/null @@ -1,211 +0,0 @@ -/* - * JetKVM Audio IPC Protocol - * - * Wire protocol for Unix domain socket communication between main process - * and audio subprocesses. This protocol is 100% compatible with the Go - * implementation in internal/audio/ipc_*.go - * - * CRITICAL: All multi-byte integers use LITTLE-ENDIAN byte order. - */ - -#ifndef JETKVM_IPC_PROTOCOL_H -#define JETKVM_IPC_PROTOCOL_H - -#include -#include - -// PROTOCOL CONSTANTS - -// Magic numbers (ASCII representation when read as little-endian) -#define IPC_MAGIC_OUTPUT 0x4A4B4F55 // "JKOU" - JetKVM Output (device → browser) -#define IPC_MAGIC_INPUT 0x4A4B4D49 // "JKMI" - JetKVM Microphone Input (browser → device) - -// Message types (matches Go UnifiedMessageType enum) -#define IPC_MSG_TYPE_OPUS_FRAME 0 // Audio frame data (Opus encoded) -#define IPC_MSG_TYPE_CONFIG 1 // Basic audio config (12 bytes) -#define IPC_MSG_TYPE_OPUS_CONFIG 2 // Complete Opus config (36 bytes) -#define IPC_MSG_TYPE_STOP 3 // Shutdown signal -#define IPC_MSG_TYPE_HEARTBEAT 4 // Keep-alive ping -#define IPC_MSG_TYPE_ACK 5 // Acknowledgment - -// Size constraints -#define IPC_HEADER_SIZE 9 // Fixed header size (reduced from 17) -#define IPC_MAX_FRAME_SIZE 1024 // Maximum payload size (128kbps @ 20ms = ~600 bytes worst case with VBR+FEC) - -// Socket paths -#define IPC_SOCKET_OUTPUT "/var/run/audio_output.sock" -#define IPC_SOCKET_INPUT "/var/run/audio_input.sock" - -// WIRE FORMAT STRUCTURES - -/** - * IPC message header (9 bytes, little-endian) - * - * Byte layout: - * [0-3] magic uint32_t LE Magic number (0x4A4B4F55 or 0x4A4B4D49) - * [4] type uint8_t Message type (0-5) - * [5-8] length uint32_t LE Payload size in bytes - * [9+] data uint8_t[] Variable payload - * - * CRITICAL: Must use __attribute__((packed)) to prevent padding. - * - * NOTE: Timestamp removed (was unused, saved 8 bytes per message) - */ -typedef struct __attribute__((packed)) { - uint32_t magic; // Magic number (LE) - uint8_t type; // Message type - uint32_t length; // Payload length in bytes (LE) -} ipc_header_t; - -/** - * Basic audio configuration (12 bytes) - * Message type: IPC_MSG_TYPE_CONFIG - * - * All fields are uint32_t little-endian. - */ -typedef struct __attribute__((packed)) { - uint32_t sample_rate; // Samples per second (e.g., 48000) - uint32_t channels; // Number of channels (e.g., 2 for stereo) - uint32_t frame_size; // Samples per frame (e.g., 960) -} ipc_config_t; - -/** - * Complete Opus encoder/decoder configuration (36 bytes) - * Message type: IPC_MSG_TYPE_OPUS_CONFIG - * - * All fields are uint32_t little-endian. - * Note: Negative values (like signal_type=-1000) are stored as two's complement uint32. - */ -typedef struct __attribute__((packed)) { - uint32_t sample_rate; // Samples per second (48000) - uint32_t channels; // Number of channels (2) - uint32_t frame_size; // Samples per frame per channel (960 = 20ms @ 48kHz) - uint32_t bitrate; // Bits per second (128000) - uint32_t complexity; // Encoder complexity 0-10 (2=balanced quality/speed) - uint32_t vbr; // Variable bitrate: 0=disabled, 1=enabled - uint32_t signal_type; // Signal type: -1000=auto, 3001=voice, 3002=music - uint32_t bandwidth; // Bandwidth: 1101=narrowband, 1102=mediumband, 1103=wideband, 1104=superwideband, 1105=fullband - uint32_t dtx; // Discontinuous transmission: 0=disabled, 1=enabled -} ipc_opus_config_t; - -/** - * Complete IPC message (header + payload) - */ -typedef struct { - ipc_header_t header; - uint8_t *data; // Dynamically allocated payload (NULL if length=0) -} ipc_message_t; - - -/** - * Read a complete IPC message from socket. - * - * This function: - * 1. Reads exactly 9 bytes (header) - * 2. Validates magic number - * 3. Validates length <= IPC_MAX_FRAME_SIZE - * 4. Allocates and reads payload if length > 0 - * 5. Stores result in msg->header and msg->data - * - * @param sock Socket file descriptor - * @param msg Output message (data will be malloc'd if length > 0) - * @param expected_magic Expected magic number (IPC_MAGIC_OUTPUT or IPC_MAGIC_INPUT) - * @return 0 on success, -1 on error - * - * CALLER MUST FREE msg->data if non-NULL! - */ -int ipc_read_message(int sock, ipc_message_t *msg, uint32_t expected_magic); - -/** - * Read a complete IPC message using pre-allocated buffer (zero-copy). - * - * @param sock Socket file descriptor - * @param msg Message structure to fill - * @param expected_magic Expected magic number for validation - * @param buffer Pre-allocated buffer for message data - * @param buffer_size Size of pre-allocated buffer - * @return 0 on success, -1 on error - * - * msg->data will point to buffer (no allocation). Caller does NOT need to free. - */ -int ipc_read_message_zerocopy(int sock, ipc_message_t *msg, uint32_t expected_magic, - uint8_t *buffer, uint32_t buffer_size); - -/** - * Write a complete IPC message to socket. - * - * This function writes header + payload atomically (if possible via writev). - * - * @param sock Socket file descriptor - * @param magic Magic number (IPC_MAGIC_OUTPUT or IPC_MAGIC_INPUT) - * @param type Message type (IPC_MSG_TYPE_*) - * @param data Payload data (can be NULL if length=0) - * @param length Payload length in bytes - * @return 0 on success, -1 on error - */ -int ipc_write_message(int sock, uint32_t magic, uint8_t type, - const uint8_t *data, uint32_t length); - -/** - * Parse Opus configuration from message data. - * - * @param data Payload data (must be exactly 36 bytes) - * @param length Payload length (must be 36) - * @param config Output Opus configuration - * @return 0 on success, -1 if length != 36 - */ -int ipc_parse_opus_config(const uint8_t *data, uint32_t length, ipc_opus_config_t *config); - -/** - * Parse basic audio configuration from message data. - * - * @param data Payload data (must be exactly 12 bytes) - * @param length Payload length (must be 12) - * @param config Output audio configuration - * @return 0 on success, -1 if length != 12 - */ -int ipc_parse_config(const uint8_t *data, uint32_t length, ipc_config_t *config); - -/** - * Free message resources. - * - * @param msg Message to free (frees msg->data if non-NULL) - */ -void ipc_free_message(ipc_message_t *msg); - - -/** - * Create Unix domain socket server. - * - * This function: - * 1. Creates socket with AF_UNIX, SOCK_STREAM - * 2. Removes existing socket file - * 3. Binds to specified path - * 4. Listens with backlog=1 (single client) - * - * @param socket_path Path to Unix socket (e.g., "/var/run/audio_output.sock") - * @return Socket fd on success, -1 on error - */ -int ipc_create_server(const char *socket_path); - -/** - * Accept client connection with automatic retry. - * - * Blocks until client connects or error occurs. - * - * @param server_sock Server socket fd from ipc_create_server() - * @return Client socket fd on success, -1 on error - */ -int ipc_accept_client(int server_sock); - -/** - * Helper: Read exactly N bytes from socket (loops until complete or error). - * - * @param sock Socket file descriptor - * @param buf Output buffer - * @param len Number of bytes to read - * @return 0 on success, -1 on error - */ -int ipc_read_full(int sock, void *buf, size_t len); - -#endif // JETKVM_IPC_PROTOCOL_H diff --git a/internal/audio/c/jetkvm_audio_input.c b/internal/audio/c/jetkvm_audio_input.c deleted file mode 100644 index e1d262be..00000000 --- a/internal/audio/c/jetkvm_audio_input.c +++ /dev/null @@ -1,169 +0,0 @@ -/* - * JetKVM Audio Input Server - * - * Standalone C binary for audio input path: - * Browser → WebRTC → Go Process → IPC Receive → Opus Decode → ALSA Playback (USB Gadget) - * - * This replaces the Go subprocess that was running with --audio-input-server flag. - * - * IMPORTANT: This binary only does OPUS DECODING (not encoding). - * The browser already encodes audio to Opus before sending via WebRTC. - */ - -#include "ipc_protocol.h" -#include "audio_common.h" -#include -#include -#include -#include -#include - -// Forward declarations from audio.c -extern int jetkvm_audio_playback_init(void); -extern void jetkvm_audio_playback_close(void); -extern int jetkvm_audio_decode_write(void *opus_buf, int opus_size); -extern 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); - - -static volatile sig_atomic_t g_running = 1; - - -/** - * Send ACK response for heartbeat messages. - */ -static inline int32_t send_ack(int32_t client_sock) { - return ipc_write_message(client_sock, IPC_MAGIC_INPUT, IPC_MSG_TYPE_ACK, NULL, 0); -} - - -/** - * Main audio decode and playback loop. - * Receives Opus frames via IPC, decodes, writes to ALSA. - */ -static int run_audio_loop(int client_sock, volatile sig_atomic_t *running) { - audio_error_tracker_t tracker; - audio_error_tracker_init(&tracker); - - // Static buffer for zero-copy IPC (no malloc/free per frame) - static uint8_t frame_buffer[IPC_MAX_FRAME_SIZE] __attribute__((aligned(64))); - - printf("Starting audio input loop...\n"); - - while (*running) { - ipc_message_t msg; - - if (ipc_read_message_zerocopy(client_sock, &msg, IPC_MAGIC_INPUT, - frame_buffer, sizeof(frame_buffer)) != 0) { - if (*running) { - fprintf(stderr, "Failed to read message from client\n"); - } - break; - } - - switch (msg.header.type) { - case IPC_MSG_TYPE_OPUS_FRAME: { - if (msg.header.length == 0 || msg.data == NULL) { - fprintf(stderr, "Warning: Empty Opus frame received\n"); - continue; - } - - int frames_written = jetkvm_audio_decode_write(msg.data, msg.header.length); - - if (frames_written < 0) { - fprintf(stderr, "Audio decode/write failed (error %d/%d)\n", - tracker.consecutive_errors + 1, AUDIO_MAX_CONSECUTIVE_ERRORS); - - if (audio_error_tracker_record_error(&tracker)) { - fprintf(stderr, "Too many consecutive errors, giving up\n"); - return -1; - } - } else { - audio_error_tracker_record_success(&tracker); - - if (audio_error_tracker_should_trace(&tracker)) { - printf("Processed frame %u (opus_size=%u, pcm_frames=%d)\n", - tracker.frame_count, msg.header.length, frames_written); - } - } - - break; - } - - case IPC_MSG_TYPE_CONFIG: - printf("Received basic audio config\n"); - send_ack(client_sock); - break; - - case IPC_MSG_TYPE_OPUS_CONFIG: - audio_common_handle_opus_config(msg.data, msg.header.length, 0); - send_ack(client_sock); - break; - - case IPC_MSG_TYPE_STOP: - printf("Received stop message\n"); - *running = 0; - return 0; - - case IPC_MSG_TYPE_HEARTBEAT: - send_ack(client_sock); - break; - - default: - printf("Warning: Unknown message type: %u\n", msg.header.type); - break; - } - } - - printf("Audio input loop ended after %u frames\n", tracker.frame_count); - return 0; -} - - -int main(int argc, char **argv) { - audio_common_print_startup("Audio Input Server"); - - // Setup signal handlers - audio_common_setup_signal_handlers(&g_running); - - // Load configuration from environment - audio_config_t config; - audio_common_load_config(&config, 0); // 0 = input server - - // Apply decoder constants to audio.c (encoder params not needed) - update_audio_decoder_constants( - config.sample_rate, - config.channels, - config.frame_size, - AUDIO_MAX_PACKET_SIZE, - AUDIO_SLEEP_MICROSECONDS, - AUDIO_MAX_ATTEMPTS, - AUDIO_MAX_BACKOFF_US - ); - - // Initialize audio playback (Opus decoder + ALSA playback) - printf("Initializing audio playback on device: %s\n", config.alsa_device); - if (jetkvm_audio_playback_init() != 0) { - fprintf(stderr, "Failed to initialize audio playback\n"); - return 1; - } - - // Create IPC server - int server_sock = ipc_create_server(IPC_SOCKET_INPUT); - if (server_sock < 0) { - fprintf(stderr, "Failed to create IPC server\n"); - jetkvm_audio_playback_close(); - return 1; - } - - // Main connection loop - audio_common_server_loop(server_sock, &g_running, run_audio_loop); - - audio_common_print_shutdown("audio input server"); - close(server_sock); - unlink(IPC_SOCKET_INPUT); - jetkvm_audio_playback_close(); - - printf("Audio input server exited cleanly\n"); - return 0; -} diff --git a/internal/audio/c/jetkvm_audio_output.c b/internal/audio/c/jetkvm_audio_output.c deleted file mode 100644 index a459e4db..00000000 --- a/internal/audio/c/jetkvm_audio_output.c +++ /dev/null @@ -1,193 +0,0 @@ -/* - * JetKVM Audio Output Server - * - * Standalone C binary for audio output path: - * ALSA Capture (TC358743 HDMI) → Opus Encode → IPC Send → Go Process → WebRTC → Browser - * - * This replaces the Go subprocess that was running with --audio-output-server flag. - */ - -#include "ipc_protocol.h" -#include "audio_common.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// Forward declarations from audio.c -extern int jetkvm_audio_capture_init(void); -extern void jetkvm_audio_capture_close(void); -extern int jetkvm_audio_read_encode(void *opus_buf); -extern 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); -extern int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity); - - -static volatile sig_atomic_t g_running = 1; - - -static void load_output_config(audio_config_t *common) { - audio_common_load_config(common, 1); // 1 = output server -} - - -/** - * Handle incoming IPC messages from client (non-blocking). - * Returns 0 on success, -1 on error. - */ -static int handle_incoming_messages(int client_sock, volatile sig_atomic_t *running) { - // Static buffer for zero-copy IPC (control messages are small) - static uint8_t msg_buffer[IPC_MAX_FRAME_SIZE] __attribute__((aligned(64))); - - // Set non-blocking mode for client socket - int flags = fcntl(client_sock, F_GETFL, 0); - fcntl(client_sock, F_SETFL, flags | O_NONBLOCK); - - ipc_message_t msg; - - // Try to read message (non-blocking, zero-copy) - int result = ipc_read_message_zerocopy(client_sock, &msg, IPC_MAGIC_OUTPUT, - msg_buffer, sizeof(msg_buffer)); - - // Restore blocking mode - fcntl(client_sock, F_SETFL, flags); - - if (result != 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return 0; // No message available, not an error - } - return -1; - } - - switch (msg.header.type) { - case IPC_MSG_TYPE_OPUS_CONFIG: - audio_common_handle_opus_config(msg.data, msg.header.length, 1); - break; - - case IPC_MSG_TYPE_STOP: - printf("Received stop message\n"); - *running = 0; - break; - - case IPC_MSG_TYPE_HEARTBEAT: - break; - - default: - printf("Warning: Unknown message type: %u\n", msg.header.type); - break; - } - - return 0; -} - - -/** - * Main audio capture and encode loop. - * Continuously reads from ALSA, encodes to Opus, sends via IPC. - */ -static int run_audio_loop(int client_sock, volatile sig_atomic_t *running) { - uint8_t opus_buffer[IPC_MAX_FRAME_SIZE]; - audio_error_tracker_t tracker; - audio_error_tracker_init(&tracker); - - printf("Starting audio output loop...\n"); - - while (*running) { - if (handle_incoming_messages(client_sock, running) < 0) { - fprintf(stderr, "Client disconnected, waiting for reconnection...\n"); - break; - } - - int opus_size = jetkvm_audio_read_encode(opus_buffer); - - if (opus_size < 0) { - fprintf(stderr, "Audio read/encode failed (error %d/%d)\n", - tracker.consecutive_errors + 1, AUDIO_MAX_CONSECUTIVE_ERRORS); - - if (audio_error_tracker_record_error(&tracker)) { - fprintf(stderr, "Too many consecutive errors, giving up\n"); - return -1; - } - // No sleep needed - jetkvm_audio_read_encode already uses snd_pcm_wait internally - continue; - } - - if (opus_size == 0) { - // Frame skipped for recovery, minimal yield - sched_yield(); - continue; - } - - audio_error_tracker_record_success(&tracker); - - if (ipc_write_message(client_sock, IPC_MAGIC_OUTPUT, IPC_MSG_TYPE_OPUS_FRAME, - opus_buffer, opus_size) != 0) { - fprintf(stderr, "Failed to send frame to client\n"); - break; - } - - if (audio_error_tracker_should_trace(&tracker)) { - printf("Sent frame %u (size=%d bytes)\n", tracker.frame_count, opus_size); - } - } - - printf("Audio output loop ended after %u frames\n", tracker.frame_count); - return 0; -} - - -int main(int argc, char **argv) { - audio_common_print_startup("Audio Output Server"); - - // Setup signal handlers - audio_common_setup_signal_handlers(&g_running); - - // Load configuration from environment - audio_config_t common; - load_output_config(&common); - - // Apply audio constants to audio.c - update_audio_constants( - common.opus_bitrate, - common.opus_complexity, - common.sample_rate, - common.channels, - common.frame_size, - AUDIO_MAX_PACKET_SIZE, - AUDIO_SLEEP_MICROSECONDS, - AUDIO_MAX_ATTEMPTS, - AUDIO_MAX_BACKOFF_US - ); - - // Initialize audio capture - printf("Initializing audio capture on device: %s\n", common.alsa_device); - if (jetkvm_audio_capture_init() != 0) { - fprintf(stderr, "Failed to initialize audio capture\n"); - return 1; - } - - // Create IPC server - int server_sock = ipc_create_server(IPC_SOCKET_OUTPUT); - if (server_sock < 0) { - fprintf(stderr, "Failed to create IPC server\n"); - jetkvm_audio_capture_close(); - return 1; - } - - // Main connection loop - audio_common_server_loop(server_sock, &g_running, run_audio_loop); - - audio_common_print_shutdown("audio output server"); - close(server_sock); - unlink(IPC_SOCKET_OUTPUT); - jetkvm_audio_capture_close(); - - printf("Audio output server exited cleanly\n"); - return 0; -} diff --git a/internal/audio/cgo_source.go b/internal/audio/cgo_source.go index 4410881f..deefcdf1 100644 --- a/internal/audio/cgo_source.go +++ b/internal/audio/cgo_source.go @@ -20,6 +20,11 @@ import ( "github.com/rs/zerolog" ) +const ( + ipcMaxFrameSize = 1024 // Max Opus frame size: 128kbps @ 20ms = ~600 bytes + ipcMsgTypeOpus = 0 // Message type for Opus audio data +) + // CgoSource implements AudioSource via direct CGO calls to C audio functions (in-process) type CgoSource struct { direction string // "output" or "input" @@ -36,7 +41,7 @@ func NewCgoOutputSource(alsaDevice string) *CgoSource { logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger() return &CgoSource{ - direction: "output", + direction: "output", alsaDevice: alsaDevice, logger: logger, opusBuf: make([]byte, ipcMaxFrameSize), @@ -48,7 +53,7 @@ func NewCgoInputSource(alsaDevice string) *CgoSource { logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger() return &CgoSource{ - direction: "input", + direction: "input", alsaDevice: alsaDevice, logger: logger, opusBuf: make([]byte, ipcMaxFrameSize), @@ -71,15 +76,15 @@ func (c *CgoSource) Connect() error { // Initialize constants C.update_audio_constants( - C.uint(128000), // bitrate - C.uchar(5), // complexity - C.uint(48000), // sample_rate - C.uchar(2), // channels - C.ushort(960), // frame_size - C.ushort(1500), // max_packet_size - C.uint(1000), // sleep_us - C.uchar(5), // max_attempts - C.uint(500000), // max_backoff_us + C.uint(128000), // bitrate + C.uchar(5), // complexity + C.uint(48000), // sample_rate + C.uchar(2), // channels + C.ushort(960), // frame_size + C.ushort(1500), // max_packet_size + C.uint(1000), // sleep_us + C.uchar(5), // max_attempts + C.uint(500000), // max_backoff_us ) // Initialize capture (HDMI/USB → browser) @@ -88,21 +93,19 @@ func (c *CgoSource) Connect() error { c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture") return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc) } - - c.logger.Debug().Str("device", c.alsaDevice).Msg("Audio capture initialized") } else { // Set playback device for input path via environment variable os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice) // Initialize decoder constants C.update_audio_decoder_constants( - C.uint(48000), // sample_rate - C.uchar(2), // channels - C.ushort(960), // frame_size - C.ushort(1500), // max_packet_size - C.uint(1000), // sleep_us - C.uchar(5), // max_attempts - C.uint(500000), // max_backoff_us + C.uint(48000), // sample_rate + C.uchar(2), // channels + C.ushort(960), // frame_size + C.ushort(1500), // max_packet_size + C.uint(1000), // sleep_us + C.uchar(5), // max_attempts + C.uint(500000), // max_backoff_us ) // Initialize playback (browser → USB speakers) @@ -111,8 +114,6 @@ func (c *CgoSource) Connect() error { c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback") return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc) } - - c.logger.Debug().Str("device", c.alsaDevice).Msg("Audio playback initialized") } c.connected = true @@ -131,10 +132,8 @@ func (c *CgoSource) Disconnect() { if c.direction == "output" { C.jetkvm_audio_capture_close() - c.logger.Debug().Msg("Audio capture closed") } else { C.jetkvm_audio_playback_close() - c.logger.Debug().Msg("Audio playback closed") } c.connected = false diff --git a/internal/audio/embed.go b/internal/audio/embed.go deleted file mode 100644 index 9caf199b..00000000 --- a/internal/audio/embed.go +++ /dev/null @@ -1,80 +0,0 @@ -package audio - -import ( - _ "embed" - "fmt" - "os" -) - -// Embedded C audio binaries (built during compilation) -// -//go:embed bin/jetkvm_audio_output -var audioOutputBinary []byte - -//go:embed bin/jetkvm_audio_input -var audioInputBinary []byte - -const ( - audioBinDir = "/userdata/jetkvm/bin" - audioOutputBinPath = audioBinDir + "/jetkvm_audio_output" - audioInputBinPath = audioBinDir + "/jetkvm_audio_input" - binaryFileMode = 0755 // rwxr-xr-x -) - -// ExtractEmbeddedBinaries extracts the embedded C audio binaries to disk -// This should be called during application startup before audio supervisors are started -func ExtractEmbeddedBinaries() error { - // Create bin directory if it doesn't exist - if err := os.MkdirAll(audioBinDir, 0755); err != nil { - return fmt.Errorf("failed to create audio bin directory: %w", err) - } - - // Extract audio output binary - if err := extractBinary(audioOutputBinary, audioOutputBinPath); err != nil { - return fmt.Errorf("failed to extract audio output binary: %w", err) - } - - // Extract audio input binary - if err := extractBinary(audioInputBinary, audioInputBinPath); err != nil { - return fmt.Errorf("failed to extract audio input binary: %w", err) - } - - return nil -} - -// extractBinary writes embedded binary data to disk with executable permissions -func extractBinary(data []byte, path string) error { - // Check if binary already exists and is valid - if info, err := os.Stat(path); err == nil { - // File exists - check if size matches - if info.Size() == int64(len(data)) { - // Binary already extracted and matches embedded version - return nil - } - // Size mismatch - need to update - } - - // Write to temporary file first for atomic replacement - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, binaryFileMode); err != nil { - return fmt.Errorf("failed to write binary to %s: %w", tmpPath, err) - } - - // Atomically rename to final path - if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) // Clean up on error - return fmt.Errorf("failed to rename binary to %s: %w", path, err) - } - - return nil -} - -// GetAudioOutputBinaryPath returns the path to the audio output binary -func GetAudioOutputBinaryPath() string { - return audioOutputBinPath -} - -// GetAudioInputBinaryPath returns the path to the audio input binary -func GetAudioInputBinaryPath() string { - return audioInputBinPath -} diff --git a/internal/audio/ipc_source.go b/internal/audio/ipc_source.go deleted file mode 100644 index 7ae82c39..00000000 --- a/internal/audio/ipc_source.go +++ /dev/null @@ -1,185 +0,0 @@ -package audio - -import ( - "encoding/binary" - "fmt" - "io" - "net" - "sync" - "time" - - "github.com/jetkvm/kvm/internal/logging" - "github.com/rs/zerolog" -) - -// Buffer pool for zero-allocation writes -var writeBufferPool = sync.Pool{ - New: func() interface{} { - buf := make([]byte, ipcHeaderSize+ipcMaxFrameSize) - return &buf - }, -} - -// IPC Protocol constants (matches C implementation in ipc_protocol.h) -const ( - ipcMagicOutput = 0x4A4B4F55 // "JKOU" - Output (device → browser) - ipcMagicInput = 0x4A4B4D49 // "JKMI" - Input (browser → device) - ipcHeaderSize = 9 // Reduced from 17 (removed 8-byte timestamp) - ipcMaxFrameSize = 1024 // 128kbps @ 20ms = ~600 bytes worst case with VBR+FEC - ipcMsgTypeOpus = 0 - ipcMsgTypeConfig = 1 - ipcMsgTypeStop = 3 - connectTimeout = 5 * time.Second - readTimeout = 2 * time.Second -) - -// IPCSource implements AudioSource via Unix socket communication with audio subprocess -type IPCSource struct { - socketPath string - magicNumber uint32 - conn net.Conn - mu sync.Mutex - logger zerolog.Logger - readBuf []byte // Reusable buffer for reads (single reader per client) -} - -// NewIPCSource creates a new IPC audio source -// For output: socketPath="/var/run/audio_output.sock", magic=ipcMagicOutput -// For input: socketPath="/var/run/audio_input.sock", magic=ipcMagicInput -func NewIPCSource(name, socketPath string, magicNumber uint32) *IPCSource { - logger := logging.GetDefaultLogger().With().Str("component", name+"-ipc").Logger() - - return &IPCSource{ - socketPath: socketPath, - magicNumber: magicNumber, - logger: logger, - readBuf: make([]byte, ipcMaxFrameSize), - } -} - -// Connect establishes connection to the subprocess -func (c *IPCSource) Connect() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - c.conn.Close() - c.conn = nil - } - - conn, err := net.DialTimeout("unix", c.socketPath, connectTimeout) - if err != nil { - return fmt.Errorf("failed to connect to %s: %w", c.socketPath, err) - } - - c.conn = conn - c.logger.Debug().Str("socket", c.socketPath).Msg("connected to subprocess") - return nil -} - -// Disconnect closes the connection -func (c *IPCSource) Disconnect() { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - c.conn.Close() - c.conn = nil - c.logger.Debug().Msg("disconnected from subprocess") - } -} - -// IsConnected returns true if currently connected -func (c *IPCSource) IsConnected() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.conn != nil -} - -// ReadMessage reads a complete IPC message (header + payload) -// Returns message type, payload data, and error -// IMPORTANT: The returned payload slice is only valid until the next ReadMessage call. -// Callers must use the data immediately or copy if retention is needed. -func (c *IPCSource) ReadMessage() (uint8, []byte, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return 0, nil, fmt.Errorf("not connected") - } - - // Set read deadline - if err := c.conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil { - return 0, nil, fmt.Errorf("failed to set read deadline: %w", err) - } - - // Read 9-byte header - var header [ipcHeaderSize]byte - if _, err := io.ReadFull(c.conn, header[:]); err != nil { - return 0, nil, fmt.Errorf("failed to read header: %w", err) - } - - // Parse header (little-endian) - magic := binary.LittleEndian.Uint32(header[0:4]) - msgType := header[4] - length := binary.LittleEndian.Uint32(header[5:9]) - - // Validate magic number - if magic != c.magicNumber { - return 0, nil, fmt.Errorf("invalid magic: got 0x%X, expected 0x%X", magic, c.magicNumber) - } - - // Validate length - if length > ipcMaxFrameSize { - return 0, nil, fmt.Errorf("message too large: %d bytes", length) - } - - // Read payload if present - if length == 0 { - return msgType, nil, nil - } - - // Read directly into reusable buffer (zero-allocation) - if _, err := io.ReadFull(c.conn, c.readBuf[:length]); err != nil { - return 0, nil, fmt.Errorf("failed to read payload: %w", err) - } - - // Return slice of readBuf - caller must use immediately, data is only valid until next ReadMessage - // This avoids allocation in hot path (50 frames/sec) - return msgType, c.readBuf[:length], nil -} - -// WriteMessage writes a complete IPC message -func (c *IPCSource) WriteMessage(msgType uint8, payload []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return fmt.Errorf("not connected") - } - - length := uint32(len(payload)) - if length > ipcMaxFrameSize { - return fmt.Errorf("payload too large: %d bytes", length) - } - - // Get buffer from pool for zero-allocation write - bufPtr := writeBufferPool.Get().(*[]byte) - defer writeBufferPool.Put(bufPtr) - buf := *bufPtr - - // Build header in pooled buffer (9 bytes, little-endian) - binary.LittleEndian.PutUint32(buf[0:4], c.magicNumber) - buf[4] = msgType - binary.LittleEndian.PutUint32(buf[5:9], length) - - // Copy payload after header - copy(buf[ipcHeaderSize:], payload) - - // Write header + payload atomically - if _, err := c.conn.Write(buf[:ipcHeaderSize+length]); err != nil { - return fmt.Errorf("failed to write message: %w", err) - } - - return nil -} diff --git a/internal/audio/relay.go b/internal/audio/relay.go index c8e3274d..66ca6b46 100644 --- a/internal/audio/relay.go +++ b/internal/audio/relay.go @@ -12,7 +12,7 @@ import ( "github.com/rs/zerolog" ) -// OutputRelay forwards audio from any AudioSource (CGO or IPC) to WebRTC (browser) +// OutputRelay forwards audio from AudioSource (CGO) to WebRTC (browser) type OutputRelay struct { source AudioSource audioTrack *webrtc.TrackLocalStaticSample @@ -109,7 +109,7 @@ func (r *OutputRelay) relayLoop() { } } -// InputRelay forwards audio from WebRTC (browser microphone) to subprocess (USB audio) +// InputRelay forwards audio from WebRTC (browser microphone) to AudioSource (USB audio) type InputRelay struct { source AudioSource ctx context.Context diff --git a/internal/audio/source.go b/internal/audio/source.go index da8486bb..bebc118a 100644 --- a/internal/audio/source.go +++ b/internal/audio/source.go @@ -1,7 +1,6 @@ package audio -// AudioSource provides audio frames from either CGO (in-process) or IPC (subprocess) -// This interface allows the relay goroutine to work with both modes transparently +// AudioSource provides audio frames via CGO (in-process) C audio functions type AudioSource interface { // ReadMessage reads the next audio message // Returns message type, payload data, and error @@ -16,9 +15,7 @@ type AudioSource interface { // IsConnected returns true if the source is connected and ready IsConnected() bool - // Connect establishes connection to the audio source - // For CGO: initializes C audio subsystem - // For IPC: connects to Unix socket + // Connect initializes the C audio subsystem Connect() error // Disconnect closes the connection and releases resources diff --git a/internal/audio/supervisor.go b/internal/audio/supervisor.go deleted file mode 100644 index 3b1ceb93..00000000 --- a/internal/audio/supervisor.go +++ /dev/null @@ -1,187 +0,0 @@ -package audio - -import ( - "bufio" - "context" - "fmt" - "io" - "os" - "os/exec" - "sync/atomic" - "time" - - "github.com/jetkvm/kvm/internal/logging" - "github.com/rs/zerolog" -) - -// Supervisor manages a subprocess lifecycle with automatic restart -type Supervisor struct { - name string - binaryPath string - socketPath string - env []string - - cmd *exec.Cmd - ctx context.Context - cancel context.CancelFunc - running atomic.Bool - done chan struct{} // Closed when supervision loop exits - logger zerolog.Logger - - // Restart state - restartCount uint8 - lastRestartAt time.Time - restartBackoff time.Duration -} - -const ( - minRestartDelay = 1 * time.Second - maxRestartDelay = 30 * time.Second - restartWindow = 5 * time.Minute // Reset backoff if process runs this long -) - -// NewSupervisor creates a new subprocess supervisor -func NewSupervisor(name, binaryPath, socketPath string, env []string) *Supervisor { - ctx, cancel := context.WithCancel(context.Background()) - logger := logging.GetDefaultLogger().With().Str("component", name).Logger() - - return &Supervisor{ - name: name, - binaryPath: binaryPath, - socketPath: socketPath, - env: env, - ctx: ctx, - cancel: cancel, - done: make(chan struct{}), - logger: logger, - restartBackoff: minRestartDelay, - } -} - -// Start begins supervising the subprocess -func (s *Supervisor) Start() error { - if s.running.Load() { - return fmt.Errorf("%s: already running", s.name) - } - - s.running.Store(true) - go s.supervisionLoop() - s.logger.Debug().Msg("supervisor started") - return nil -} - -// Stop gracefully stops the subprocess -func (s *Supervisor) Stop() { - if !s.running.Swap(false) { - return // Already stopped - } - - s.logger.Debug().Msg("stopping supervisor") - s.cancel() - - // Kill process if running - if s.cmd != nil && s.cmd.Process != nil { - _ = s.cmd.Process.Kill() // Ignore error, process may already be dead - } - - // Wait for supervision loop to exit - <-s.done - - // Clean up socket file - os.Remove(s.socketPath) - s.logger.Debug().Msg("supervisor stopped") -} - -// supervisionLoop manages the subprocess lifecycle -func (s *Supervisor) supervisionLoop() { - defer close(s.done) - - for s.running.Load() { - // Check if we should reset backoff (process ran long enough) - if !s.lastRestartAt.IsZero() && time.Since(s.lastRestartAt) > restartWindow { - s.restartCount = 0 - s.restartBackoff = minRestartDelay - s.logger.Debug().Msg("reset restart backoff after stable run") - } - - // Start the process - if err := s.startProcess(); err != nil { - s.logger.Error().Err(err).Msg("failed to start process") - } else { - // Wait for process to exit - err := s.cmd.Wait() - - if s.running.Load() { - // Process crashed (not intentional shutdown) - s.logger.Warn(). - Err(err). - Uint8("restart_count", s.restartCount). - Dur("backoff", s.restartBackoff). - Msg("process exited unexpectedly, will restart") - - s.restartCount++ - s.lastRestartAt = time.Now() - - // Calculate next backoff (exponential: 1s, 2s, 4s, 8s, 16s, 30s) - s.restartBackoff *= 2 - if s.restartBackoff > maxRestartDelay { - s.restartBackoff = maxRestartDelay - } - - // Wait before restart - select { - case <-time.After(s.restartBackoff): - // Continue to next iteration - case <-s.ctx.Done(): - return // Shutting down - } - } else { - // Intentional shutdown - s.logger.Debug().Msg("process exited cleanly") - return - } - } - } -} - -// logPipe reads from a pipe and logs each line at debug level -func (s *Supervisor) logPipe(reader io.ReadCloser, stream string) { - scanner := bufio.NewScanner(reader) - for scanner.Scan() { - line := scanner.Text() - s.logger.Debug().Str("stream", stream).Msg(line) - } - reader.Close() -} - -// startProcess starts the subprocess -func (s *Supervisor) startProcess() error { - s.cmd = exec.CommandContext(s.ctx, s.binaryPath) - s.cmd.Env = append(os.Environ(), s.env...) - - // Create pipes for subprocess output - stdout, err := s.cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("failed to create stdout pipe: %w", err) - } - stderr, err := s.cmd.StderrPipe() - if err != nil { - return fmt.Errorf("failed to create stderr pipe: %w", err) - } - - if err := s.cmd.Start(); err != nil { - return fmt.Errorf("failed to start %s: %w", s.name, err) - } - - // Start goroutines to log subprocess output at debug level - go s.logPipe(stdout, "stdout") - go s.logPipe(stderr, "stderr") - - s.logger.Debug(). - Int("pid", s.cmd.Process.Pid). - Str("binary", s.binaryPath). - Strs("custom_env", s.env). - Msg("process started") - - return nil -} diff --git a/jsonrpc.go b/jsonrpc.go index 6d129105..a14683f1 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -935,12 +935,12 @@ func updateUsbRelatedConfig(wasAudioEnabled bool) error { audioMutex.Unlock() } - // Stop audio subprocesses before USB reconfiguration + // Stop audio before USB reconfiguration // Input always uses USB, output depends on audioSourceChanged audioMutex.Lock() - stopInputSubprocessLocked() + stopInputLocked() if audioSourceChanged { - stopOutputSubprocessLocked() + stopOutputLocked() } audioMutex.Unlock() @@ -953,9 +953,9 @@ func updateUsbRelatedConfig(wasAudioEnabled bool) error { } // Restart audio if source changed or USB audio is enabled with active connections - // The subprocess supervisor and relay handle device readiness via retry logic + // The relay handles device readiness via retry logic if activeConnections.Load() > 0 && (audioSourceChanged || (config.UsbDevices != nil && config.UsbDevices.Audio)) { - if err := startAudioSubprocesses(); err != nil { + if err := startAudio(); err != nil { logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration") } } @@ -1021,18 +1021,6 @@ func rpcSetAudioInputEnabled(enabled bool) error { return SetAudioInputEnabled(enabled) } -func rpcGetAudioMode() (string, error) { - ensureConfigLoaded() - if config.AudioMode == "" { - return "subprocess", nil // Default - } - return config.AudioMode, nil -} - -func rpcSetAudioMode(mode string) error { - return SetAudioMode(mode) -} - func rpcSetCloudUrl(apiUrl string, appUrl string) error { currentCloudURL := config.CloudURL config.CloudURL = apiUrl @@ -1355,8 +1343,6 @@ var rpcHandlers = map[string]RPCHandler{ "setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}}, "getAudioInputEnabled": {Func: rpcGetAudioInputEnabled}, "setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}}, - "getAudioMode": {Func: rpcGetAudioMode}, - "setAudioMode": {Func: rpcSetAudioMode, Params: []string{"mode"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "getKeyboardLayout": {Func: rpcGetKeyboardLayout}, "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, diff --git a/main.go b/main.go index 89cda6f3..31ceff43 100644 --- a/main.go +++ b/main.go @@ -125,7 +125,7 @@ func Main() { <-sigs logger.Info().Msg("JetKVM Shutting Down") - stopAudioSubprocesses() + stopAudio() //if fuseServer != nil { // err := setMassStorageImage(" ") diff --git a/ui/src/components/popovers/AudioPopover.tsx b/ui/src/components/popovers/AudioPopover.tsx index a3c114eb..0e6f62af 100644 --- a/ui/src/components/popovers/AudioPopover.tsx +++ b/ui/src/components/popovers/AudioPopover.tsx @@ -4,28 +4,17 @@ import { LuVolume2 } from "react-icons/lu"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { GridCard } from "@components/Card"; import { SettingsItem } from "@components/SettingsItem"; -import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { Button } from "@components/Button"; import notifications from "@/notifications"; export default function AudioPopover() { const { send } = useJsonRpc(); - const [audioOutputSource, setAudioOutputSource] = useState("usb"); const [audioOutputEnabled, setAudioOutputEnabled] = useState(true); const [audioInputEnabled, setAudioInputEnabled] = useState(true); const [usbAudioEnabled, setUsbAudioEnabled] = useState(false); const [loading, setLoading] = useState(false); useEffect(() => { - // Load current audio settings - send("getAudioOutputSource", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - console.error("Failed to load audio output source:", resp.error); - } else { - setAudioOutputSource(resp.result as string); - } - }); - send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { console.error("Failed to load audio output enabled:", resp.error); @@ -52,62 +41,37 @@ export default function AudioPopover() { }); }, [send]); - const handleAudioOutputSourceChange = useCallback( - (e: React.ChangeEvent) => { - const newSource = e.target.value; - setLoading(true); - send("setAudioOutputSource", { source: newSource }, (resp: JsonRpcResponse) => { - setLoading(false); - if ("error" in resp) { - notifications.error( - `Failed to set audio output source: ${resp.error.data || "Unknown error"}`, - ); - } else { - setAudioOutputSource(newSource); - notifications.success(`Audio output source set to ${newSource.toUpperCase()}`); - } - }); - }, - [send], - ); + const handleAudioOutputEnabledToggle = useCallback(() => { + const enabled = !audioOutputEnabled; + setLoading(true); + send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { + setLoading(false); + if ("error" in resp) { + notifications.error( + `Failed to ${enabled ? "enable" : "disable"} audio output: ${resp.error.data || "Unknown error"}`, + ); + } else { + setAudioOutputEnabled(enabled); + notifications.success(`Audio output ${enabled ? "enabled" : "disabled"}`); + } + }); + }, [send, audioOutputEnabled]); - const handleAudioOutputEnabledToggle = useCallback( - (e: React.ChangeEvent) => { - const enabled = e.target.checked; - setLoading(true); - send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { - setLoading(false); - if ("error" in resp) { - notifications.error( - `Failed to ${enabled ? "enable" : "disable"} audio output: ${resp.error.data || "Unknown error"}`, - ); - } else { - setAudioOutputEnabled(enabled); - notifications.success(`Audio output ${enabled ? "enabled" : "disabled"}`); - } - }); - }, - [send], - ); - - const handleAudioInputEnabledToggle = useCallback( - (e: React.ChangeEvent) => { - const enabled = e.target.checked; - setLoading(true); - send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => { - setLoading(false); - if ("error" in resp) { - notifications.error( - `Failed to ${enabled ? "enable" : "disable"} audio input: ${resp.error.data || "Unknown error"}`, - ); - } else { - setAudioInputEnabled(enabled); - notifications.success(`Audio input ${enabled ? "enabled" : "disabled"}`); - } - }); - }, - [send], - ); + const handleAudioInputEnabledToggle = useCallback(() => { + const enabled = !audioInputEnabled; + setLoading(true); + send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => { + setLoading(false); + if ("error" in resp) { + notifications.error( + `Failed to ${enabled ? "enable" : "disable"} audio input: ${resp.error.data || "Unknown error"}`, + ); + } else { + setAudioInputEnabled(enabled); + notifications.success(`Audio input ${enabled ? "enabled" : "disabled"}`); + } + }); + }, [send, audioInputEnabled]); return ( @@ -115,7 +79,7 @@ export default function AudioPopover() {
-

Audio Settings

+

Audio

@@ -128,31 +92,7 @@ export default function AudioPopover() { size="SM" theme={audioOutputEnabled ? "light" : "primary"} text={audioOutputEnabled ? "Disable" : "Enable"} - onClick={() => handleAudioOutputEnabledToggle({ target: { checked: !audioOutputEnabled } } as React.ChangeEvent)} - /> - - - - @@ -169,7 +109,7 @@ export default function AudioPopover() { size="SM" theme={audioInputEnabled ? "light" : "primary"} text={audioInputEnabled ? "Disable" : "Enable"} - onClick={() => handleAudioInputEnabledToggle({ target: { checked: !audioInputEnabled } } as React.ChangeEvent)} + onClick={handleAudioInputEnabledToggle} /> diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 50a6066f..a2204794 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -354,9 +354,11 @@ export interface SettingsState { // Audio settings audioOutputSource: string; - audioMode: string; + setAudioOutputSource: (source: string) => void; audioOutputEnabled: boolean; + setAudioOutputEnabled: (enabled: boolean) => void; audioInputEnabled: boolean; + setAudioInputEnabled: (enabled: boolean) => void; } export const useSettingsStore = create( @@ -405,9 +407,11 @@ export const useSettingsStore = create( // Audio settings with defaults audioOutputSource: "usb", - audioMode: "subprocess", + setAudioOutputSource: (source: string) => set({ audioOutputSource: source }), audioOutputEnabled: true, + setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }), audioInputEnabled: true, + setAudioInputEnabled: (enabled: boolean) => set({ audioInputEnabled: enabled }), }), { name: "settings", diff --git a/ui/src/routes/devices.$id.settings.audio.tsx b/ui/src/routes/devices.$id.settings.audio.tsx index 500973fd..a994c9e3 100644 --- a/ui/src/routes/devices.$id.settings.audio.tsx +++ b/ui/src/routes/devices.$id.settings.audio.tsx @@ -19,31 +19,23 @@ export default function SettingsAudioRoute() { if ("error" in resp) { return; } - const source = resp.result as string; - settings.audioOutputSource = source; - }); - - send("getAudioMode", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - return; - } - const mode = resp.result as string; - settings.audioMode = mode; + settings.setAudioOutputSource(resp.result as string); }); send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { return; } - settings.audioOutputEnabled = resp.result as boolean; + settings.setAudioOutputEnabled(resp.result as boolean); }); send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { return; } - settings.audioInputEnabled = resp.result as boolean; + settings.setAudioInputEnabled(resp.result as boolean); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [send]); const handleAudioOutputSourceChange = (source: string) => { @@ -54,24 +46,11 @@ export default function SettingsAudioRoute() { ); return; } - settings.audioOutputSource = source; + settings.setAudioOutputSource(source); notifications.success("Audio output source updated successfully"); }); }; - const handleAudioModeChange = (mode: string) => { - send("setAudioMode", { mode }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - `Failed to set audio mode: ${resp.error.data || "Unknown error"}`, - ); - return; - } - settings.audioMode = mode; - notifications.success("Audio mode updated successfully. Changes will take effect on next connection."); - }); - }; - const handleAudioOutputEnabledChange = (enabled: boolean) => { send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -80,7 +59,7 @@ export default function SettingsAudioRoute() { ); return; } - settings.audioOutputEnabled = enabled; + settings.setAudioOutputEnabled(enabled); notifications.success(`Audio output ${enabled ? "enabled" : "disabled"} successfully`); }); }; @@ -93,7 +72,7 @@ export default function SettingsAudioRoute() { ); return; } - settings.audioInputEnabled = enabled; + settings.setAudioInputEnabled(enabled); notifications.success(`Audio input ${enabled ? "enabled" : "disabled"} successfully`); }); }; @@ -144,30 +123,6 @@ export default function SettingsAudioRoute() { onChange={(e) => handleAudioInputEnabledChange(e.target.checked)} /> - -
-

Advanced

- - { - handleAudioModeChange(e.target.value); - }} - /> - -

- Changing the audio mode will take effect when the next WebRTC connection is established. -

-
); diff --git a/webrtc.go b/webrtc.go index 8c13619b..532f7dcd 100644 --- a/webrtc.go +++ b/webrtc.go @@ -320,7 +320,7 @@ func newSession(config SessionConfig) (*Session, error) { Str("track_id", track.ID()). Msg("Received incoming audio track from browser") - // Store track for connection when audio subprocesses start + // Store track for connection when audio starts // OnTrack fires during SDP exchange, before ICE connection completes setPendingInputTrack(track) })