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