mirror of https://github.com/jetkvm/kvm.git
433 lines
11 KiB
Go
433 lines
11 KiB
Go
package kvm
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/jetkvm/kvm/internal/audio"
|
|
"github.com/jetkvm/kvm/internal/logging"
|
|
"github.com/pion/webrtc/v4"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
var (
|
|
audioMutex sync.Mutex
|
|
inputSourceMutex sync.Mutex // Prevents concurrent WebRTC packets from racing during lazy connect + write
|
|
outputSource atomic.Pointer[audio.AudioSource]
|
|
inputSource atomic.Pointer[audio.AudioSource]
|
|
outputRelay atomic.Pointer[audio.OutputRelay]
|
|
inputRelay atomic.Pointer[audio.InputRelay]
|
|
audioInitialized bool
|
|
activeConnections atomic.Int32
|
|
audioLogger zerolog.Logger
|
|
currentAudioTrack *webrtc.TrackLocalStaticSample
|
|
currentInputTrack atomic.Pointer[string]
|
|
audioOutputEnabled atomic.Bool
|
|
audioInputEnabled atomic.Bool
|
|
)
|
|
|
|
func getAlsaDevice(source string) string {
|
|
if source == "hdmi" {
|
|
return "hw:0,0"
|
|
}
|
|
return "hw:1,0"
|
|
}
|
|
|
|
func initAudio() {
|
|
audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger()
|
|
|
|
ensureConfigLoaded()
|
|
audioOutputEnabled.Store(config.AudioOutputEnabled)
|
|
audioInputEnabled.Store(config.AudioInputAutoEnable)
|
|
|
|
audioLogger.Debug().Msg("Audio subsystem initialized")
|
|
audioInitialized = true
|
|
}
|
|
|
|
func getAudioConfig() audio.AudioConfig {
|
|
cfg := audio.DefaultAudioConfig()
|
|
|
|
// Apply bitrate (64-256 kbps)
|
|
if config.AudioBitrate >= 64 && config.AudioBitrate <= 256 {
|
|
cfg.Bitrate = uint16(config.AudioBitrate)
|
|
} else if config.AudioBitrate != 0 {
|
|
audioLogger.Warn().Int("bitrate", config.AudioBitrate).Msg("Invalid audio bitrate, using default")
|
|
}
|
|
|
|
// Apply complexity (0-10)
|
|
if config.AudioComplexity >= 0 && config.AudioComplexity <= 10 {
|
|
cfg.Complexity = uint8(config.AudioComplexity)
|
|
} else if config.AudioComplexity != 0 {
|
|
audioLogger.Warn().Int("complexity", config.AudioComplexity).Msg("Invalid audio complexity, using default")
|
|
}
|
|
|
|
// Apply buffer periods (2-24)
|
|
if config.AudioBufferPeriods >= 2 && config.AudioBufferPeriods <= 24 {
|
|
cfg.BufferPeriods = uint8(config.AudioBufferPeriods)
|
|
} else if config.AudioBufferPeriods != 0 {
|
|
audioLogger.Warn().Int("buffer_periods", config.AudioBufferPeriods).Msg("Invalid buffer periods, using default")
|
|
}
|
|
|
|
// Apply sample rate (Opus supports: 8k, 12k, 16k, 24k, 48k)
|
|
switch config.AudioSampleRate {
|
|
case 8000, 12000, 16000, 24000, 48000:
|
|
cfg.SampleRate = uint32(config.AudioSampleRate)
|
|
default:
|
|
if config.AudioSampleRate != 0 {
|
|
audioLogger.Warn().Int("sample_rate", config.AudioSampleRate).Msg("Invalid sample rate, using default")
|
|
}
|
|
}
|
|
|
|
// Apply packet loss percentage (0-100)
|
|
if config.AudioPacketLossPerc >= 0 && config.AudioPacketLossPerc <= 100 {
|
|
cfg.PacketLossPerc = uint8(config.AudioPacketLossPerc)
|
|
} else if config.AudioPacketLossPerc != 0 {
|
|
audioLogger.Warn().Int("packet_loss_perc", config.AudioPacketLossPerc).Msg("Invalid packet loss percentage, using default")
|
|
}
|
|
|
|
cfg.DTXEnabled = config.AudioDTXEnabled
|
|
cfg.FECEnabled = config.AudioFECEnabled
|
|
|
|
return cfg
|
|
}
|
|
|
|
func startAudio() error {
|
|
audioMutex.Lock()
|
|
defer audioMutex.Unlock()
|
|
|
|
if !audioInitialized {
|
|
audioLogger.Warn().Msg("Audio not initialized, skipping start")
|
|
return nil
|
|
}
|
|
|
|
if activeConnections.Load() <= 0 {
|
|
audioLogger.Debug().Msg("No active connections, skipping audio start")
|
|
return nil
|
|
}
|
|
|
|
ensureConfigLoaded()
|
|
|
|
var outputErr, inputErr error
|
|
if audioOutputEnabled.Load() && currentAudioTrack != nil {
|
|
outputErr = startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource))
|
|
}
|
|
|
|
if audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
|
|
inputErr = startInputAudioUnderMutex(getAlsaDevice("usb"))
|
|
}
|
|
|
|
// Simplified error handling - both errors are worth reporting
|
|
if outputErr != nil || inputErr != nil {
|
|
if outputErr != nil && inputErr != nil {
|
|
return fmt.Errorf("audio start failed - output: %w, input: %v", outputErr, inputErr)
|
|
}
|
|
if outputErr != nil {
|
|
return outputErr
|
|
}
|
|
return inputErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func startOutputAudioUnderMutex(alsaOutputDevice string) error {
|
|
oldRelay := outputRelay.Swap(nil)
|
|
oldSource := outputSource.Swap(nil)
|
|
|
|
if oldRelay != nil {
|
|
oldRelay.Stop()
|
|
}
|
|
if oldSource != nil {
|
|
(*oldSource).Disconnect()
|
|
}
|
|
|
|
newSource := audio.NewCgoOutputSource(alsaOutputDevice, getAudioConfig())
|
|
newRelay := audio.NewOutputRelay(&newSource, currentAudioTrack)
|
|
|
|
if err := newRelay.Start(); err != nil {
|
|
audioLogger.Error().Err(err).Str("alsaOutputDevice", alsaOutputDevice).Msg("Failed to start audio output relay")
|
|
return err
|
|
}
|
|
|
|
outputSource.Swap(&newSource)
|
|
outputRelay.Swap(newRelay)
|
|
return nil
|
|
}
|
|
|
|
func startInputAudioUnderMutex(alsaPlaybackDevice string) error {
|
|
oldRelay := inputRelay.Swap(nil)
|
|
oldSource := inputSource.Swap(nil)
|
|
|
|
if oldRelay != nil {
|
|
oldRelay.Stop()
|
|
}
|
|
if oldSource != nil {
|
|
(*oldSource).Disconnect()
|
|
}
|
|
|
|
newSource := audio.NewCgoInputSource(alsaPlaybackDevice, getAudioConfig())
|
|
newRelay := audio.NewInputRelay(&newSource)
|
|
|
|
if err := newRelay.Start(); err != nil {
|
|
audioLogger.Error().Err(err).Str("alsaPlaybackDevice", alsaPlaybackDevice).Msg("Failed to start input relay")
|
|
return err
|
|
}
|
|
|
|
inputSource.Swap(&newSource)
|
|
inputRelay.Swap(newRelay)
|
|
return nil
|
|
}
|
|
|
|
func stopOutputAudio() {
|
|
audioMutex.Lock()
|
|
oldRelay := outputRelay.Swap(nil)
|
|
oldSource := outputSource.Swap(nil)
|
|
audioMutex.Unlock()
|
|
|
|
if oldRelay != nil {
|
|
oldRelay.Stop()
|
|
}
|
|
if oldSource != nil {
|
|
(*oldSource).Disconnect()
|
|
}
|
|
}
|
|
|
|
func stopInputAudio() {
|
|
audioMutex.Lock()
|
|
oldRelay := inputRelay.Swap(nil)
|
|
oldSource := inputSource.Swap(nil)
|
|
audioMutex.Unlock()
|
|
|
|
if oldRelay != nil {
|
|
oldRelay.Stop()
|
|
}
|
|
if oldSource != nil {
|
|
(*oldSource).Disconnect()
|
|
}
|
|
}
|
|
|
|
func stopAudio() {
|
|
stopOutputAudio()
|
|
stopInputAudio()
|
|
}
|
|
|
|
func onWebRTCConnect() {
|
|
count := activeConnections.Add(1)
|
|
if count == 1 {
|
|
if err := startAudio(); err != nil {
|
|
audioLogger.Error().Err(err).Msg("Failed to start audio")
|
|
}
|
|
}
|
|
}
|
|
|
|
func onWebRTCDisconnect() {
|
|
count := activeConnections.Add(-1)
|
|
if count <= 0 {
|
|
// Stop audio immediately to release HDMI audio device which shares hardware with video device
|
|
stopAudio()
|
|
}
|
|
}
|
|
|
|
func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
|
|
audioMutex.Lock()
|
|
defer audioMutex.Unlock()
|
|
|
|
outRelay := outputRelay.Swap(nil)
|
|
outSource := outputSource.Swap(nil)
|
|
if outRelay != nil {
|
|
outRelay.Stop()
|
|
}
|
|
if outSource != nil {
|
|
(*outSource).Disconnect()
|
|
}
|
|
|
|
currentAudioTrack = audioTrack
|
|
|
|
if audioInitialized && activeConnections.Load() > 0 && audioOutputEnabled.Load() && currentAudioTrack != nil {
|
|
if err := startOutputAudioUnderMutex(getAlsaDevice(config.AudioOutputSource)); err != nil {
|
|
audioLogger.Error().Err(err).Msg("Failed to start output audio after track change")
|
|
}
|
|
}
|
|
}
|
|
|
|
func setPendingInputTrack(track *webrtc.TrackRemote) {
|
|
trackID := track.ID()
|
|
currentInputTrack.Store(&trackID)
|
|
go handleInputTrackForSession(track)
|
|
}
|
|
|
|
// SetAudioOutputEnabled enables or disables audio output capture.
|
|
// Returns immediately; when enabling, audio starts asynchronously to prevent UI blocking.
|
|
// Check logs for async operation status.
|
|
func SetAudioOutputEnabled(enabled bool) error {
|
|
if audioOutputEnabled.Swap(enabled) == enabled {
|
|
return nil
|
|
}
|
|
|
|
if enabled && activeConnections.Load() > 0 {
|
|
go func() {
|
|
if err := startAudio(); err != nil {
|
|
audioLogger.Error().Err(err).Msg("Failed to start output audio after enable")
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
stopOutputAudio()
|
|
return nil
|
|
}
|
|
|
|
// SetAudioInputEnabled enables or disables audio input playback.
|
|
// Returns immediately; when enabling, audio starts asynchronously to prevent UI blocking.
|
|
// Check logs for async operation status.
|
|
func SetAudioInputEnabled(enabled bool) error {
|
|
if audioInputEnabled.Swap(enabled) == enabled {
|
|
return nil
|
|
}
|
|
|
|
if enabled && activeConnections.Load() > 0 {
|
|
go func() {
|
|
if err := startAudio(); err != nil {
|
|
audioLogger.Error().Err(err).Msg("Failed to start input audio after enable")
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
stopInputAudio()
|
|
return nil
|
|
}
|
|
|
|
// SetAudioOutputSource switches between HDMI (hw:0,0) and USB (hw:1,0) audio capture.
|
|
//
|
|
// The function returns immediately after updating and persisting the config change,
|
|
// while the actual audio device switch happens asynchronously in the background:
|
|
// - Config save is synchronous to ensure the change persists even if the process crashes
|
|
// - Audio restart is async to avoid blocking the RPC caller during ALSA reconfiguration
|
|
//
|
|
// Note: The HDMI audio device (hw:0,0) can take 30-60 seconds to initialize due to
|
|
// TC358743 hardware characteristics. Callers receive success before audio actually switches.
|
|
func SetAudioOutputSource(source string) error {
|
|
if source != "hdmi" && source != "usb" {
|
|
return fmt.Errorf("invalid audio source: %s (must be 'hdmi' or 'usb')", source)
|
|
}
|
|
|
|
ensureConfigLoaded()
|
|
if config.AudioOutputSource == source {
|
|
return nil
|
|
}
|
|
|
|
config.AudioOutputSource = source
|
|
|
|
// Save config synchronously before starting async audio operations
|
|
if err := SaveConfig(); err != nil {
|
|
audioLogger.Error().Err(err).Msg("Failed to save config after audio source change")
|
|
return err
|
|
}
|
|
|
|
// Handle audio restart asynchronously
|
|
go func() {
|
|
stopOutputAudio()
|
|
if err := startAudio(); err != nil {
|
|
audioLogger.Error().Err(err).Str("source", source).Msg("Failed to start audio output after source change")
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// RestartAudioOutput stops and restarts the audio output capture.
|
|
// Returns immediately; restart happens asynchronously to prevent UI blocking.
|
|
// Check logs for async operation status.
|
|
func RestartAudioOutput() error {
|
|
audioMutex.Lock()
|
|
hasActiveOutput := audioOutputEnabled.Load() && currentAudioTrack != nil && outputSource.Load() != nil
|
|
audioMutex.Unlock()
|
|
|
|
if !hasActiveOutput {
|
|
return nil
|
|
}
|
|
|
|
audioLogger.Info().Msg("Restarting audio output")
|
|
stopOutputAudio()
|
|
go func() {
|
|
if err := startAudio(); err != nil {
|
|
audioLogger.Error().Err(err).Msg("Failed to restart audio output")
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func handleInputTrackForSession(track *webrtc.TrackRemote) {
|
|
myTrackID := track.ID()
|
|
|
|
trackLogger := audioLogger.With().
|
|
Str("codec", track.Codec().MimeType).
|
|
Str("track_id", myTrackID).
|
|
Logger()
|
|
|
|
trackLogger.Debug().Msg("starting input track handler")
|
|
|
|
for {
|
|
// Check if we've been superseded by another track
|
|
currentTrackID := currentInputTrack.Load()
|
|
if currentTrackID != nil && *currentTrackID != myTrackID {
|
|
trackLogger.Debug().
|
|
Str("current_track_id", *currentTrackID).
|
|
Msg("input track handler exiting - superseded")
|
|
return
|
|
}
|
|
|
|
// Read RTP packet
|
|
rtpPacket, _, err := track.ReadRTP()
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
trackLogger.Debug().Msg("input track ended")
|
|
return
|
|
}
|
|
trackLogger.Warn().Err(err).Msg("failed to read RTP packet")
|
|
continue
|
|
}
|
|
|
|
// Skip empty payloads
|
|
if len(rtpPacket.Payload) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Skip if input is disabled
|
|
if !audioInputEnabled.Load() {
|
|
continue
|
|
}
|
|
|
|
// Process the audio packet
|
|
if err := processInputPacket(rtpPacket.Payload); err != nil {
|
|
trackLogger.Warn().Err(err).Msg("failed to process audio packet")
|
|
}
|
|
}
|
|
}
|
|
|
|
// processInputPacket handles writing audio data to the input source
|
|
func processInputPacket(opusData []byte) error {
|
|
inputSourceMutex.Lock()
|
|
defer inputSourceMutex.Unlock()
|
|
|
|
source := inputSource.Load()
|
|
if source == nil || *source == nil {
|
|
return nil
|
|
}
|
|
|
|
// Ensure source is connected
|
|
if !(*source).IsConnected() {
|
|
if err := (*source).Connect(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Write the message
|
|
if err := (*source).WriteMessage(0, opusData); err != nil {
|
|
(*source).Disconnect()
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|