Fix audio quality with per-user mixing buffer and prevent TUI layout break on log overflow
This commit is contained in:
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
cmd/tui/model.go
140
cmd/tui/model.go
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user