Fix audio quality with per-user mixing buffer and prevent TUI layout break on log overflow
This commit is contained in:
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/moutend/go-wca/pkg/wca"
|
||||
)
|
||||
|
||||
// Player handles WASAPI audio playback
|
||||
// Player handles WASAPI audio playback with mixing support
|
||||
type Player struct {
|
||||
client *wca.IAudioClient
|
||||
renderClient *wca.IAudioRenderClient
|
||||
@@ -23,24 +23,21 @@ type Player struct {
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
|
||||
// Audio buffer - accumulates incoming audio
|
||||
audioBuffer []int16
|
||||
// User buffers for mixing
|
||||
// map[SenderID] -> AudioQueue
|
||||
userBuffers map[uint16][]int16
|
||||
bufferMu sync.Mutex
|
||||
|
||||
// Frame queue (960 samples = 20ms at 48kHz)
|
||||
frameQueue chan []int16
|
||||
}
|
||||
|
||||
const frameSamples = 960 // 20ms at 48kHz
|
||||
const (
|
||||
frameSamples = 960 // 20ms at 48kHz
|
||||
)
|
||||
|
||||
// NewPlayer creates a new WASAPI audio player
|
||||
func NewPlayer() (*Player, error) {
|
||||
// Initialize COM using go-ole
|
||||
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
|
||||
// Ignore if already initialized
|
||||
}
|
||||
// Initialize COM
|
||||
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED)
|
||||
|
||||
// Get default audio endpoint
|
||||
var deviceEnumerator *wca.IMMDeviceEnumerator
|
||||
if err := wca.CoCreateInstance(
|
||||
wca.CLSID_MMDeviceEnumerator,
|
||||
@@ -59,13 +56,11 @@ func NewPlayer() (*Player, error) {
|
||||
}
|
||||
defer device.Release()
|
||||
|
||||
// Activate audio client
|
||||
var audioClient *wca.IAudioClient
|
||||
if err := device.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &audioClient); err != nil {
|
||||
return nil, fmt.Errorf("failed to activate audio client: %w", err)
|
||||
}
|
||||
|
||||
// Set up format for 48kHz mono 16-bit (TeamSpeak format)
|
||||
waveFormat := &wca.WAVEFORMATEX{
|
||||
WFormatTag: wca.WAVE_FORMAT_PCM,
|
||||
NChannels: 1,
|
||||
@@ -76,8 +71,7 @@ func NewPlayer() (*Player, error) {
|
||||
CbSize: 0,
|
||||
}
|
||||
|
||||
// Initialize in shared mode - 100ms buffer
|
||||
duration := wca.REFERENCE_TIME(100 * 10000) // 100ms in 100-nanosecond units
|
||||
duration := wca.REFERENCE_TIME(100 * 10000) // 100ms buffer
|
||||
if err := audioClient.Initialize(
|
||||
wca.AUDCLNT_SHAREMODE_SHARED,
|
||||
wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM|wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
||||
@@ -90,14 +84,12 @@ func NewPlayer() (*Player, error) {
|
||||
return nil, fmt.Errorf("failed to initialize audio client: %w", err)
|
||||
}
|
||||
|
||||
// Get buffer size
|
||||
var bufferSize uint32
|
||||
if err := audioClient.GetBufferSize(&bufferSize); err != nil {
|
||||
audioClient.Release()
|
||||
return nil, fmt.Errorf("failed to get buffer size: %w", err)
|
||||
}
|
||||
|
||||
// Get render client
|
||||
var renderClient *wca.IAudioRenderClient
|
||||
if err := audioClient.GetService(wca.IID_IAudioRenderClient, &renderClient); err != nil {
|
||||
audioClient.Release()
|
||||
@@ -112,8 +104,7 @@ func NewPlayer() (*Player, error) {
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
stopChan: make(chan struct{}),
|
||||
audioBuffer: make([]int16, 0, frameSamples*50), // ~1 second buffer
|
||||
frameQueue: make(chan []int16, 100), // ~2 seconds of frames
|
||||
userBuffers: make(map[uint16][]int16),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -125,15 +116,14 @@ func (p *Player) Start() error {
|
||||
return nil
|
||||
}
|
||||
p.running = true
|
||||
p.stopChan = make(chan struct{})
|
||||
p.mu.Unlock()
|
||||
|
||||
if err := p.client.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start audio client: %w", err)
|
||||
}
|
||||
|
||||
// Playback loop writes frames from queue to WASAPI
|
||||
go p.playbackLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,43 +153,25 @@ func (p *Player) Close() {
|
||||
ole.CoUninitialize()
|
||||
}
|
||||
|
||||
// PlayPCM queues PCM audio for playback
|
||||
// Accumulates samples and queues complete 960-sample frames
|
||||
func (p *Player) PlayPCM(samples []int16) {
|
||||
// PlayPCM adds audio samples to a specific user's buffer
|
||||
func (p *Player) PlayPCM(senderID uint16, samples []int16) {
|
||||
if p.muted {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply volume
|
||||
adjusted := samples
|
||||
if p.volume != 1.0 {
|
||||
adjusted = make([]int16, len(samples))
|
||||
for i, s := range samples {
|
||||
adjusted[i] = int16(float32(s) * p.volume)
|
||||
}
|
||||
}
|
||||
|
||||
p.bufferMu.Lock()
|
||||
p.audioBuffer = append(p.audioBuffer, adjusted...)
|
||||
defer p.bufferMu.Unlock()
|
||||
|
||||
// Queue complete 960-sample frames
|
||||
for len(p.audioBuffer) >= frameSamples {
|
||||
frame := make([]int16, frameSamples)
|
||||
copy(frame, p.audioBuffer[:frameSamples])
|
||||
p.audioBuffer = p.audioBuffer[frameSamples:]
|
||||
// Append to user's specific buffer
|
||||
// This ensures sequential playback for the same user
|
||||
p.userBuffers[senderID] = append(p.userBuffers[senderID], samples...)
|
||||
|
||||
select {
|
||||
case p.frameQueue <- frame:
|
||||
default:
|
||||
// Queue full, drop oldest frame
|
||||
select {
|
||||
case <-p.frameQueue:
|
||||
default:
|
||||
}
|
||||
p.frameQueue <- frame
|
||||
}
|
||||
// Limit buffer size per user to avoid memory leaks if stalled
|
||||
if len(p.userBuffers[senderID]) > 48000*2 { // 2 seconds max
|
||||
// Drop oldest
|
||||
drop := len(p.userBuffers[senderID]) - 48000
|
||||
p.userBuffers[senderID] = p.userBuffers[senderID][drop:]
|
||||
}
|
||||
p.bufferMu.Unlock()
|
||||
}
|
||||
|
||||
// SetVolume sets playback volume (0.0 to 1.0)
|
||||
@@ -237,7 +209,6 @@ func (p *Player) IsMuted() bool {
|
||||
}
|
||||
|
||||
func (p *Player) playbackLoop() {
|
||||
// Use 20ms ticker matching TeamSpeak frame rate
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -252,7 +223,6 @@ func (p *Player) playbackLoop() {
|
||||
}
|
||||
|
||||
func (p *Player) writeFrame() {
|
||||
// Get current padding (samples already in buffer)
|
||||
var padding uint32
|
||||
if err := p.client.GetCurrentPadding(&padding); err != nil {
|
||||
return
|
||||
@@ -260,27 +230,65 @@ func (p *Player) writeFrame() {
|
||||
|
||||
available := p.bufferSize - padding
|
||||
if available < frameSamples {
|
||||
return // Not enough space for a full frame
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get a frame from the queue
|
||||
select {
|
||||
case frame := <-p.frameQueue:
|
||||
var buffer *byte
|
||||
if err := p.renderClient.GetBuffer(frameSamples, &buffer); err != nil {
|
||||
return
|
||||
p.bufferMu.Lock()
|
||||
|
||||
// Mix audio from all active user buffers
|
||||
mixed := make([]int32, frameSamples)
|
||||
activeUsers := 0
|
||||
|
||||
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++ {
|
||||
mixed[i] += int32(buf[i])
|
||||
}
|
||||
|
||||
// Advance buffer
|
||||
if len(buf) <= frameSamples {
|
||||
delete(p.userBuffers, id)
|
||||
} else {
|
||||
p.userBuffers[id] = buf[frameSamples:]
|
||||
}
|
||||
}
|
||||
|
||||
// Write frame to WASAPI buffer
|
||||
bufSlice := unsafe.Slice(buffer, frameSamples*2)
|
||||
for i := 0; i < frameSamples; i++ {
|
||||
binary.LittleEndian.PutUint16(bufSlice[i*2:], uint16(frame[i]))
|
||||
}
|
||||
|
||||
p.renderClient.ReleaseBuffer(frameSamples, 0)
|
||||
|
||||
default:
|
||||
// No audio available - optionally write silence
|
||||
// (skip for now to avoid crackling)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
binary.LittleEndian.PutUint16(bufSlice[i*2:], uint16(val))
|
||||
}
|
||||
|
||||
p.renderClient.ReleaseBuffer(uint32(frameSamples), 0)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user