Add mnemonic mute controls

This commit is contained in:
Storm Dragon
2026-09-05 00:57:37 -04:00
parent ef19a558d2
commit 97fcde7e96
13 changed files with 232 additions and 17 deletions
+4 -2
View File
@@ -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
- <kbd>F1</kbd>: toggle voice transmission
- <kbd>Ctrl+T</kbd>: toggle voice transmission
- <kbd>Alt+M</kbd>: toggle your Mumble self-mute state from anywhere
- <kbd>F9</kbd>: toggle noise suppression
- <kbd>F12</kbd>: toggle automatic gain control
- <kbd>F10</kbd>: 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)
- <kbd>End</kbd>: scroll chat to the bottom
- <kbd>Ctrl+Q</kbd>: 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
+28 -6
View File
@@ -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)
+18
View File
@@ -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}
+1
View File
@@ -22,4 +22,5 @@ type Hotkeys struct {
AdminMenu *uiterm.Key
NoiseSuppressionToggle *uiterm.Key
AGCToggle *uiterm.Key
SelfMuteToggle *uiterm.Key
}
+9 -4
View File
@@ -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 {
+7 -3
View File
@@ -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)
}
+8
View File
@@ -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"
+45
View File
@@ -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)
+20 -2
View File
@@ -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})
}
+36
View File
@@ -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) {
+16
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+5
View File
@@ -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'