Files
mediamtx/internal/protocols/webrtc/from_stream_test.go
T
8568d8c57c webrtc: fix random absolute timestamps with Opus, G711 and LPCM (#5597)
When rewriting audio RTP timestamps in WebRTC egress, NTP was
derived using regenerated packet timestamps minus the incoming
RTP base timestamp.

That mixed timestamp domains and could shift absolute time by an
arbitrary offset while still exposing mapping as available.

Fix by using a consistent outgoing RTP domain in rewritten audio
paths:
- snapshot outgoing base timestamp before rewriting each unit
- compute NTP from (outgoing packet timestamp - outgoing base
  timestamp)

This keeps RTP<->NTP mapping coherent for sender reports and prevents
random absolute-time offsets in WebRTC loopback with
useAbsoluteTimestamp.

---------

Co-authored-by: aler9 <46489434+aler9@users.noreply.github.com>
2026-03-21 19:46:39 +01:00

386 lines
8.1 KiB
Go

package webrtc
import (
"fmt"
"testing"
"time"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/stream"
"github.com/bluenviron/mediamtx/internal/test"
"github.com/bluenviron/mediamtx/internal/unit"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
)
func TestFromStreamNoSupportedCodecs(t *testing.T) {
desc := &description.Session{Medias: []*description.Media{{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.MJPEG{}},
}}}
r := &stream.Reader{
Parent: test.Logger(func(logger.Level, string, ...any) {
t.Error("should not happen")
}),
}
pc := &PeerConnection{}
err := FromStream(desc, r, pc)
require.Equal(t, errNoSupportedCodecsFrom, err)
}
func TestFromStreamSkipUnsupportedTracks(t *testing.T) {
desc := &description.Session{Medias: []*description.Media{
{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.H264{}},
},
{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.MJPEG{}},
},
}}
n := 0
r := &stream.Reader{
Parent: test.Logger(func(l logger.Level, format string, args ...any) {
require.Equal(t, logger.Warn, l)
if n == 0 {
require.Equal(t, "skipping track 2 (M-JPEG)", fmt.Sprintf(format, args...))
}
n++
}),
}
pc := &PeerConnection{}
err := FromStream(desc, r, pc)
require.NoError(t, err)
require.Equal(t, 1, n)
}
func TestFromStream(t *testing.T) {
for _, ca := range toFromStreamCases {
t.Run(ca.name, func(t *testing.T) {
desc := &description.Session{
Medias: []*description.Media{{
Formats: []format.Format{ca.in},
}},
}
pc := &PeerConnection{}
r := &stream.Reader{Parent: test.NilLogger}
err := FromStream(desc, r, pc)
require.NoError(t, err)
require.Equal(t, ca.webrtcCaps, pc.OutgoingTracks[0].Caps)
})
}
}
func TestFromStreamResampleOpus(t *testing.T) {
strm := &stream.Stream{
Desc: &description.Session{Medias: []*description.Media{
{
Type: description.MediaTypeAudio,
Formats: []format.Format{&format.Opus{
ChannelCount: 2,
}},
},
}},
WriteQueueSize: 512,
RTPMaxPayloadSize: 1450,
ReplaceNTP: false,
Parent: test.NilLogger,
}
err := strm.Initialize()
require.NoError(t, err)
subStream := &stream.SubStream{
Stream: strm,
UseRTPPackets: true,
}
err = subStream.Initialize()
require.NoError(t, err)
pc1 := &PeerConnection{
LocalRandomUDP: true,
IPsFromInterfaces: true,
Publish: false,
Log: test.NilLogger,
}
err = pc1.Start()
require.NoError(t, err)
defer pc1.Close()
pc2 := &PeerConnection{
LocalRandomUDP: true,
IPsFromInterfaces: true,
Publish: true,
Log: test.NilLogger,
}
r := &stream.Reader{Parent: nil}
err = FromStream(strm.Desc, r, pc2)
require.NoError(t, err)
err = pc2.Start()
require.NoError(t, err)
defer pc2.Close()
offer, err := pc1.CreatePartialOffer()
require.NoError(t, err)
answer, err := pc2.CreateFullAnswer(offer)
require.NoError(t, err)
err = pc1.SetAnswer(answer)
require.NoError(t, err)
err = pc1.WaitUntilConnected(10 * time.Second)
require.NoError(t, err)
err = pc2.WaitUntilConnected(10 * time.Second)
require.NoError(t, err)
strm.AddReader(r)
defer strm.RemoveReader(r)
subStream.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{
PTS: 0,
NTP: time.Now(),
RTPPackets: []*rtp.Packet{{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 111,
SequenceNumber: 1123,
Timestamp: 45343,
SSRC: 563424,
},
Payload: []byte{1},
}},
})
subStream.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{
PTS: 0,
NTP: time.Now(),
RTPPackets: []*rtp.Packet{{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 111,
SequenceNumber: 1124,
Timestamp: 45343,
SSRC: 563424,
},
Payload: []byte{1},
}},
})
err = pc1.GatherIncomingTracks(2 * time.Second)
require.NoError(t, err)
tracks := pc1.IncomingTracks()
done := make(chan struct{})
n := 0
var ts uint32
tracks[0].OnPacketRTP = func(pkt *rtp.Packet) {
n++
switch n {
case 1:
ts = pkt.Timestamp
case 2:
require.Equal(t, uint32(960), pkt.Timestamp-ts)
close(done)
}
}
pc1.StartReading()
<-done
}
func TestFromStreamResampleOpusAbsoluteTimestamp(t *testing.T) {
strm := &stream.Stream{
Desc: &description.Session{Medias: []*description.Media{
{
Type: description.MediaTypeAudio,
Formats: []format.Format{&format.Opus{
ChannelCount: 2,
}},
},
}},
WriteQueueSize: 512,
RTPMaxPayloadSize: 1450,
ReplaceNTP: false,
Parent: test.NilLogger,
}
err := strm.Initialize()
require.NoError(t, err)
subStream := &stream.SubStream{
Stream: strm,
UseRTPPackets: true,
}
err = subStream.Initialize()
require.NoError(t, err)
pcReader := &PeerConnection{
LocalRandomUDP: true,
IPsFromInterfaces: true,
Publish: false,
Log: test.NilLogger,
}
err = pcReader.Start()
require.NoError(t, err)
t.Cleanup(pcReader.Close)
pcPublisher := &PeerConnection{
LocalRandomUDP: true,
IPsFromInterfaces: true,
Publish: true,
Log: test.NilLogger,
}
r := &stream.Reader{Parent: nil}
err = FromStream(strm.Desc, r, pcPublisher)
require.NoError(t, err)
err = pcPublisher.Start()
require.NoError(t, err)
t.Cleanup(pcPublisher.Close)
offer, err := pcReader.CreatePartialOffer()
require.NoError(t, err)
answer, err := pcPublisher.CreateFullAnswer(offer)
require.NoError(t, err)
err = pcReader.SetAnswer(answer)
require.NoError(t, err)
err = pcReader.WaitUntilConnected(10 * time.Second)
require.NoError(t, err)
err = pcPublisher.WaitUntilConnected(10 * time.Second)
require.NoError(t, err)
strm.AddReader(r)
t.Cleanup(func() { strm.RemoveReader(r) })
baseNTP := time.Unix(1710000000, 0)
step := 20 * time.Millisecond
// prime the pipeline to allow track gathering
subStream.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{
PTS: 0,
NTP: baseNTP,
RTPPackets: []*rtp.Packet{{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 111,
SequenceNumber: 1123,
Timestamp: 45343,
SSRC: 563424,
},
Payload: []byte{1},
}},
})
err = pcReader.GatherIncomingTracks(2 * time.Second)
require.NoError(t, err)
tracks := pcReader.IncomingTracks()
require.Len(t, tracks, 1)
done := make(chan struct{})
errCh := make(chan string, 1)
const startSeq = uint16(2000)
expectedNTP := func(seq uint16) (time.Time, bool) {
if seq < startSeq {
return time.Time{}, false
}
return baseNTP.Add(time.Duration(seq-startSeq) * step), true
}
tracks[0].OnPacketRTP = func(pkt *rtp.Packet) {
expected, ok := expectedNTP(pkt.SequenceNumber)
if !ok {
return
}
ntp, avail := tracks[0].PacketNTP(pkt)
if !avail {
return
}
if ntp.Sub(expected).Abs() > 50*time.Millisecond {
select {
case errCh <- fmt.Sprintf("absolute NTP mismatch for seq=%d: got=%v expected=%v",
pkt.SequenceNumber, ntp, expected):
default:
}
return
}
select {
case done <- struct{}{}:
default:
}
}
pcReader.StartReading()
go func() {
ticker := time.NewTicker(step)
defer ticker.Stop()
for i := range uint16(150) {
seq := startSeq + i
expected, _ := expectedNTP(seq)
subStream.WriteUnit(strm.Desc.Medias[0], strm.Desc.Medias[0].Formats[0], &unit.Unit{
PTS: 0,
NTP: expected,
RTPPackets: []*rtp.Packet{{
Header: rtp.Header{
Version: 2,
Marker: true,
PayloadType: 111,
SequenceNumber: seq,
Timestamp: 45343,
SSRC: 563424,
},
Payload: []byte{1},
}},
})
<-ticker.C
}
}()
select {
case <-done:
case err := <-errCh:
t.Fatal(err)
case <-time.After(8 * time.Second):
t.Fatal("absolute timestamp mapping did not become available")
}
}