feat: Implement TeamSpeak 3 client with connection management, event handling, and audio support.
This commit is contained in:
227
cmd/tui/model.go
227
cmd/tui/model.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go-ts/pkg/audio"
|
||||||
"go-ts/pkg/ts3client"
|
"go-ts/pkg/ts3client"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -75,6 +76,14 @@ type Model struct {
|
|||||||
// Voice activity
|
// Voice activity
|
||||||
talkingClients map[uint16]bool // ClientID -> isTalking
|
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 reference for sending messages from event handlers
|
||||||
program *tea.Program
|
program *tea.Program
|
||||||
}
|
}
|
||||||
@@ -99,6 +108,7 @@ func NewModel(serverAddr, nickname string) *Model {
|
|||||||
chatMessages: []ChatMessage{},
|
chatMessages: []ChatMessage{},
|
||||||
logMessages: []string{"Starting..."},
|
logMessages: []string{"Starting..."},
|
||||||
talkingClients: make(map[uint16]bool),
|
talkingClients: make(map[uint16]bool),
|
||||||
|
playbackVol: 80, // Default 80% volume
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +182,8 @@ type errorMsg struct {
|
|||||||
err string
|
err string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type micLevelMsg int // Mic level 0-100
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
|
|
||||||
// Update handles messages and user input
|
// Update handles messages and user input
|
||||||
@@ -197,6 +209,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
talking: e.Talking,
|
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
|
// Connect asynchronously
|
||||||
@@ -236,8 +285,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue ticking
|
// Update mic level when PTT is active (multiply for better visibility)
|
||||||
return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
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)
|
return tickMsg(t)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -265,6 +325,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case micLevelMsg:
|
||||||
|
// Update microphone level for display
|
||||||
|
m.micLevel = int(msg)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case logMsg:
|
case logMsg:
|
||||||
// External log message
|
// External log message
|
||||||
m.addLog("%s", string(msg))
|
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) {
|
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
// Global keys
|
// Global keys (work regardless of focus)
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "q":
|
case "ctrl+c", "q":
|
||||||
if m.client != nil {
|
if m.client != nil {
|
||||||
m.client.Disconnect()
|
m.client.Disconnect()
|
||||||
}
|
}
|
||||||
|
// Cleanup audio
|
||||||
|
if m.audioPlayer != nil {
|
||||||
|
m.audioPlayer.Close()
|
||||||
|
}
|
||||||
|
if m.audioCapturer != nil {
|
||||||
|
m.audioCapturer.Close()
|
||||||
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
@@ -328,9 +400,55 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.focus = (m.focus + 1) % 3
|
m.focus = (m.focus + 1) % 3
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "m":
|
case "m", "M":
|
||||||
// Toggle mute (would need to implement)
|
// Toggle mute
|
||||||
|
m.isMuted = !m.isMuted
|
||||||
|
if m.audioPlayer != nil {
|
||||||
|
m.audioPlayer.SetMuted(m.isMuted)
|
||||||
|
}
|
||||||
return m, nil
|
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
|
// Focus-specific keys
|
||||||
@@ -419,26 +537,23 @@ func (m *Model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
headerStyle := lipgloss.NewStyle().
|
// Layout: header(1) + panels + input(3) + help(1) = header + panels + 4
|
||||||
Bold(true).
|
// panels should be height - 5 (1 for header, 3 for input with border, 1 for help)
|
||||||
Foreground(lipgloss.Color("229")).
|
panelHeight := m.height - 7
|
||||||
Background(lipgloss.Color("57")).
|
|
||||||
Padding(0, 1).
|
|
||||||
Width(m.width)
|
|
||||||
|
|
||||||
channelPanelStyle := lipgloss.NewStyle().
|
channelPanelStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("63")).
|
BorderForeground(lipgloss.Color("63")).
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Width(m.width/3 - 2).
|
Width(m.width/3 - 2).
|
||||||
Height(m.height - 6)
|
Height(panelHeight)
|
||||||
|
|
||||||
chatPanelStyle := lipgloss.NewStyle().
|
chatPanelStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("63")).
|
BorderForeground(lipgloss.Color("63")).
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Width(m.width*2/3 - 2).
|
Width(m.width*2/3 - 2).
|
||||||
Height(m.height - 6)
|
Height(panelHeight)
|
||||||
|
|
||||||
inputStyle := lipgloss.NewStyle().
|
inputStyle := lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder()).
|
Border(lipgloss.NormalBorder()).
|
||||||
@@ -446,12 +561,8 @@ func (m *Model) View() string {
|
|||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
Width(m.width - 4)
|
Width(m.width - 4)
|
||||||
|
|
||||||
// Header
|
// Header with status bar
|
||||||
status := "Connecting..."
|
header := m.renderStatusBar()
|
||||||
if m.connected {
|
|
||||||
status = fmt.Sprintf("Server: %s │ You: %s (ID: %d)", m.serverName, m.nickname, m.selfID)
|
|
||||||
}
|
|
||||||
header := headerStyle.Render(status)
|
|
||||||
|
|
||||||
// Channel panel
|
// Channel panel
|
||||||
channelContent := m.renderChannels()
|
channelContent := m.renderChannels()
|
||||||
@@ -476,7 +587,7 @@ 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 │ q quit")
|
help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ V talk │ M mute │ +/- vol │ q quit")
|
||||||
|
|
||||||
// Combine panels
|
// Combine panels
|
||||||
panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel)
|
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 {
|
func (m *Model) renderChannels() string {
|
||||||
if len(m.channels) == 0 {
|
if len(m.channels) == 0 {
|
||||||
return "No channels..."
|
return "No channels..."
|
||||||
|
|||||||
13
go.mod
13
go.mod
@@ -9,19 +9,22 @@ require (
|
|||||||
github.com/dgryski/go-quicklz v0.0.0-20151014073603-d7042a82d57e
|
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 (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
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/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/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
|||||||
9
go.sum
9
go.sum
@@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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=
|
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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
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=
|
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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
|||||||
261
pkg/audio/capture.go
Normal file
261
pkg/audio/capture.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
100
pkg/audio/level.go
Normal file
100
pkg/audio/level.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
286
pkg/audio/playback.go
Normal file
286
pkg/audio/playback.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,6 +269,14 @@ func (c *Client) IsConnected() bool {
|
|||||||
return c.connected
|
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
|
// handleInternalEvent processes events from the internal client
|
||||||
func (c *Client) handleInternalEvent(eventType string, data map[string]any) {
|
func (c *Client) handleInternalEvent(eventType string, data map[string]any) {
|
||||||
switch eventType {
|
switch eventType {
|
||||||
|
|||||||
Reference in New Issue
Block a user