Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion pkg/sip/media_port.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,15 @@ func (p *mediaPort) GenerateAnswer(offerData []byte) ([]byte, error) {
return p.GetLocalSDP()
}

offer, err := parseOfferWith(p.log, p.mon, p.codecs, offerData)
// Strip AMR/AMR-WB formats we cannot answer per RFC 4867 (e.g. octet-align=1)
// before negotiation, then echo required fmtp on the answer (livekit/sip#747).
filteredOffer, err := filterAMROfferSDP(offerData)
if err != nil {
// Non-fatal: fall back to the original offer and let media-sdk parse it.
p.log.Debugw("cannot filter AMR formats from offer", "error", err)
filteredOffer = offerData
}
offer, err := parseOfferWith(p.log, p.mon, p.codecs, filteredOffer)
if err != nil {
return nil, SDPError{Err: err}
}
Expand All @@ -797,6 +805,7 @@ func (p *mediaPort) GenerateAnswer(offerData []byte) ([]byte, error) {
if err != nil {
return nil, SDPError{Err: err}
}
appendAMRFmtpToAnswer(&answer.SDP, filteredOffer)

answerData, err := answer.SDP.Marshal()
if err != nil {
Expand Down
310 changes: 310 additions & 0 deletions pkg/sip/sdp_amr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
package sip

import (
"strings"

"github.com/pion/sdp/v3"
)

// AMR/AMR-WB SDP fmtp handling for answers (livekit/sip#747).
//
// media-sdk registers AMR/AMR-WB as bandwidth-efficient only (octet-align=0),
// and AnswerMedia emits rtpmap without echoing offer fmtp. Accepting an
// octet-aligned offer (or other unsupported modes) therefore produces a
// mismatched answer and garbled audio. RFC 4867 §8.3.1 requires the answerer
// to echo certain parameters for the accepted payload type (or reject it).
//
// Until media-sdk can negotiate octet-aligned RTP we:
// 1. strip unsupported AMR formats from the offer before answering, and
// 2. echo the required fmtp attributes for any AMR format we accept.

var amrAnswerUnsupported = map[string]func(string) bool{
// Default is 0 (bandwidth-efficient). octet-align=1 needs a different
// RTP payload format that media-sdk does not implement yet.
"octet-align": func(v string) bool { return v == "1" },
// CRC-protected frames are not implemented.
"crc": func(v string) bool { return v == "1" },
// Robust sorting is not implemented.
"robust-sorting": func(v string) bool { return v == "1" },
// Interleaving requires a different de-interleave path.
"interleaving": func(v string) bool { return v != "" && v != "0" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Zero interleaving corrupts AMR audio

An AMR offer with interleaving=0 passes amrFmtpUnsupported. Any interleaving parameter implies octet alignment, but the decoder expects bandwidth-efficient packets.

Learn more

RFC 4867 defines the presence of interleaving as implying octet-aligned payload framing. A value of zero creates an interleaving group containing one frame-block, but it does not switch framing back to bandwidth-efficient mode. The local AMR-WB codec only implements bandwidth-efficient framing, so every offer containing this parameter is unsupported.

Example: A peer offers octet-align=0;interleaving=0. The filter retains the payload, negotiation selects it, and the peer sends octet-aligned packets while the local decoder parses bandwidth-efficient packets.

Recommended fix: Reject AMR payload types whenever the interleaving key is present, regardless of its value.

Suggested change
"interleaving": func(v string) bool { return v != "" && v != "0" },
"interleaving": func(string) bool { return true },

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

func isAMRName(name string) bool {
n := strings.ToUpper(name)
return n == "AMR" || n == "AMR-WB"
}

func parseFmtpParams(fmtp string) map[string]string {
out := make(map[string]string)
for _, part := range strings.Split(fmtp, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
out[strings.ToLower(strings.TrimSpace(part))] = ""
continue
}
out[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
return out
}

func amrFmtpUnsupported(fmtp string) bool {
if fmtp == "" {
// No fmtp → octet-align defaults to 0 (bandwidth-efficient).
return false
}
params := parseFmtpParams(fmtp)
for key, bad := range amrAnswerUnsupported {
if v, ok := params[key]; ok && bad(v) {
return true
}
}
return false
}

func amrFmtpForAnswer(offerFmtp string) string {
if offerFmtp == "" || amrFmtpUnsupported(offerFmtp) {
return ""
}
params := parseFmtpParams(offerFmtp)
var parts []string
// RFC 4867 §8.3.1: answerer MUST include these if present in the offer
// for the accepted payload type (omit defaults).
for _, key := range []string{
"octet-align",
"mode-change-capability",
"max-red",
} {
v, ok := params[key]
if !ok {
continue
}
// Skip octet-align=0 (default).
if key == "octet-align" && (v == "" || v == "0") {
continue
}
parts = append(parts, key+"="+v)
}
// mode-set / mode-change-period / mode-change-neighbor may be answered
// with a subset; echo offer values when present so both sides agree.
for _, key := range []string{
"mode-set",
"mode-change-period",
"mode-change-neighbor",
} {
if v, ok := params[key]; ok {
parts = append(parts, key+"="+v)
}
}
return strings.Join(parts, ";")
}

func attrPayloadType(value string) string {
pt, _, ok := strings.Cut(value, " ")
if !ok {
return value
}
return pt
}

func rtpmapCodecName(value string) string {
_, rest, ok := strings.Cut(value, " ")
if !ok {
return ""
}
name, _, _ := strings.Cut(rest, "/")
return name
}

// filterAMROfferSDP removes AMR/AMR-WB dynamic formats whose fmtp we cannot
// answer correctly, returning a rewritten offer suitable for media-sdk Answer.
// Remaining formats (including bandwidth-efficient AMR) are left intact.
// On parse/marshal failure it returns the original offerData with an error;
// callers should treat the error as non-fatal and continue with the original.
func filterAMROfferSDP(offerData []byte) ([]byte, error) {
var offer sdp.SessionDescription
if err := offer.Unmarshal(offerData); err != nil {
return offerData, err
}
changed := filterOfferAMRFormats(&offer)
if !changed {
return offerData, nil
}
out, err := offer.Marshal()
if err != nil {
return offerData, err
}
return out, nil
}

// filterOfferAMRFormats removes unsupported AMR formats in-place.
// Returns true if the description was modified.
func filterOfferAMRFormats(offer *sdp.SessionDescription) bool {
if offer == nil {
return false
}
changed := false
for _, md := range offer.MediaDescriptions {
if md.MediaName.Media != "audio" {
continue
}
dropPT := make(map[string]struct{})
rtpmapNameByPT := make(map[string]string)
fmtpByPT := make(map[string]string)
for _, a := range md.Attributes {
switch a.Key {
case "rtpmap":
pt := attrPayloadType(a.Value)
rtpmapNameByPT[pt] = rtpmapCodecName(a.Value)
case "fmtp":
pt, rest, ok := strings.Cut(a.Value, " ")
if ok {
fmtpByPT[pt] = rest
}
}
}
for pt, name := range rtpmapNameByPT {
if !isAMRName(name) {
continue
}
if amrFmtpUnsupported(fmtpByPT[pt]) {
dropPT[pt] = struct{}{}
}
}
if len(dropPT) == 0 {
continue
}
changed = true
filtered := make([]string, 0, len(md.MediaName.Formats))
for _, f := range md.MediaName.Formats {
if _, drop := dropPT[f]; !drop {
filtered = append(filtered, f)
}
}
md.MediaName.Formats = filtered
attrs := make([]sdp.Attribute, 0, len(md.Attributes))
for _, a := range md.Attributes {
switch a.Key {
case "rtpmap", "fmtp":
if _, drop := dropPT[attrPayloadType(a.Value)]; drop {
continue
}
}
attrs = append(attrs, a)
}
md.Attributes = attrs
}
return changed
}

// appendAMRFmtpToAnswer mutates answer SDP: for each accepted AMR payload type,
// copy the offer's answerable fmtp onto the answer (RFC 4867 §8.3.1).
func appendAMRFmtpToAnswer(answer *sdp.SessionDescription, offerData []byte) {
if answer == nil {
return
}
var offer sdp.SessionDescription
if err := offer.Unmarshal(offerData); err != nil {
return
}

offerFmtpByPT := map[string]string{}
for _, md := range offer.MediaDescriptions {
if md.MediaName.Media != "audio" {
continue
}
rtpNames := map[string]string{}
for _, a := range md.Attributes {
if a.Key == "rtpmap" {
rtpNames[attrPayloadType(a.Value)] = rtpmapCodecName(a.Value)
}
}
for _, a := range md.Attributes {
if a.Key != "fmtp" {
continue
}
pt, rest, ok := strings.Cut(a.Value, " ")
if !ok || !isAMRName(rtpNames[pt]) {
continue
}
offerFmtpByPT[pt] = rest
}
}
if len(offerFmtpByPT) == 0 {
return
}

for _, md := range answer.MediaDescriptions {
if md.MediaName.Media != "audio" {
continue
}
rtpNames := map[string]string{}
for _, a := range md.Attributes {
if a.Key == "rtpmap" {
rtpNames[attrPayloadType(a.Value)] = rtpmapCodecName(a.Value)
}
}
for pt, name := range rtpNames {
if !isAMRName(name) {
continue
}
offerFmtp, ok := offerFmtpByPT[pt]
if !ok {
continue
}
echo := amrFmtpForAnswer(offerFmtp)
if echo == "" {
continue
}
// media-sdk already emits its own fmtp for AMR (e.g. octet-align=0
// since livekit/sip#781); merge the echoed offer params into it
// instead of appending a duplicate attribute.
merged := false
for i := range md.Attributes {
a := &md.Attributes[i]
if a.Key != "fmtp" || attrPayloadType(a.Value) != pt {
continue
}
_, existing, _ := strings.Cut(a.Value, " ")
a.Value = pt + " " + mergeFmtp(existing, echo)
merged = true
Comment on lines +272 to +274

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reused payload types receive wrong fmtp

When audio sections reuse an AMR payload type, appendAMRFmtpToAnswer merges the last offered parameters into every matching section. offerFmtpByPT discards media-section identity. An answer can advertise another section's mode-set.

Learn more

Payload type numbers identify formats only within one SDP media section. The offer map instead combines every audio section into one namespace, so a later section overwrites an earlier section using the same number. The merge now applies that surviving value even when media-sdk already supplied an AMR fmtp. RFC 4867 requires an offered mode-set to be returned unmodified for that payload type or rejected.

Example: An offer has two audio sections. Both use payload type 98, but the first declares mode-set=0,1 and the second declares mode-set=7,8. Both answer sections initially contain octet-align=0; the merge adds mode-set=7,8 to both, changing the first section's negotiated modes.

Recommended fix: Associate each offer fmtp with its media section as well as its payload type. Match offer and answer sections using the offer-answer media-section ordering or stable mid values, then merge only the corresponding section's parameters.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
if !merged {
md.Attributes = append(md.Attributes, sdp.Attribute{
Key: "fmtp",
Value: pt + " " + echo,
})
}
}
}
}

// mergeFmtp appends the params from echo to existing without duplicating keys
// that existing already declares.
func mergeFmtp(existing, echo string) string {
if echo == "" {
return existing
}
have := parseFmtpParams(existing)
var b strings.Builder
b.WriteString(existing)
for _, part := range strings.Split(echo, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
k, _, _ := strings.Cut(part, "=")
if _, ok := have[strings.ToLower(strings.TrimSpace(k))]; ok {
continue
}
if b.Len() > 0 {
b.WriteString(";")
}
b.WriteString(part)
}
return b.String()
}
Loading