diff --git a/cmd/tui/model.go b/cmd/tui/model.go index 5c908d8..e96e5a6 100644 --- a/cmd/tui/model.go +++ b/cmd/tui/model.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "go-ts/pkg/audio" "go-ts/pkg/ts3client" tea "github.com/charmbracelet/bubbletea" @@ -75,6 +76,14 @@ type Model struct { // Voice activity talkingClients map[uint16]bool // ClientID -> isTalking + // Audio state + audioPlayer *audio.Player + audioCapturer *audio.Capturer + playbackVol int // 0-100 + micLevel int // 0-100 (current input level) + isMuted bool // Mic muted + isPTT bool // Push-to-talk active + // Program reference for sending messages from event handlers program *tea.Program } @@ -99,6 +108,7 @@ func NewModel(serverAddr, nickname string) *Model { chatMessages: []ChatMessage{}, logMessages: []string{"Starting..."}, talkingClients: make(map[uint16]bool), + playbackVol: 80, // Default 80% volume } } @@ -172,6 +182,8 @@ type errorMsg struct { err string } +type micLevelMsg int // Mic level 0-100 + type tickMsg time.Time // Update handles messages and user input @@ -197,6 +209,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { talking: e.Talking, }) }) + + // Handle incoming audio - play through speakers + m.client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { + if m.audioPlayer != nil { + m.audioPlayer.PlayPCM(e.PCM) + } + }) + } + + // Initialize audio player + player, err := audio.NewPlayer() + if err != nil { + m.addLog("Audio player init failed: %v", err) + } else { + m.audioPlayer = player + m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0) + m.audioPlayer.Start() + m.addLog("Audio player initialized") + } + + // Initialize audio capturer + capturer, err := audio.NewCapturer() + if err != nil { + m.addLog("Audio capturer init failed: %v", err) + } else { + m.audioCapturer = capturer + // Set callback to send audio to server when PTT is active + m.audioCapturer.SetCallback(func(samples []int16) { + if m.isPTT && m.client != nil && !m.isMuted { + m.client.SendAudio(samples) + } + // Update mic level for display + if m.program != nil { + m.program.Send(micLevelMsg(m.audioCapturer.GetLevel())) + } + }) + m.addLog("Audio capturer initialized") } // Connect asynchronously @@ -236,8 +285,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Continue ticking - return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + // Update mic level when PTT is active (multiply for better visibility) + if m.isPTT && m.audioCapturer != nil { + level := m.audioCapturer.GetLevel() * 4 // Boost for visibility + if level > 100 { + level = 100 + } + m.micLevel = level + } else { + m.micLevel = 0 // Reset when not transmitting + } + + // Continue ticking (100ms for responsive mic level) + return m, tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) @@ -265,6 +325,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case micLevelMsg: + // Update microphone level for display + m.micLevel = int(msg) + return m, nil + case logMsg: // External log message m.addLog("%s", string(msg)) @@ -315,12 +380,19 @@ func (m *Model) updateChannelList(channels []*ts3client.Channel) { } func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Global keys + // Global keys (work regardless of focus) switch msg.String() { case "ctrl+c", "q": if m.client != nil { m.client.Disconnect() } + // Cleanup audio + if m.audioPlayer != nil { + m.audioPlayer.Close() + } + if m.audioCapturer != nil { + m.audioCapturer.Close() + } return m, tea.Quit case "tab": @@ -328,9 +400,55 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focus = (m.focus + 1) % 3 return m, nil - case "m": - // Toggle mute (would need to implement) + case "m", "M": + // Toggle mute + m.isMuted = !m.isMuted + if m.audioPlayer != nil { + m.audioPlayer.SetMuted(m.isMuted) + } return m, nil + + case "+", "=": + // Increase volume + m.playbackVol += 10 + if m.playbackVol > 100 { + m.playbackVol = 100 + } + if m.audioPlayer != nil { + m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0) + } + return m, nil + + case "-", "_": + // Decrease volume + m.playbackVol -= 10 + if m.playbackVol < 0 { + m.playbackVol = 0 + } + if m.audioPlayer != nil { + m.audioPlayer.SetVolume(float32(m.playbackVol) / 100.0) + } + return m, nil + + case "v", "V": + // Toggle voice (PTT) - V to start/stop transmitting + if m.focus != FocusInput { + m.isPTT = !m.isPTT + if m.isPTT { + // Start capturing when PTT enabled + if m.audioCapturer != nil { + m.audioCapturer.Start() + } + m.addLog("🎤 Transmitting...") + } else { + // Stop capturing when PTT disabled + if m.audioCapturer != nil { + m.audioCapturer.Stop() + } + m.addLog("🎤 Stopped transmitting") + } + return m, nil + } } // Focus-specific keys @@ -419,26 +537,23 @@ func (m *Model) View() string { } // Styles - headerStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Padding(0, 1). - Width(m.width) + // 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) + panelHeight := m.height - 7 channelPanelStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(0, 1). Width(m.width/3 - 2). - Height(m.height - 6) + Height(panelHeight) chatPanelStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(0, 1). Width(m.width*2/3 - 2). - Height(m.height - 6) + Height(panelHeight) inputStyle := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). @@ -446,12 +561,8 @@ func (m *Model) View() string { Padding(0, 1). Width(m.width - 4) - // Header - status := "Connecting..." - if m.connected { - status = fmt.Sprintf("Server: %s │ You: %s (ID: %d)", m.serverName, m.nickname, m.selfID) - } - header := headerStyle.Render(status) + // Header with status bar + header := m.renderStatusBar() // Channel panel channelContent := m.renderChannels() @@ -476,7 +587,7 @@ func (m *Model) View() string { input := inputStyle.Render(inputContent) // Footer help - help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ q quit") + help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ V talk │ M mute │ +/- vol │ q quit") // Combine panels panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel) @@ -489,6 +600,82 @@ func (m *Model) View() string { ) } +// renderStatusBar renders the top status bar with ping, volume, and mic level +func (m *Model) renderStatusBar() string { + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Width(m.width) + + if !m.connected { + return headerStyle.Render("[ Connecting... ]") + } + + // Left: Server name (add padding manually) + leftPart := " " + m.serverName + + // Center: Ping + ping := 0.0 + if m.client != nil { + ping = m.client.GetPing() + } + centerPart := fmt.Sprintf("PING: %.0fms", ping) + + // Right: Volume and Mic (add padding manually) + volBar := audio.LevelToBar(m.playbackVol, 6) + muteIcon := "VOL" + if m.isMuted { + muteIcon = "MUTE" + } + volPart := fmt.Sprintf("%s:%s%d%%", muteIcon, volBar, m.playbackVol) + + micBar := audio.LevelToBar(m.micLevel, 6) + pttIcon := "MIC" + if m.isPTT { + pttIcon = "*TX*" + } + micPart := fmt.Sprintf("%s:%s", pttIcon, micBar) + + rightPart := fmt.Sprintf("%s | %s ", volPart, micPart) + + // Calculate spacing for centered ping + totalWidth := m.width + if totalWidth < 10 { + return "" + } + leftLen := lipgloss.Width(leftPart) + centerLen := lipgloss.Width(centerPart) + rightLen := lipgloss.Width(rightPart) + + // Calculate spaces needed + leftSpace := (totalWidth - leftLen - centerLen - rightLen) / 2 + rightSpace := totalWidth - leftLen - centerLen - rightLen - leftSpace + + if leftSpace < 1 { + leftSpace = 1 + } + if rightSpace < 1 { + rightSpace = 1 + } + + // Build the status line + spaces := func(n int) string { + if n <= 0 { + return "" + } + s := "" + for i := 0; i < n; i++ { + s += " " + } + return s + } + + status := leftPart + spaces(leftSpace) + centerPart + spaces(rightSpace) + rightPart + + return headerStyle.Render(status) +} + func (m *Model) renderChannels() string { if len(m.channels) == 0 { return "No channels..." diff --git a/go.mod b/go.mod index 9ebdef0..7caf7ac 100644 --- a/go.mod +++ b/go.mod @@ -9,19 +9,22 @@ require ( github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e ) -require gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/go-ole/go-ole v1.2.6 + github.com/gorilla/websocket v1.5.3 + github.com/moutend/go-wca v0.3.0 + gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 99cc97a..79b1e16 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -20,6 +18,8 @@ github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e h1:MhBotBstN1h/ github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e/go.mod h1:XLmYwGWgVzMPLlMmcNcWt3b5ixRabPLstWnPVEDRhzc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 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/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -30,6 +30,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8= +github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -41,6 +43,9 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= diff --git a/pkg/audio/capture.go b/pkg/audio/capture.go new file mode 100644 index 0000000..e43ada7 --- /dev/null +++ b/pkg/audio/capture.go @@ -0,0 +1,261 @@ +package audio + +import ( + "encoding/binary" + "fmt" + "sync" + "time" + "unsafe" + + "github.com/go-ole/go-ole" + "github.com/moutend/go-wca/pkg/wca" +) + +const captureFrameSamples = 960 // 20ms at 48kHz + +// Capturer handles WASAPI audio capture from microphone +type Capturer struct { + client *wca.IAudioClient + captureClient *wca.IAudioCaptureClient + waveFormat *wca.WAVEFORMATEX + bufferSize uint32 + running bool + mu sync.Mutex + stopChan chan struct{} + + // Callback for captured audio (called with 960-sample frames) + onAudio func(samples []int16) + + // Sample accumulation buffer + sampleBuffer []int16 + bufferMu sync.Mutex + + // Current audio level (0-100) + currentLevel int + levelMu sync.RWMutex +} + +// NewCapturer creates a new WASAPI audio capturer +func NewCapturer() (*Capturer, error) { + // Initialize COM (may already be initialized) + ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) + + // Get default capture endpoint (microphone) + var deviceEnumerator *wca.IMMDeviceEnumerator + if err := wca.CoCreateInstance( + wca.CLSID_MMDeviceEnumerator, + 0, + wca.CLSCTX_ALL, + wca.IID_IMMDeviceEnumerator, + &deviceEnumerator, + ); err != nil { + return nil, fmt.Errorf("failed to create device enumerator: %w", err) + } + defer deviceEnumerator.Release() + + var device *wca.IMMDevice + if err := deviceEnumerator.GetDefaultAudioEndpoint(wca.ECapture, wca.EConsole, &device); err != nil { + return nil, fmt.Errorf("failed to get default capture device: %w", err) + } + 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, + NSamplesPerSec: 48000, + WBitsPerSample: 16, + NBlockAlign: 2, + NAvgBytesPerSec: 96000, + CbSize: 0, + } + + // Initialize in shared mode - 100ms buffer + duration := wca.REFERENCE_TIME(100 * 10000) // 100ms in 100-nanosecond units + if err := audioClient.Initialize( + wca.AUDCLNT_SHAREMODE_SHARED, + wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM|wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, + duration, + 0, + waveFormat, + nil, + ); err != nil { + audioClient.Release() + 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 capture client + var captureClient *wca.IAudioCaptureClient + if err := audioClient.GetService(wca.IID_IAudioCaptureClient, &captureClient); err != nil { + audioClient.Release() + return nil, fmt.Errorf("failed to get capture client: %w", err) + } + + return &Capturer{ + client: audioClient, + captureClient: captureClient, + waveFormat: waveFormat, + bufferSize: bufferSize, + stopChan: make(chan struct{}), + sampleBuffer: make([]int16, 0, captureFrameSamples*50), // ~1 second buffer + }, nil +} + +// SetCallback sets the callback for captured audio (receives 960-sample frames) +func (c *Capturer) SetCallback(fn func(samples []int16)) { + c.mu.Lock() + c.onAudio = fn + c.mu.Unlock() +} + +// Start begins audio capture +func (c *Capturer) Start() error { + c.mu.Lock() + if c.running { + c.mu.Unlock() + return nil + } + c.running = true + c.stopChan = make(chan struct{}) // Recreate channel for each start + c.mu.Unlock() + + if err := c.client.Start(); err != nil { + return fmt.Errorf("failed to start audio client: %w", err) + } + + go c.captureLoop() + return nil +} + +// Stop stops audio capture +func (c *Capturer) Stop() { + c.mu.Lock() + if !c.running { + c.mu.Unlock() + return + } + c.running = false + c.mu.Unlock() + + close(c.stopChan) + c.client.Stop() +} + +// Close releases all resources +func (c *Capturer) Close() { + c.Stop() + if c.captureClient != nil { + c.captureClient.Release() + } + if c.client != nil { + c.client.Release() + } +} + +// GetLevel returns the current audio input level (0-100) +func (c *Capturer) GetLevel() int { + c.levelMu.RLock() + defer c.levelMu.RUnlock() + return c.currentLevel +} + +// IsRunning returns whether capture is active +func (c *Capturer) IsRunning() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.running +} + +func (c *Capturer) captureLoop() { + ticker := time.NewTicker(10 * time.Millisecond) // Check more often than 20ms + defer ticker.Stop() + + for { + select { + case <-c.stopChan: + return + case <-ticker.C: + c.readFromBuffer() + } + } +} + +func (c *Capturer) readFromBuffer() { + // Read all available packets + for { + var packetLength uint32 + if err := c.captureClient.GetNextPacketSize(&packetLength); err != nil { + return + } + + if packetLength == 0 { + break + } + + var buffer *byte + var numFrames uint32 + var flags uint32 + if err := c.captureClient.GetBuffer(&buffer, &numFrames, &flags, nil, nil); err != nil { + return + } + + if numFrames == 0 { + c.captureClient.ReleaseBuffer(numFrames) + continue + } + + samples := make([]int16, numFrames) + bufSlice := unsafe.Slice(buffer, numFrames*2) + + for i := uint32(0); i < numFrames; i++ { + samples[i] = int16(binary.LittleEndian.Uint16(bufSlice[i*2:])) + } + + c.captureClient.ReleaseBuffer(numFrames) + + // Skip silent buffers + if flags&wca.AUDCLNT_BUFFERFLAGS_SILENT != 0 { + continue + } + + // Add to sample buffer + c.bufferMu.Lock() + c.sampleBuffer = append(c.sampleBuffer, samples...) + + // Calculate level from latest samples + level := CalculateRMSLevel(samples) + c.levelMu.Lock() + c.currentLevel = level + c.levelMu.Unlock() + + // Send complete 960-sample frames + for len(c.sampleBuffer) >= captureFrameSamples { + frame := make([]int16, captureFrameSamples) + copy(frame, c.sampleBuffer[:captureFrameSamples]) + c.sampleBuffer = c.sampleBuffer[captureFrameSamples:] + + // Call callback with the frame + c.mu.Lock() + callback := c.onAudio + c.mu.Unlock() + + if callback != nil { + callback(frame) + } + } + c.bufferMu.Unlock() + } +} diff --git a/pkg/audio/level.go b/pkg/audio/level.go new file mode 100644 index 0000000..b2d52dd --- /dev/null +++ b/pkg/audio/level.go @@ -0,0 +1,100 @@ +package audio + +import ( + "math" +) + +// CalculateRMSLevel calculates the RMS level of PCM samples and returns 0-100 +func CalculateRMSLevel(samples []int16) int { + if len(samples) == 0 { + return 0 + } + + var sum float64 + for _, s := range samples { + sum += float64(s) * float64(s) + } + + rms := math.Sqrt(sum / float64(len(samples))) + // Normalize to 0-100 (max int16 is 32767) + level := int(rms / 32767.0 * 100.0) + if level > 100 { + level = 100 + } + return level +} + +// CalculatePeakLevel returns the peak level of PCM samples as 0-100 +func CalculatePeakLevel(samples []int16) int { + if len(samples) == 0 { + return 0 + } + + var peak int16 + for _, s := range samples { + if s < 0 { + s = -s + } + if s > peak { + peak = s + } + } + + return int(float64(peak) / 32767.0 * 100.0) +} + +// LevelToBar converts a 0-100 level to a visual bar string +func LevelToBar(level, width int) string { + if level < 0 { + level = 0 + } + if level > 100 { + level = 100 + } + + filled := level * width / 100 + empty := width - filled + + bar := "" + for i := 0; i < filled; i++ { + bar += "█" + } + for i := 0; i < empty; i++ { + bar += "░" + } + + return bar +} + +// LevelToMeter converts a 0-100 level to a visual VU meter with varying heights +func LevelToMeter(level, width int) string { + if level < 0 { + level = 0 + } + if level > 100 { + level = 100 + } + + // Use block characters of varying heights + blocks := []rune{'░', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + + meter := "" + for i := 0; i < width; i++ { + // Each position represents a portion of the level + threshold := (i + 1) * 100 / width + if level >= threshold { + meter += string(blocks[8]) // Full + } else if level >= threshold-10 { + // Partial - calculate which block to use + partial := (level - (threshold - 10)) * 8 / 10 + if partial < 0 { + partial = 0 + } + meter += string(blocks[partial]) + } else { + meter += string(blocks[0]) // Empty + } + } + + return meter +} diff --git a/pkg/audio/playback.go b/pkg/audio/playback.go new file mode 100644 index 0000000..6072f8c --- /dev/null +++ b/pkg/audio/playback.go @@ -0,0 +1,286 @@ +package audio + +import ( + "encoding/binary" + "fmt" + "sync" + "time" + "unsafe" + + "github.com/go-ole/go-ole" + "github.com/moutend/go-wca/pkg/wca" +) + +// Player handles WASAPI audio playback +type Player struct { + client *wca.IAudioClient + renderClient *wca.IAudioRenderClient + waveFormat *wca.WAVEFORMATEX + bufferSize uint32 + volume float32 + muted bool + mu sync.Mutex + running bool + stopChan chan struct{} + + // Audio buffer - accumulates incoming audio + audioBuffer []int16 + bufferMu sync.Mutex + + // Frame queue (960 samples = 20ms at 48kHz) + frameQueue chan []int16 +} + +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 + } + + // Get default audio endpoint + var deviceEnumerator *wca.IMMDeviceEnumerator + if err := wca.CoCreateInstance( + wca.CLSID_MMDeviceEnumerator, + 0, + wca.CLSCTX_ALL, + wca.IID_IMMDeviceEnumerator, + &deviceEnumerator, + ); err != nil { + return nil, fmt.Errorf("failed to create device enumerator: %w", err) + } + defer deviceEnumerator.Release() + + var device *wca.IMMDevice + if err := deviceEnumerator.GetDefaultAudioEndpoint(wca.ERender, wca.EConsole, &device); err != nil { + return nil, fmt.Errorf("failed to get default render device: %w", err) + } + 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, + NSamplesPerSec: 48000, + WBitsPerSample: 16, + NBlockAlign: 2, + NAvgBytesPerSec: 96000, + CbSize: 0, + } + + // Initialize in shared mode - 100ms buffer + duration := wca.REFERENCE_TIME(100 * 10000) // 100ms in 100-nanosecond units + if err := audioClient.Initialize( + wca.AUDCLNT_SHAREMODE_SHARED, + wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM|wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, + duration, + 0, + waveFormat, + nil, + ); err != nil { + audioClient.Release() + 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() + return nil, fmt.Errorf("failed to get render client: %w", err) + } + + return &Player{ + client: audioClient, + renderClient: renderClient, + waveFormat: waveFormat, + bufferSize: bufferSize, + 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 + }, nil +} + +// Start begins audio playback +func (p *Player) Start() error { + p.mu.Lock() + if p.running { + p.mu.Unlock() + return nil + } + p.running = true + 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 +} + +// Stop stops audio playback +func (p *Player) Stop() { + p.mu.Lock() + if !p.running { + p.mu.Unlock() + return + } + p.running = false + p.mu.Unlock() + + close(p.stopChan) + p.client.Stop() +} + +// Close releases all resources +func (p *Player) Close() { + p.Stop() + if p.renderClient != nil { + p.renderClient.Release() + } + if p.client != nil { + p.client.Release() + } + ole.CoUninitialize() +} + +// PlayPCM queues PCM audio for playback +// Accumulates samples and queues complete 960-sample frames +func (p *Player) PlayPCM(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...) + + // Queue complete 960-sample frames + for len(p.audioBuffer) >= frameSamples { + frame := make([]int16, frameSamples) + copy(frame, p.audioBuffer[:frameSamples]) + p.audioBuffer = p.audioBuffer[frameSamples:] + + select { + case p.frameQueue <- frame: + default: + // Queue full, drop oldest frame + select { + case <-p.frameQueue: + default: + } + p.frameQueue <- frame + } + } + p.bufferMu.Unlock() +} + +// SetVolume sets playback volume (0.0 to 1.0) +func (p *Player) SetVolume(vol float32) { + if vol < 0 { + vol = 0 + } + if vol > 1.0 { + vol = 1.0 + } + p.mu.Lock() + p.volume = vol + p.mu.Unlock() +} + +// GetVolume returns current volume (0.0 to 1.0) +func (p *Player) GetVolume() float32 { + p.mu.Lock() + defer p.mu.Unlock() + return p.volume +} + +// SetMuted sets mute state +func (p *Player) SetMuted(muted bool) { + p.mu.Lock() + p.muted = muted + p.mu.Unlock() +} + +// IsMuted returns mute state +func (p *Player) IsMuted() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.muted +} + +func (p *Player) playbackLoop() { + // Use 20ms ticker matching TeamSpeak frame rate + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-p.stopChan: + return + case <-ticker.C: + p.writeFrame() + } + } +} + +func (p *Player) writeFrame() { + // Get current padding (samples already in buffer) + var padding uint32 + if err := p.client.GetCurrentPadding(&padding); err != nil { + return + } + + available := p.bufferSize - padding + if available < frameSamples { + return // Not enough space for a full frame + } + + // 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 + } + + // 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) + } +} diff --git a/pkg/ts3client/client.go b/pkg/ts3client/client.go index 6c61e05..1cdab70 100644 --- a/pkg/ts3client/client.go +++ b/pkg/ts3client/client.go @@ -269,6 +269,14 @@ func (c *Client) IsConnected() bool { return c.connected } +// GetPing returns the current RTT in milliseconds +func (c *Client) GetPing() float64 { + if c.internal == nil { + return 0 + } + return c.internal.PingRTT +} + // handleInternalEvent processes events from the internal client func (c *Client) handleInternalEvent(eventType string, data map[string]any) { switch eventType {