Fix audio quality with per-user mixing buffer and prevent TUI layout break on log overflow

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-16 22:33:35 +01:00
parent f83f525600
commit 9675f3764c
4 changed files with 177 additions and 129 deletions

View File

@@ -27,7 +27,7 @@ func main() {
debug := flag.Bool("debug", true, "Enable debug logging to file (default true)") debug := flag.Bool("debug", true, "Enable debug logging to file (default true)")
flag.Parse() flag.Parse()
// Disable log output completely to prevent TUI corruption // Disable log output completely to prevent TUI corruption (stdout is reserved for UI)
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
// Enable debug file logging if requested // Enable debug file logging if requested
@@ -39,6 +39,8 @@ func main() {
if err == nil { if err == nil {
defer debugFile.Close() defer debugFile.Close()
debugLog("TUI Debug started at %s", timestamp) debugLog("TUI Debug started at %s", timestamp)
// Redirect standard log output to debug file initially
log.SetOutput(debugFile)
} }
} }
@@ -51,16 +53,19 @@ func main() {
// Pass program reference to model for event handlers // Pass program reference to model for event handlers
m.SetProgram(p) m.SetProgram(p)
// Set up log capture // Set up log capture for UI display
r, w, _ := os.Pipe() r, w, _ := os.Pipe()
if debugFile != nil { if debugFile != nil {
// Log to both UI (pipe) and Debug File
log.SetOutput(io.MultiWriter(w, debugFile)) log.SetOutput(io.MultiWriter(w, debugFile))
} else { } else {
// Log to UI only
log.SetOutput(w) log.SetOutput(w)
} }
// Make sure logs have timestamp removed (TUI adds it if needed, or we keep it) // Make sure logs have timestamp removed (TUI adds it if needed, or we keep it)
log.SetFlags(log.Ltime) // Just time log.SetFlags(log.Ltime) // Just time
// Goroutine to capture logs and send them to the UI
go func() { go func() {
buf := make([]byte, 1024) buf := make([]byte, 1024)
for { for {
@@ -71,7 +76,6 @@ func main() {
if n > 0 { if n > 0 {
lines := string(buf[:n]) lines := string(buf[:n])
// Split by newline and send each line // Split by newline and send each line
// Simple split, might need better buffering for partial lines but OK for debug
p.Send(logMsg(lines)) p.Send(logMsg(lines))
} }
} }

View File

@@ -66,7 +66,6 @@ type Model struct {
selectedIdx int selectedIdx int
chatMessages []ChatMessage chatMessages []ChatMessage
logMessages []string // Debug logs shown in chat panel logMessages []string // Debug logs shown in chat panel
logFullscreen bool // Toggle fullscreen log view
inputText string inputText string
inputActive bool inputActive bool
@@ -86,6 +85,7 @@ type Model struct {
// Program reference for sending messages from event handlers // Program reference for sending messages from event handlers
program *tea.Program program *tea.Program
showLog bool
} }
// addLog adds a message to the log panel // addLog adds a message to the log panel
@@ -213,7 +213,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle incoming audio - play through speakers // Handle incoming audio - play through speakers
m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) {
if m.audioPlayer != nil { if m.audioPlayer != nil {
m.audioPlayer.PlayPCM(e.PCM) m.audioPlayer.PlayPCM(e.SenderID, e.PCM)
} }
}) })
} }
@@ -449,6 +449,13 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
case "l", "L":
// Toggle Log/Chat view
if m.focus != FocusInput {
m.showLog = !m.showLog
return m, nil
}
} }
// Focus-specific keys // Focus-specific keys
@@ -464,10 +471,6 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m *Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "f":
m.logFullscreen = !m.logFullscreen
}
return m, nil return m, nil
} }
@@ -524,18 +527,6 @@ func (m *Model) View() string {
return "Loading..." return "Loading..."
} }
// Fullscreen Log Mode
if m.logFullscreen {
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("212")). // Active color
Padding(0, 1).
Width(m.width - 2).
Height(m.height - 2)
return style.Render(m.renderChat())
}
// Styles // Styles
// Layout: header(1) + panels + input(3) + help(1) = header + panels + 4 // Layout: header(1) + panels + input(3) + help(1) = header + panels + 4
// panels should be height - 5 (1 for header, 3 for input with border, 1 for help) // panels should be height - 5 (1 for header, 3 for input with border, 1 for help)
@@ -571,12 +562,60 @@ func (m *Model) View() string {
} }
channelPanel := channelPanelStyle.Render(channelContent) channelPanel := channelPanelStyle.Render(channelContent)
// Chat panel // Right panel (Chat or Log)
chatContent := m.renderChat() var rightPanel string
if m.focus == FocusChat {
chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212")) if m.showLog {
// Log View
// Use chatPanelStyle default but add focus logic
logStyle := chatPanelStyle.Copy()
if m.focus == FocusChat {
logStyle = logStyle.BorderForeground(lipgloss.Color("212"))
}
// Limit to last N messages log to fit panel
// Panel height is m.height - 7, padding reduces it more
maxLines := m.height - 11
if maxLines < 1 {
maxLines = 1
}
start := 0
if len(m.logMessages) > maxLines {
start = len(m.logMessages) - maxLines
}
// Calculate available width for text
textWidth := (m.width * 2 / 3) - 6
if textWidth < 10 {
textWidth = 10
}
textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
var lines []string
for _, msg := range m.logMessages[start:] {
// Sanitize: remove newlines/returns to prevent double spacing
msg = strings.ReplaceAll(msg, "\n", " ")
msg = strings.ReplaceAll(msg, "\r", "")
// Truncate simplisticly to prevent wrapping issues if too long
if len(msg) > textWidth {
msg = msg[:textWidth-3] + "..."
}
lines = append(lines, textStyle.Render(msg))
}
rightPanel = logStyle.Render(strings.Join(lines, "\n"))
} else {
// Chat View
chatContent := m.renderChat()
if m.focus == FocusChat {
chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212"))
}
rightPanel = chatPanelStyle.Render(chatContent)
} }
chatPanel := chatPanelStyle.Render(chatContent)
// Input // Input
inputContent := "> " + m.inputText inputContent := "> " + m.inputText
@@ -587,10 +626,14 @@ func (m *Model) View() string {
input := inputStyle.Render(inputContent) input := inputStyle.Render(inputContent)
// Footer help // Footer help
help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ V talk │ M mute │ +/- vol │ q quit") logHelp := "L log"
if m.showLog {
logHelp = "L chat"
}
help := lipgloss.NewStyle().Faint(true).Render(fmt.Sprintf("↑↓ navigate │ Enter join │ Tab switch │ %s │ V talk │ M mute │ +/- vol │ q quit", logHelp))
// Combine panels // Combine panels
panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel) panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, rightPanel)
return lipgloss.JoinVertical(lipgloss.Left, return lipgloss.JoinVertical(lipgloss.Left,
header, header,
@@ -721,43 +764,36 @@ func (m *Model) renderChannels() string {
func (m *Model) renderChat() string { func (m *Model) renderChat() string {
var lines []string var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("LOG"))
lines = append(lines, "")
if len(m.logMessages) == 0 { if len(m.chatMessages) == 0 {
lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No logs yet...")) lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No chat messages yet..."))
} else { } else {
// Limit to last N messages that fit in the panel // Limit messages to fit panel
maxLines := m.height - 10 // Panel height is roughly m.height - 7
if maxLines < 5 { maxLines := m.height - 9
maxLines = 5 if maxLines < 1 {
maxLines = 1
} }
start := 0 start := 0
if len(m.logMessages) > maxLines { if len(m.chatMessages) > maxLines {
start = len(m.logMessages) - maxLines start = len(m.chatMessages) - maxLines
} }
panelWidth := (m.width / 2) - 4 for _, msg := range m.chatMessages[start:] {
if m.logFullscreen { prefix := msg.Time.Format("15:04") + " " + msg.Sender + ": "
panelWidth = m.width - 6 content := msg.Content
}
if panelWidth < 10 {
panelWidth = 10
}
for _, msg := range m.logMessages[start:] { // Simple wrapping logic could go here, but for now simple truncation/display
// Truncate if too long to prevent wrapping breaking the layout line := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(prefix) + content
displayMsg := msg lines = append(lines, line)
if len(displayMsg) > panelWidth {
displayMsg = displayMsg[:panelWidth-3] + "..."
}
// Replace newlines just in case
displayMsg = strings.ReplaceAll(displayMsg, "\n", " ")
lines = append(lines, lipgloss.NewStyle().Faint(true).Render(displayMsg))
} }
} }
// Fill remaining lines with empty space to maintain stable layout
for len(lines) < m.height-9 {
lines = append(lines, "")
}
return lipgloss.JoinVertical(lipgloss.Left, lines...) return lipgloss.JoinVertical(lipgloss.Left, lines...)
} }

View File

@@ -458,7 +458,7 @@ func (h *HandshakeState) ImproveSecurityLevel(targetLevel int) {
// Start from current offset (usually 0) // Start from current offset (usually 0)
counter := h.IdentityOffset counter := h.IdentityOffset
fmt.Printf("Mining Identity Level %d... ", targetLevel) log.Printf("Mining Identity Level %d... ", targetLevel)
for { for {
// Construct data: Omega + Counter (ASCII) // Construct data: Omega + Counter (ASCII)
@@ -473,7 +473,7 @@ func (h *HandshakeState) ImproveSecurityLevel(targetLevel int) {
if zeros >= targetLevel { if zeros >= targetLevel {
h.IdentityLevel = zeros h.IdentityLevel = zeros
h.IdentityOffset = counter h.IdentityOffset = counter
fmt.Printf("Found! Offset=%d, Level=%d\n", counter, zeros) log.Printf("Found! Offset=%d, Level=%d\n", counter, zeros)
return return
} }

View File

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