From 2b5fe91d6c45742a5ec3262820a27b61e125dc80 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 6 Sep 2026 01:04:10 -0400 Subject: [PATCH] Restore transmit state after unmute/deafen. Send proper notifications to other users. --- barnard.go | 29 +++++++--- client.go | 100 ++++++++++++++++++++++++++++++++++ client_notification_test.go | 103 +++++++++++++++++++++++++++++++++++- extras/barnard-sound.sh | 52 +++++++++++++++--- ui.go | 8 ++- 5 files changed, 276 insertions(+), 16 deletions(-) diff --git a/barnard.go b/barnard.go index 603b84f..71ceb2d 100644 --- a/barnard.go +++ b/barnard.go @@ -32,13 +32,14 @@ type Barnard struct { Address string TLSConfig tls.Config - Stream *gumbleopenal.Stream - connectionMutex sync.RWMutex - Tx bool - AutoTransmit bool // auto-start transmission on connect - Connected bool - unmuteOnUndeafen bool - stateMutex sync.RWMutex + Stream *gumbleopenal.Stream + connectionMutex sync.RWMutex + Tx bool + AutoTransmit bool // auto-start transmission on connect + Connected bool + unmuteOnUndeafen bool + transmitOnUndeafen bool + stateMutex sync.RWMutex Ui *uiterm.Ui UiOutput uiterm.Textview @@ -67,6 +68,7 @@ type Barnard struct { MutedChannels map[uint32]bool MutedChannelsMutex sync.RWMutex userChannels map[uint32]*gumble.Channel + userAudioStates map[uint32]userAudioState // Added for noise suppression NoiseSuppressor *noise.Suppressor @@ -216,6 +218,7 @@ func (b *Barnard) setConnected(connected bool) { b.Connected = connected if !connected { b.unmuteOnUndeafen = false + b.transmitOnUndeafen = false } b.stateMutex.Unlock() } @@ -232,6 +235,18 @@ func (b *Barnard) setUnmuteOnUndeafen(unmute bool) { b.stateMutex.Unlock() } +func (b *Barnard) shouldTransmitOnUndeafen() bool { + b.stateMutex.RLock() + defer b.stateMutex.RUnlock() + return b.transmitOnUndeafen +} + +func (b *Barnard) setTransmitOnUndeafen(transmit bool) { + b.stateMutex.Lock() + b.transmitOnUndeafen = transmit + b.stateMutex.Unlock() +} + func (b *Barnard) stopReconnects() { b.reconnectStopOnce.Do(func() { if b.reconnectStop != nil { diff --git a/client.go b/client.go index 66d4a8a..858e3a6 100644 --- a/client.go +++ b/client.go @@ -161,6 +161,7 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) { b.MutedChannels = make(map[uint32]bool) b.MutedChannelsMutex.Unlock() b.userChannels = make(map[uint32]*gumble.Channel) + b.userAudioStates = make(map[uint32]userAudioState) b.RecordingMutex.Lock() b.recordingAllowed = nil b.recordingStarting = false @@ -182,6 +183,7 @@ func (b *Barnard) OnConnect(e *gumble.ConnectEvent) { for _, u := range users { b.UserConfig.UpdateUser(u) b.rememberUserChannel(u) + b.rememberUserAudioState(u) } b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name)) @@ -339,6 +341,7 @@ func (b *Barnard) isPublicTextMessage(e *gumble.TextMessageEvent) bool { func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { notification, hasNotification := b.userChangeNotification(e) + audioNotification, hasAudioNotification := b.userAudioChangeNotification(e) if e.User != nil { b.UserConfig.UpdateUser(e.User) @@ -366,6 +369,11 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { b.Notify(notification.event, notification.who, notification.what) b.AddOutputLine(notification.line) } + if hasAudioNotification { + b.Notify(audioNotification.event, audioNotification.who, audioNotification.what) + b.AddOutputLine(audioNotification.line) + } + b.restoreTransmissionAfterUndeafen(e) if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self { b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name)) } @@ -383,6 +391,7 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { b.AddOutputLine(formatUserStats(e.User)) } b.updateUserChannel(e) + b.updateUserAudioState(e) b.postUI(func() { b.RebuildUserChannelTreePreservingSelection() b.Ui.Refresh() @@ -396,6 +405,11 @@ type userChangeNotification struct { line string } +type userAudioState struct { + selfMuted bool + selfDeafened bool +} + 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 @@ -426,6 +440,68 @@ func (b *Barnard) userChangeNotification(e *gumble.UserChangeEvent) (userChangeN return userChangeNotification{}, false } +func (b *Barnard) userAudioChangeNotification(e *gumble.UserChangeEvent) (userChangeNotification, bool) { + if e == nil || e.User == nil || !e.Type.Has(gumble.UserChangeAudio) || b.Client == nil || b.Client.Self == nil || b.Client.Self.Channel == nil { + return userChangeNotification{}, false + } + // Barnard announces its own hotkey actions immediately. The protocol echo + // is only needed to announce state changes made by other users. + if e.User.Session == b.Client.Self.Session { + return userChangeNotification{}, false + } + previous, known := b.userAudioStates[e.User.Session] + if !known || !sameChannel(e.User.Channel, b.Client.Self.Channel) { + return userChangeNotification{}, false + } + + muteEvent, muteVerb := "", "" + if previous.selfMuted != e.User.SelfMuted { + if e.User.SelfMuted { + muteEvent, muteVerb = "mute", "muted" + } else { + muteEvent, muteVerb = "unmute", "unmuted" + } + } + deafenEvent, deafenVerb := "", "" + if previous.selfDeafened != e.User.SelfDeafened { + if e.User.SelfDeafened { + deafenEvent, deafenVerb = "deafen", "deafened" + } else { + deafenEvent, deafenVerb = "undeafen", "undeafened" + } + } + + event := muteEvent + deafenEvent + verbs := make([]string, 0, 2) + if muteVerb != "" { + verbs = append(verbs, muteVerb) + } + if deafenVerb != "" { + verbs = append(verbs, deafenVerb) + } + if event == "" { + return userChangeNotification{}, false + } + verb := strings.Join(verbs, " and ") + return userChangeNotification{ + event: event, + who: e.User.Name, + what: e.User.Channel.Name, + line: fmt.Sprintf("%s %s", e.User.Name, verb), + }, true +} + +func (b *Barnard) restoreTransmissionAfterUndeafen(e *gumble.UserChangeEvent) { + if e == nil || e.User == nil || !e.Type.Has(gumble.UserChangeAudio) || b.Client == nil || b.Client.Self == nil { + return + } + if e.User.Session != b.Client.Self.Session || e.User.SelfDeafened || !b.shouldTransmitOnUndeafen() { + return + } + b.setTransmitOnUndeafen(false) + b.postUI(func() { b.setTransmit(nil, 1) }) +} + func buildUserChangeNotification(event string, verb string, user *gumble.User, eventChannel *gumble.Channel, currentChannel *gumble.Channel) (userChangeNotification, bool) { if !sameChannel(eventChannel, currentChannel) { return userChangeNotification{}, false @@ -472,6 +548,30 @@ func (b *Barnard) updateUserChannel(e *gumble.UserChangeEvent) { b.rememberUserChannel(e.User) } +func (b *Barnard) rememberUserAudioState(user *gumble.User) { + if user == nil { + return + } + if b.userAudioStates == nil { + b.userAudioStates = make(map[uint32]userAudioState) + } + b.userAudioStates[user.Session] = userAudioState{ + selfMuted: user.SelfMuted, + selfDeafened: user.SelfDeafened, + } +} + +func (b *Barnard) updateUserAudioState(e *gumble.UserChangeEvent) { + if e == nil || e.User == nil { + return + } + if e.Type.Has(gumble.UserChangeDisconnected) { + delete(b.userAudioStates, e.User.Session) + return + } + b.rememberUserAudioState(e.User) +} + func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) { b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name)) if e.Type.Has(gumble.ChannelChangeDescription) { diff --git a/client_notification_test.go b/client_notification_test.go index 59e421e..8ff2e8f 100644 --- a/client_notification_test.go +++ b/client_notification_test.go @@ -174,13 +174,114 @@ func TestSelfDeafenRestoresPriorMuteState(t *testing.T) { } } -func TestDisconnectForgetsAutomaticDeafenMute(t *testing.T) { +func TestDisconnectForgetsAutomaticDeafenState(t *testing.T) { b := &Barnard{} b.setUnmuteOnUndeafen(true) + b.setTransmitOnUndeafen(true) b.setConnected(false) if b.shouldUnmuteOnUndeafen() { t.Fatal("disconnect retained automatic deafen mute state") } + if b.shouldTransmitOnUndeafen() { + t.Fatal("disconnect retained automatic deafen transmit state") + } +} + +func TestUserAudioChangeNotification(t *testing.T) { + current := &gumble.Channel{ID: 1, Name: "Current"} + other := &gumble.Channel{ID: 2, Name: "Other"} + self := &gumble.User{Session: 1, Name: "Username", Channel: current} + + tests := []struct { + name string + user *gumble.User + previous userAudioState + remember bool + change gumble.UserChangeType + want userChangeNotification + wantOK bool + }{ + { + name: "user mutes", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true}, + remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "mute", who: "Guest", what: "Current", line: "Guest muted"}, wantOK: true, + }, + { + name: "user unmutes", user: &gumble.User{Session: 2, Name: "Guest", Channel: current}, + previous: userAudioState{selfMuted: true}, remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "unmute", who: "Guest", what: "Current", line: "Guest unmuted"}, wantOK: true, + }, + { + name: "user deafens", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true, SelfDeafened: true}, + previous: userAudioState{selfMuted: true}, remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "deafen", who: "Guest", what: "Current", line: "Guest deafened"}, wantOK: true, + }, + { + name: "user undeafens", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true}, + previous: userAudioState{selfMuted: true, selfDeafened: true}, remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "undeafen", who: "Guest", what: "Current", line: "Guest undeafened"}, wantOK: true, + }, + { + name: "user mutes and deafens", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true, SelfDeafened: true}, + remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "mutedeafen", who: "Guest", what: "Current", line: "Guest muted and deafened"}, wantOK: true, + }, + { + name: "user unmutes and undeafens", user: &gumble.User{Session: 2, Name: "Guest", Channel: current}, + previous: userAudioState{selfMuted: true, selfDeafened: true}, remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "unmuteundeafen", who: "Guest", what: "Current", line: "Guest unmuted and undeafened"}, wantOK: true, + }, + { + name: "user unmutes while deafening", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfDeafened: true}, + previous: userAudioState{selfMuted: true}, remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "unmutedeafen", who: "Guest", what: "Current", line: "Guest unmuted and deafened"}, wantOK: true, + }, + { + name: "user mutes while undeafening", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true}, + previous: userAudioState{selfDeafened: true}, remember: true, change: gumble.UserChangeAudio, + want: userChangeNotification{event: "muteundeafen", who: "Guest", what: "Current", line: "Guest muted and undeafened"}, wantOK: true, + }, + {name: "self protocol echo is ignored", user: &gumble.User{Session: 1, Name: "Username", Channel: current, SelfMuted: true}, remember: true, change: gumble.UserChangeAudio}, + {name: "other channel is ignored", user: &gumble.User{Session: 2, Name: "Guest", Channel: other, SelfMuted: true}, remember: true, change: gumble.UserChangeAudio}, + {name: "unknown initial state is ignored", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true}, change: gumble.UserChangeConnected | gumble.UserChangeAudio}, + {name: "unrelated change is ignored", user: &gumble.User{Session: 2, Name: "Guest", Channel: current, SelfMuted: true}, remember: true, change: gumble.UserChangeComment}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + b := &Barnard{Client: &gumble.Client{Self: self}} + if test.remember { + b.userAudioStates = map[uint32]userAudioState{test.user.Session: test.previous} + } + got, ok := b.userAudioChangeNotification(&gumble.UserChangeEvent{Client: b.Client, Type: test.change, User: test.user}) + if ok != test.wantOK || got != test.want { + t.Fatalf("got (%#v, %t), want (%#v, %t)", got, ok, test.want, test.wantOK) + } + }) + } +} + +func TestUpdateUserAudioState(t *testing.T) { + user := &gumble.User{Session: 2, SelfMuted: true, SelfDeafened: true} + b := &Barnard{} + b.updateUserAudioState(&gumble.UserChangeEvent{User: user}) + if got := b.userAudioStates[user.Session]; got != (userAudioState{selfMuted: true, selfDeafened: true}) { + t.Fatalf("unexpected remembered audio state: %#v", got) + } + b.updateUserAudioState(&gumble.UserChangeEvent{Type: gumble.UserChangeDisconnected, User: user}) + if _, ok := b.userAudioStates[user.Session]; ok { + t.Fatal("disconnected user audio state was retained") + } +} + +func TestUndeafenConsumesRememberedTransmission(t *testing.T) { + self := &gumble.User{Session: 1} + b := &Barnard{Client: &gumble.Client{Self: self}} + b.setTransmitOnUndeafen(true) + b.restoreTransmissionAfterUndeafen(&gumble.UserChangeEvent{Type: gumble.UserChangeAudio, User: self}) + if b.shouldTransmitOnUndeafen() { + t.Fatal("undeafen did not consume remembered transmission state") + } } func TestConcurrentSelectedUserAccess(t *testing.T) { diff --git a/extras/barnard-sound.sh b/extras/barnard-sound.sh index cf5974a..833fba2 100755 --- a/extras/barnard-sound.sh +++ b/extras/barnard-sound.sh @@ -65,27 +65,67 @@ micup() { } mute() { - [[ $notify ]] && notify "You muted yourself." + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You muted yourself." + else + [[ $notify ]] && notify "$2 muted." + fi } unmute() { - [[ $notify ]] && notify "You unmuted yourself." + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You unmuted yourself." + else + [[ $notify ]] && notify "$2 unmuted." + fi } mutedeafen() { - [[ $notify ]] && notify "You muted and deafened yourself." + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You muted and deafened yourself." + else + [[ $notify ]] && notify "$2 muted and deafened." + fi } deafen() { - [[ $notify ]] && notify "You deafened yourself." + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You deafened yourself." + else + [[ $notify ]] && notify "$2 deafened." + fi +} + +undeafen() { + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You undeafened yourself." + else + [[ $notify ]] && notify "$2 undeafened." + fi } muteundeafen() { - [[ $notify ]] && notify "You undeafened yourself. Your microphone remains muted." + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You undeafened yourself. Your microphone remains muted." + else + [[ $notify ]] && notify "$2 muted and undeafened." + fi } unmuteundeafen() { - [[ $notify ]] && notify "You unmuted and undeafened yourself." + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You unmuted and undeafened yourself." + else + [[ $notify ]] && notify "$2 unmuted and undeafened." + fi +} + +unmutedeafen() { + if [[ "$2" == "me" ]]; then + [[ $notify ]] && notify "You unmuted and deafened yourself." + else + [[ $notify ]] && notify "$2 unmuted and deafened." + fi } msg() { diff --git a/ui.go b/ui.go index e7c1280..a382b8b 100644 --- a/ui.go +++ b/ui.go @@ -279,8 +279,12 @@ func (b *Barnard) setSelfDeafen(deafened bool) { if b.selfDeafened() == deafened { return } - if deafened && b.isTransmitting() { - b.StopTransmission() + if deafened { + wasTransmitting := b.isTransmitting() + b.setTransmitOnUndeafen(wasTransmitting) + if wasTransmitting { + b.StopTransmission() + } } muted, unmuteOnUndeafen := selfDeafenTarget(deafened, b.selfMuted(), b.shouldUnmuteOnUndeafen()) b.setUnmuteOnUndeafen(unmuteOnUndeafen)