From 97fcde7e960d8c7daf7dcfd55bba490c8fc72a34 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 5 Sep 2026 00:57:37 -0400 Subject: [PATCH] Add mnemonic mute controls --- README.md | 6 +++-- barnard.go | 34 +++++++++++++++++++++++----- client_notification_test.go | 18 +++++++++++++++ config/hotkey_config.go | 1 + config/user_config.go | 13 +++++++---- config/user_config_test.go | 10 ++++++--- extras/barnard-sound.sh | 8 +++++++ ui.go | 45 +++++++++++++++++++++++++++++++++++++ ui_tree.go | 22 ++++++++++++++++-- ui_tree_test.go | 36 +++++++++++++++++++++++++++++ uiterm/key_toml.go | 16 +++++++++++++ uiterm/key_toml_test.go | 35 +++++++++++++++++++++++++++++ uiterm/printable_keys.go | 5 +++++ 13 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 uiterm/key_toml_test.go create mode 100644 uiterm/printable_keys.go diff --git a/README.md b/README.md index d2507fc..0c01d25 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ When in the treeview, pressing: * left or right arrow on a channel changes the volume for all users in that channel * left or right arrow on a user changes the volume for that user. * backspace restores the focused user, or every user in the focused channel, to unmuted, 100% volume, and normal boost. +* m locally mutes or unmutes the focused user or channel. On your own user, it changes your Mumble self-mute state instead. * enter on de-selected user selects that user for PM mode. * enter on selected user de-selects the user * enter on a channel de-selects any selected users (if any) and moves you to the specified channel. @@ -300,7 +301,8 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH) ### Key bindings -- F1: toggle voice transmission +- Ctrl+T: toggle voice transmission +- Alt+M: toggle your Mumble self-mute state from anywhere - F9: toggle noise suppression - F12: toggle automatic gain control - F10: open actions menu for the focused tree item @@ -313,7 +315,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH) - End: scroll chat to the bottom - Ctrl+Q: quit -With the user/channel tree focused, Left/Right changes incoming volume and Backspace restores the focused user or channel to unmuted, 100% volume, and normal boost. In the message input, those keys retain their normal text-editing behavior. +With the user/channel tree focused, M locally mutes the focused user or channel, Left/Right changes incoming volume, and Backspace restores the focused user or channel to unmuted, 100% volume, and normal boost. M on your own user toggles self-mute. In the message input, printable letters and editing keys retain their normal behavior. ## License diff --git a/barnard.go b/barnard.go index a6c636a..448f139 100644 --- a/barnard.go +++ b/barnard.go @@ -3,6 +3,7 @@ package main import ( "crypto/tls" "sync" + "unicode" "git.stormux.org/storm/barnard/config" "git.stormux.org/storm/barnard/fileplayback" @@ -251,6 +252,13 @@ func (b *Barnard) StopTransmission() { } func (b *Barnard) TreeItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) { + if b.Hotkeys != nil && matchesCharacterHotkey(b.Hotkeys.MuteToggle, ch) { + b.TreeItemKeyPress(ui, tree, item, *b.Hotkeys.MuteToggle) + } +} + +func matchesCharacterHotkey(configured *uiterm.Key, ch rune) bool { + return configured != nil && uiterm.Key(unicode.ToLower(ch)) == *configured } func matchesVolumeResetKey(configured *uiterm.Key, pressed uiterm.Key) bool { @@ -308,6 +316,11 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm // Update channel mute state b.setChannelMuted(treeItem.Channel.ID, channelWillBeMuted) + state := "unmuted" + if channelWillBeMuted { + state = "muted" + } + b.AddOutputLine("Locally " + state + " channel " + treeItem.Channel.Name) if channelWillBeMuted && b.Client.Self.Channel.ID == treeItem.Channel.ID && b.isTransmitting() { b.StopTransmission() } @@ -329,13 +342,22 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm if treeItem.User != nil { if key == *b.Hotkeys.MuteToggle { - // Toggle mute for single user - if err := b.UserConfig.ToggleMute(treeItem.User); err != nil { - b.AddOutputLine("Mute: could not save setting: " + err.Error()) + if b.Client.Self != nil && treeItem.User.Session == b.Client.Self.Session { + b.toggleSelfMute() + } else { + // Other users are muted only in this Barnard client. + if err := b.UserConfig.ToggleMute(treeItem.User); err != nil { + b.AddOutputLine("Mute: could not save setting: " + err.Error()) + } + b.updateUserGain(treeItem.User) + state := "unmuted" + if treeItem.User.LocallyMuted() { + state = "muted" + } + b.AddOutputLine("Locally " + state + " " + treeItem.User.Name) + b.RebuildUserChannelTreePreservingSelection() + b.Ui.Refresh() } - b.updateUserGain(treeItem.User) - b.RebuildUserChannelTreePreservingSelection() - b.Ui.Refresh() } if key == *b.Hotkeys.VolumeDown { b.changeVolume([]*gumble.User{treeItem.User}, -0.1) diff --git a/client_notification_test.go b/client_notification_test.go index 9cbde63..4d3b909 100644 --- a/client_notification_test.go +++ b/client_notification_test.go @@ -132,6 +132,24 @@ func TestConcurrentConnectionStateAccess(t *testing.T) { wg.Wait() } +func TestSelfMutedReadsProtocolState(t *testing.T) { + b := &Barnard{Client: &gumble.Client{Self: &gumble.User{SelfMuted: true}}} + if !b.selfMuted() { + t.Fatal("self mute state was not reported") + } +} + +func TestTransmitDoesNotStartWhileSelfMuted(t *testing.T) { + b := &Barnard{ + Client: &gumble.Client{Self: &gumble.User{SelfMuted: true, Channel: &gumble.Channel{ID: 1}}}, + Connected: true, + } + b.setTransmit(nil, 2) + if b.isTransmitting() { + t.Fatal("transmission started while self-muted") + } +} + func TestConcurrentSelectedUserAccess(t *testing.T) { b := &Barnard{} user := &gumble.User{Session: 1} diff --git a/config/hotkey_config.go b/config/hotkey_config.go index 2eaf883..b1a6317 100644 --- a/config/hotkey_config.go +++ b/config/hotkey_config.go @@ -22,4 +22,5 @@ type Hotkeys struct { AdminMenu *uiterm.Key NoiseSuppressionToggle *uiterm.Key AGCToggle *uiterm.Key + SelfMuteToggle *uiterm.Key } diff --git a/config/user_config.go b/config/user_config.go index 80a87f8..fe2a656 100644 --- a/config/user_config.go +++ b/config/user_config.go @@ -94,11 +94,11 @@ func (c *Config) LoadConfig() { var jc exportableConfig jc = exportableConfig{} jc.Hotkeys = &Hotkeys{ - Talk: key(uiterm.KeyF1), + Talk: key(uiterm.KeyCtrlT), VolumeDown: key(uiterm.KeyArrowLeft), VolumeUp: key(uiterm.KeyArrowRight), VolumeReset: key(uiterm.KeyBackspace), - MuteToggle: key(uiterm.KeyF7), // Added mute toggle hotkey + MuteToggle: key(uiterm.KeyM), RecordToggle: key(uiterm.KeyCtrlR), Exit: key(uiterm.KeyCtrlQ), ToggleTimestamps: key(uiterm.KeyF3), @@ -111,6 +111,7 @@ func (c *Config) LoadConfig() { AdminMenu: key(uiterm.KeyF10), NoiseSuppressionToggle: key(uiterm.KeyF9), AGCToggle: key(uiterm.KeyF12), + SelfMuteToggle: key(uiterm.KeyAltM), } if fileExists(c.fn) { var data []byte @@ -181,11 +182,11 @@ func (c *Config) ensureHotkeys() { c.config.Hotkeys = &Hotkeys{} } defaults := Hotkeys{ - Talk: key(uiterm.KeyF1), + Talk: key(uiterm.KeyCtrlT), VolumeDown: key(uiterm.KeyArrowLeft), VolumeUp: key(uiterm.KeyArrowRight), VolumeReset: key(uiterm.KeyBackspace), - MuteToggle: key(uiterm.KeyF7), + MuteToggle: key(uiterm.KeyM), RecordToggle: key(uiterm.KeyCtrlR), Exit: key(uiterm.KeyCtrlQ), ToggleTimestamps: key(uiterm.KeyF3), @@ -198,6 +199,7 @@ func (c *Config) ensureHotkeys() { AdminMenu: key(uiterm.KeyF10), NoiseSuppressionToggle: key(uiterm.KeyF9), AGCToggle: key(uiterm.KeyF12), + SelfMuteToggle: key(uiterm.KeyAltM), } hotkeys := c.config.Hotkeys if hotkeys.Talk == nil { @@ -251,6 +253,9 @@ func (c *Config) ensureHotkeys() { if hotkeys.AGCToggle == nil { hotkeys.AGCToggle = defaults.AGCToggle } + if hotkeys.SelfMuteToggle == nil { + hotkeys.SelfMuteToggle = defaults.SelfMuteToggle + } } func (c *Config) findServer(address string) *server { diff --git a/config/user_config_test.go b/config/user_config_test.go index 4b6f1c3..cc97978 100644 --- a/config/user_config_test.go +++ b/config/user_config_test.go @@ -87,9 +87,12 @@ func TestNewHotkeyDefaults(t *testing.T) { got *uiterm.Key want uiterm.Key }{ + {name: "talk", got: hotkeys.Talk, want: uiterm.KeyCtrlT}, {name: "volume down", got: hotkeys.VolumeDown, want: uiterm.KeyArrowLeft}, {name: "volume up", got: hotkeys.VolumeUp, want: uiterm.KeyArrowRight}, {name: "volume reset", got: hotkeys.VolumeReset, want: uiterm.KeyBackspace}, + {name: "tree mute", got: hotkeys.MuteToggle, want: uiterm.KeyM}, + {name: "self mute", got: hotkeys.SelfMuteToggle, want: uiterm.KeyAltM}, {name: "actions menu", got: hotkeys.AdminMenu, want: uiterm.KeyF10}, {name: "exit", got: hotkeys.Exit, want: uiterm.KeyCtrlQ}, } @@ -102,14 +105,15 @@ func TestNewHotkeyDefaults(t *testing.T) { func TestExplicitLegacyHotkeysRemainConfigured(t *testing.T) { configPath := filepath.Join(t.TempDir(), "barnard.toml") - data := []byte("[hotkeys]\nvolumedown = \"f5\"\nvolumeup = \"f6\"\nvolumereset = \"f8\"\nadminmenu = \"f11\"\nexit = \"f10\"\n") + data := []byte("[hotkeys]\ntalk = \"f1\"\nvolumedown = \"f5\"\nvolumeup = \"f6\"\nvolumereset = \"f8\"\nmutetoggle = \"f7\"\nadminmenu = \"f11\"\nexit = \"f10\"\n") if err := os.WriteFile(configPath, data, 0600); err != nil { t.Fatal(err) } hotkeys := NewConfig(&configPath).GetHotkeys() - if *hotkeys.VolumeDown != uiterm.KeyF5 || *hotkeys.VolumeUp != uiterm.KeyF6 || - *hotkeys.VolumeReset != uiterm.KeyF8 || *hotkeys.AdminMenu != uiterm.KeyF11 || + if *hotkeys.Talk != uiterm.KeyF1 || *hotkeys.VolumeDown != uiterm.KeyF5 || + *hotkeys.VolumeUp != uiterm.KeyF6 || *hotkeys.VolumeReset != uiterm.KeyF8 || + *hotkeys.MuteToggle != uiterm.KeyF7 || *hotkeys.AdminMenu != uiterm.KeyF11 || *hotkeys.Exit != uiterm.KeyF10 { t.Fatalf("explicit legacy hotkeys were replaced: %+v", hotkeys) } diff --git a/extras/barnard-sound.sh b/extras/barnard-sound.sh index 044167c..4e156c8 100755 --- a/extras/barnard-sound.sh +++ b/extras/barnard-sound.sh @@ -64,6 +64,14 @@ micup() { [[ $notify ]] && notify "You are now transmitting." } +mute() { + [[ $notify ]] && notify "You muted yourself." +} + +unmute() { + [[ $notify ]] && notify "You unmuted yourself." +} + msg() { [[ $sound ]] && play -n synth .3 sin 1290:1490 sin 1494:1294 remix - norm -8 [[ $notify ]] && notify "$1 from $2: $3" diff --git a/ui.go b/ui.go index e266cea..8566a58 100644 --- a/ui.go +++ b/ui.go @@ -205,6 +205,46 @@ func (b *Barnard) OnVoiceToggle(ui *uiterm.Ui, key uiterm.Key) { b.setTransmit(ui, 2) } +func (b *Barnard) OnSelfMuteToggle(ui *uiterm.Ui, key uiterm.Key) { + b.toggleSelfMute() +} + +func (b *Barnard) selfMuted() bool { + if b.Client == nil || b.Client.Self == nil { + return false + } + muted := false + b.Client.Do(func() { muted = b.Client.Self.SelfMuted }) + return muted +} + +func (b *Barnard) setSelfMute(muted bool) { + if !b.isConnected() || b.Client == nil || b.Client.Self == nil { + b.Notify("error", "me", "cannot change self mute while disconnected") + b.UpdateGeneralStatus("cannot change self mute while disconnected", true) + return + } + if b.selfMuted() == muted { + return + } + if muted && b.isTransmitting() { + b.StopTransmission() + } + b.Client.Self.SetSelfMuted(muted) + event := "unmute" + line := "You unmuted yourself" + if muted { + event = "mute" + line = "You muted yourself" + } + b.Notify(event, "me", "") + b.AddOutputLine(line) +} + +func (b *Barnard) toggleSelfMute() { + b.setSelfMute(!b.selfMuted()) +} + func (b *Barnard) CommandLog(ui *uiterm.Ui, cmd string) { b.AddOutputLine("command " + cmd) } @@ -381,6 +421,10 @@ func (b *Barnard) setTransmit(ui *uiterm.Ui, val int) { b.Notify("error", "me", "cannot transmit in muted channel") b.setTransmitting(false) b.UpdateGeneralStatus("cannot transmit in muted channel", true) + } else if b.selfMuted() { + b.Notify("error", "me", "cannot transmit while self-muted") + b.setTransmitting(false) + b.UpdateGeneralStatus("cannot transmit while self-muted", true) } else { b.setTransmitting(true) if b.ToneTest { @@ -640,6 +684,7 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews) b.Ui.AddKeyListener(b.OnAdminMenuPress, b.Hotkeys.AdminMenu) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) + b.Ui.AddKeyListener(b.OnSelfMuteToggle, b.Hotkeys.SelfMuteToggle) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) b.Ui.AddKeyListener(b.OnAGCToggle, b.Hotkeys.AGCToggle) diff --git a/ui_tree.go b/ui_tree.go index 3db51b0..8a5fa03 100644 --- a/ui_tree.go +++ b/ui_tree.go @@ -14,7 +14,13 @@ func (ti TreeItem) String() string { } if ti.User != nil { if ti.User.LocallyMuted() { - return "[MUTED] " + esc(ti.User.Name) + return "[LOCALLY MUTED] " + esc(ti.User.Name) + } + if ti.User.Muted { + return "[SERVER MUTED] " + esc(ti.User.Name) + } + if ti.User.SelfMuted { + return "[SELF MUTED] " + esc(ti.User.Name) } // Calculate total volume as percentage boostPercent := float32(ti.User.Boost()-1) * 10 @@ -76,6 +82,14 @@ func (b *Barnard) resetVolume(users []*gumble.User) { b.AddOutputLine("Volume: could not save reset: " + err.Error()) return } + if b.Client.Self != nil { + for _, user := range users { + if user != nil && user.Session == b.Client.Self.Session { + b.setSelfMute(false) + break + } + } + } b.withStream(func(stream *gumbleopenal.Stream) { for _, u := range users { stream.UpdateUserGain(u) @@ -148,7 +162,11 @@ func (b *Barnard) TreeItemBuild(item uiterm.TreeItem) []uiterm.TreeItem { totalVolume := user.Volume()*100 + boostPercent display := fmt.Sprintf("%s [%.0f%%]", esc(user.Name), totalVolume) if user.LocallyMuted() { - display = "[MUTED] " + display + display = "[LOCALLY MUTED] " + display + } else if user.Muted { + display = "[SERVER MUTED] " + display + } else if user.SelfMuted { + display = "[SELF MUTED] " + display } ul = append(ul, userDisplay{user: user, name: user.Name, session: user.Session, display: display}) } diff --git a/ui_tree_test.go b/ui_tree_test.go index 6d339d3..f2aa832 100644 --- a/ui_tree_test.go +++ b/ui_tree_test.go @@ -20,6 +20,42 @@ func TestMatchesVolumeResetKeyAcceptsBothTerminalBackspaces(t *testing.T) { } } +func TestTreeMuteDefaultMatchesLowerAndUpperM(t *testing.T) { + configured := uiterm.KeyM + for _, ch := range []rune{'m', 'M'} { + if !matchesCharacterHotkey(&configured, ch) { + t.Errorf("%q did not match tree mute binding", ch) + } + } +} + +func TestTreeItemDistinguishesMuteKinds(t *testing.T) { + tests := []struct { + name string + user func() *gumble.User + want string + }{ + { + name: "local mute", + user: func() *gumble.User { + u := &gumble.User{Name: "Username"} + u.SetLocallyMuted(true) + return u + }, + want: "[LOCALLY MUTED] Username", + }, + {name: "server mute", user: func() *gumble.User { return &gumble.User{Name: "Username", Muted: true} }, want: "[SERVER MUTED] Username"}, + {name: "self mute", user: func() *gumble.User { return &gumble.User{Name: "Username", SelfMuted: true} }, want: "[SELF MUTED] Username"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := (TreeItem{User: tt.user()}).String(); got != tt.want { + t.Fatalf("display = %q, want %q", got, tt.want) + } + }) + } +} + // Regression: rebuilding the channel tree ranged protocol-owned maps without // Client.Do while TCP handlers could add or remove users/channels. func TestTreeItemBuildReadsMapsUnderClientSnapshot(t *testing.T) { diff --git a/uiterm/key_toml.go b/uiterm/key_toml.go index ca6d59e..7e340cf 100644 --- a/uiterm/key_toml.go +++ b/uiterm/key_toml.go @@ -1,15 +1,31 @@ package uiterm +import ( + "fmt" + "unicode" + "unicode/utf8" +) + // MarshalText implements the encoding.TextMarshaler interface for Key // This allows TOML to serialize Key values as strings func (i Key) MarshalText() ([]byte, error) { + if r := rune(i); r >= 0x21 && r <= unicode.MaxASCII && unicode.IsPrint(r) { + return []byte(string(r)), nil + } return []byte(i.String()), nil } // UnmarshalText implements the encoding.TextUnmarshaler interface for Key // This allows TOML to deserialize strings back into Key values func (i *Key) UnmarshalText(data []byte) error { + if r, size := utf8.DecodeRune(data); r != utf8.RuneError && size == len(data) && unicode.IsPrint(r) { + *i = Key(unicode.ToLower(r)) + return nil + } var err error *i, err = KeyString(string(data)) + if err != nil { + return fmt.Errorf("invalid key %q: %w", data, err) + } return err } diff --git a/uiterm/key_toml_test.go b/uiterm/key_toml_test.go new file mode 100644 index 0000000..a54e2f3 --- /dev/null +++ b/uiterm/key_toml_test.go @@ -0,0 +1,35 @@ +package uiterm + +import "testing" + +func TestPrintableKeyTextRoundTrip(t *testing.T) { + encoded, err := KeyM.MarshalText() + if err != nil { + t.Fatal(err) + } + if string(encoded) != "m" { + t.Fatalf("encoded key = %q, want m", encoded) + } + + var decoded Key + if err := decoded.UnmarshalText([]byte("M")); err != nil { + t.Fatal(err) + } + if decoded != KeyM { + t.Fatalf("decoded key = %v, want %v", decoded, KeyM) + } +} + +func TestNamedKeyTextStillRoundTrips(t *testing.T) { + encoded, err := KeyCtrlT.MarshalText() + if err != nil { + t.Fatal(err) + } + var decoded Key + if err := decoded.UnmarshalText(encoded); err != nil { + t.Fatal(err) + } + if decoded != KeyCtrlT { + t.Fatalf("decoded key = %v, want %v", decoded, KeyCtrlT) + } +} diff --git a/uiterm/printable_keys.go b/uiterm/printable_keys.go new file mode 100644 index 0000000..a3b2710 --- /dev/null +++ b/uiterm/printable_keys.go @@ -0,0 +1,5 @@ +package uiterm + +// Printable keys normally arrive through a view's character handler rather +// than the special-key handler. +const KeyM Key = 'm'