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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b77a491ec9
commit
3dc77cedd6
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user