Files
barnard/client.go
Tyler SpiveyandClaude Opus 5 4393739ffa Add F12 hotkey to toggle microphone AGC
Automatic gain control was always on with no way to turn it off. Toggle
it with F12, the /agc command, or the agc FIFO command, and persist the
choice in the configuration file the same way noise suppression does.

AgcEnabled defaults to true so existing setups keep their current
behavior, and the saved value is applied to the stream on connect. The
enabled flag becomes an atomic.Bool because the capture goroutine reads
it while the UI goroutine writes it, and the lazily created right
channel AGC now inherits the left channel's state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:16:44 -07:00

544 lines
15 KiB
Go

package main
import (
"fmt"
"net"
"strings"
"time"
"git.stormux.org/storm/barnard/fileplayback"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
"git.stormux.org/storm/barnard/gumble/gumbleutil"
"git.stormux.org/storm/barnard/gumble/opus"
)
func (b *Barnard) start() {
b.reconnectStop = make(chan struct{})
b.Config.Attach(gumbleutil.AutoBitrate)
b.Config.Attach(b)
b.Config.Address = b.Address
if b.ToneTest {
// Tone test mode: skip all OpenAL/soundcard initialization.
// We just need a network connection — the tone generator and
// file saver are set up in connect().
} else {
// test Audio
_, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true)
if err != nil {
b.exitWithError(err)
return
}
}
//connect, not reconnect
b.connect(false)
}
func (b *Barnard) toneTestAutoTransmit() bool {
return b.ToneTest && b.AutoTransmit
}
func (b *Barnard) exitWithError(err error) {
b.Ui.Close()
b.exitStatus = 1
b.exitMessage = err.Error()
}
func (b *Barnard) connect(reconnect bool) bool {
var err error
_, err = gumble.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, b.Config, &b.TLSConfig)
if err != nil {
if reconnect {
b.Log(err.Error())
} else {
b.exitWithError(err)
}
return false
}
if b.ToneTest {
// --- Tone test mode: skip all OpenAL; generate 440 Hz tone
// --- and save incoming audio to a file.
// Open the output first. Starting transmission before this succeeds
// leaves an orphaned tone goroutine when the path is unusable.
saver, err := NewAudioFileSaver(b.ToneTestOutput)
if err != nil {
b.exitWithError(err)
return false
}
b.toneTestSaver = saver
b.toneTestSaverDetach = b.Client.Config.AttachAudio(saver)
b.setConnected(true)
if b.toneTestAutoTransmit() {
b.toneTestStop = make(chan struct{})
go StartToneGenerator(b.Client, b.toneTestStop)
b.setTransmitting(true)
b.UpdateGeneralStatus(" Tx ", true)
b.AddOutputLine("Tone test transmission started")
}
return true
}
stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false)
if err != nil {
b.exitWithError(err)
return false
}
stream.SetMicVolume(b.UserConfig.GetMicVolume(), false)
stream.AttachStream(b.Client)
stream.SetNoiseProcessor(b.NoiseSuppressor)
stream.SetAGCEnabled(b.UserConfig.GetAGCEnabled())
stream.SetErrorFunc(func(err error) {
if err != nil {
b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error()))
} else {
b.AddOutputLine("Microphone: recovered")
}
})
// Initialize stereo encoder for file playback
b.Client.SetStereoEncoder(opus.NewStereoEncoder())
// Initialize file player
b.FileStreamMutex.Lock()
b.FileStream = fileplayback.New(b.Client)
b.FileStream.SetErrorFunc(func(err error) {
// Disable stereo when file finishes or errors
b.Client.DisableStereoEncoder()
b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
})
stream.SetFilePlayer(b.FileStream)
b.FileStreamMutex.Unlock()
b.connectionMutex.Lock()
b.Stream = stream
b.connectionMutex.Unlock()
b.setConnected(true)
// Dial delivers OnConnect before connect creates the OpenAL stream, so
// start auto-transmit here as well for initial connections and reconnects.
b.startAutoTransmit()
return true
}
func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
b.Client = e.Client
// Reset muted channels state on connect
b.MutedChannelsMutex.Lock()
b.MutedChannels = make(map[uint32]bool)
b.MutedChannelsMutex.Unlock()
b.userChannels = make(map[uint32]*gumble.Channel)
b.RecordingMutex.Lock()
b.recordingAllowed = nil
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.postUI(func() {
b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild()
b.Ui.Refresh()
})
var users []*gumble.User
b.Client.Do(func() {
users = make([]*gumble.User, 0, len(b.Client.Users))
for _, u := range b.Client.Users {
users = append(users, u)
}
})
for _, u := range users {
b.UserConfig.UpdateUser(u)
b.rememberUserChannel(u)
}
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name))
b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn.RemoteAddr()))
wmsg := ""
if e.WelcomeMessage != nil {
wmsg = esc(*e.WelcomeMessage)
}
b.Notify("connect", "me", wmsg)
if wmsg != "" {
b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg))
}
b.startAutoTransmit()
}
func (b *Barnard) startAutoTransmit() {
if !b.AutoTransmit || b.isTransmitting() {
return
}
started := b.withStream(func(stream *gumbleopenal.Stream) {
if err := stream.StartSource(b.UserConfig.GetInputDevice()); err != nil {
b.AddOutputLine(fmt.Sprintf("auto-transmit failed: %s", err.Error()))
return
}
b.setTransmitting(true)
b.UpdateGeneralStatus(" AutoTx ", true)
b.AddOutputLine("Auto-transmit started")
})
if !started {
return
}
}
func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
var reason string
switch e.Type {
case gumble.DisconnectError:
reason = "connection error"
case gumble.DisconnectKicked:
reason = "kicked"
case gumble.DisconnectBanned:
reason = "banned"
}
if e.String != "" {
reason = e.String
}
b.stopRecordingForDisconnect()
b.cleanupConnectionAudio()
// Tone test cleanup
if b.ToneTest {
if b.toneTestStop != nil {
close(b.toneTestStop)
b.toneTestStop = nil
}
b.cleanupToneTestAudio()
}
b.Notify("disconnect", "me", reason)
if reason == "" {
b.AddOutputLine("Disconnected")
} else {
b.AddOutputLine("Disconnected: " + reason)
}
b.setTransmitting(false)
b.setConnected(false)
b.postUI(func() {
b.UiTree.Rebuild()
b.Ui.Refresh()
})
go b.reconnectGoroutine()
}
func (b *Barnard) reconnectGoroutine() {
for !b.reconnectCanceled() {
if b.connect(true) {
return
}
select {
case <-b.reconnectStop:
return
case <-time.After(15 * time.Second):
}
}
}
func (b *Barnard) Log(s string) {
b.AddOutputMessage(nil, s)
}
func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) {
if b.isPublicTextMessage(e) {
sender := "Server"
if e.Sender != nil {
sender = e.Sender.Name
}
b.Notify("msg", sender, e.Message)
b.AddOutputMessage(e.Sender, e.Message)
} else {
var sender string
if e.Sender == nil {
sender = "Server"
} else {
sender = e.Sender.Name
}
b.Notify("pm", sender, e.Message)
b.AddOutputPrivateMessage(e.Sender, b.Client.Self, e.Message)
}
}
// isPublicTextMessage reports whether a message targets the current channel,
// either directly or through a recursive channel-tree recipient.
func (b *Barnard) isPublicTextMessage(e *gumble.TextMessageEvent) bool {
if e == nil || b.Client == nil || b.Client.Self == nil || b.Client.Self.Channel == nil {
return false
}
current := b.Client.Self.Channel
for _, channel := range e.Channels {
if sameChannel(channel, current) {
return true
}
}
for _, root := range e.Trees {
for channel := current; channel != nil; channel = channel.Parent {
if sameChannel(channel, root) {
return true
}
}
}
return false
}
func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
notification, hasNotification := b.userChangeNotification(e)
if e.User != nil {
b.UserConfig.UpdateUser(e.User)
// Check if user is joining a muted channel
if e.Type.Has(gumble.UserChangeConnected) || e.Type.Has(gumble.UserChangeChannel) {
// If the channel is muted, ensure the user is muted
if b.isChannelMuted(e.User.Channel.ID) {
// Only mute if not already muted
if !e.User.LocallyMuted() {
if err := b.UserConfig.ToggleMute(e.User); err != nil {
b.AddOutputLine("Mute: could not save setting: " + err.Error())
}
}
b.updateUserGain(e.User)
}
}
}
if e.Type.Has(gumble.UserChangeDisconnected) {
if e.User == b.selectedUserValue() {
b.SetSelectedUser(nil)
}
}
if hasNotification {
b.Notify(notification.event, notification.who, notification.what)
b.AddOutputLine(notification.line)
}
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
}
if e.Type.Has(gumble.UserChangeRecording) {
b.HandleRecordingChange(e)
}
if e.Type.Has(gumble.UserChangeComment) {
comment := strings.TrimSpace(esc(e.User.Comment))
if comment == "" {
comment = "empty"
}
b.AddOutputLine(fmt.Sprintf("User comment for %s: %s", e.User.Name, comment))
}
if e.Type.Has(gumble.UserChangeStats) && e.User.Stats != nil {
b.AddOutputLine(formatUserStats(e.User))
}
b.updateUserChannel(e)
b.postUI(func() {
b.RebuildUserChannelTreePreservingSelection()
b.Ui.Refresh()
})
}
type userChangeNotification struct {
event string
who string
what string
line string
}
func (b *Barnard) userChangeNotification(e *gumble.UserChangeEvent) (userChangeNotification, bool) {
if e == nil || e.User == nil || b.Client == nil || b.Client.Self == nil || b.Client.Self.Channel == nil {
return userChangeNotification{}, false
}
currentChannel := b.Client.Self.Channel
previousChannel := b.previousUserChannel(e.User)
userChannel := e.User.Channel
if e.Type.Has(gumble.UserChangeConnected) {
return buildUserChangeNotification("join", "joined", e.User, userChannel, currentChannel)
}
if e.Type.Has(gumble.UserChangeDisconnected) {
if previousChannel == nil {
previousChannel = userChannel
}
return buildUserChangeNotification("leave", "left", e.User, previousChannel, currentChannel)
}
if e.Type.Has(gumble.UserChangeChannel) && e.User != b.Client.Self {
if sameChannel(userChannel, currentChannel) && !sameChannel(previousChannel, currentChannel) {
return buildUserChangeNotification("join", "joined", e.User, userChannel, currentChannel)
}
if sameChannel(previousChannel, currentChannel) && !sameChannel(userChannel, currentChannel) {
return buildUserChangeNotification("leave", "left", e.User, previousChannel, currentChannel)
}
}
return userChangeNotification{}, false
}
func buildUserChangeNotification(event string, verb string, user *gumble.User, eventChannel *gumble.Channel, currentChannel *gumble.Channel) (userChangeNotification, bool) {
if !sameChannel(eventChannel, currentChannel) {
return userChangeNotification{}, false
}
return userChangeNotification{
event: event,
who: user.Name,
what: eventChannel.Name,
line: fmt.Sprintf("%s %s %s", user.Name, verb, eventChannel.Name),
}, true
}
func sameChannel(a *gumble.Channel, b *gumble.Channel) bool {
return a != nil && b != nil && a.ID == b.ID
}
func (b *Barnard) previousUserChannel(user *gumble.User) *gumble.Channel {
if b.userChannels == nil || user == nil {
return nil
}
return b.userChannels[user.Session]
}
func (b *Barnard) rememberUserChannel(user *gumble.User) {
if user == nil || user.Channel == nil {
return
}
if b.userChannels == nil {
b.userChannels = make(map[uint32]*gumble.Channel)
}
b.userChannels[user.Session] = user.Channel
}
func (b *Barnard) updateUserChannel(e *gumble.UserChangeEvent) {
if e == nil || e.User == nil {
return
}
if e.Type.Has(gumble.UserChangeDisconnected) {
if b.userChannels != nil {
delete(b.userChannels, e.User.Session)
}
return
}
b.rememberUserChannel(e.User)
}
func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name))
if e.Type.Has(gumble.ChannelChangeDescription) {
description := strings.TrimSpace(esc(e.Channel.Description))
if description == "" {
description = "empty"
}
b.AddOutputLine(fmt.Sprintf("Channel description for %s: %s", e.Channel.Name, description))
}
if e.Type.Has(gumble.ChannelChangePermission) {
if permission := e.Channel.Permission(); permission != nil {
b.AddOutputLine(fmt.Sprintf("Channel permissions for %s: %s", e.Channel.Name, permissionList(*permission)))
}
}
b.postUI(func() {
b.RebuildUserChannelTreePreservingSelection()
b.Ui.Refresh()
})
}
func formatUserStats(user *gumble.User) string {
stats := user.Stats
connected := "unknown"
if !stats.Connected.IsZero() {
connected = time.Since(stats.Connected).Round(time.Second).String()
}
ip := "unknown"
if stats.IP != nil {
ip = stats.IP.String()
}
version := formatUserVersion(stats.Version)
return fmt.Sprintf(
"User stats for %s: version %s, connected %s, idle %s, bandwidth %d, UDP ping %.1f ms, TCP ping %.1f ms, IP %s, Opus %s",
user.Name,
version,
connected,
stats.Idle.Round(time.Second),
stats.Bandwidth,
stats.UDPPingAverage,
stats.TCPPingAverage,
ip,
onOff(stats.Opus),
)
}
func formatUserVersion(version gumble.Version) string {
major, minor, patch := (&version).SemanticVersion()
semantic := fmt.Sprintf("%d.%d.%d", major, minor, patch)
parts := []string{}
if version.Release != "" {
parts = append(parts, version.Release)
}
if version.OS != "" {
parts = append(parts, version.OS)
}
if version.OSVersion != "" {
parts = append(parts, version.OSVersion)
}
if len(parts) == 0 && version.Version == 0 {
return "unknown"
}
if len(parts) == 0 {
return semantic
}
return semantic + " " + strings.Join(parts, " ")
}
func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
var info string
switch e.Type {
case gumble.PermissionDeniedOther:
info = e.String
case gumble.PermissionDeniedPermission:
info = "insufficient permissions"
case gumble.PermissionDeniedSuperUser:
info = "cannot modify SuperUser"
case gumble.PermissionDeniedInvalidChannelName:
info = "invalid channel name"
case gumble.PermissionDeniedTextTooLong:
info = "text too long"
case gumble.PermissionDeniedTemporaryChannel:
info = "temporary channel"
case gumble.PermissionDeniedMissingCertificate:
info = "missing certificate"
case gumble.PermissionDeniedInvalidUserName:
info = "invalid user name"
case gumble.PermissionDeniedChannelFull:
info = "channel full"
case gumble.PermissionDeniedNestingLimit:
info = "nesting limit"
}
b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info))
}
func (b *Barnard) OnUserList(e *gumble.UserListEvent) {
b.AddOutputLine(fmt.Sprintf("Admin: received %d registered users", len(e.UserList)))
b.postUI(func() { b.adminUserList = e.UserList; b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnACL(e *gumble.ACLEvent) {
if e.ACL != nil && e.ACL.Channel != nil {
b.AddOutputLine(fmt.Sprintf("Admin: received ACLs for %s", e.ACL.Channel.Name))
}
b.postUI(func() { b.adminACL = e.ACL; b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnBanList(e *gumble.BanListEvent) {
b.AddOutputLine(fmt.Sprintf("Admin: received %d bans", len(e.BanList)))
b.postUI(func() { b.adminBanList = e.BanList; b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) {
if e.ContextAction != nil {
b.AddOutputLine(fmt.Sprintf("Admin: context action updated: %s", e.ContextAction.Name))
}
b.postUI(func() { b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
b.HandleRecordingAllowed(e.RecordingAllowed)
}