mirror of https://github.com/jetkvm/kvm.git
refactor: Remove subprocess audio infrastructure, use CGO-only
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.
This commit is contained in:
parent
1bca60ae6b
commit
bb5634be58
46
Makefile
46
Makefile
|
|
@ -53,7 +53,6 @@ KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||||
BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf
|
BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf
|
||||||
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
|
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
|
||||||
SKIP_NATIVE_IF_EXISTS ?= 0
|
SKIP_NATIVE_IF_EXISTS ?= 0
|
||||||
SKIP_AUDIO_BINARIES_IF_EXISTS ?= 0
|
|
||||||
SKIP_UI_BUILD ?= 0
|
SKIP_UI_BUILD ?= 0
|
||||||
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
|
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
|
||||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||||
|
|
@ -92,48 +91,7 @@ build_native:
|
||||||
./scripts/build_cgo.sh; \
|
./scripts/build_cgo.sh; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build audio output C binary (ALSA capture → Opus encode → IPC)
|
build_dev: build_native build_audio_deps
|
||||||
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
|
|
||||||
$(CLEAN_GO_CACHE)
|
$(CLEAN_GO_CACHE)
|
||||||
@echo "Building..."
|
@echo "Building..."
|
||||||
go build \
|
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 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
|
||||||
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
|
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)
|
$(CLEAN_GO_CACHE)
|
||||||
@echo "Building release..."
|
@echo "Building release..."
|
||||||
go build \
|
go build \
|
||||||
|
|
|
||||||
194
audio.go
194
audio.go
|
|
@ -1,7 +1,6 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -12,15 +11,8 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
socketPathOutput = "/var/run/audio_output.sock"
|
|
||||||
socketPathInput = "/var/run/audio_input.sock"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
audioMutex sync.Mutex
|
audioMutex sync.Mutex
|
||||||
outputSupervisor *audio.Supervisor
|
|
||||||
inputSupervisor *audio.Supervisor
|
|
||||||
outputSource audio.AudioSource
|
outputSource audio.AudioSource
|
||||||
inputSource audio.AudioSource
|
inputSource audio.AudioSource
|
||||||
outputRelay *audio.OutputRelay
|
outputRelay *audio.OutputRelay
|
||||||
|
|
@ -38,11 +30,6 @@ var (
|
||||||
func initAudio() {
|
func initAudio() {
|
||||||
audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger()
|
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
|
// Load audio output source from config
|
||||||
ensureConfigLoaded()
|
ensureConfigLoaded()
|
||||||
useUSBForAudioOutput = config.AudioOutputSource == "usb"
|
useUSBForAudioOutput = config.AudioOutputSource == "usb"
|
||||||
|
|
@ -57,13 +44,13 @@ func initAudio() {
|
||||||
audioInitialized = true
|
audioInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// startAudioSubprocesses starts audio subprocesses and relays (skips already running ones)
|
// startAudio starts audio sources and relays (skips already running ones)
|
||||||
func startAudioSubprocesses() error {
|
func startAudio() error {
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
defer audioMutex.Unlock()
|
defer audioMutex.Unlock()
|
||||||
|
|
||||||
if !audioInitialized {
|
if !audioInitialized {
|
||||||
audioLogger.Warn().Msg("Audio not initialized, skipping subprocess start")
|
audioLogger.Warn().Msg("Audio not initialized, skipping start")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,44 +61,8 @@ func startAudioSubprocesses() error {
|
||||||
alsaDevice = "hw:1,0" // USB
|
alsaDevice = "hw:1,0" // USB
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureConfigLoaded()
|
// Create CGO audio source
|
||||||
audioMode := config.AudioMode
|
outputSource = audio.NewCgoOutputSource(alsaDevice)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentAudioTrack != nil {
|
if currentAudioTrack != nil {
|
||||||
outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack)
|
outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack)
|
||||||
|
|
@ -126,42 +77,8 @@ func startAudioSubprocesses() error {
|
||||||
if inputSource == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
|
if inputSource == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
|
||||||
alsaPlaybackDevice := "hw:1,0" // USB speakers
|
alsaPlaybackDevice := "hw:1,0" // USB speakers
|
||||||
|
|
||||||
audioMode := config.AudioMode
|
// Create CGO audio source
|
||||||
if audioMode == "" {
|
inputSource = audio.NewCgoInputSource(alsaPlaybackDevice)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
inputRelay = audio.NewInputRelay(inputSource)
|
inputRelay = audio.NewInputRelay(inputSource)
|
||||||
if err := inputRelay.Start(); err != nil {
|
if err := inputRelay.Start(); err != nil {
|
||||||
|
|
@ -172,8 +89,8 @@ func startAudioSubprocesses() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopOutputSubprocessLocked stops output subprocess (assumes mutex is held)
|
// stopOutputLocked stops output audio (assumes mutex is held)
|
||||||
func stopOutputSubprocessLocked() {
|
func stopOutputLocked() {
|
||||||
if outputRelay != nil {
|
if outputRelay != nil {
|
||||||
outputRelay.Stop()
|
outputRelay.Stop()
|
||||||
outputRelay = nil
|
outputRelay = nil
|
||||||
|
|
@ -182,14 +99,10 @@ func stopOutputSubprocessLocked() {
|
||||||
outputSource.Disconnect()
|
outputSource.Disconnect()
|
||||||
outputSource = nil
|
outputSource = nil
|
||||||
}
|
}
|
||||||
if outputSupervisor != nil {
|
|
||||||
outputSupervisor.Stop()
|
|
||||||
outputSupervisor = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopInputSubprocessLocked stops input subprocess (assumes mutex is held)
|
// stopInputLocked stops input audio (assumes mutex is held)
|
||||||
func stopInputSubprocessLocked() {
|
func stopInputLocked() {
|
||||||
if inputRelay != nil {
|
if inputRelay != nil {
|
||||||
inputRelay.Stop()
|
inputRelay.Stop()
|
||||||
inputRelay = nil
|
inputRelay = nil
|
||||||
|
|
@ -198,30 +111,26 @@ func stopInputSubprocessLocked() {
|
||||||
inputSource.Disconnect()
|
inputSource.Disconnect()
|
||||||
inputSource = nil
|
inputSource = nil
|
||||||
}
|
}
|
||||||
if inputSupervisor != nil {
|
|
||||||
inputSupervisor.Stop()
|
|
||||||
inputSupervisor = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopAudioSubprocessesLocked stops all audio subprocesses (assumes mutex is held)
|
// stopAudioLocked stops all audio (assumes mutex is held)
|
||||||
func stopAudioSubprocessesLocked() {
|
func stopAudioLocked() {
|
||||||
stopOutputSubprocessLocked()
|
stopOutputLocked()
|
||||||
stopInputSubprocessLocked()
|
stopInputLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopAudioSubprocesses stops all audio subprocesses
|
// stopAudio stops all audio
|
||||||
func stopAudioSubprocesses() {
|
func stopAudio() {
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
defer audioMutex.Unlock()
|
defer audioMutex.Unlock()
|
||||||
stopAudioSubprocessesLocked()
|
stopAudioLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func onWebRTCConnect() {
|
func onWebRTCConnect() {
|
||||||
count := activeConnections.Add(1)
|
count := activeConnections.Add(1)
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
if err := startAudioSubprocesses(); err != nil {
|
if err := startAudio(); err != nil {
|
||||||
audioLogger.Error().Err(err).Msg("Failed to start audio subprocesses")
|
audioLogger.Error().Err(err).Msg("Failed to start audio")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +139,7 @@ func onWebRTCDisconnect() {
|
||||||
count := activeConnections.Add(-1)
|
count := activeConnections.Add(-1)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
// Stop audio immediately to release HDMI audio device which shares hardware with video device
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioLogger.Info().
|
||||||
|
Bool("old_usb", useUSBForAudioOutput).
|
||||||
|
Bool("new_usb", useUSB).
|
||||||
|
Msg("Switching audio output source")
|
||||||
|
|
||||||
useUSBForAudioOutput = useUSB
|
useUSBForAudioOutput = useUSB
|
||||||
|
|
||||||
ensureConfigLoaded()
|
ensureConfigLoaded()
|
||||||
|
|
@ -275,12 +189,12 @@ func SetAudioOutputSource(useUSB bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
stopOutputSubprocessLocked()
|
stopOutputLocked()
|
||||||
|
|
||||||
// Restart if there are active connections
|
// Restart if there are active connections
|
||||||
if activeConnections.Load() > 0 {
|
if activeConnections.Load() > 0 {
|
||||||
audioMutex.Unlock()
|
audioMutex.Unlock()
|
||||||
err := startAudioSubprocesses()
|
err := startAudio()
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
audioLogger.Error().Err(err).Msg("Failed to restart audio output")
|
audioLogger.Error().Err(err).Msg("Failed to restart audio output")
|
||||||
|
|
@ -291,50 +205,6 @@ func SetAudioOutputSource(useUSB bool) error {
|
||||||
return nil
|
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) {
|
func setPendingInputTrack(track *webrtc.TrackRemote) {
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
defer audioMutex.Unlock()
|
defer audioMutex.Unlock()
|
||||||
|
|
@ -353,11 +223,11 @@ func SetAudioOutputEnabled(enabled bool) error {
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
if activeConnections.Load() > 0 {
|
if activeConnections.Load() > 0 {
|
||||||
return startAudioSubprocesses()
|
return startAudio()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
stopOutputSubprocessLocked()
|
stopOutputLocked()
|
||||||
audioMutex.Unlock()
|
audioMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,11 +242,11 @@ func SetAudioInputEnabled(enabled bool) error {
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
if activeConnections.Load() > 0 {
|
if activeConnections.Load() > 0 {
|
||||||
return startAudioSubprocesses()
|
return startAudio()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
stopInputSubprocessLocked()
|
stopInputLocked()
|
||||||
audioMutex.Unlock()
|
audioMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ type Config struct {
|
||||||
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
NetworkConfig *network.NetworkConfig `json:"network_config"`
|
||||||
DefaultLogLevel string `json:"default_log_level"`
|
DefaultLogLevel string `json:"default_log_level"`
|
||||||
AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb"
|
AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb"
|
||||||
AudioMode string `json:"audio_mode"` // "subprocess" or "in-process"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetDisplayRotation() uint16 {
|
func (c *Config) GetDisplayRotation() uint16 {
|
||||||
|
|
@ -166,7 +165,6 @@ var defaultConfig = &Config{
|
||||||
NetworkConfig: &network.NetworkConfig{},
|
NetworkConfig: &network.NetworkConfig{},
|
||||||
DefaultLogLevel: "INFO",
|
DefaultLogLevel: "INFO",
|
||||||
AudioOutputSource: "usb",
|
AudioOutputSource: "usb",
|
||||||
AudioMode: "subprocess", // Default to subprocess mode for stability
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
* JetKVM Audio Processing Module
|
* JetKVM Audio Processing Module
|
||||||
*
|
*
|
||||||
* Bidirectional audio processing optimized for ARM NEON SIMD:
|
* Bidirectional audio processing optimized for ARM NEON SIMD:
|
||||||
* - OUTPUT PATH: TC358743 HDMI audio → Client speakers
|
* TODO: Remove USB Gadget audio once new system image release is made available
|
||||||
* Pipeline: ALSA hw:0,0 capture → Opus encode (128kbps, FEC enabled)
|
* - 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
|
* - INPUT PATH: Client microphone → Device speakers
|
||||||
* Pipeline: Opus decode (with FEC) → ALSA hw:1,0 playback
|
* 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
|
* Must be called before jetkvm_audio_capture_init or jetkvm_audio_playback_init
|
||||||
*/
|
*/
|
||||||
static void init_alsa_devices_from_env(void) {
|
static void init_alsa_devices_from_env(void) {
|
||||||
if (alsa_capture_device == NULL) {
|
// Always read from environment to support device switching
|
||||||
alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE");
|
alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE");
|
||||||
if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') {
|
if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') {
|
||||||
alsa_capture_device = "hw:0,0"; // Default to HDMI
|
alsa_capture_device = "hw:1,0"; // Default to USB gadget
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (alsa_playback_device == NULL) {
|
|
||||||
alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE");
|
alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE");
|
||||||
if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') {
|
if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') {
|
||||||
alsa_playback_device = "hw:1,0"; // Default to USB gadget
|
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)
|
* 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
|
* @return 0 on success, -1 if not initialized, >0 if some settings failed
|
||||||
*/
|
*/
|
||||||
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity) {
|
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity) {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
/*
|
/*
|
||||||
* JetKVM Audio Common Utilities
|
* JetKVM Audio Common Utilities
|
||||||
*
|
*
|
||||||
* Shared functions used by both audio input and output servers
|
* Shared functions for audio processing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "audio_common.h"
|
#include "audio_common.h"
|
||||||
#include "ipc_protocol.h"
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
|
||||||
// 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
|
// GLOBAL STATE FOR SIGNAL HANDLER
|
||||||
|
|
||||||
// Pointer to the running flag that will be set to 0 on shutdown
|
// 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) {
|
void audio_common_load_config(audio_config_t *config, int is_output) {
|
||||||
// ALSA device configuration
|
// ALSA device configuration
|
||||||
if (is_output) {
|
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 {
|
} else {
|
||||||
config->alsa_device = audio_common_parse_env_string("ALSA_PLAYBACK_DEVICE", "hw:1,0");
|
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) {
|
void audio_common_print_shutdown(const char *server_name) {
|
||||||
printf("Shutting down %s...\n", 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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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
|
#endif // JETKVM_AUDIO_COMMON_H
|
||||||
|
|
|
||||||
|
|
@ -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 <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <time.h>
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <sys/un.h>
|
|
||||||
#include <sys/uio.h>
|
|
||||||
#include <endian.h>
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 <stdint.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
@ -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 <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <signal.h>
|
|
||||||
#include <errno.h>
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <signal.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <sched.h>
|
|
||||||
#include <time.h>
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -20,6 +20,11 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"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)
|
// CgoSource implements AudioSource via direct CGO calls to C audio functions (in-process)
|
||||||
type CgoSource struct {
|
type CgoSource struct {
|
||||||
direction string // "output" or "input"
|
direction string // "output" or "input"
|
||||||
|
|
@ -36,7 +41,7 @@ func NewCgoOutputSource(alsaDevice string) *CgoSource {
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger()
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger()
|
||||||
|
|
||||||
return &CgoSource{
|
return &CgoSource{
|
||||||
direction: "output",
|
direction: "output",
|
||||||
alsaDevice: alsaDevice,
|
alsaDevice: alsaDevice,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
opusBuf: make([]byte, ipcMaxFrameSize),
|
opusBuf: make([]byte, ipcMaxFrameSize),
|
||||||
|
|
@ -48,7 +53,7 @@ func NewCgoInputSource(alsaDevice string) *CgoSource {
|
||||||
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger()
|
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger()
|
||||||
|
|
||||||
return &CgoSource{
|
return &CgoSource{
|
||||||
direction: "input",
|
direction: "input",
|
||||||
alsaDevice: alsaDevice,
|
alsaDevice: alsaDevice,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
opusBuf: make([]byte, ipcMaxFrameSize),
|
opusBuf: make([]byte, ipcMaxFrameSize),
|
||||||
|
|
@ -71,15 +76,15 @@ func (c *CgoSource) Connect() error {
|
||||||
|
|
||||||
// Initialize constants
|
// Initialize constants
|
||||||
C.update_audio_constants(
|
C.update_audio_constants(
|
||||||
C.uint(128000), // bitrate
|
C.uint(128000), // bitrate
|
||||||
C.uchar(5), // complexity
|
C.uchar(5), // complexity
|
||||||
C.uint(48000), // sample_rate
|
C.uint(48000), // sample_rate
|
||||||
C.uchar(2), // channels
|
C.uchar(2), // channels
|
||||||
C.ushort(960), // frame_size
|
C.ushort(960), // frame_size
|
||||||
C.ushort(1500), // max_packet_size
|
C.ushort(1500), // max_packet_size
|
||||||
C.uint(1000), // sleep_us
|
C.uint(1000), // sleep_us
|
||||||
C.uchar(5), // max_attempts
|
C.uchar(5), // max_attempts
|
||||||
C.uint(500000), // max_backoff_us
|
C.uint(500000), // max_backoff_us
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize capture (HDMI/USB → browser)
|
// 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")
|
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture")
|
||||||
return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc)
|
return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Debug().Str("device", c.alsaDevice).Msg("Audio capture initialized")
|
|
||||||
} else {
|
} else {
|
||||||
// Set playback device for input path via environment variable
|
// Set playback device for input path via environment variable
|
||||||
os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice)
|
os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice)
|
||||||
|
|
||||||
// Initialize decoder constants
|
// Initialize decoder constants
|
||||||
C.update_audio_decoder_constants(
|
C.update_audio_decoder_constants(
|
||||||
C.uint(48000), // sample_rate
|
C.uint(48000), // sample_rate
|
||||||
C.uchar(2), // channels
|
C.uchar(2), // channels
|
||||||
C.ushort(960), // frame_size
|
C.ushort(960), // frame_size
|
||||||
C.ushort(1500), // max_packet_size
|
C.ushort(1500), // max_packet_size
|
||||||
C.uint(1000), // sleep_us
|
C.uint(1000), // sleep_us
|
||||||
C.uchar(5), // max_attempts
|
C.uchar(5), // max_attempts
|
||||||
C.uint(500000), // max_backoff_us
|
C.uint(500000), // max_backoff_us
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize playback (browser → USB speakers)
|
// 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")
|
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback")
|
||||||
return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc)
|
return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Debug().Str("device", c.alsaDevice).Msg("Audio playback initialized")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.connected = true
|
c.connected = true
|
||||||
|
|
@ -131,10 +132,8 @@ func (c *CgoSource) Disconnect() {
|
||||||
|
|
||||||
if c.direction == "output" {
|
if c.direction == "output" {
|
||||||
C.jetkvm_audio_capture_close()
|
C.jetkvm_audio_capture_close()
|
||||||
c.logger.Debug().Msg("Audio capture closed")
|
|
||||||
} else {
|
} else {
|
||||||
C.jetkvm_audio_playback_close()
|
C.jetkvm_audio_playback_close()
|
||||||
c.logger.Debug().Msg("Audio playback closed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.connected = false
|
c.connected = false
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"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 {
|
type OutputRelay struct {
|
||||||
source AudioSource
|
source AudioSource
|
||||||
audioTrack *webrtc.TrackLocalStaticSample
|
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 {
|
type InputRelay struct {
|
||||||
source AudioSource
|
source AudioSource
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
// AudioSource provides audio frames from either CGO (in-process) or IPC (subprocess)
|
// AudioSource provides audio frames via CGO (in-process) C audio functions
|
||||||
// This interface allows the relay goroutine to work with both modes transparently
|
|
||||||
type AudioSource interface {
|
type AudioSource interface {
|
||||||
// ReadMessage reads the next audio message
|
// ReadMessage reads the next audio message
|
||||||
// Returns message type, payload data, and error
|
// 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 returns true if the source is connected and ready
|
||||||
IsConnected() bool
|
IsConnected() bool
|
||||||
|
|
||||||
// Connect establishes connection to the audio source
|
// Connect initializes the C audio subsystem
|
||||||
// For CGO: initializes C audio subsystem
|
|
||||||
// For IPC: connects to Unix socket
|
|
||||||
Connect() error
|
Connect() error
|
||||||
|
|
||||||
// Disconnect closes the connection and releases resources
|
// Disconnect closes the connection and releases resources
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
24
jsonrpc.go
24
jsonrpc.go
|
|
@ -935,12 +935,12 @@ func updateUsbRelatedConfig(wasAudioEnabled bool) error {
|
||||||
audioMutex.Unlock()
|
audioMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop audio subprocesses before USB reconfiguration
|
// Stop audio before USB reconfiguration
|
||||||
// Input always uses USB, output depends on audioSourceChanged
|
// Input always uses USB, output depends on audioSourceChanged
|
||||||
audioMutex.Lock()
|
audioMutex.Lock()
|
||||||
stopInputSubprocessLocked()
|
stopInputLocked()
|
||||||
if audioSourceChanged {
|
if audioSourceChanged {
|
||||||
stopOutputSubprocessLocked()
|
stopOutputLocked()
|
||||||
}
|
}
|
||||||
audioMutex.Unlock()
|
audioMutex.Unlock()
|
||||||
|
|
||||||
|
|
@ -953,9 +953,9 @@ func updateUsbRelatedConfig(wasAudioEnabled bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart audio if source changed or USB audio is enabled with active connections
|
// 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 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")
|
logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1021,18 +1021,6 @@ func rpcSetAudioInputEnabled(enabled bool) error {
|
||||||
return SetAudioInputEnabled(enabled)
|
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 {
|
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
||||||
currentCloudURL := config.CloudURL
|
currentCloudURL := config.CloudURL
|
||||||
config.CloudURL = apiUrl
|
config.CloudURL = apiUrl
|
||||||
|
|
@ -1355,8 +1343,6 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
|
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
|
||||||
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
|
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
|
||||||
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
|
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
|
||||||
"getAudioMode": {Func: rpcGetAudioMode},
|
|
||||||
"setAudioMode": {Func: rpcSetAudioMode, Params: []string{"mode"}},
|
|
||||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||||
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
|
||||||
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -125,7 +125,7 @@ func Main() {
|
||||||
<-sigs
|
<-sigs
|
||||||
logger.Info().Msg("JetKVM Shutting Down")
|
logger.Info().Msg("JetKVM Shutting Down")
|
||||||
|
|
||||||
stopAudioSubprocesses()
|
stopAudio()
|
||||||
|
|
||||||
//if fuseServer != nil {
|
//if fuseServer != nil {
|
||||||
// err := setMassStorageImage(" ")
|
// err := setMassStorageImage(" ")
|
||||||
|
|
|
||||||
|
|
@ -4,28 +4,17 @@ import { LuVolume2 } from "react-icons/lu";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { SettingsItem } from "@components/SettingsItem";
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
||||||
export default function AudioPopover() {
|
export default function AudioPopover() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const [audioOutputSource, setAudioOutputSource] = useState<string>("usb");
|
|
||||||
const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true);
|
const [audioOutputEnabled, setAudioOutputEnabled] = useState<boolean>(true);
|
||||||
const [audioInputEnabled, setAudioInputEnabled] = useState<boolean>(true);
|
const [audioInputEnabled, setAudioInputEnabled] = useState<boolean>(true);
|
||||||
const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false);
|
const [usbAudioEnabled, setUsbAudioEnabled] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
console.error("Failed to load audio output enabled:", resp.error);
|
console.error("Failed to load audio output enabled:", resp.error);
|
||||||
|
|
@ -52,62 +41,37 @@ export default function AudioPopover() {
|
||||||
});
|
});
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleAudioOutputSourceChange = useCallback(
|
const handleAudioOutputEnabledToggle = useCallback(() => {
|
||||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
const enabled = !audioOutputEnabled;
|
||||||
const newSource = e.target.value;
|
setLoading(true);
|
||||||
setLoading(true);
|
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
send("setAudioOutputSource", { source: newSource }, (resp: JsonRpcResponse) => {
|
setLoading(false);
|
||||||
setLoading(false);
|
if ("error" in resp) {
|
||||||
if ("error" in resp) {
|
notifications.error(
|
||||||
notifications.error(
|
`Failed to ${enabled ? "enable" : "disable"} audio output: ${resp.error.data || "Unknown error"}`,
|
||||||
`Failed to set audio output source: ${resp.error.data || "Unknown error"}`,
|
);
|
||||||
);
|
} else {
|
||||||
} else {
|
setAudioOutputEnabled(enabled);
|
||||||
setAudioOutputSource(newSource);
|
notifications.success(`Audio output ${enabled ? "enabled" : "disabled"}`);
|
||||||
notifications.success(`Audio output source set to ${newSource.toUpperCase()}`);
|
}
|
||||||
}
|
});
|
||||||
});
|
}, [send, audioOutputEnabled]);
|
||||||
},
|
|
||||||
[send],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAudioOutputEnabledToggle = useCallback(
|
const handleAudioInputEnabledToggle = useCallback(() => {
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
const enabled = !audioInputEnabled;
|
||||||
const enabled = e.target.checked;
|
setLoading(true);
|
||||||
setLoading(true);
|
send("setAudioInputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
setLoading(false);
|
||||||
setLoading(false);
|
if ("error" in resp) {
|
||||||
if ("error" in resp) {
|
notifications.error(
|
||||||
notifications.error(
|
`Failed to ${enabled ? "enable" : "disable"} audio input: ${resp.error.data || "Unknown error"}`,
|
||||||
`Failed to ${enabled ? "enable" : "disable"} audio output: ${resp.error.data || "Unknown error"}`,
|
);
|
||||||
);
|
} else {
|
||||||
} else {
|
setAudioInputEnabled(enabled);
|
||||||
setAudioOutputEnabled(enabled);
|
notifications.success(`Audio input ${enabled ? "enabled" : "disabled"}`);
|
||||||
notifications.success(`Audio output ${enabled ? "enabled" : "disabled"}`);
|
}
|
||||||
}
|
});
|
||||||
});
|
}, [send, audioInputEnabled]);
|
||||||
},
|
|
||||||
[send],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAudioInputEnabledToggle = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCard>
|
<GridCard>
|
||||||
|
|
@ -115,7 +79,7 @@ export default function AudioPopover() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
<div className="flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<LuVolume2 className="h-5 w-5" />
|
<LuVolume2 className="h-5 w-5" />
|
||||||
<h3 className="font-semibold">Audio Settings</h3>
|
<h3 className="font-semibold">Audio</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -128,31 +92,7 @@ export default function AudioPopover() {
|
||||||
size="SM"
|
size="SM"
|
||||||
theme={audioOutputEnabled ? "light" : "primary"}
|
theme={audioOutputEnabled ? "light" : "primary"}
|
||||||
text={audioOutputEnabled ? "Disable" : "Enable"}
|
text={audioOutputEnabled ? "Disable" : "Enable"}
|
||||||
onClick={() => handleAudioOutputEnabledToggle({ target: { checked: !audioOutputEnabled } } as React.ChangeEvent<HTMLInputElement>)}
|
onClick={handleAudioOutputEnabledToggle}
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
|
|
||||||
<SettingsItem
|
|
||||||
loading={loading}
|
|
||||||
title="Audio Output Source"
|
|
||||||
description={usbAudioEnabled ? "Select where to capture audio from" : "Enable USB Audio to use USB as source"}
|
|
||||||
>
|
|
||||||
<SelectMenuBasic
|
|
||||||
size="SM"
|
|
||||||
label=""
|
|
||||||
className="max-w-[180px]"
|
|
||||||
value={audioOutputSource}
|
|
||||||
fullWidth
|
|
||||||
disabled={!audioOutputEnabled}
|
|
||||||
onChange={handleAudioOutputSourceChange}
|
|
||||||
options={
|
|
||||||
usbAudioEnabled
|
|
||||||
? [
|
|
||||||
{ label: "HDMI", value: "hdmi" },
|
|
||||||
{ label: "USB", value: "usb" },
|
|
||||||
]
|
|
||||||
: [{ label: "HDMI", value: "hdmi" }]
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
|
@ -169,7 +109,7 @@ export default function AudioPopover() {
|
||||||
size="SM"
|
size="SM"
|
||||||
theme={audioInputEnabled ? "light" : "primary"}
|
theme={audioInputEnabled ? "light" : "primary"}
|
||||||
text={audioInputEnabled ? "Disable" : "Enable"}
|
text={audioInputEnabled ? "Disable" : "Enable"}
|
||||||
onClick={() => handleAudioInputEnabledToggle({ target: { checked: !audioInputEnabled } } as React.ChangeEvent<HTMLInputElement>)}
|
onClick={handleAudioInputEnabledToggle}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -354,9 +354,11 @@ export interface SettingsState {
|
||||||
|
|
||||||
// Audio settings
|
// Audio settings
|
||||||
audioOutputSource: string;
|
audioOutputSource: string;
|
||||||
audioMode: string;
|
setAudioOutputSource: (source: string) => void;
|
||||||
audioOutputEnabled: boolean;
|
audioOutputEnabled: boolean;
|
||||||
|
setAudioOutputEnabled: (enabled: boolean) => void;
|
||||||
audioInputEnabled: boolean;
|
audioInputEnabled: boolean;
|
||||||
|
setAudioInputEnabled: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create(
|
export const useSettingsStore = create(
|
||||||
|
|
@ -405,9 +407,11 @@ export const useSettingsStore = create(
|
||||||
|
|
||||||
// Audio settings with defaults
|
// Audio settings with defaults
|
||||||
audioOutputSource: "usb",
|
audioOutputSource: "usb",
|
||||||
audioMode: "subprocess",
|
setAudioOutputSource: (source: string) => set({ audioOutputSource: source }),
|
||||||
audioOutputEnabled: true,
|
audioOutputEnabled: true,
|
||||||
|
setAudioOutputEnabled: (enabled: boolean) => set({ audioOutputEnabled: enabled }),
|
||||||
audioInputEnabled: true,
|
audioInputEnabled: true,
|
||||||
|
setAudioInputEnabled: (enabled: boolean) => set({ audioInputEnabled: enabled }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|
|
||||||
|
|
@ -19,31 +19,23 @@ export default function SettingsAudioRoute() {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const source = resp.result as string;
|
settings.setAudioOutputSource(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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
send("getAudioOutputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.audioOutputEnabled = resp.result as boolean;
|
settings.setAudioOutputEnabled(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => {
|
send("getAudioInputEnabled", {}, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.audioInputEnabled = resp.result as boolean;
|
settings.setAudioInputEnabled(resp.result as boolean);
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [send]);
|
}, [send]);
|
||||||
|
|
||||||
const handleAudioOutputSourceChange = (source: string) => {
|
const handleAudioOutputSourceChange = (source: string) => {
|
||||||
|
|
@ -54,24 +46,11 @@ export default function SettingsAudioRoute() {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.audioOutputSource = source;
|
settings.setAudioOutputSource(source);
|
||||||
notifications.success("Audio output source updated successfully");
|
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) => {
|
const handleAudioOutputEnabledChange = (enabled: boolean) => {
|
||||||
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
send("setAudioOutputEnabled", { enabled }, (resp: JsonRpcResponse) => {
|
||||||
if ("error" in resp) {
|
if ("error" in resp) {
|
||||||
|
|
@ -80,7 +59,7 @@ export default function SettingsAudioRoute() {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.audioOutputEnabled = enabled;
|
settings.setAudioOutputEnabled(enabled);
|
||||||
notifications.success(`Audio output ${enabled ? "enabled" : "disabled"} successfully`);
|
notifications.success(`Audio output ${enabled ? "enabled" : "disabled"} successfully`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -93,7 +72,7 @@ export default function SettingsAudioRoute() {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.audioInputEnabled = enabled;
|
settings.setAudioInputEnabled(enabled);
|
||||||
notifications.success(`Audio input ${enabled ? "enabled" : "disabled"} successfully`);
|
notifications.success(`Audio input ${enabled ? "enabled" : "disabled"} successfully`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -144,30 +123,6 @@ export default function SettingsAudioRoute() {
|
||||||
onChange={(e) => handleAudioInputEnabledChange(e.target.checked)}
|
onChange={(e) => handleAudioInputEnabledChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
<div className="border-t border-slate-200 pt-4 dark:border-slate-700">
|
|
||||||
<h3 className="mb-2 text-sm font-medium">Advanced</h3>
|
|
||||||
<SettingsItem
|
|
||||||
title="Audio Processing Mode"
|
|
||||||
description="In-process mode uses less CPU but subprocess mode provides better isolation"
|
|
||||||
>
|
|
||||||
<SelectMenuBasic
|
|
||||||
size="SM"
|
|
||||||
label=""
|
|
||||||
value={settings.audioMode || "subprocess"}
|
|
||||||
options={[
|
|
||||||
{ value: "subprocess", label: "Subprocess (Recommended)" },
|
|
||||||
{ value: "in-process", label: "In-Process" },
|
|
||||||
]}
|
|
||||||
onChange={e => {
|
|
||||||
handleAudioModeChange(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SettingsItem>
|
|
||||||
<p className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
|
||||||
Changing the audio mode will take effect when the next WebRTC connection is established.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
Str("track_id", track.ID()).
|
Str("track_id", track.ID()).
|
||||||
Msg("Received incoming audio track from browser")
|
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
|
// OnTrack fires during SDP exchange, before ICE connection completes
|
||||||
setPendingInputTrack(track)
|
setPendingInputTrack(track)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue