From 46942aeeddb70d49c57ecc824cd0b7dd17aeb28d Mon Sep 17 00:00:00 2001 From: Denys Smirnov Date: Wed, 5 Aug 2026 13:52:23 +0200 Subject: [PATCH] Support static/required codec params. Fix codec order. --- amrwb/amrwb.go | 3 ++ codecs.go | 58 ++++++++++++++++++++++++++- sdp/codecs.go | 9 ++++- sdp/offer.go | 92 ++++++++++++++++++++++++++++++++++--------- sdp/offer_test.go | 99 +++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 223 insertions(+), 38 deletions(-) diff --git a/amrwb/amrwb.go b/amrwb/amrwb.go index 59445a7..244635a 100644 --- a/amrwb/amrwb.go +++ b/amrwb/amrwb.go @@ -46,6 +46,9 @@ func init() { Priority: -4, FileExt: "amrwb", Disabled: true, + ReqParams: []media.CodecParam{ + {Key: "octet-align", Val: "0"}, + }, }, func(w media.PCM16Writer) media.WriteCloser[Sample] { return Decode(w, RTPBandwidthEfficient) }, func(w media.WriteCloser[Sample]) media.PCM16Writer { diff --git a/codecs.go b/codecs.go index ec7b1d5..701a159 100644 --- a/codecs.go +++ b/codecs.go @@ -19,16 +19,72 @@ import ( "strings" ) +type CodecParams []CodecParam + +func (arr CodecParams) String() string { + params := make([]string, 0, len(arr)) + for _, p := range arr { + params = append(params, p.String()) + } + return strings.Join(params, ";") +} + +func (arr CodecParams) Get(key string) (string, bool) { + for _, p := range arr { + if p.Key == key { + return p.Val, true + } + } + return "", false +} +func (arr CodecParams) Has(key string) bool { + for _, p := range arr { + if p.Key == key { + return true + } + } + return false +} +func (arr CodecParams) HasValue(key, val string) bool { + for _, p := range arr { + if p.Key == key { + return p.Val == val + } + } + return false +} +func (arr CodecParams) HasParam(p2 CodecParam) bool { + for _, p := range arr { + if p.Key == p2.Key { + return p.Val == p2.Val + } + } + return false +} + +type CodecParam struct { + Key string + Val string +} + +func (p CodecParam) String() string { + if p.Val == "" { + return p.Key + } + return p.Key + "=" + p.Val +} + type CodecInfo struct { SDPName string SampleRate int RTPClockRate int RTPDefType byte RTPIsStatic bool - Priority int + Priority int // higher is preferable Disabled bool // codec is disabled in GlobalCodecs by default Hidden bool // codec should not appear in SDP offer, but can be used in the answer FileExt string + ReqParams CodecParams // a list of required codec params (fmtp) } type Codec interface { diff --git a/sdp/codecs.go b/sdp/codecs.go index d4f9060..e22a521 100644 --- a/sdp/codecs.go +++ b/sdp/codecs.go @@ -39,7 +39,7 @@ func init() { // CodecByNameWith finds the codec with a given SDP name. // If the codec is not found or disabled in the codec set, it returns nil. -func CodecByNameWith(s *media.CodecSet, name string) media.Codec { +func CodecByNameWith(s *media.CodecSet, name string, params media.CodecParams) media.Codec { if s == nil { s = media.GlobalCodecs() } @@ -47,6 +47,11 @@ func CodecByNameWith(s *media.CodecSet, name string) media.Codec { if !s.IsEnabled(c) { return nil } + for _, p := range c.Info().ReqParams { + if val, ok := params.Get(p.Key); ok && val != p.Val { + return nil + } + } return c } @@ -54,5 +59,5 @@ func CodecByNameWith(s *media.CodecSet, name string) media.Codec { // // Deprecated: use CodecByNameWith func CodecByName(name string) media.Codec { - return CodecByNameWith(media.GlobalCodecs(), name) + return CodecByNameWith(media.GlobalCodecs(), name, nil) } diff --git a/sdp/offer.go b/sdp/offer.go index fa48673..ae0f94a 100644 --- a/sdp/offer.go +++ b/sdp/offer.go @@ -61,12 +61,11 @@ func OfferCodecsWith(s *media.CodecSet) []CodecInfo { codecs := s.ListEnabled() slices.SortFunc(codecs, func(a, b media.Codec) int { ai, bi := a.Info(), b.Info() - if ai.RTPIsStatic != bi.RTPIsStatic { - if ai.RTPIsStatic { - return -1 - } else if bi.RTPIsStatic { - return 1 + if ai.Priority == bi.Priority { + if ai.SampleRate != bi.SampleRate { + return bi.SampleRate - ai.SampleRate } + return strings.Compare(ai.SDPName, bi.SDPName) } return bi.Priority - ai.Priority }) @@ -128,15 +127,22 @@ func OfferMediaWith(s *media.CodecSet, rtpListenerPort int, encrypted Encryption formats := make([]string, 0, len(codecs)) dtmfType := byte(0) for _, codec := range codecs { - if codec.Codec.Info().SDPName == dtmf.SDPNameAndRate { + ci := codec.Codec.Info() + if ci.SDPName == dtmf.SDPNameAndRate { dtmfType = codec.Type } styp := strconv.Itoa(int(codec.Type)) formats = append(formats, styp) attrs = append(attrs, sdp.Attribute{ Key: "rtpmap", - Value: styp + " " + codec.Codec.Info().SDPName, + Value: styp + " " + ci.SDPName, }) + if len(ci.ReqParams) != 0 { + attrs = append(attrs, sdp.Attribute{ + Key: "fmtp", + Value: styp + " " + ci.ReqParams.String(), + }) + } } if dtmfType > 0 { attrs = append(attrs, sdp.Attribute{ @@ -190,10 +196,16 @@ func AnswerMedia(rtpListenerPort int, audio *AudioConfig, crypt *srtp.Profile) * // Static compiler check for frame duration hardcoded below. var _ = [1]struct{}{}[20*time.Millisecond-rtp.DefFrameDur] - attrs := make([]sdp.Attribute, 0, 6) + attrs := make([]sdp.Attribute, 0, 7) + ac := audio.Codec.Info() attrs = append(attrs, sdp.Attribute{ - Key: "rtpmap", Value: fmt.Sprintf("%d %s", audio.Type, audio.Codec.Info().SDPName), + Key: "rtpmap", Value: fmt.Sprintf("%d %s", audio.Type, ac.SDPName), }) + if len(ac.ReqParams) != 0 { + attrs = append(attrs, sdp.Attribute{ + Key: "fmtp", Value: fmt.Sprintf("%d %s", audio.Type, ac.ReqParams.String()), + }) + } formats := make([]string, 0, 2) formats = append(formats, strconv.Itoa(int(audio.Type))) if audio.DTMFType != 0 { @@ -642,7 +654,25 @@ func parseSRTPProfile(val string) (*srtp.Profile, error) { // ParseMediaWith parses SDP media description based on the given codec set. func ParseMediaWith(s *media.CodecSet, d *sdp.MediaDescription) (*MediaDesc, error) { - var out MediaDesc + type codecInfo struct { + Type int + Name string + Params media.CodecParams + } + var ( + out MediaDesc + codecs []*codecInfo + ) + getCodec := func(typ int) *codecInfo { + for _, c := range codecs { + if c.Type == typ { + return c + } + } + c := &codecInfo{Type: typ} + codecs = append(codecs, c) + return c + } for _, m := range d.Attributes { switch m.Key { case "rtpmap": @@ -659,11 +689,26 @@ func ParseMediaWith(s *media.CodecSet, d *sdp.MediaDescription) (*MediaDesc, err out.DTMFType = byte(typ) continue } - codec, _ := CodecByNameWith(s, name).(media.AudioCodec) - out.Codecs = append(out.Codecs, CodecInfo{ - Type: byte(typ), - Codec: codec, - }) + c := getCodec(typ) + c.Name = name + case "fmtp": + sub := strings.SplitN(m.Value, " ", 2) + if len(sub) != 2 { + continue + } + typ, err := strconv.Atoi(sub[0]) + if err != nil { + continue + } + c := getCodec(typ) + for _, par := range strings.Split(sub[1], ";") { + p := media.CodecParam{Key: par} + if i := strings.IndexByte(par, '='); i >= 0 { + p.Key = par[:i] + p.Val = par[i+1:] + } + c.Params = append(c.Params, p) + } case "crypto": p, err := parseSRTPProfile(m.Value) if err != nil { @@ -685,12 +730,21 @@ func ParseMediaWith(s *media.CodecSet, d *sdp.MediaDescription) (*MediaDesc, err if err != nil { continue } - codec, _ := rtp.CodecByPayloadType(byte(typ)).(media.AudioCodec) - if !s.IsEnabled(codec) { - codec = nil + c := getCodec(typ) + _ = c // just add + } + for _, ci := range codecs { + var codec media.AudioCodec + if ci.Name != "" { + codec, _ = CodecByNameWith(s, ci.Name, ci.Params).(media.AudioCodec) + } else { + codec, _ = rtp.CodecByPayloadType(byte(ci.Type)).(media.AudioCodec) + if !s.IsEnabled(codec) { + codec = nil + } } out.Codecs = append(out.Codecs, CodecInfo{ - Type: byte(typ), + Type: byte(ci.Type), Codec: codec, }) } diff --git a/sdp/offer_test.go b/sdp/offer_test.go index 6ca8f2a..45e5e32 100644 --- a/sdp/offer_test.go +++ b/sdp/offer_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/require" "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/amrwb" "github.com/livekit/media-sdk/g711" "github.com/livekit/media-sdk/g722" "github.com/livekit/media-sdk/rtp" @@ -44,8 +45,14 @@ func getInline(s string) string { return s[i+len(word):] } +func codecSet() *media.CodecSet { + g := media.GlobalCodecs().NewSet() + g.SetEnabled(amrwb.SDPNameAndRate, true) + return g +} + func TestSDPMediaOffer(t *testing.T) { - g := media.GlobalCodecs() + g := codecSet() const port = 12345 _, offer, err := OfferMediaWith(g, port, EncryptionNone) @@ -55,14 +62,16 @@ func TestSDPMediaOffer(t *testing.T) { Media: "audio", Port: sdp.RangedPort{Value: port}, Protos: []string{"RTP", "AVP"}, - Formats: []string{"9", "0", "8", "101"}, + Formats: []string{"101", "9", "0", "8", "102"}, }, Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: "101 AMR-WB/16000"}, + {Key: "fmtp", Value: "101 octet-align=0"}, {Key: "rtpmap", Value: "9 G722/8000"}, {Key: "rtpmap", Value: "0 PCMU/8000"}, {Key: "rtpmap", Value: "8 PCMA/8000"}, - {Key: "rtpmap", Value: "101 telephone-event/8000"}, - {Key: "fmtp", Value: "101 0-16"}, + {Key: "rtpmap", Value: "102 telephone-event/8000"}, + {Key: "fmtp", Value: "102 0-16"}, {Key: "ptime", Value: "20"}, {Key: "sendrecv"}, }, @@ -79,14 +88,16 @@ func TestSDPMediaOffer(t *testing.T) { Media: "audio", Port: sdp.RangedPort{Value: port}, Protos: []string{"RTP", "SAVP"}, - Formats: []string{"9", "0", "8", "101"}, + Formats: []string{"101", "9", "0", "8", "102"}, }, Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: "101 AMR-WB/16000"}, + {Key: "fmtp", Value: "101 octet-align=0"}, {Key: "rtpmap", Value: "9 G722/8000"}, {Key: "rtpmap", Value: "0 PCMU/8000"}, {Key: "rtpmap", Value: "8 PCMA/8000"}, - {Key: "rtpmap", Value: "101 telephone-event/8000"}, - {Key: "fmtp", Value: "101 0-16"}, + {Key: "rtpmap", Value: "102 telephone-event/8000"}, + {Key: "fmtp", Value: "102 0-16"}, {Key: "crypto", Value: "1 AES_CM_128_HMAC_SHA1_80 inline:" + getInline(offer.Attributes[i+0].Value)}, {Key: "crypto", Value: "2 AES_CM_128_HMAC_SHA1_32 inline:" + getInline(offer.Attributes[i+1].Value)}, {Key: "crypto", Value: "3 AES_256_CM_HMAC_SHA1_80 inline:" + getInline(offer.Attributes[i+2].Value)}, @@ -96,10 +107,11 @@ func TestSDPMediaOffer(t *testing.T) { }, }, offer) - noG722 := g.NewSet() - noG722.SetEnabled(g722.SDPNameAndRate, false) + g2 := g.NewSet() + g2.SetEnabled(g722.SDPNameAndRate, false) + g2.SetEnabled(amrwb.SDPNameAndRate, false) - _, offer, err = OfferMediaWith(noG722, port, EncryptionNone) + _, offer, err = OfferMediaWith(g2, port, EncryptionNone) require.NoError(t, err) require.Equal(t, &sdp.MediaDescription{ MediaName: sdp.MediaName{ @@ -119,12 +131,12 @@ func TestSDPMediaOffer(t *testing.T) { }, offer) } -func getCodec(s *media.CodecSet, name string) media.AudioCodec { - return CodecByNameWith(s, name).(media.AudioCodec) +func getCodec(s *media.CodecSet, name string, params ...media.CodecParam) media.AudioCodec { + return CodecByNameWith(s, name, params).(media.AudioCodec) } func TestSDPMediaAnswer(t *testing.T) { - g := media.GlobalCodecs() + g := codecSet() const port = 12345 cases := []struct { name string @@ -310,6 +322,59 @@ func TestSDPMediaAnswer(t *testing.T) { Type: 8, }, }, + { + name: "amrwb no params", + offer: sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Formats: []string{"101", "0"}, + }, + Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: "101 AMR-WB/16000"}, + {Key: "rtpmap", Value: "0 PCMU/8000"}, + }, + }, + exp: &AudioConfig{ + // Pick AMR-WB, assume missing parameter matches ours. + Codec: getCodec(g, amrwb.SDPNameAndRate, media.CodecParam{"octet-align", "0"}), + Type: 101, + }, + }, + { + name: "amrwb bandwidth efficient", + offer: sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Formats: []string{"101", "0"}, + }, + Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: "101 AMR-WB/16000"}, + {Key: "fmtp", Value: "101 octet-align=0;mode-change-capability=2"}, + {Key: "rtpmap", Value: "0 PCMU/8000"}, + }, + }, + exp: &AudioConfig{ + // Pick AMR-WB, encoding matches, ignore other params. + Codec: getCodec(g, amrwb.SDPNameAndRate, media.CodecParam{"octet-align", "0"}), + Type: 101, + }, + }, + { + name: "amrwb octet aligned", + offer: sdp.MediaDescription{ + MediaName: sdp.MediaName{ + Formats: []string{"101", "0"}, + }, + Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: "101 AMR-WB/16000"}, + {Key: "fmtp", Value: "101 octet-align=1;mode-change-capability=2"}, + {Key: "rtpmap", Value: "0 PCMU/8000"}, + }, + }, + exp: &AudioConfig{ + // Pick PCMU, encoding doesn't match. + Codec: getCodec(g, g711.ULawSDPNameAndRate), + Type: 0, + }, + }, } for _, c := range cases { c := c @@ -333,14 +398,16 @@ func TestSDPMediaAnswer(t *testing.T) { Media: "audio", Port: sdp.RangedPort{Value: port}, Protos: []string{"RTP", "AVP"}, - Formats: []string{"9", "0", "8", "101"}, + Formats: []string{"101", "9", "0", "8", "102"}, }, Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: "101 AMR-WB/16000"}, + {Key: "fmtp", Value: "101 octet-align=0"}, {Key: "rtpmap", Value: "9 G722/8000"}, {Key: "rtpmap", Value: "0 PCMU/8000"}, {Key: "rtpmap", Value: "8 PCMA/8000"}, - {Key: "rtpmap", Value: "101 telephone-event/8000"}, - {Key: "fmtp", Value: "101 0-16"}, + {Key: "rtpmap", Value: "102 telephone-event/8000"}, + {Key: "fmtp", Value: "102 0-16"}, {Key: "ptime", Value: "20"}, {Key: "sendrecv"}, },