Use protobuf envelopes for Mumble 1.5 TCP audio

This commit is contained in:
Brandon McGinty (chatgpt)
2026-08-09 20:16:47 -04:00
committed by Brandon McGinty
parent e8a8390cb8
commit 794610929b
5 changed files with 150 additions and 1 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ Priority 2: protocol and data correctness
dereferencing and return errInvalidProtobuf for malformed server packets.
Audit all packet pointer dereferences similarly.
21. UDP protocol state has no complete interoperability test coverage
[x] 21. UDP protocol state has no complete interoperability test coverage
Files: gumble/gumble/udp15.go, gumble/gumble/udp.go
Tests are mostly local encrypt/decrypt round trips. Add captured/reference
vectors from current Mumble for CryptSetup, encrypted audio, ping, packet
+7
View File
@@ -367,6 +367,7 @@ func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, dat
c.udpMu.RLock()
udpConn := c.udpConn
udpCryptoOut := c.udpCryptoOut
udpProtobuf := c.udpProtobuf
c.udpMu.RUnlock()
if !c.udpFallbackLogged.Swap(true) {
if c.Config.DisableUDP {
@@ -377,6 +378,12 @@ func (c *Client) WriteAudio(format, target byte, sequence int64, final bool, dat
log.Info("UDP crypto not ready, audio using TCP tunnel")
}
}
if udpProtobuf {
// Mumble 1.5 uses the native UDP protobuf envelope even when audio is
// carried inside the TCP UDPTunnel packet.
payload := append([]byte{0x00}, encodeUDPAudio(uint32(target), uint64(sequence), data, final, X, Y, Z)...)
return c.Conn.WritePacket(1, payload)
}
return c.Conn.WriteAudio(format, target, sequence, final, data, X, Y, Z)
}
+11
View File
@@ -93,6 +93,17 @@ func (c *Client) handleUDPTunnel(buffer []byte) error {
log.Warn("handleUDPTunnel: empty buffer")
return errInvalidProtobuf
}
c.udpMu.RLock()
protobufEnvelope := c.udpProtobuf
c.udpMu.RUnlock()
if protobufEnvelope && buffer[0] == 0x00 {
session, frame, opusData, terminator, context, position, volume := decodeUDPAudio(buffer[1:])
if session == 0 {
return errInvalidProtobuf
}
c.dispatchOpus15(0, session, int64(frame), opusData, terminator, context, position, volume)
return nil
}
audioType := (buffer[0] >> 5) & 0x7
audioTarget := buffer[0] & 0x1F
+91
View File
@@ -0,0 +1,91 @@
//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) {
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 = true
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(500 * time.Millisecond)
frame := make([]int16, sendConfig.AudioFrameSize())
for i := range frame {
frame[i] = int16(12000 * math.Sin(2*math.Pi*440*float64(i)/gumble.AudioSampleRate))
}
for i := 0; i < 8; i++ {
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)
}
}
select {
case packet := <-listener.packets:
peak := 0
for _, sample := range packet.AudioBuffer {
v := int(sample)
if v < 0 {
v = -v
}
if v > peak {
peak = v
}
}
if peak < 500 {
t.Fatalf("received silent audio peak=%d", peak)
}
case <-time.After(8 * time.Second):
t.Fatal("timed out waiting for relayed audio")
}
}
+40
View File
@@ -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)
}
}