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>
544 lines
15 KiB
Go
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)
|
|
}
|