diff --git a/cmd/tui/main.go b/cmd/tui/main.go index bca65e1..36da3fe 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -56,6 +56,9 @@ func main() { } } + // Silence noise (ALSA/PortAudio) on Linux or if debugFile is set + redirectStderr(debugFile) + // Create the TUI model m := NewModel(*serverAddr, *nickname) diff --git a/cmd/tui/noise_linux.go b/cmd/tui/noise_linux.go new file mode 100644 index 0000000..20e20d7 --- /dev/null +++ b/cmd/tui/noise_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package main + +import ( + "os" + "syscall" +) + +func redirectStderr(f *os.File) { + if f == nil { + // Silence altogether if no debug file + null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err == nil { + syscall.Dup2(int(null.Fd()), int(os.Stderr.Fd())) + } + return + } + + // Redirect fd 2 (stderr) to our debug file + // This captures C-level library noise (ALSA, PortAudio) into the log + syscall.Dup2(int(f.Fd()), int(os.Stderr.Fd())) +} diff --git a/cmd/tui/noise_other.go b/cmd/tui/noise_other.go new file mode 100644 index 0000000..165e3db --- /dev/null +++ b/cmd/tui/noise_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package main + +import ( + "os" +) + +func redirectStderr(f *os.File) { + // No-op on other platforms for now + // Windows doesn't have the ALSA noise problem +} diff --git a/go.mod b/go.mod index 7caf7ac..8fdf82a 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/go-ole/go-ole v1.2.6 + github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b github.com/gorilla/websocket v1.5.3 github.com/moutend/go-wca v0.3.0 gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 diff --git a/go.sum b/go.sum index 79b1e16..e90ab76 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b h1:WEuQWBxelOGHA6z9lABqaMLMrfwVyMdN3UgRLT+YUPo= +github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b/go.mod h1:esZFQEUwqC+l76f2R8bIWSwXMaPbp79PppwZ1eJhFco= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/pkg/audio/capture.go b/pkg/audio/capture.go index e43ada7..6f0572b 100644 --- a/pkg/audio/capture.go +++ b/pkg/audio/capture.go @@ -1,3 +1,5 @@ +//go:build windows + package audio import ( @@ -11,8 +13,6 @@ import ( "github.com/moutend/go-wca/pkg/wca" ) -const captureFrameSamples = 960 // 20ms at 48kHz - // Capturer handles WASAPI audio capture from microphone type Capturer struct { client *wca.IAudioClient diff --git a/pkg/audio/capture_linux.go b/pkg/audio/capture_linux.go new file mode 100644 index 0000000..a9bf92b --- /dev/null +++ b/pkg/audio/capture_linux.go @@ -0,0 +1,112 @@ +//go:build linux + +package audio + +import ( + "fmt" + "sync" + + "github.com/gordonklaus/portaudio" +) + +// Capturer handles audio capture using PortAudio +type Capturer struct { + stream *portaudio.Stream + running bool + mu sync.Mutex + onAudio func(samples []int16) + currentLevel int + levelMu sync.RWMutex +} + +func NewCapturer() (*Capturer, error) { + if err := initPortAudio(); err != nil { + return nil, err + } + + return &Capturer{}, nil +} + +func (c *Capturer) SetCallback(fn func(samples []int16)) { + c.mu.Lock() + c.onAudio = fn + c.mu.Unlock() +} + +func (c *Capturer) Start() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.running { + return nil + } + + var err error + c.stream, err = portaudio.OpenDefaultStream(1, 0, 48000, frameSamples, c.processCapture) + if err != nil { + return fmt.Errorf("failed to open portaudio capture stream: %w", err) + } + + if err := c.stream.Start(); err != nil { + c.stream.Close() + return fmt.Errorf("failed to start portaudio capture stream: %w", err) + } + + c.running = true + return nil +} + +func (c *Capturer) Stop() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running { + return + } + c.running = false + if c.stream != nil { + c.stream.Abort() + } +} + +func (c *Capturer) Close() { + c.Stop() + c.mu.Lock() + if c.stream != nil { + c.stream.Close() + } + c.mu.Unlock() + terminatePortAudio() +} + +func (c *Capturer) processCapture(in []int16) { + c.mu.Lock() + callback := c.onAudio + running := c.running + c.mu.Unlock() + + if !running || callback == nil { + return + } + + // Calculate level + level := CalculateRMSLevel(in) + c.levelMu.Lock() + c.currentLevel = level + c.levelMu.Unlock() + + // Clone buffer and send to callback + samples := make([]int16, len(in)) + copy(samples, in) + callback(samples) +} + +func (c *Capturer) GetLevel() int { + c.levelMu.RLock() + defer c.levelMu.RUnlock() + return c.currentLevel +} + +func (c *Capturer) IsRunning() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.running +} diff --git a/pkg/audio/common.go b/pkg/audio/common.go new file mode 100644 index 0000000..a1cf3f8 --- /dev/null +++ b/pkg/audio/common.go @@ -0,0 +1,13 @@ +package audio + +// Shared constants +const ( + frameSamples = 960 // 20ms at 48kHz + captureFrameSamples = 960 // 20ms at 48kHz +) + +// UserSettings represents per-user audio configuration +type UserSettings struct { + Volume float32 // 0.0 - 1.0 (or higher for boost) + Muted bool +} diff --git a/pkg/audio/global_linux.go b/pkg/audio/global_linux.go new file mode 100644 index 0000000..c90d01a --- /dev/null +++ b/pkg/audio/global_linux.go @@ -0,0 +1,45 @@ +//go:build linux + +package audio + +import ( + "fmt" + "os" + "sync" + + "github.com/gordonklaus/portaudio" +) + +var ( + paMu sync.Mutex + paRefCount int +) + +func initPortAudio() error { + paMu.Lock() + defer paMu.Unlock() + + if paRefCount == 0 { + if err := portaudio.Initialize(); err != nil { + return err + } + + devices, err := portaudio.Devices() + if err == nil { + fmt.Fprintf(os.Stderr, "[Audio] Linux/PortAudio initialized globally. Devices found: %d\n", len(devices)) + } + } + paRefCount++ + return nil +} + +func terminatePortAudio() { + paMu.Lock() + defer paMu.Unlock() + + paRefCount-- + if paRefCount == 0 { + fmt.Fprintf(os.Stderr, "[Audio] Linux/PortAudio terminating globally...\n") + portaudio.Terminate() + } +} diff --git a/pkg/audio/playback.go b/pkg/audio/playback.go index 5e9c47a..20afca7 100644 --- a/pkg/audio/playback.go +++ b/pkg/audio/playback.go @@ -1,8 +1,11 @@ +//go:build windows + package audio import ( "encoding/binary" "fmt" + "log" "sync" "time" "unsafe" @@ -32,19 +35,11 @@ type Player struct { bufferMu sync.Mutex } -type UserSettings struct { - Volume float32 // 0.0 - 1.0 (or higher for boost) - Muted bool -} - -const ( - frameSamples = 960 // 20ms at 48kHz -) - // NewPlayer creates a new WASAPI audio player func NewPlayer() (*Player, error) { // Initialize COM ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) + log.Printf("[Audio] Windows/WASAPI initializing...") var deviceEnumerator *wca.IMMDeviceEnumerator if err := wca.CoCreateInstance( @@ -255,7 +250,7 @@ func (p *Player) GetUserSettings(clientID uint16) (float32, bool) { } func (p *Player) playbackLoop() { - ticker := time.NewTicker(20 * time.Millisecond) + ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() for { @@ -269,79 +264,88 @@ func (p *Player) playbackLoop() { } func (p *Player) writeFrame() { - var padding uint32 - if err := p.client.GetCurrentPadding(&padding); err != nil { - return - } + for { + var padding uint32 + if err := p.client.GetCurrentPadding(&padding); err != nil { + return + } - available := p.bufferSize - padding - if available < frameSamples { - return - } + available := p.bufferSize - padding + if available < frameSamples { + return + } - p.bufferMu.Lock() + p.bufferMu.Lock() - // Mix audio from all active user buffers - mixed := make([]int32, frameSamples) - activeUsers := 0 + // Mix audio from all active user buffers + mixed := make([]int32, frameSamples) + activeUsers := 0 + hasAnyAudio := false - for id, buf := range p.userBuffers { - if len(buf) > 0 { - activeUsers++ - // Take up to frameSamples from this user - toTake := frameSamples - if len(buf) < frameSamples { - toTake = len(buf) - } - - for i := 0; i < toTake; i++ { - sample := int32(buf[i]) - - // Apply user volume if set - if settings, ok := p.userSettings[id]; ok { - sample = int32(float32(sample) * settings.Volume) + for id, buf := range p.userBuffers { + if len(buf) > 0 { + hasAnyAudio = true + activeUsers++ + // Take up to frameSamples from this user + toTake := frameSamples + if len(buf) < frameSamples { + toTake = len(buf) } - mixed[i] += sample - } + for i := 0; i < toTake; i++ { + sample := int32(buf[i]) - // Advance buffer - if len(buf) <= frameSamples { - delete(p.userBuffers, id) - } else { - p.userBuffers[id] = buf[frameSamples:] + // Apply user volume if set + if settings, ok := p.userSettings[id]; ok { + sample = int32(float32(sample) * settings.Volume) + } + + mixed[i] += sample + } + + // Advance buffer + if len(buf) <= frameSamples { + delete(p.userBuffers, id) + } else { + p.userBuffers[id] = buf[frameSamples:] + } } } - } - p.bufferMu.Unlock() + p.bufferMu.Unlock() - // Get WASAPI buffer - var buffer *byte - if err := p.renderClient.GetBuffer(uint32(frameSamples), &buffer); err != nil { - return - } - - p.mu.Lock() - vol := p.volume - p.mu.Unlock() - - // Write mixed samples with clipping protection and volume application - bufSlice := unsafe.Slice(buffer, int(frameSamples)*2) - for i := 0; i < int(frameSamples); i++ { - val := mixed[i] - - // Apply volume - val = int32(float32(val) * vol) - - // Hard clipping - if val > 32767 { - val = 32767 - } else if val < -32768 { - val = -32768 + // If no audio is playing, don't write anything (keep buffer empty for lower latency when audio starts) + if !hasAnyAudio { + return } - binary.LittleEndian.PutUint16(bufSlice[i*2:], uint16(val)) - } - p.renderClient.ReleaseBuffer(uint32(frameSamples), 0) + // Get WASAPI buffer + var buffer *byte + if err := p.renderClient.GetBuffer(uint32(frameSamples), &buffer); err != nil { + return + } + + p.mu.Lock() + vol := p.volume + p.mu.Unlock() + + // Write mixed samples with clipping protection and volume application + bufSlice := unsafe.Slice(buffer, int(frameSamples)*2) + for i := 0; i < int(frameSamples); i++ { + val := mixed[i] + + // Apply master volume + val = int32(float32(val) * vol) + + // Hard clipping + if val > 32767 { + val = 32767 + } else if val < -32768 { + val = -32768 + } + binary.LittleEndian.PutUint16(bufSlice[i*2:], uint16(val)) + } + + p.renderClient.ReleaseBuffer(uint32(frameSamples), 0) + } } diff --git a/pkg/audio/playback_linux.go b/pkg/audio/playback_linux.go new file mode 100644 index 0000000..fcf3a6c --- /dev/null +++ b/pkg/audio/playback_linux.go @@ -0,0 +1,201 @@ +//go:build linux + +package audio + +import ( + "fmt" + "sync" + + "github.com/gordonklaus/portaudio" +) + +// Player handles audio playback using PortAudio +type Player struct { + stream *portaudio.Stream + volume float32 + muted bool + mu sync.Mutex + running bool + stopChan chan struct{} + + // User buffers for mixing + userBuffers map[uint16][]int16 + // User settings + userSettings map[uint16]*UserSettings + bufferMu sync.Mutex +} + +func NewPlayer() (*Player, error) { + if err := initPortAudio(); err != nil { + return nil, err + } + + p := &Player{ + volume: 1.0, + muted: false, + stopChan: make(chan struct{}), + userBuffers: make(map[uint16][]int16), + userSettings: make(map[uint16]*UserSettings), + } + + return p, nil +} + +func (p *Player) Start() error { + p.mu.Lock() + if p.running { + p.mu.Unlock() + return nil + } + + // Create stream (Mono, 48kHz, 16-bit) + // We'll use a callback-based stream for lower latency + var err error + p.stream, err = portaudio.OpenDefaultStream(0, 1, 48000, frameSamples, p.processAudio) + if err != nil { + p.mu.Unlock() + return fmt.Errorf("failed to open portaudio stream: %w", err) + } + + if err := p.stream.Start(); err != nil { + p.stream.Close() + p.mu.Unlock() + return fmt.Errorf("failed to start portaudio stream: %w", err) + } + + p.running = true + p.mu.Unlock() + return nil +} + +func (p *Player) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + if !p.running { + return + } + p.running = false + if p.stream != nil { + p.stream.Abort() + } +} + +func (p *Player) Close() { + p.Stop() + p.mu.Lock() + if p.stream != nil { + p.stream.Close() + } + p.mu.Unlock() + terminatePortAudio() +} + +// processAudio is the PortAudio callback +func (p *Player) processAudio(out []int16) { + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + + // Initial silence + for i := range out { + out[i] = 0 + } + + if p.muted { + return + } + + p.mu.Lock() + vol := p.volume + p.mu.Unlock() + + mixed := make([]int32, len(out)) + + for id, buf := range p.userBuffers { + if len(buf) > 0 { + toTake := len(out) + if len(buf) < toTake { + toTake = len(buf) + } + + for i := 0; i < toTake; i++ { + sample := int32(buf[i]) + if settings, ok := p.userSettings[id]; ok { + sample = int32(float32(sample) * settings.Volume) + } + mixed[i] += sample + } + + // Advance buffer + if len(buf) <= len(out) { + delete(p.userBuffers, id) + } else { + p.userBuffers[id] = buf[len(out):] + } + } + } + + // Apply master volume and clip + for i := 0; i < len(out); i++ { + val := int32(float32(mixed[i]) * vol) + if val > 32767 { + val = 32767 + } else if val < -32768 { + val = -32768 + } + out[i] = int16(val) + } +} + +func (p *Player) PlayPCM(senderID uint16, samples []int16) { + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + + if settings, ok := p.userSettings[senderID]; ok && settings.Muted { + return + } + + p.userBuffers[senderID] = append(p.userBuffers[senderID], samples...) + if len(p.userBuffers[senderID]) > 48000*2 { + drop := len(p.userBuffers[senderID]) - 48000 + p.userBuffers[senderID] = p.userBuffers[senderID][drop:] + } +} + +func (p *Player) SetVolume(vol float32) { + p.mu.Lock() + defer p.mu.Unlock() + p.volume = vol +} + +func (p *Player) SetMuted(muted bool) { + p.mu.Lock() + defer p.mu.Unlock() + p.muted = muted +} + +func (p *Player) SetUserVolume(clientID uint16, vol float32) { + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + if _, ok := p.userSettings[clientID]; !ok { + p.userSettings[clientID] = &UserSettings{Volume: 1.0, Muted: false} + } + p.userSettings[clientID].Volume = vol +} + +func (p *Player) SetUserMuted(clientID uint16, muted bool) { + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + if _, ok := p.userSettings[clientID]; !ok { + p.userSettings[clientID] = &UserSettings{Volume: 1.0, Muted: false} + } + p.userSettings[clientID].Muted = muted +} + +func (p *Player) GetUserSettings(clientID uint16) (float32, bool) { + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + if settings, ok := p.userSettings[clientID]; ok { + return settings.Volume, settings.Muted + } + return 1.0, false +} diff --git a/run.ps1 b/run.ps1 index b5ef970..5f3d101 100644 --- a/run.ps1 +++ b/run.ps1 @@ -3,4 +3,5 @@ $env:PKG_CONFIG_PATH = "D:\esto_al_path\msys64\mingw64\lib\pkgconfig" Write-Host "Starting TeamSpeak Client (Windows Native)..." -ForegroundColor Cyan # go run ./cmd/client/main.go --server localhost:9987 -go run ./cmd/example --server localhost:9987 +# go run ./cmd/example --server localhost:9987 +go run ./cmd/tui --server ts.vlazaro.es:9987 --nickname Adam --debug