stop file playback processes and their children reliably

Kill ffmpeg's whole process group rather than just the process.
ffmpeg spawns helpers for some inputs, so stopping playback left them
running and holding the output pipe open. The player now starts the
command in its own process group and signals the group.

Wait for the playback worker to finish before starting a new file.
Playing a second file while the first was shutting down left two
workers writing to the same stream.

Make pausing nonblocking.
The pause path could block on a full pipe and hang the UI thread that
requested it.

Install and reset the stereo encoder under the client lock.
File playback swapped the encoder field directly while a voice frame
could be encoding with it, and a finished file left the encoder
carrying state into the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon McGinty
2026-08-20 14:42:52 -04:00
co-authored by Claude Opus 5
parent fbb6a148ff
commit c5baaae6a7
9 changed files with 189 additions and 80 deletions
+13 -1
View File
@@ -396,6 +396,13 @@ func (c *Client) Send(message Message) {
message.writeMessage(c)
}
// SetStereoEncoder installs the encoder used for stereo file playback.
func (c *Client) SetStereoEncoder(encoder AudioEncoder) {
c.volatile.Lock()
defer c.volatile.Unlock()
c.AudioEncoderStereo = encoder
}
// EnableStereoEncoder switches to stereo encoding for file playback.
func (c *Client) EnableStereoEncoder() {
c.volatile.Lock()
@@ -448,11 +455,16 @@ func (c *Client) UDPActive() bool {
return c.udpActive
}
// DisableStereoEncoder switches back to mono encoding for voice.
// DisableStereoEncoder switches back to mono encoding for voice and
// resets the stereo encoder so stale state does not bleed into the
// next file playback.
func (c *Client) DisableStereoEncoder() {
c.volatile.Lock()
defer c.volatile.Unlock()
c.useStereoEncoder = false
if c.AudioEncoderStereo != nil {
c.AudioEncoderStereo.Reset()
}
}
// IsStereoEncoderEnabled returns true if stereo encoding is currently active.
+11 -3
View File
@@ -57,7 +57,7 @@ func New(client *gumble.Client, source Source) *Stream {
Volume: 1.0,
Source: source,
Command: "ffmpeg",
pause: make(chan struct{}),
pause: make(chan struct{}, 1),
state: StateInitial,
}
}
@@ -124,7 +124,12 @@ func (s *Stream) Pause() error {
}
s.state = StatePaused
s.l.Unlock()
s.pause <- struct{}{}
// The process can exit after the state check. A buffered, coalesced pause
// request preserves the state transition without blocking the caller.
select {
case s.pause <- struct{}{}:
default:
}
return nil
}
@@ -176,9 +181,12 @@ func (s *Stream) process() {
return
}
int16Buffer := make([]int16, frameSize)
s.l.Lock()
volume := s.Volume
s.l.Unlock()
for i := range int16Buffer {
float := float32(int16(binary.LittleEndian.Uint16(byteBuffer[i*2 : (i+1)*2])))
int16Buffer[i] = int16(s.Volume * float)
int16Buffer[i] = int16(volume * float)
}
atomic.AddInt64(&s.elapsed, int64(interval))
outgoing <- gumble.AudioBuffer(int16Buffer)
+22
View File
@@ -0,0 +1,22 @@
package gumbleffmpeg
import (
"testing"
"time"
)
// Regression: Pause sent on an unbuffered channel after the process had
// exited, leaving callers blocked forever.
func TestPauseDoesNotBlockWhenProcessHasExited(t *testing.T) {
s := &Stream{state: StatePlaying, pause: make(chan struct{}, 1)}
done := make(chan error, 1)
go func() { done <- s.Pause() }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("Pause blocked after process exit")
}
}