Files
mediamtx/internal/protocols/webrtc/from_stream.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

731 lines
17 KiB
Go

package webrtc
import (
"crypto/rand"
"errors"
"fmt"
"slices"
"time"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpav1"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph264"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph265"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtplpcm"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpvp8"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpvp9"
"github.com/bluenviron/mediacommon/v2/pkg/codecs/g711"
"github.com/bluenviron/mediacommon/v2/pkg/codecs/opus"
"github.com/bluenviron/mediamtx/internal/formatlabel"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/stream"
"github.com/bluenviron/mediamtx/internal/unit"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
const (
webrtcPayloadMaxSize = 1188 // 1200 - 12 (RTP header)
)
var multichannelOpusSDP = map[int]string{
3: "channel_mapping=0,2,1;num_streams=2;coupled_streams=1",
4: "channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2",
5: "channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2",
6: "channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2",
7: "channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4",
8: "channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4",
}
var errNoSupportedCodecsFrom = errors.New(
"the stream doesn't contain any supported codec, which are currently " +
"AV1, VP9, VP8, H265, H264, Opus, G722, G711, LPCM")
func ptrOf[T any](v T) *T {
p := new(T)
*p = v
return p
}
func randUint32() (uint32, error) {
var b [4]byte
_, err := rand.Read(b[:])
if err != nil {
return 0, err
}
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]), nil
}
func multiplyAndDivide2(v, m, d time.Duration) time.Duration {
secs := v / d
dec := v % d
return (secs*m + dec*m/d)
}
func timestampToDuration(t int64, clockRate int) time.Duration {
return multiplyAndDivide2(time.Duration(t), time.Second, time.Duration(clockRate))
}
func setupVideoTrack(
desc *description.Session,
r *stream.Reader,
) (*OutgoingTrack, error) {
var av1Format *format.AV1
media := desc.FindFormat(&av1Format)
if av1Format != nil { //nolint:dupl
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeAV1,
ClockRate: 90000,
},
}
encoder := &rtpav1.Encoder{
PayloadType: 105,
PayloadMaxSize: webrtcPayloadMaxSize,
}
err := encoder.Init()
if err != nil {
return nil, err
}
r.OnData(
media,
av1Format,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
packets, err2 := encoder.Encode(u.Payload.(unit.PayloadAV1))
if err2 != nil {
return nil //nolint:nilerr
}
for _, pkt := range packets {
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp), 90000))
pkt.Timestamp += u.RTPPackets[0].Timestamp
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
var vp9Format *format.VP9
media = desc.FindFormat(&vp9Format)
if vp9Format != nil {
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP9,
ClockRate: 90000,
SDPFmtpLine: "profile-id=0",
},
}
encoder := &rtpvp9.Encoder{
PayloadType: 96,
PayloadMaxSize: webrtcPayloadMaxSize,
InitialPictureID: ptrOf(uint16(8445)),
}
err := encoder.Init()
if err != nil {
return nil, err
}
r.OnData(
media,
vp9Format,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
packets, err2 := encoder.Encode(u.Payload.(unit.PayloadVP9))
if err2 != nil {
return nil //nolint:nilerr
}
for _, pkt := range packets {
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp), 90000))
pkt.Timestamp += u.RTPPackets[0].Timestamp
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
var vp8Format *format.VP8
media = desc.FindFormat(&vp8Format)
if vp8Format != nil { //nolint:dupl
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8,
ClockRate: 90000,
},
}
encoder := &rtpvp8.Encoder{
PayloadType: 96,
PayloadMaxSize: webrtcPayloadMaxSize,
}
err := encoder.Init()
if err != nil {
return nil, err
}
r.OnData(
media,
vp8Format,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
packets, err2 := encoder.Encode(u.Payload.(unit.PayloadVP8))
if err2 != nil {
return nil //nolint:nilerr
}
for _, pkt := range packets {
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp), 90000))
pkt.Timestamp += u.RTPPackets[0].Timestamp
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
var h265Format *format.H265
media = desc.FindFormat(&h265Format)
if h265Format != nil { //nolint:dupl
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH265,
ClockRate: 90000,
SDPFmtpLine: "level-id=93;profile-id=1;tier-flag=0;tx-mode=SRST",
},
}
encoder := &rtph265.Encoder{
PayloadType: 96,
PayloadMaxSize: webrtcPayloadMaxSize,
}
err := encoder.Init()
if err != nil {
return nil, err
}
firstReceived := false
var lastPTS int64
r.OnData(
media,
h265Format,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
if !firstReceived {
firstReceived = true
} else if u.PTS < lastPTS {
return fmt.Errorf("WebRTC doesn't support H265 streams with B-frames")
}
lastPTS = u.PTS
packets, err2 := encoder.Encode(u.Payload.(unit.PayloadH265))
if err2 != nil {
return nil //nolint:nilerr
}
for _, pkt := range packets {
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp), 90000))
pkt.Timestamp += u.RTPPackets[0].Timestamp
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
var h264Format *format.H264
media = desc.FindFormat(&h264Format)
if h264Format != nil { //nolint:dupl
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
},
}
encoder := &rtph264.Encoder{
PayloadType: 96,
PayloadMaxSize: webrtcPayloadMaxSize,
}
err := encoder.Init()
if err != nil {
return nil, err
}
firstReceived := false
var lastPTS int64
r.OnData(
media,
h264Format,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
if !firstReceived {
firstReceived = true
} else if u.PTS < lastPTS {
return fmt.Errorf("WebRTC doesn't support H264 streams with B-frames")
}
lastPTS = u.PTS
packets, err2 := encoder.Encode(u.Payload.(unit.PayloadH264))
if err2 != nil {
return nil //nolint:nilerr
}
for _, pkt := range packets {
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp), 90000))
pkt.Timestamp += u.RTPPackets[0].Timestamp
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
return nil, nil
}
func setupAudioTrack(
desc *description.Session,
r *stream.Reader,
) (*OutgoingTrack, error) {
var opusFormat *format.Opus
media := desc.FindFormat(&opusFormat)
if opusFormat != nil {
var caps webrtc.RTPCodecCapability
switch opusFormat.ChannelCount {
case 1, 2:
caps = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus,
ClockRate: 48000,
Channels: 2,
SDPFmtpLine: func() string {
s := "minptime=10;useinbandfec=1"
if opusFormat.ChannelCount == 2 {
s += ";stereo=1;sprop-stereo=1"
}
return s
}(),
}
case 3, 4, 5, 6, 7, 8:
caps = webrtc.RTPCodecCapability{
MimeType: mimeTypeMultiopus,
ClockRate: 48000,
Channels: uint16(opusFormat.ChannelCount),
SDPFmtpLine: multichannelOpusSDP[opusFormat.ChannelCount],
}
default:
return nil, fmt.Errorf("unsupported channel count: %d", opusFormat.ChannelCount)
}
track := &OutgoingTrack{
Caps: caps,
}
curTimestamp, err := randUint32()
if err != nil {
return nil, err
}
r.OnData(
media,
opusFormat,
func(u *unit.Unit) error {
baseTimestamp := curTimestamp
for _, orig := range u.RTPPackets {
// create a copy of the packet that we can edit freely
pkt := &rtp.Packet{
Header: orig.Header,
Payload: orig.Payload,
}
// recompute timestamp from scratch.
// Chrome requires a precise timestamp that FFmpeg doesn't provide.
pkt.Timestamp = curTimestamp
curTimestamp += uint32(opus.PacketDuration2(pkt.Payload))
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp-baseTimestamp), 48000))
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
var g722Format *format.G722
media = desc.FindFormat(&g722Format)
if g722Format != nil {
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeG722,
ClockRate: 8000,
},
}
r.OnData(
media,
g722Format,
func(u *unit.Unit) error {
for _, pkt := range u.RTPPackets {
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp-u.RTPPackets[0].Timestamp), 8000))
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
var g711Format *format.G711
media = desc.FindFormat(&g711Format)
if g711Format != nil {
// These are the sample rates and channels supported by Chrome.
// Different sample rates and channels can be streamed too but we don't want compatibility issues.
// https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_coding/codecs/pcm16b/audio_decoder_pcm16b.cc#23
if g711Format.ClockRate() != 8000 && g711Format.ClockRate() != 16000 &&
g711Format.ClockRate() != 32000 && g711Format.ClockRate() != 48000 {
return nil, fmt.Errorf("unsupported clock rate: %d", g711Format.ClockRate())
}
if g711Format.ChannelCount != 1 && g711Format.ChannelCount != 2 {
return nil, fmt.Errorf("unsupported channel count: %d", g711Format.ChannelCount)
}
var caps webrtc.RTPCodecCapability
if g711Format.ClockRate() == 8000 {
if g711Format.MULaw {
if g711Format.ChannelCount != 1 {
caps = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMU,
ClockRate: uint32(g711Format.ClockRate()),
Channels: uint16(g711Format.ChannelCount),
}
} else {
caps = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMU,
ClockRate: 8000,
}
}
} else {
if g711Format.ChannelCount != 1 {
caps = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMA,
ClockRate: uint32(g711Format.ClockRate()),
Channels: uint16(g711Format.ChannelCount),
}
} else {
caps = webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMA,
ClockRate: 8000,
}
}
}
} else {
caps = webrtc.RTPCodecCapability{
MimeType: mimeTypeL16,
ClockRate: uint32(g711Format.ClockRate()),
Channels: uint16(g711Format.ChannelCount),
}
}
track := &OutgoingTrack{
Caps: caps,
}
if g711Format.ClockRate() == 8000 {
curTimestamp, err := randUint32()
if err != nil {
return nil, err
}
r.OnData(
media,
g711Format,
func(u *unit.Unit) error {
baseTimestamp := curTimestamp
for _, orig := range u.RTPPackets {
// create a copy of the packet that we can edit freely
pkt := &rtp.Packet{
Header: orig.Header,
Payload: orig.Payload,
}
// recompute timestamp from scratch.
// Chrome requires a precise timestamp that FFmpeg doesn't provide.
pkt.Timestamp = curTimestamp
curTimestamp += uint32(len(pkt.Payload)) / uint32(g711Format.ChannelCount)
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp-baseTimestamp), 8000))
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
} else {
encoder := &rtplpcm.Encoder{
PayloadType: 96,
PayloadMaxSize: webrtcPayloadMaxSize,
BitDepth: 16,
ChannelCount: g711Format.ChannelCount,
}
err := encoder.Init()
if err != nil {
return nil, err
}
curTimestamp, err := randUint32()
if err != nil {
return nil, err
}
r.OnData(
media,
g711Format,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
var lpcm []byte
if g711Format.MULaw {
var mu g711.Mulaw
mu.Unmarshal(u.Payload.(unit.PayloadG711))
lpcm = mu
} else {
var al g711.Alaw
al.Unmarshal(u.Payload.(unit.PayloadG711))
lpcm = al
}
packets, err2 := encoder.Encode(lpcm)
if err2 != nil {
return nil //nolint:nilerr
}
baseTimestamp := curTimestamp
for _, pkt := range packets {
// recompute timestamp from scratch.
// Chrome requires a precise timestamp that FFmpeg doesn't provide.
pkt.Timestamp = curTimestamp
curTimestamp += uint32(len(pkt.Payload)) / 2 / uint32(g711Format.ChannelCount)
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp-baseTimestamp), g711Format.ClockRate()))
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
}
return track, nil
}
var lpcmFormat *format.LPCM
media = desc.FindFormat(&lpcmFormat)
if lpcmFormat != nil {
if lpcmFormat.BitDepth != 16 {
return nil, fmt.Errorf("unsupported LPCM bit depth: %d", lpcmFormat.BitDepth)
}
// These are the sample rates and channels supported by Chrome.
// Different sample rates and channels can be streamed too but we don't want compatibility issues.
// https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_coding/codecs/pcm16b/audio_decoder_pcm16b.cc#23
if lpcmFormat.ClockRate() != 8000 && lpcmFormat.ClockRate() != 16000 &&
lpcmFormat.ClockRate() != 32000 && lpcmFormat.ClockRate() != 48000 {
return nil, fmt.Errorf("unsupported clock rate: %d", lpcmFormat.ClockRate())
}
if lpcmFormat.ChannelCount != 1 && lpcmFormat.ChannelCount != 2 {
return nil, fmt.Errorf("unsupported channel count: %d", lpcmFormat.ChannelCount)
}
track := &OutgoingTrack{
Caps: webrtc.RTPCodecCapability{
MimeType: mimeTypeL16,
ClockRate: uint32(lpcmFormat.ClockRate()),
Channels: uint16(lpcmFormat.ChannelCount),
},
}
encoder := &rtplpcm.Encoder{
PayloadType: 96,
BitDepth: 16,
ChannelCount: lpcmFormat.ChannelCount,
PayloadMaxSize: webrtcPayloadMaxSize,
}
err := encoder.Init()
if err != nil {
return nil, err
}
curTimestamp, err := randUint32()
if err != nil {
return nil, err
}
r.OnData(
media,
lpcmFormat,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
packets, err2 := encoder.Encode(u.Payload.(unit.PayloadLPCM))
if err2 != nil {
return nil //nolint:nilerr
}
baseTimestamp := curTimestamp
for _, pkt := range packets {
// recompute timestamp from scratch.
// Chrome requires a precise timestamp that FFmpeg doesn't provide.
pkt.Timestamp = curTimestamp
curTimestamp += uint32(len(pkt.Payload)) / 2 / uint32(lpcmFormat.ChannelCount)
ntp := u.NTP.Add(timestampToDuration(int64(pkt.Timestamp-baseTimestamp), lpcmFormat.ClockRate()))
track.WriteRTPWithNTP(pkt, ntp) //nolint:errcheck
}
return nil
})
return track, nil
}
return nil, nil
}
func setupKLVDataChannel(
desc *description.Session,
r *stream.Reader,
) (*OutgoingDataChannel, error) {
var klvFormat *format.KLV
media := desc.FindFormat(&klvFormat)
if klvFormat != nil {
dataChan := &OutgoingDataChannel{
Label: "KLV",
}
r.OnData(
media,
klvFormat,
func(u *unit.Unit) error {
if u.NilPayload() {
return nil
}
dataChan.Write(u.Payload.(unit.PayloadKLV))
return nil
})
return dataChan, nil
}
return nil, nil
}
// FromStream maps a MediaMTX stream to a WebRTC connection
func FromStream(
desc *description.Session,
r *stream.Reader,
pc *PeerConnection,
) error {
videoTrack, err := setupVideoTrack(desc, r)
if err != nil {
return err
}
if videoTrack != nil {
pc.OutgoingTracks = append(pc.OutgoingTracks, videoTrack)
}
audioTrack, err := setupAudioTrack(desc, r)
if err != nil {
return err
}
if audioTrack != nil {
pc.OutgoingTracks = append(pc.OutgoingTracks, audioTrack)
}
klvDataChan, err := setupKLVDataChannel(desc, r)
if err != nil {
return err
}
if klvDataChan != nil {
pc.OutgoingDataChannels = append(pc.OutgoingDataChannels, klvDataChan)
}
if len(pc.OutgoingTracks) == 0 && len(pc.OutgoingDataChannels) == 0 {
return errNoSupportedCodecsFrom
}
setuppedFormats := r.Formats()
n := 1
for _, media := range desc.Medias {
for _, forma := range media.Formats {
if !slices.Contains(setuppedFormats, forma) {
r.Parent.Log(logger.Warn, "skipping track %d (%s)", n, formatlabel.FormatToLabel(forma))
}
n++
}
}
return nil
}