handle dropped packets and network delays
Add a per-user jitter buffer with restart resync. Incoming audio was rendered straight to OpenAL as packets arrived, so normal network jitter produced gaps and the renderer starved between frames. Buffer each user's decoded frames for a configurable playout delay (-jitter-buffer, default 40ms) and let the render thread pull from that buffer instead. Drop late packets, and use packet duration to set the expected frame number. A packet below the expected frame number is dropped rather than stalling delivery, and the expected frame is stepped by the packet's real duration instead of a fixed 10ms. After detecting a large backward jump in frame numbering, along with a sustained run of late packets, resync to the new frame numbering. When a sender switches audio devices, Mumble destroys and recreates their audio stream. This resets their frame number to zero without sending a terminator packet, which causes a potentially endless hang while we wait for a frame number that will not arrive for hours. Own the OpenAL context on one dedicated render thread. Contexts are current to an OS thread, so creating sources and buffers from whichever goroutine happened to be running could operate on no context at all. Every source and buffer is now created, filled, and deleted on that thread, and file playback renders through it too. Report device and capture failures with the device name. Startup errors said only that a device could not be opened, and a capture stall was reported as a microphone failure even though it is normal scheduler timing. Fatal microphone errors now exit instead of leaving a client that cannot transmit. Release queued buffers and deactivate the context on shutdown. Streams were torn down while buffers were still queued on a source, and the context was destroyed while still current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5ec82eb1fd
commit
6dcaa7f7a6
+715
-181
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
package gumbleopenal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/barnard/gumble/go-openal/openal"
|
||||
"git.stormux.org/storm/barnard/gumble/gumble"
|
||||
)
|
||||
|
||||
// Regression: audio cleanup could send a final render command after Destroy
|
||||
// had closed renderCh, panicking instead of safely discarding that work.
|
||||
// Regression: StopSource returned before the capture worker ended, allowing
|
||||
// Destroy to close the device while that worker still used it.
|
||||
// Regression: OpenAL returned only a generic input/output error, hiding the
|
||||
// actual configured device that a user must correct.
|
||||
func TestDeviceOpenErrorsIncludeConfiguredDevice(t *testing.T) {
|
||||
input := openInputDeviceError("virtual_mic.monitor", openal.FormatMono16)
|
||||
if !errors.Is(input, ErrInputDevice) || !strings.Contains(input.Error(), "virtual_mic.monitor") {
|
||||
t.Fatalf("input error %q", input)
|
||||
}
|
||||
output := openOutputDeviceError("")
|
||||
if !errors.Is(output, ErrOutputDevice) || !strings.Contains(output.Error(), "default") {
|
||||
t.Fatalf("output error %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: later capture start failures also omitted the configured device.
|
||||
func TestStartSourceUnavailableDeviceIncludesName(t *testing.T) {
|
||||
s := &Stream{inputDeviceName: "virtual_mic.monitor"}
|
||||
err := s.StartSource(nil)
|
||||
if !errors.Is(err, ErrMic) || !strings.Contains(err.Error(), "virtual_mic.monitor") {
|
||||
t.Fatalf("start error %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopSourceWaitsForWorker(t *testing.T) {
|
||||
stop, done := make(chan bool), make(chan struct{})
|
||||
s := &Stream{sourceStop: stop, sourceDone: done}
|
||||
returned := make(chan struct{})
|
||||
go func() { _ = s.StopSource(); close(returned) }()
|
||||
select {
|
||||
case <-returned:
|
||||
t.Fatal("StopSource returned before worker")
|
||||
default:
|
||||
}
|
||||
close(done)
|
||||
<-returned
|
||||
}
|
||||
|
||||
func TestJitterPlaybackDelayAppliesOnlyAtStartup(t *testing.T) {
|
||||
if jitterPlaybackReady(false, 20*time.Millisecond, 40*time.Millisecond) {
|
||||
t.Fatal("jitter playback started before initial buffer filled")
|
||||
}
|
||||
if !jitterPlaybackReady(false, 40*time.Millisecond, 40*time.Millisecond) {
|
||||
t.Fatal("jitter playback did not start after initial buffer filled")
|
||||
}
|
||||
if !jitterPlaybackReady(true, 0, 40*time.Millisecond) {
|
||||
t.Fatal("jitter playback paused while refilling after startup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJitterResyncsAfterSenderRestartsSequence(t *testing.T) {
|
||||
// Mumble restarts frame numbering at zero when the sender switches audio
|
||||
// devices mid-burst, and sends no terminator to announce it.
|
||||
if !jitterShouldResync(jitterLateResync, 52724) {
|
||||
t.Fatal("jitter did not resync after the sender restarted its frame numbering")
|
||||
}
|
||||
if jitterShouldResync(jitterLateResync-1, 52724) {
|
||||
t.Fatal("jitter resynced before the late run was conclusive")
|
||||
}
|
||||
// A clump of reordered packets is bounded and recovers on its own; it must
|
||||
// not drag the expected sequence backwards.
|
||||
if jitterShouldResync(jitterLateResync, jitterResyncJump-1) {
|
||||
t.Fatal("jitter resynced on a backwards jump small enough to be reordering")
|
||||
}
|
||||
if jitterShouldResync(1, 52724) {
|
||||
t.Fatal("jitter resynced on a single late packet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioPacketDurationUsesStereoFrameCount(t *testing.T) {
|
||||
packet := &gumble.AudioPacket{AudioBuffer: make(gumble.AudioBuffer, 2*gumble.AudioDefaultFrameSize)}
|
||||
if got := audioPacketDuration(packet); got != 10*time.Millisecond {
|
||||
t.Fatalf("audioPacketDuration = %v, want 10ms", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsWorkAfterShutdown(t *testing.T) {
|
||||
s := &Stream{renderClosed: true}
|
||||
called := false
|
||||
if s.render(func() { called = true }) {
|
||||
t.Fatal("closed renderer accepted work")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("closed renderer executed work")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user