From 3dc77cedd66ffdf1df8ebb6ca07b8bf96c89e97e Mon Sep 17 00:00:00 2001 From: Brandon McGinty Date: Thu, 20 Aug 2026 14:39:46 -0400 Subject: [PATCH] add audio tests against a live Mumble server Add integration tests behind a build tag that connect to a local Mumble server and check the audio path end to end. Unit tests cover the codec and the packet format separately, but neither catches a mismatch that only appears against a real server. These send a known tone and verify the frequency, packet ordering, and sequence continuity that come back, at 10 ms and longer intervals, over native UDP and over the TCP tunnel. Add a reference-vector test for the 1.5 protobuf envelope carried inside the TCP tunnel. Co-Authored-By: Claude Opus 5 --- gumble/gumble/integration_audio_test.go | 141 ++++++++++++++++++++++ gumble/gumble/integration_quality_test.go | 45 +++++++ gumble/gumble/tcp_audio_protobuf_test.go | 40 ++++++ 3 files changed, 226 insertions(+) create mode 100644 gumble/gumble/integration_audio_test.go create mode 100644 gumble/gumble/integration_quality_test.go create mode 100644 gumble/gumble/tcp_audio_protobuf_test.go diff --git a/gumble/gumble/integration_audio_test.go b/gumble/gumble/integration_audio_test.go new file mode 100644 index 0000000..f958114 --- /dev/null +++ b/gumble/gumble/integration_audio_test.go @@ -0,0 +1,141 @@ +//go:build integration + +package gumble_test + +import ( + "crypto/tls" + "fmt" + "math" + "net" + "os" + "testing" + "time" + + "git.stormux.org/storm/barnard/gumble/gumble" + _ "git.stormux.org/storm/barnard/gumble/opus" +) + +type integrationAudioListener struct{ packets chan *gumble.AudioPacket } + +func (l *integrationAudioListener) OnAudioStream(e *gumble.AudioStreamEvent) { + go func() { + for p := range e.C { + l.packets <- p + } + }() +} + +// TestLocalMumbleAudioRoundTrip sends generated 440 Hz audio through a real +// local Mumble server and requires the other client to decode non-silent PCM. +func TestLocalMumbleAudioRoundTrip(t *testing.T) { + testLocalMumbleAudioRoundTrip(t, false, gumble.AudioDefaultInterval) +} + +// Regression: non-default permitted intervals must preserve generated audio +// instead of using the old fixed-10ms bitrate calculation. +func TestLocalMumbleAudioTwentyMilliseconds(t *testing.T) { + testLocalMumbleAudioRoundTrip(t, false, 20*time.Millisecond) +} + +func TestLocalMumbleAudioFortyMilliseconds(t *testing.T) { + testLocalMumbleAudioRoundTrip(t, false, 40*time.Millisecond) +} + +func TestLocalMumbleAudioSixtyMilliseconds(t *testing.T) { + testLocalMumbleAudioRoundTrip(t, false, 60*time.Millisecond) +} + +// Regression: TCP tunnel fallback must remain usable when UDP is deliberately +// disabled or blocked, rather than silently dropping valid audio. +func TestLocalMumbleTCPAudioFallback(t *testing.T) { + testLocalMumbleAudioRoundTrip(t, true, gumble.AudioDefaultInterval) +} + +func testLocalMumbleAudioRoundTrip(t *testing.T, disableUDP bool, interval time.Duration) { + if os.Getenv("BARNARD_MUMBLE_INTEGRATION") != "1" { + t.Skip("set BARNARD_MUMBLE_INTEGRATION=1") + } + newConfig := func(name string) *gumble.Config { + c := gumble.NewConfig() + c.Address = "localhost:64738" + c.Username = name + c.DisableUDP = disableUDP + c.AudioInterval = interval + return c + } + tlsConfig := &tls.Config{InsecureSkipVerify: true} + listener := &integrationAudioListener{packets: make(chan *gumble.AudioPacket, 8)} + recvConfig := newConfig(fmt.Sprintf("barnard-it-recv-%d", time.Now().UnixNano())) + recvConfig.AttachAudio(listener) + receiver, err := gumble.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, recvConfig, tlsConfig) + if err != nil { + t.Fatal(err) + } + defer receiver.Disconnect() + sendConfig := newConfig(fmt.Sprintf("barnard-it-send-%d", time.Now().UnixNano())) + sender, err := gumble.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, sendConfig, tlsConfig) + if err != nil { + t.Fatal(err) + } + defer sender.Disconnect() + if sender.AudioEncoder == nil { + t.Fatal("no negotiated audio encoder") + } + time.Sleep(1500 * time.Millisecond) + if disableUDP { + if sender.UDPActive() || receiver.UDPActive() { + t.Fatal("UDP activated despite TCP-only configuration") + } + } else if !sender.UDPActive() || !receiver.UDPActive() { + t.Fatalf("native UDP did not become active: sender=%v receiver=%v", sender.UDPActive(), receiver.UDPActive()) + } + frame := make([]int16, sendConfig.AudioFrameSize()) + for i := 0; i < 8; i++ { + for sample := range frame { + index := i*len(frame) + sample + frame[sample] = int16(12000 * math.Sin(2*math.Pi*440*float64(index)/gumble.AudioSampleRate)) + } + raw, err := sender.AudioEncoder.Encode(frame, len(frame), sendConfig.AudioDataBytes) + if err != nil { + t.Fatal(err) + } + if err := sender.WriteAudio(4, 0, int64(i), i == 7, raw, nil, nil, nil); err != nil { + t.Fatal(err) + } + } + // Discard the first few decoder warm-up frames before measuring pitch. + var packet *gumble.AudioPacket + var previousSequence int64 + frameStep := int64(sendConfig.AudioFrameSize() / gumble.AudioDefaultFrameSize) + for i := 0; i < 3; i++ { + select { + case packet = <-listener.packets: + if i > 0 && packet.Sequence != previousSequence+frameStep { + t.Fatalf("choppy relay: sequence gap %d -> %d", previousSequence, packet.Sequence) + } + previousSequence = packet.Sequence + case <-time.After(8 * time.Second): + t.Fatal("timed out waiting for relayed audio") + } + } + select { + case packet = <-listener.packets: + if packet.Sequence != previousSequence+frameStep { + t.Fatalf("choppy relay: sequence gap %d -> %d", previousSequence, packet.Sequence) + } + frequency, purity, peak := audioQuality(packet.AudioBuffer) + if peak < 500 { + t.Fatalf("received silent audio peak=%d", peak) + } + if math.Abs(frequency-440) > 120 { + t.Fatalf("received frequency %.1f Hz, want generated 440 Hz", frequency) + } + // A clean sine projects strongly onto its fundamental. This detects + // severe codec distortion beyond simple packet arrival and pitch checks. + if purity < 0.65 { + t.Fatalf("received audio is distorted: 440 Hz purity=%.2f", purity) + } + case <-time.After(8 * time.Second): + t.Fatal("timed out waiting for relayed audio") + } +} diff --git a/gumble/gumble/integration_quality_test.go b/gumble/gumble/integration_quality_test.go new file mode 100644 index 0000000..aa7e383 --- /dev/null +++ b/gumble/gumble/integration_quality_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package gumble_test + +import ( + "math" + + "git.stormux.org/storm/barnard/gumble/gumble" +) + +// audioQuality returns the rising-crossing pitch estimate, the fraction of +// RMS energy explained by the 440 Hz fundamental, and the sample peak. +func audioQuality(audio gumble.AudioBuffer) (frequency, purity float64, peak int) { + if len(audio) < gumble.AudioChannels { + return 0, 0, 0 + } + samples := len(audio) / gumble.AudioChannels + crossings, previous := 0, 0 + var energy, sine, cosine float64 + for i := 0; i < samples; i++ { + value := int(audio[i*gumble.AudioChannels]) + if value < 0 { + if -value > peak { + peak = -value + } + } else if value > peak { + peak = value + } + if previous <= 0 && value > 0 { + crossings++ + } + previous = value + x := float64(value) + phase := 2 * math.Pi * 440 * float64(i) / gumble.AudioSampleRate + energy += x * x + sine += x * math.Sin(phase) + cosine += x * math.Cos(phase) + } + frequency = float64(crossings*gumble.AudioSampleRate) / float64(samples) + if energy != 0 { + // Projection amplitude divided by RMS, normalized for sine RMS. + purity = math.Sqrt(2) * math.Hypot(sine, cosine) / math.Sqrt(energy*float64(samples)) + } + return +} diff --git a/gumble/gumble/tcp_audio_protobuf_test.go b/gumble/gumble/tcp_audio_protobuf_test.go new file mode 100644 index 0000000..b17ea2c --- /dev/null +++ b/gumble/gumble/tcp_audio_protobuf_test.go @@ -0,0 +1,40 @@ +package gumble + +import ( + "net" + "testing" +) + +// Regression: Mumble 1.5 decodes UDPTunnel packets as native protobuf UDP +// envelopes. Sending the legacy tunnel envelope made a current server silently +// discard otherwise valid Opus audio when UDP was unavailable. +func TestWriteAudioUsesProtobufEnvelopeForTCPFallback(t *testing.T) { + local, remote := net.Pipe() + defer remote.Close() + c := &Client{Config: NewConfig(), Conn: NewConn(local), udpProtobuf: true} + c.Config.DisableUDP = true + result := make(chan struct { + typ uint16 + data []byte + err error + }, 1) + go func() { + typ, data, err := NewConn(remote).ReadPacket() + result <- struct { + typ uint16 + data []byte + err error + }{typ, data, err} + }() + if err := c.WriteAudio(4, 2, 300, true, []byte{0xaa, 0xbb}, nil, nil, nil); err != nil { + t.Fatal(err) + } + got := <-result + if got.err != nil || got.typ != 1 { + t.Fatalf("packet: type=%d err=%v", got.typ, got.err) + } + want := mustDecodeHex("00080220ac022a02aabb800101") + if string(got.data) != string(want) { + t.Fatalf("payload=%x want=%x", got.data, want) + } +}