fix: add synchronization to User audio fields to prevent data races

User.AudioSource, Boost, Volume, and LocallyMuted were accessed from
both the OnAudioStream audio goroutine and the UI goroutine without
synchronization, a data race under the Go memory model.

Replace direct field access with thread-safe getter/setter methods
protected by a per-user mutex:
- SetAudioSource/GetAudioSource for the OpenAL source pointer
- SetBoost/Boost for the audio boost multiplier
- SetVolume/Volume for the volume level
- SetLocallyMuted/LocallyMuted for the local mute state

Update all call sites across config/, barnard.go, client.go,
ui_tree.go, and stream.go.
This commit is contained in:
Brandon McGinty (deepseek)
2026-08-08 20:58:53 -04:00
committed by Brandon McGinty
parent 1bdd7ac52e
commit 9dd0137975
6 changed files with 112 additions and 51 deletions
+10 -10
View File
@@ -115,17 +115,17 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm
users := makeUsersArray(treeItem.Channel.Users) users := makeUsersArray(treeItem.Channel.Users)
for _, u := range users { for _, u := range users {
// Explicitly set user mute state to match channel state // Explicitly set user mute state to match channel state
if channelWillBeMuted && !u.LocallyMuted { if channelWillBeMuted && !u.LocallyMuted() {
b.UserConfig.ToggleMute(u) b.UserConfig.ToggleMute(u)
} else if !channelWillBeMuted && u.LocallyMuted { } else if !channelWillBeMuted && u.LocallyMuted() {
b.UserConfig.ToggleMute(u) b.UserConfig.ToggleMute(u)
} }
if u.AudioSource != nil { if au := u.AudioSource(); au != nil {
if u.LocallyMuted { if u.LocallyMuted() {
u.AudioSource.SetGain(0) au.SetGain(0)
} else { } else {
u.AudioSource.SetGain(u.Volume) au.SetGain(u.Volume())
} }
} }
} }
@@ -159,11 +159,11 @@ func (b *Barnard) TreeItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm
if key == *b.Hotkeys.MuteToggle { if key == *b.Hotkeys.MuteToggle {
// Toggle mute for single user // Toggle mute for single user
b.UserConfig.ToggleMute(treeItem.User) b.UserConfig.ToggleMute(treeItem.User)
if treeItem.User.AudioSource != nil { if au := treeItem.User.AudioSource(); au != nil {
if treeItem.User.LocallyMuted { if treeItem.User.LocallyMuted() {
treeItem.User.AudioSource.SetGain(0) au.SetGain(0)
} else { } else {
treeItem.User.AudioSource.SetGain(treeItem.User.Volume) au.SetGain(treeItem.User.Volume())
} }
} }
b.RebuildUserChannelTreePreservingSelection() b.RebuildUserChannelTreePreservingSelection()
+3 -3
View File
@@ -179,11 +179,11 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
// If the channel is muted, ensure the user is muted // If the channel is muted, ensure the user is muted
if b.MutedChannels[e.User.Channel.ID] { if b.MutedChannels[e.User.Channel.ID] {
// Only mute if not already muted // Only mute if not already muted
if !e.User.LocallyMuted { if !e.User.LocallyMuted() {
b.UserConfig.ToggleMute(e.User) b.UserConfig.ToggleMute(e.User)
} }
if e.User.AudioSource != nil { if au := e.User.AudioSource(); au != nil {
e.User.AudioSource.SetGain(0) au.SetGain(0)
} }
} }
} }
+9 -9
View File
@@ -253,7 +253,7 @@ func (c *Config) findUser(address string, username string) *eUser {
func (c *Config) ToggleMute(u *gumble.User) { func (c *Config) ToggleMute(u *gumble.User) {
j := c.findUser(u.GetClient().Config.Address, u.Name) j := c.findUser(u.GetClient().Config.Address, u.Name)
j.LocallyMuted = !j.LocallyMuted j.LocallyMuted = !j.LocallyMuted
u.LocallyMuted = j.LocallyMuted u.SetLocallyMuted(j.LocallyMuted)
c.SaveConfig() c.SaveConfig()
} }
@@ -329,11 +329,11 @@ func (c *Config) UpdateUser(u *gumble.User) {
uc = u.GetClient() uc = u.GetClient()
if uc != nil { if uc != nil {
j = c.findUser(uc.Config.Address, u.Name) j = c.findUser(uc.Config.Address, u.Name)
u.Boost = j.Boost u.SetBoost(j.Boost)
u.Volume = j.Volume u.SetVolume(j.Volume)
u.LocallyMuted = j.LocallyMuted // Update LocallyMuted state from config u.SetLocallyMuted(j.LocallyMuted) // Update LocallyMuted state from config
if u.Boost < 1 { if u.Boost() < 1 {
u.Boost = 1 u.SetBoost(1)
} }
} }
} }
@@ -341,9 +341,9 @@ func (c *Config) UpdateUser(u *gumble.User) {
func (c *Config) UpdateConfig(u *gumble.User) { func (c *Config) UpdateConfig(u *gumble.User) {
var j *eUser var j *eUser
j = c.findUser(u.GetClient().Config.Address, u.Name) j = c.findUser(u.GetClient().Config.Address, u.Name)
j.Boost = u.Boost j.Boost = u.Boost()
j.Volume = u.Volume j.Volume = u.Volume()
j.LocallyMuted = u.LocallyMuted // Save LocallyMuted state to config j.LocallyMuted = u.LocallyMuted() // Save LocallyMuted state to config
} }
func NewConfig(fn *string) *Config { func NewConfig(fn *string) *Config {
+68 -7
View File
@@ -1,6 +1,8 @@
package gumble package gumble
import ( import (
"sync"
"git.stormux.org/storm/barnard/gumble/go-openal/openal" "git.stormux.org/storm/barnard/gumble/go-openal/openal"
"git.stormux.org/storm/barnard/gumble/gumble/MumbleProto" "git.stormux.org/storm/barnard/gumble/gumble/MumbleProto"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@@ -31,9 +33,6 @@ type User struct {
PrioritySpeaker bool PrioritySpeaker bool
// Is the user recording audio? // Is the user recording audio?
Recording bool Recording bool
// Has the user been locally muted by the client?
LocallyMuted bool
// The user's comment. Contains the empty string if the user does not have a // The user's comment. Contains the empty string if the user does not have a
// comment, or if the comment needs to be requested. // comment, or if the comment needs to be requested.
Comment string Comment string
@@ -58,14 +57,76 @@ type User struct {
audioSequence int64 audioSequence int64
audioSequenceValid bool audioSequenceValid bool
AudioSource *openal.Source // audioMu protects audio-related fields accessed from both the
Boost uint16 // audio processing goroutine (OnAudioStream) and the UI goroutine.
Volume float32 audioMu sync.Mutex
audioSource *openal.Source
boost uint16
volume float32
locallyMuted bool
}
// SetAudioSource sets the user's OpenAL audio source (thread-safe).
func (u *User) SetAudioSource(src *openal.Source) {
u.audioMu.Lock()
u.audioSource = src
u.audioMu.Unlock()
}
// AudioSource returns the user's OpenAL audio source (thread-safe).
// The caller must not retain the pointer across unlock boundaries;
// it is only valid while the caller ensures the source is not deleted.
func (u *User) AudioSource() *openal.Source {
u.audioMu.Lock()
defer u.audioMu.Unlock()
return u.audioSource
}
// SetBoost sets the user's audio boost multiplier (thread-safe).
func (u *User) SetBoost(b uint16) {
u.audioMu.Lock()
u.boost = b
u.audioMu.Unlock()
}
// Boost returns the user's audio boost multiplier (thread-safe).
func (u *User) Boost() uint16 {
u.audioMu.Lock()
defer u.audioMu.Unlock()
return u.boost
}
// SetVolume sets the user's volume level (thread-safe).
func (u *User) SetVolume(v float32) {
u.audioMu.Lock()
u.volume = v
u.audioMu.Unlock()
}
// Volume returns the user's volume level (thread-safe).
func (u *User) Volume() float32 {
u.audioMu.Lock()
defer u.audioMu.Unlock()
return u.volume
}
// SetLocallyMuted sets whether the user is locally muted (thread-safe).
func (u *User) SetLocallyMuted(m bool) {
u.audioMu.Lock()
u.locallyMuted = m
u.audioMu.Unlock()
}
// LocallyMuted returns whether the user is locally muted (thread-safe).
func (u *User) LocallyMuted() bool {
u.audioMu.Lock()
defer u.audioMu.Unlock()
return u.locallyMuted
} }
// IsMuted returns true if the user is muted either server-side or locally // IsMuted returns true if the user is muted either server-side or locally
func (u *User) IsMuted() bool { func (u *User) IsMuted() bool {
return u.Muted || u.LocallyMuted return u.Muted || u.LocallyMuted()
} }
func (u *User) GetClient() *Client { func (u *User) GetClient() *Client {
+10 -10
View File
@@ -236,13 +236,13 @@ func (s *Stream) SetMicVolume(change float32, relative bool) {
func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) { func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
go func(e *gumble.AudioStreamEvent) { go func(e *gumble.AudioStreamEvent) {
var source = openal.NewSource() var source = openal.NewSource()
e.User.AudioSource = &source e.User.SetAudioSource(&source)
// Set initial gain based on volume and mute state // Set initial gain based on volume and mute state
if e.User.LocallyMuted { if e.User.LocallyMuted() {
e.User.AudioSource.SetGain(0) source.SetGain(0)
} else { } else {
e.User.AudioSource.SetGain(e.User.Volume) source.SetGain(e.User.Volume())
} }
bufferCount := e.Client.Config.Buffers bufferCount := e.Client.Config.Buffers
@@ -263,17 +263,17 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
for packet := range e.C { for packet := range e.C {
// Skip processing if user is locally muted // Skip processing if user is locally muted
if e.User.LocallyMuted { if e.User.LocallyMuted() {
continue continue
} }
var boost uint16 = uint16(1)
samples := len(packet.AudioBuffer) samples := len(packet.AudioBuffer)
if samples > cap(raw)/2 { if samples > cap(raw)/2 {
continue continue
} }
boost = e.User.Boost boost := e.User.Boost()
userVolume := e.User.Volume()
recorder := s.getRecorder() recorder := s.getRecorder()
var recordBuffer []int16 var recordBuffer []int16
recordPtr := 0 recordPtr := 0
@@ -306,7 +306,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
} }
if recorder != nil { if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume) recordBuffer[recordPtr] = scaleForRecording(sample, userVolume)
recordPtr++ recordPtr++
} }
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
@@ -325,7 +325,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
} }
if recorder != nil { if recorder != nil {
recordBuffer[recordPtr] = scaleForRecording(sample, e.User.Volume) recordBuffer[recordPtr] = scaleForRecording(sample, userVolume)
recordPtr++ recordPtr++
} }
binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample)) binary.LittleEndian.PutUint16(raw[rawPtr:], uint16(sample))
@@ -346,7 +346,7 @@ func (s *Stream) OnAudioStream(e *gumble.AudioStreamEvent) {
} }
} }
if recorder != nil { if recorder != nil {
recordSample := scaleForRecording(sample, e.User.Volume) recordSample := scaleForRecording(sample, userVolume)
recordBuffer[recordPtr] = recordSample recordBuffer[recordPtr] = recordSample
recordBuffer[recordPtr+1] = recordSample recordBuffer[recordPtr+1] = recordSample
recordPtr += 2 recordPtr += 2
+12 -12
View File
@@ -9,12 +9,12 @@ import (
func (ti TreeItem) String() string { func (ti TreeItem) String() string {
if ti.User != nil { if ti.User != nil {
if ti.User.LocallyMuted { if ti.User.LocallyMuted() {
return "[MUTED] " + ti.User.Name return "[MUTED] " + ti.User.Name
} }
// Calculate total volume as percentage // Calculate total volume as percentage
boostPercent := float32(ti.User.Boost-1) * 10 boostPercent := float32(ti.User.Boost()-1) * 10
totalVolume := ti.User.Volume*100 + boostPercent totalVolume := ti.User.Volume()*100 + boostPercent
return fmt.Sprintf("%s [%.0f%%]", ti.User.Name, totalVolume) return fmt.Sprintf("%s [%.0f%%]", ti.User.Name, totalVolume)
} }
if ti.Channel != nil { if ti.Channel != nil {
@@ -35,7 +35,7 @@ func (ti TreeItem) TreeItemStyle(fg, bg uiterm.Attribute, active bool) (uiterm.A
func (b *Barnard) changeVolume(users []*gumble.User, change float32) { func (b *Barnard) changeVolume(users []*gumble.User, change float32) {
for _, u := range users { for _, u := range users {
au := u.AudioSource au := u.AudioSource()
if au == nil { if au == nil {
continue continue
} }
@@ -43,7 +43,7 @@ func (b *Barnard) changeVolume(users []*gumble.User, change float32) {
var cv float32 var cv float32
var ng float32 var ng float32
var curboost float32 var curboost float32
curboost = float32((u.Boost - 1)) / 10 curboost = float32((u.Boost() - 1)) / 10
cv = au.GetGain() + curboost cv = au.GetGain() + curboost
ng = cv + change ng = cv + change
boost = uint16(1) boost = uint16(1)
@@ -56,9 +56,9 @@ func (b *Barnard) changeVolume(users []*gumble.User, change float32) {
if ng < 0 { if ng < 0 {
ng = 0.0 ng = 0.0
} }
u.Boost = boost u.SetBoost(boost)
u.Volume = ng u.SetVolume(ng)
if !u.LocallyMuted { if !u.LocallyMuted() {
au.SetGain(ng) au.SetGain(ng)
} }
b.UserConfig.UpdateConfig(u) b.UserConfig.UpdateConfig(u)
@@ -68,14 +68,14 @@ func (b *Barnard) changeVolume(users []*gumble.User, change float32) {
func (b *Barnard) resetVolume(users []*gumble.User) { func (b *Barnard) resetVolume(users []*gumble.User) {
for _, u := range users { for _, u := range users {
au := u.AudioSource au := u.AudioSource()
if au == nil { if au == nil {
continue continue
} }
// Reset to original volume (1.0) and boost (1) // Reset to original volume (1.0) and boost (1)
u.Boost = uint16(1) u.SetBoost(uint16(1))
u.Volume = 1.0 u.SetVolume(1.0)
if !u.LocallyMuted { if !u.LocallyMuted() {
au.SetGain(1.0) au.SetGain(1.0)
} }
b.UserConfig.UpdateConfig(u) b.UserConfig.UpdateConfig(u)