engine/sdp: accept AMR-NB and G.711 offers; name the offered codecs on 488
An incoming INVITE whose offer carried no AMR-WB was refused with 488
before ringing. A landline caller arrives through the PSTN gateway, which
offers narrowband — AMR (NB) and/or G.711 — so every landline call was
refused, deterministically. Field report 2026-09-01 (Telia Norge): a
doctor's office called twice, both 488; a mobile caller rang fine.
The parser now recognises PCMA/PCMU by rtpmap name or by static payload
type (a G.711 offer may carry no rtpmap line at all, RFC 3551 §6), matches
encoding names case-insensitively (RFC 4566 §6), and prefers
AMR-WB > AMR > PCMA > PCMU with octet-aligned first within AMR — a mobile
caller offering everything still lands on AMR-WB. The engine accepts any
codec the media leg plays and answers at the offer's own payload type with
the DTMF clock matching the codec (8 kHz for narrowband). A refused offer
now logs what WAS offered ('offered: 18 G729, 101 TELEPHONE-EVENT'), so a
field journal answers 'which codec did the gateway want' without a raw SIP
dump.
The media leg gains the matching codecs in the next commit.
This commit is contained in:
parent
4558f2afd8
commit
daaba2d657
4 changed files with 295 additions and 57 deletions
|
|
@ -8,8 +8,10 @@
|
|||
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
|
||||
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
|
||||
// with the SDP answer at the offer's payload types + media, ACK silence,
|
||||
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
|
||||
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
|
||||
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
|
||||
// what was offered), a landline gateway's G.711-only and AMR-NB offers
|
||||
// ring and are answered at the offer's own payload types, ring timeout ->
|
||||
// 480, BYE both ways. Pure reducer, no I/O.
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
|
|
@ -479,26 +481,109 @@ int main() {
|
|||
Check(m.Terminated(), "terminated after remote CANCEL");
|
||||
}
|
||||
|
||||
// ---- offer we cannot serve (no AMR-WB): 488 before ringing
|
||||
// an inbound INVITE around an arbitrary offer (the gateway shapes below)
|
||||
auto MtInviteSdp = [](std::string_view callid, std::string_view sdp) {
|
||||
return std::format(
|
||||
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 1 INVITE\r\n"
|
||||
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
|
||||
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
|
||||
callid, sdp.size(), sdp);
|
||||
};
|
||||
|
||||
// ---- a landline caller: the PSTN gateway offers G.711 only, by static
|
||||
// payload type with no rtpmap (the shape that used to be 488'd). It
|
||||
// rings, and Accept answers PCMA at the offer's pt with narrowband DTMF.
|
||||
{
|
||||
imsd::util::Rng rng(29);
|
||||
std::string sdp =
|
||||
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
|
||||
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
|
||||
"m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n";
|
||||
std::string inv = std::format(
|
||||
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>\r\n"
|
||||
"Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n"
|
||||
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
|
||||
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
|
||||
sdp.size(), sdp);
|
||||
CallMachine m(c, rng, "ims-call-11", IncomingInvite{inv}, 50004);
|
||||
"m=audio 27864 RTP/AVP 8 0 101\r\na=rtpmap:101 telephone-event/8000\r\na=ptime:20\r\n";
|
||||
CallMachine m(c, rng, "ims-call-11", IncomingInvite{MtInviteSdp("mt-pcma", sdp)}, 50004);
|
||||
auto ai = m.OnInvite();
|
||||
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488");
|
||||
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") == nullptr, "G.711 offer is not refused");
|
||||
Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "G.711 offer rings");
|
||||
Check(!m.Terminated(), "G.711 call alive after INVITE");
|
||||
auto aa = m.OnAccept();
|
||||
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
|
||||
Check(ok != nullptr, "Accept answers 200");
|
||||
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 8 101\r\n"), "answer keeps the offer's PCMA + DTMF pts");
|
||||
Check(ok && ok->text.contains("a=rtpmap:8 PCMA/8000/1\r\n"), "answer names PCMA");
|
||||
Check(ok && ok->text.contains("a=rtpmap:101 telephone-event/8000\r\n"), "DTMF at the narrowband clock");
|
||||
Check(ok && !ok->text.contains("octet-align"), "no octet-align for G.711");
|
||||
const Action* med = Find(aa, Action::Type::StartMedia);
|
||||
Check(med && med->media.codec == "PCMA" && med->media.payloadType == 8 && !med->media.octetAlign, "media leg starts PCMA pt 8");
|
||||
Check(med && med->media.remoteIp == "2001:db8::30c" && med->media.remotePort == 27864, "media leg targets the gateway");
|
||||
}
|
||||
|
||||
// ---- a gateway offering narrowband AMR (octet-aligned) + G.711: AMR wins
|
||||
{
|
||||
imsd::util::Rng rng(31);
|
||||
std::string sdp =
|
||||
"v=0\r\no=- 6 6 IN IP6 2001:db8::30c\r\ns=-\r\n"
|
||||
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
|
||||
"m=audio 27866 RTP/AVP 8 100 110\r\n"
|
||||
"a=rtpmap:100 AMR/8000/1\r\na=fmtp:100 octet-align=1;mode-set=0,2,4,7\r\n"
|
||||
"a=rtpmap:110 telephone-event/8000\r\n";
|
||||
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInviteSdp("mt-amrnb", sdp)}, 50004);
|
||||
auto ai = m.OnInvite();
|
||||
Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "AMR-NB offer rings");
|
||||
auto aa = m.OnAccept();
|
||||
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
|
||||
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 100 110\r\n"), "answer picks AMR over PCMA");
|
||||
Check(ok && ok->text.contains("a=rtpmap:100 AMR/8000/1\r\n") && ok->text.contains("a=fmtp:100 octet-align=1\r\n"), "answer mirrors octet-aligned AMR");
|
||||
const Action* med = Find(aa, Action::Type::StartMedia);
|
||||
Check(med && med->media.codec == "AMR" && med->media.payloadType == 100 && med->media.octetAlign, "media leg starts octet-aligned AMR pt 100");
|
||||
}
|
||||
|
||||
// ---- a mobile caller offering everything: still AMR-WB (no regression)
|
||||
{
|
||||
imsd::util::Rng rng(33);
|
||||
std::string sdp =
|
||||
"v=0\r\no=- 7 7 IN IP6 2001:db8::310\r\ns=-\r\n"
|
||||
"c=IN IP6 2001:db8::310\r\nt=0 0\r\n"
|
||||
"m=audio 27868 RTP/AVP 116 107 8 100 111 110 13\r\n"
|
||||
"a=rtpmap:116 AMR-WB/16000/1\r\na=fmtp:116 mode-change-capability=2;max-red=0\r\n"
|
||||
"a=rtpmap:107 AMR-WB/16000/1\r\na=fmtp:107 octet-align=1;mode-change-capability=2;max-red=0\r\n"
|
||||
"a=rtpmap:100 AMR/8000/1\r\na=rtpmap:111 telephone-event/16000\r\n"
|
||||
"a=rtpmap:110 telephone-event/8000\r\na=rtpmap:13 CN/8000\r\n";
|
||||
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInviteSdp("mt-mobile", sdp)}, 50004);
|
||||
m.OnInvite();
|
||||
auto aa = m.OnAccept();
|
||||
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
|
||||
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 107 111\r\n"), "mobile caller answered with octet-aligned AMR-WB + wideband DTMF");
|
||||
const Action* med = Find(aa, Action::Type::StartMedia);
|
||||
Check(med && med->media.codec == "AMR-WB" && med->media.payloadType == 107, "media leg stays AMR-WB for a mobile caller");
|
||||
}
|
||||
|
||||
// ---- offer we cannot serve (nothing we play): 488 before ringing, and
|
||||
// the log names what was offered
|
||||
{
|
||||
imsd::util::Rng rng(35);
|
||||
std::string sdp =
|
||||
"v=0\r\no=- 8 8 IN IP6 2001:db8::30c\r\ns=-\r\n"
|
||||
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
|
||||
"m=audio 27864 RTP/AVP 18 101\r\na=rtpmap:18 G729/8000\r\na=rtpmap:101 telephone-event/8000\r\n";
|
||||
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInviteSdp("mt-g729", sdp)}, 50004);
|
||||
auto ai = m.OnInvite();
|
||||
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "G729-only offer refused with 488");
|
||||
Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
|
||||
Check(m.Terminated(), "terminated on 488");
|
||||
bool named = false;
|
||||
for (const auto& x : ai)
|
||||
if (x.type == Action::Type::Log && x.text.contains("offered: 18 G729, 101 TELEPHONE-EVENT")) named = true;
|
||||
Check(named, "488 log names the offered codecs");
|
||||
}
|
||||
// ---- no audio line at all: 488 too
|
||||
{
|
||||
imsd::util::Rng rng(37);
|
||||
CallMachine m(c, rng, "ims-call-15", IncomingInvite{MtInviteSdp("mt-noaudio", "v=0\r\no=- 9 9 IN IP6 2001:db8::30c\r\ns=-\r\nc=IN IP6 2001:db8::30c\r\nt=0 0\r\n")}, 50004);
|
||||
auto ai = m.OnInvite();
|
||||
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "offer without m=audio refused with 488");
|
||||
Check(m.Terminated(), "terminated on 488 (no audio line)");
|
||||
}
|
||||
|
||||
// ---- ring timeout: 480, missed call
|
||||
|
|
|
|||
|
|
@ -126,12 +126,68 @@ int main() {
|
|||
Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
|
||||
Check(a.octetAlign, "octet-align seen for AMR too");
|
||||
}
|
||||
// ---- no rtpmap at all -> first payload type, bandwidth-efficient
|
||||
// ---- G.711 by static payload type, no rtpmap at all (RFC 3551 §6):
|
||||
// the shape a PSTN gateway sends for a landline caller
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 0\r\n");
|
||||
Check(a.payloadType == 8, "no rtpmap -> first payload type");
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 0 8\r\n");
|
||||
Check(a.payloadType == 8, "PCMA preferred over an earlier PCMU");
|
||||
Check(a.codec == "PCMA", "static pt 8 -> PCMA without an rtpmap line");
|
||||
Check(!a.octetAlign, "G.711 never octet-aligned");
|
||||
}
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 0\r\n");
|
||||
Check(a.payloadType == 0 && a.codec == "PCMU", "static pt 0 -> PCMU");
|
||||
}
|
||||
// ---- G.711 with an rtpmap, lower-case name (RFC 4566: case-insensitive)
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:8 pcma/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n");
|
||||
Check(a.payloadType == 8 && a.codec == "PCMA", "lower-case rtpmap name still recognised");
|
||||
}
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP6 2001:db8::1\r\n" "m=audio 5000 RTP/AVP 96\r\n" "a=rtpmap:96 amr-wb/16000/1\r\n" "a=fmtp:96 octet-align=1\r\n");
|
||||
Check(a.payloadType == 96 && a.codec == "AMR-WB" && a.octetAlign, "lower-case amr-wb recognised, octet-align kept");
|
||||
}
|
||||
// ---- narrowband AMR beats G.711 (a gateway listing both)
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 100 101\r\n" "a=rtpmap:100 AMR/8000/1\r\n" "a=fmtp:100 octet-align=1\r\n" "a=rtpmap:101 telephone-event/8000\r\n");
|
||||
Check(a.payloadType == 100 && a.codec == "AMR", "AMR preferred over PCMA listed first");
|
||||
Check(a.octetAlign, "AMR octet-align seen");
|
||||
}
|
||||
// ---- wideband beats everything: KPN's real mobile-origin list
|
||||
// (AMR-WB BE 116, AMR-WB OA 107, PCMA 8, AMR 100, DTMF 111/110, CN 13)
|
||||
{
|
||||
Answer a = ParseAnswer(
|
||||
"c=IN IP6 2001:db8::310\r\n"
|
||||
"m=audio 5000 RTP/AVP 116 107 8 100 111 110 13\r\n"
|
||||
"a=rtpmap:116 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:116 mode-change-capability=2;max-red=0\r\n"
|
||||
"a=rtpmap:107 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=0\r\n"
|
||||
"a=rtpmap:100 AMR/8000/1\r\n"
|
||||
"a=rtpmap:111 telephone-event/16000\r\n"
|
||||
"a=rtpmap:110 telephone-event/8000\r\n"
|
||||
"a=rtpmap:13 CN/8000\r\n");
|
||||
Check(a.payloadType == 107 && a.codec == "AMR-WB" && a.octetAlign, "mobile caller still lands on octet-aligned AMR-WB");
|
||||
}
|
||||
// ---- nothing we play -> first payload type, codec EMPTY (the 488 case)
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 18 101\r\n" "a=rtpmap:18 G729/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n");
|
||||
Check(a.payloadType == 18, "no playable codec -> first payload type");
|
||||
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
|
||||
Check(a.codec.empty(), "no AMR rtpmap -> codec unknown, not assumed");
|
||||
Check(a.codec.empty(), "G729-only -> codec unknown, not assumed");
|
||||
}
|
||||
|
||||
// ---- OfferedCodecs: the 488 log summary
|
||||
{
|
||||
Check(OfferedCodecs("m=audio 5000 RTP/AVP 18 101\r\na=rtpmap:18 G729/8000\r\na=rtpmap:101 telephone-event/8000\r\n") == "18 G729, 101 TELEPHONE-EVENT", "offered list names each pt");
|
||||
Check(OfferedCodecs("m=audio 5000 RTP/AVP 0 8\r\n") == "0 PCMU, 8 PCMA", "static G.711 types named without rtpmap");
|
||||
Check(OfferedCodecs("m=audio 5000 RTP/AVP 96\r\n") == "96", "unknown dynamic pt listed bare");
|
||||
Check(OfferedCodecs("v=0\r\n") == "no m=audio line", "no audio line says so");
|
||||
}
|
||||
// ---- CodecRate
|
||||
{
|
||||
Check(CodecRate("AMR-WB") == 16000, "AMR-WB is 16 kHz");
|
||||
Check(CodecRate("AMR") == 8000 && CodecRate("PCMA") == 8000 && CodecRate("PCMU") == 8000, "narrowband codecs are 8 kHz");
|
||||
}
|
||||
// ---- degenerate body
|
||||
{
|
||||
|
|
@ -198,6 +254,41 @@ int main() {
|
|||
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
|
||||
Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth");
|
||||
}
|
||||
// ---- BuildAnswer: G.711 for a landline caller — static pt, no fmtp,
|
||||
// narrowband DTMF, byte-exact
|
||||
{
|
||||
std::string sdp = BuildAnswer({
|
||||
.local = "2001:db8:29e9:a05f::1",
|
||||
.rtpPort = 50004,
|
||||
.sessionId = 778,
|
||||
.payloadType = 8,
|
||||
.octetAlign = false,
|
||||
.codec = "PCMA",
|
||||
.dtmfPt = 101,
|
||||
});
|
||||
std::string expected =
|
||||
"v=0\r\n"
|
||||
"o=- 778 778 IN IP6 2001:db8:29e9:a05f::1\r\n"
|
||||
"s=-\r\n"
|
||||
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
|
||||
"t=0 0\r\n"
|
||||
"m=audio 50004 RTP/AVP 8 101\r\n"
|
||||
"b=AS:80\r\n"
|
||||
"b=RS:512\r\n"
|
||||
"b=RR:1536\r\n"
|
||||
"a=rtpmap:8 PCMA/8000/1\r\n"
|
||||
"a=rtpmap:101 telephone-event/8000\r\n"
|
||||
"a=fmtp:101 0-15\r\n"
|
||||
"a=ptime:20\r\n"
|
||||
"a=maxptime:240\r\n"
|
||||
"a=sendrecv\r\n";
|
||||
Check(sdp == expected, "PCMA answer is byte-exact");
|
||||
}
|
||||
// ---- BuildAnswer never marks G.711 octet-aligned even if asked
|
||||
{
|
||||
std::string sdp = BuildAnswer({.local = "10.0.0.2", .rtpPort = 4000, .sessionId = 9, .payloadType = 0, .octetAlign = true, .codec = "PCMU"});
|
||||
Check(sdp.contains("a=rtpmap:0 PCMU/8000/1\r\n") && !sdp.contains("fmtp:0"), "PCMU answer has no fmtp");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Sdp: all tests passed");
|
||||
return Failures;
|
||||
|
|
|
|||
Loading…
Reference in a new issue