feat: full WSL2 audio support and Windows audio stability fix

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-17 01:38:13 +01:00
parent c1bb24473e
commit c81f64d9ca
12 changed files with 492 additions and 75 deletions

View File

@@ -56,6 +56,9 @@ func main() {
} }
} }
// Silence noise (ALSA/PortAudio) on Linux or if debugFile is set
redirectStderr(debugFile)
// Create the TUI model // Create the TUI model
m := NewModel(*serverAddr, *nickname) m := NewModel(*serverAddr, *nickname)

23
cmd/tui/noise_linux.go Normal file
View File

@@ -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()))
}

12
cmd/tui/noise_other.go Normal file
View File

@@ -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
}

1
go.mod
View File

@@ -13,6 +13,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/go-ole/go-ole v1.2.6 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/gorilla/websocket v1.5.3
github.com/moutend/go-wca v0.3.0 github.com/moutend/go-wca v0.3.0
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302

2
go.sum
View File

@@ -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/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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

View File

@@ -1,3 +1,5 @@
//go:build windows
package audio package audio
import ( import (
@@ -11,8 +13,6 @@ import (
"github.com/moutend/go-wca/pkg/wca" "github.com/moutend/go-wca/pkg/wca"
) )
const captureFrameSamples = 960 // 20ms at 48kHz
// Capturer handles WASAPI audio capture from microphone // Capturer handles WASAPI audio capture from microphone
type Capturer struct { type Capturer struct {
client *wca.IAudioClient client *wca.IAudioClient

112
pkg/audio/capture_linux.go Normal file
View File

@@ -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
}

13
pkg/audio/common.go Normal file
View File

@@ -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
}

45
pkg/audio/global_linux.go Normal file
View File

@@ -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()
}
}

View File

@@ -1,8 +1,11 @@
//go:build windows
package audio package audio
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"log"
"sync" "sync"
"time" "time"
"unsafe" "unsafe"
@@ -32,19 +35,11 @@ type Player struct {
bufferMu sync.Mutex 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 // NewPlayer creates a new WASAPI audio player
func NewPlayer() (*Player, error) { func NewPlayer() (*Player, error) {
// Initialize COM // Initialize COM
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED)
log.Printf("[Audio] Windows/WASAPI initializing...")
var deviceEnumerator *wca.IMMDeviceEnumerator var deviceEnumerator *wca.IMMDeviceEnumerator
if err := wca.CoCreateInstance( if err := wca.CoCreateInstance(
@@ -255,7 +250,7 @@ func (p *Player) GetUserSettings(clientID uint16) (float32, bool) {
} }
func (p *Player) playbackLoop() { func (p *Player) playbackLoop() {
ticker := time.NewTicker(20 * time.Millisecond) ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
for { for {
@@ -269,6 +264,7 @@ func (p *Player) playbackLoop() {
} }
func (p *Player) writeFrame() { func (p *Player) writeFrame() {
for {
var padding uint32 var padding uint32
if err := p.client.GetCurrentPadding(&padding); err != nil { if err := p.client.GetCurrentPadding(&padding); err != nil {
return return
@@ -284,9 +280,11 @@ func (p *Player) writeFrame() {
// Mix audio from all active user buffers // Mix audio from all active user buffers
mixed := make([]int32, frameSamples) mixed := make([]int32, frameSamples)
activeUsers := 0 activeUsers := 0
hasAnyAudio := false
for id, buf := range p.userBuffers { for id, buf := range p.userBuffers {
if len(buf) > 0 { if len(buf) > 0 {
hasAnyAudio = true
activeUsers++ activeUsers++
// Take up to frameSamples from this user // Take up to frameSamples from this user
toTake := frameSamples toTake := frameSamples
@@ -316,6 +314,11 @@ func (p *Player) writeFrame() {
p.bufferMu.Unlock() p.bufferMu.Unlock()
// If no audio is playing, don't write anything (keep buffer empty for lower latency when audio starts)
if !hasAnyAudio {
return
}
// Get WASAPI buffer // Get WASAPI buffer
var buffer *byte var buffer *byte
if err := p.renderClient.GetBuffer(uint32(frameSamples), &buffer); err != nil { if err := p.renderClient.GetBuffer(uint32(frameSamples), &buffer); err != nil {
@@ -331,7 +334,7 @@ func (p *Player) writeFrame() {
for i := 0; i < int(frameSamples); i++ { for i := 0; i < int(frameSamples); i++ {
val := mixed[i] val := mixed[i]
// Apply volume // Apply master volume
val = int32(float32(val) * vol) val = int32(float32(val) * vol)
// Hard clipping // Hard clipping
@@ -344,4 +347,5 @@ func (p *Player) writeFrame() {
} }
p.renderClient.ReleaseBuffer(uint32(frameSamples), 0) p.renderClient.ReleaseBuffer(uint32(frameSamples), 0)
}
} }

201
pkg/audio/playback_linux.go Normal file
View File

@@ -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
}

View File

@@ -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 Write-Host "Starting TeamSpeak Client (Windows Native)..." -ForegroundColor Cyan
# go run ./cmd/client/main.go --server localhost:9987 # 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