From 9675f3764c1c73f81a23a70bcc60d39027cdc5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Fri, 16 Jan 2026 22:33:35 +0100 Subject: [PATCH] Fix audio quality with per-user mixing buffer and prevent TUI layout break on log overflow --- cmd/tui/main.go | 10 ++- cmd/tui/model.go | 140 ++++++++++++++++++++------------ internal/client/handshake.go | 4 +- pkg/audio/playback.go | 152 ++++++++++++++++++----------------- 4 files changed, 177 insertions(+), 129 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 07d08d5..78fc9af 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -27,7 +27,7 @@ func main() { debug := flag.Bool("debug", true, "Enable debug logging to file (default true)") 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) // Enable debug file logging if requested @@ -39,6 +39,8 @@ func main() { if err == nil { defer debugFile.Close() 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 m.SetProgram(p) - // Set up log capture + // Set up log capture for UI display r, w, _ := os.Pipe() if debugFile != nil { + // Log to both UI (pipe) and Debug File log.SetOutput(io.MultiWriter(w, debugFile)) } else { + // Log to UI only log.SetOutput(w) } // Make sure logs have timestamp removed (TUI adds it if needed, or we keep it) log.SetFlags(log.Ltime) // Just time + // Goroutine to capture logs and send them to the UI go func() { buf := make([]byte, 1024) for { @@ -71,7 +76,6 @@ func main() { if n > 0 { lines := string(buf[:n]) // Split by newline and send each line - // Simple split, might need better buffering for partial lines but OK for debug p.Send(logMsg(lines)) } } diff --git a/cmd/tui/model.go b/cmd/tui/model.go index e96e5a6..60cc1e3 100644 --- a/cmd/tui/model.go +++ b/cmd/tui/model.go @@ -66,7 +66,6 @@ type Model struct { selectedIdx int chatMessages []ChatMessage logMessages []string // Debug logs shown in chat panel - logFullscreen bool // Toggle fullscreen log view inputText string inputActive bool @@ -86,6 +85,7 @@ type Model struct { // Program reference for sending messages from event handlers program *tea.Program + showLog bool } // 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 m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { 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 } + + case "l", "L": + // Toggle Log/Chat view + if m.focus != FocusInput { + m.showLog = !m.showLog + return m, nil + } } // 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) { - switch msg.String() { - case "f": - m.logFullscreen = !m.logFullscreen - } return m, nil } @@ -524,18 +527,6 @@ func (m *Model) View() string { 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 // 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) @@ -571,12 +562,60 @@ func (m *Model) View() string { } channelPanel := channelPanelStyle.Render(channelContent) - // Chat panel - chatContent := m.renderChat() - if m.focus == FocusChat { - chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212")) + // Right panel (Chat or Log) + var rightPanel string + + 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 inputContent := "> " + m.inputText @@ -587,10 +626,14 @@ func (m *Model) View() string { input := inputStyle.Render(inputContent) // 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 - panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel) + panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, rightPanel) return lipgloss.JoinVertical(lipgloss.Left, header, @@ -721,43 +764,36 @@ func (m *Model) renderChannels() string { func (m *Model) renderChat() string { var lines []string - lines = append(lines, lipgloss.NewStyle().Bold(true).Render("LOG")) - lines = append(lines, "") - if len(m.logMessages) == 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No logs yet...")) + if len(m.chatMessages) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No chat messages yet...")) } else { - // Limit to last N messages that fit in the panel - maxLines := m.height - 10 - if maxLines < 5 { - maxLines = 5 + // Limit messages to fit panel + // Panel height is roughly m.height - 7 + maxLines := m.height - 9 + if maxLines < 1 { + maxLines = 1 } start := 0 - if len(m.logMessages) > maxLines { - start = len(m.logMessages) - maxLines + if len(m.chatMessages) > maxLines { + start = len(m.chatMessages) - maxLines } - panelWidth := (m.width / 2) - 4 - if m.logFullscreen { - panelWidth = m.width - 6 - } - if panelWidth < 10 { - panelWidth = 10 - } + for _, msg := range m.chatMessages[start:] { + prefix := msg.Time.Format("15:04") + " " + msg.Sender + ": " + content := msg.Content - for _, msg := range m.logMessages[start:] { - // Truncate if too long to prevent wrapping breaking the layout - displayMsg := msg - 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)) + // Simple wrapping logic could go here, but for now simple truncation/display + line := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(prefix) + content + lines = append(lines, line) } } + // 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...) } diff --git a/internal/client/handshake.go b/internal/client/handshake.go index 6a295c9..4ed5045 100644 --- a/internal/client/handshake.go +++ b/internal/client/handshake.go @@ -458,7 +458,7 @@ func (h *HandshakeState) ImproveSecurityLevel(targetLevel int) { // Start from current offset (usually 0) counter := h.IdentityOffset - fmt.Printf("Mining Identity Level %d... ", targetLevel) + log.Printf("Mining Identity Level %d... ", targetLevel) for { // Construct data: Omega + Counter (ASCII) @@ -473,7 +473,7 @@ func (h *HandshakeState) ImproveSecurityLevel(targetLevel int) { if zeros >= targetLevel { h.IdentityLevel = zeros h.IdentityOffset = counter - fmt.Printf("Found! Offset=%d, Level=%d\n", counter, zeros) + log.Printf("Found! Offset=%d, Level=%d\n", counter, zeros) return } diff --git a/pkg/audio/playback.go b/pkg/audio/playback.go index 6072f8c..9d4dc0d 100644 --- a/pkg/audio/playback.go +++ b/pkg/audio/playback.go @@ -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) }