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
|
|
@ -52,7 +52,8 @@ export namespace imsd::engine {
|
||||||
return d == Direction::Incoming ? "incoming" : "outgoing";
|
return d == Direction::Incoming ? "incoming" : "outgoing";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed SDP answer needed to bring up the media leg.
|
// Parsed SDP answer needed to bring up the media leg. `codec` is one of
|
||||||
|
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
|
||||||
struct MediaLeg {
|
struct MediaLeg {
|
||||||
std::string remoteIp;
|
std::string remoteIp;
|
||||||
int remotePort = 0;
|
int remotePort = 0;
|
||||||
|
|
@ -156,16 +157,20 @@ export namespace imsd::engine {
|
||||||
|
|
||||||
// UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made
|
// UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made
|
||||||
// reliable only when the caller Requires 100rel) and arm the ring
|
// reliable only when the caller Requires 100rel) and arm the ring
|
||||||
// timeout. An offer we cannot serve — no audio line, or no AMR-WB
|
// timeout. An offer we cannot serve — no audio line, or none of the
|
||||||
// (the media plane is AMR-WB-only) — is refused with 488 up front,
|
// codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU) — is refused
|
||||||
// before the UI ever rings.
|
// with 488 up front, before the UI ever rings; the log line names
|
||||||
|
// what WAS offered, so a field journal answers "which codec did the
|
||||||
|
// gateway want" without a raw SIP dump.
|
||||||
std::vector<Action> OnInvite() {
|
std::vector<Action> OnInvite() {
|
||||||
std::vector<Action> a;
|
std::vector<Action> a;
|
||||||
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
|
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
|
||||||
MediaLeg leg = ParseAnswer();
|
MediaLeg leg = ParseAnswer();
|
||||||
if (!leg.Valid() || leg.codec != "AMR-WB") {
|
if (!leg.Valid() || leg.codec.empty()) {
|
||||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag)));
|
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag)));
|
||||||
Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent"));
|
std::string why = !leg.Valid() ? "no usable audio line"
|
||||||
|
: std::format("no playable codec, offered: {}", imsd::sdp::OfferedCodecs(answerSdp_));
|
||||||
|
Append(a, Finish("error", std::format("incoming offer unusable ({}); 488 sent", why)));
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag)));
|
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag)));
|
||||||
|
|
@ -196,7 +201,7 @@ export namespace imsd::engine {
|
||||||
spec.payloadType = leg.payloadType;
|
spec.payloadType = leg.payloadType;
|
||||||
spec.octetAlign = leg.octetAlign;
|
spec.octetAlign = leg.octetAlign;
|
||||||
spec.codec = leg.codec;
|
spec.codec = leg.codec;
|
||||||
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), 16000);
|
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec));
|
||||||
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
||||||
// session timers: echo the caller's Session-Expires; with no
|
// session timers: echo the caller's Session-Expires; with no
|
||||||
// refresher stated, make the caller (uac) the refresher — its
|
// refresher stated, make the caller (uac) the refresher — its
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,20 @@
|
||||||
|
|
||||||
// lint-disable-file fixed-width-types
|
// lint-disable-file fixed-width-types
|
||||||
/*
|
/*
|
||||||
Imsd:Sdp — AMR-WB SDP offer/answer building + parsing.
|
Imsd:Sdp — SDP offer/answer building + parsing for the voice media plane.
|
||||||
|
|
||||||
The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an
|
The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an
|
||||||
optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores
|
optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores
|
||||||
expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The
|
expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The
|
||||||
parser extracts only what the media plane needs: remote address/port, the
|
parser extracts only what the media plane needs: remote address/port, the
|
||||||
negotiated payload type, and octet-align mode — it reads answers to our
|
negotiated payload type, the codec and octet-align mode — it reads answers
|
||||||
offers and inbound offers alike (same fields either way). BuildAnswer is the
|
to our offers and inbound offers alike (same fields either way). The codecs
|
||||||
terminating side: it answers an inbound offer with the offer's own payload
|
it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) and
|
||||||
type numbers (RFC 3264) and mirrors its octet-align mode (RFC 4867 requires
|
G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
|
||||||
both directions to use one mode). Pure std C++.
|
landline calls. BuildAnswer is the terminating side: it answers an inbound
|
||||||
|
offer with the offer's own payload type numbers (RFC 3264) and mirrors its
|
||||||
|
octet-align mode (RFC 4867 requires both directions to use one mode). Pure
|
||||||
|
std C++.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export module Imsd:Sdp;
|
export module Imsd:Sdp;
|
||||||
|
|
@ -88,18 +91,60 @@ export namespace imsd::sdp {
|
||||||
int port = -1; // -1 = no m=audio line found
|
int port = -1; // -1 = no m=audio line found
|
||||||
int payloadType = -1;
|
int payloadType = -1;
|
||||||
bool octetAlign = false;
|
bool octetAlign = false;
|
||||||
std::string codec = "AMR-WB";
|
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The encoding name an offer gives a payload type: its a=rtpmap line,
|
||||||
|
// upper-cased (RFC 4566 §6: encoding names are case-insensitive), else
|
||||||
|
// the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry
|
||||||
|
// no rtpmap at all). Empty when neither applies.
|
||||||
|
std::string CodecName(std::string_view body, int pt) {
|
||||||
|
std::string key = std::format("a=rtpmap:{} ", pt);
|
||||||
|
if (auto at = Find(body, key)) {
|
||||||
|
std::size_t nameBegin = *at + key.size();
|
||||||
|
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
|
||||||
|
std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin));
|
||||||
|
for (char& c : name)
|
||||||
|
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
if (pt == 0) return "PCMU";
|
||||||
|
if (pt == 8) return "PCMA";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offer's audio payload types with their names, "8 PCMA, 96 AMR,
|
||||||
|
// 101 telephone-event" — for the log line of a refused offer, so a
|
||||||
|
// field journal says WHAT the gateway offered without a raw SIP dump.
|
||||||
|
std::string OfferedCodecs(std::string_view body) {
|
||||||
|
auto m = Find(body, "m=audio ");
|
||||||
|
if (!m) return "no m=audio line";
|
||||||
|
std::size_t lineEnd = body.find_first_of("\r\n", *m);
|
||||||
|
std::string_view line = body.substr(*m, lineEnd == std::string_view::npos ? std::string_view::npos : lineEnd - *m);
|
||||||
|
std::string out;
|
||||||
|
int field = 0;
|
||||||
|
for (auto part : std::views::split(line, ' ')) {
|
||||||
|
std::string_view tok(part);
|
||||||
|
if (field++ < 3 || tok.empty()) continue; // m=audio <port> <proto> <pt>...
|
||||||
|
auto pt = ParseInt(tok);
|
||||||
|
if (!pt) continue;
|
||||||
|
std::string name = CodecName(body, *pt);
|
||||||
|
if (!out.empty()) out += ", ";
|
||||||
|
out += name.empty() ? std::format("{}", *pt) : std::format("{} {}", *pt, name);
|
||||||
|
}
|
||||||
|
return out.empty() ? std::string(line) : out;
|
||||||
|
}
|
||||||
|
|
||||||
// Payload-type preference: AMR-WB with octet-align=1 > AMR-WB > AMR with
|
// Payload-type preference: AMR-WB with octet-align=1 > AMR-WB > AMR with
|
||||||
// octet-align=1 > AMR. Octet-aligned wins within a codec because it is
|
// octet-align=1 > AMR > PCMA > PCMU. Octet-aligned wins within a codec
|
||||||
// the media leg's native, call-proven format (bandwidth-efficient is
|
// because it is the media leg's native, call-proven format
|
||||||
// supported but was the s56 static-audio culprit) — when the peer offers
|
// (bandwidth-efficient is supported but was the s56 static-audio
|
||||||
// both variants, taking the octet-aligned pt keeps real calls on the
|
// culprit) — when the peer offers both variants, taking the
|
||||||
// battle-tested path. With no rtpmap match at all, the first pt is used
|
// octet-aligned pt keeps real calls on the battle-tested path. Wideband
|
||||||
// and codec comes back EMPTY — the body named no AMR variant (e.g. a
|
// beats narrowband, AMR beats G.711 (a mobile-to-mobile call must never
|
||||||
// PCMA offer), and pretending otherwise would defeat codec checks
|
// land on a G.711 leg just because the gateway listed it). With no
|
||||||
// upstream.
|
// playable codec at all, the first pt is used and codec comes back EMPTY
|
||||||
|
// — pretending otherwise would defeat codec checks upstream.
|
||||||
Answer ParseAnswer(std::string_view body) {
|
Answer ParseAnswer(std::string_view body) {
|
||||||
Answer a;
|
Answer a;
|
||||||
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
|
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
|
||||||
|
|
@ -135,20 +180,20 @@ export namespace imsd::sdp {
|
||||||
int wb = -1;
|
int wb = -1;
|
||||||
int nbOa = -1;
|
int nbOa = -1;
|
||||||
int nb = -1;
|
int nb = -1;
|
||||||
|
int pcma = -1;
|
||||||
|
int pcmu = -1;
|
||||||
for (int p : pts) {
|
for (int p : pts) {
|
||||||
std::string key = std::format("a=rtpmap:{} ", p);
|
std::string name = CodecName(body, p);
|
||||||
auto at = Find(body, key);
|
if (name == "AMR-WB") {
|
||||||
if (!at) continue;
|
|
||||||
// codec name: up to '/', CR or LF
|
|
||||||
std::size_t nameBegin = *at + key.size();
|
|
||||||
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
|
|
||||||
std::string_view name = body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin);
|
|
||||||
if (name.contains("AMR-WB")) {
|
|
||||||
if (wb == -1) wb = p;
|
if (wb == -1) wb = p;
|
||||||
if (wbOa == -1 && hasOctetAlign(p)) wbOa = p;
|
if (wbOa == -1 && hasOctetAlign(p)) wbOa = p;
|
||||||
} else if (name.contains("AMR")) {
|
} else if (name == "AMR") {
|
||||||
if (nb == -1) nb = p;
|
if (nb == -1) nb = p;
|
||||||
if (nbOa == -1 && hasOctetAlign(p)) nbOa = p;
|
if (nbOa == -1 && hasOctetAlign(p)) nbOa = p;
|
||||||
|
} else if (name == "PCMA") {
|
||||||
|
if (pcma == -1) pcma = p;
|
||||||
|
} else if (name == "PCMU") {
|
||||||
|
if (pcmu == -1) pcmu = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (wbOa != -1 || wb != -1) {
|
if (wbOa != -1 || wb != -1) {
|
||||||
|
|
@ -157,11 +202,17 @@ export namespace imsd::sdp {
|
||||||
} else if (nbOa != -1 || nb != -1) {
|
} else if (nbOa != -1 || nb != -1) {
|
||||||
a.payloadType = nbOa != -1 ? nbOa : nb;
|
a.payloadType = nbOa != -1 ? nbOa : nb;
|
||||||
a.codec = "AMR";
|
a.codec = "AMR";
|
||||||
|
} else if (pcma != -1) {
|
||||||
|
a.payloadType = pcma;
|
||||||
|
a.codec = "PCMA";
|
||||||
|
} else if (pcmu != -1) {
|
||||||
|
a.payloadType = pcmu;
|
||||||
|
a.codec = "PCMU";
|
||||||
} else {
|
} else {
|
||||||
a.payloadType = pts.front();
|
a.payloadType = pts.front();
|
||||||
a.codec.clear();
|
a.codec.clear();
|
||||||
}
|
}
|
||||||
a.octetAlign = hasOctetAlign(a.payloadType);
|
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,18 +240,24 @@ export namespace imsd::sdp {
|
||||||
int rtpPort = 0;
|
int rtpPort = 0;
|
||||||
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
|
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
|
||||||
int payloadType = 97; // the offer's pt for the chosen codec
|
int payloadType = 97; // the offer's pt for the chosen codec
|
||||||
bool octetAlign = true; // mirror of the offer's mode
|
bool octetAlign = true; // mirror of the offer's mode (AMR codecs only)
|
||||||
std::string codec = "AMR-WB"; // "AMR-WB" or "AMR"
|
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU"
|
||||||
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
|
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
|
||||||
|
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
|
||||||
|
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
|
||||||
|
|
||||||
// SDP answer to an inbound audio offer: single codec at the offer's own
|
// SDP answer to an inbound audio offer: single codec at the offer's own
|
||||||
// payload type, octet-align echoed only when the offer used it (absence
|
// payload type, octet-align echoed only when the offer used it (absence
|
||||||
// = bandwidth-efficient, RFC 4867 §8.1), telephone-event kept when
|
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all),
|
||||||
// offered. Byte layout pinned by tests/Sdp.
|
// telephone-event kept when offered. b=AS is the codec's peak payload
|
||||||
|
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
|
||||||
std::string BuildAnswer(const AnswerSpec& s) {
|
std::string BuildAnswer(const AnswerSpec& s) {
|
||||||
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
|
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
|
||||||
int rate = s.codec == "AMR-WB" ? 16000 : 8000;
|
int rate = CodecRate(s.codec);
|
||||||
|
int as = s.codec == "AMR-WB" ? 41 : (s.codec == "AMR" ? 30 : 80);
|
||||||
std::string out;
|
std::string out;
|
||||||
out += "v=0\r\n";
|
out += "v=0\r\n";
|
||||||
out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local);
|
out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local);
|
||||||
|
|
@ -210,11 +267,11 @@ export namespace imsd::sdp {
|
||||||
if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt);
|
if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt);
|
||||||
else
|
else
|
||||||
out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType);
|
out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType);
|
||||||
out += std::format("b=AS:{}\r\n", s.codec == "AMR-WB" ? 41 : 30);
|
out += std::format("b=AS:{}\r\n", as);
|
||||||
out += "b=RS:512\r\n";
|
out += "b=RS:512\r\n";
|
||||||
out += "b=RR:1536\r\n";
|
out += "b=RR:1536\r\n";
|
||||||
out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate);
|
out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate);
|
||||||
if (s.octetAlign) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType);
|
if (s.octetAlign && s.codec.starts_with("AMR")) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType);
|
||||||
if (s.dtmfPt) {
|
if (s.dtmfPt) {
|
||||||
out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate);
|
out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate);
|
||||||
out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt);
|
out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt);
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@
|
||||||
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
|
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
|
||||||
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
|
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
|
||||||
// with the SDP answer at the offer's payload types + media, ACK silence,
|
// with the SDP answer at the offer's payload types + media, ACK silence,
|
||||||
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
|
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
|
||||||
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
|
// 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 std;
|
||||||
import Imsd;
|
import Imsd;
|
||||||
|
|
||||||
|
|
@ -479,26 +481,109 @@ int main() {
|
||||||
Check(m.Terminated(), "terminated after remote CANCEL");
|
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);
|
imsd::util::Rng rng(29);
|
||||||
std::string sdp =
|
std::string sdp =
|
||||||
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
|
"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"
|
"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";
|
"m=audio 27864 RTP/AVP 8 0 101\r\na=rtpmap:101 telephone-event/8000\r\na=ptime:20\r\n";
|
||||||
std::string inv = std::format(
|
CallMachine m(c, rng, "ims-call-11", IncomingInvite{MtInviteSdp("mt-pcma", sdp)}, 50004);
|
||||||
"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);
|
|
||||||
auto ai = m.OnInvite();
|
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(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
|
||||||
Check(m.Terminated(), "terminated on 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
|
// ---- ring timeout: 480, missed call
|
||||||
|
|
|
||||||
|
|
@ -126,12 +126,68 @@ int main() {
|
||||||
Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
|
Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
|
||||||
Check(a.octetAlign, "octet-align seen for AMR too");
|
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");
|
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, "no rtpmap -> first payload type");
|
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.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
|
// ---- degenerate body
|
||||||
{
|
{
|
||||||
|
|
@ -198,6 +254,41 @@ int main() {
|
||||||
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
|
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
|
||||||
Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth");
|
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");
|
if (Failures == 0) std::println("Sdp: all tests passed");
|
||||||
return Failures;
|
return Failures;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue