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:
Jorijn van der Graaf 2026-09-02 03:11:11 +02:00
commit daaba2d657
4 changed files with 295 additions and 57 deletions

View file

@ -52,7 +52,8 @@ export namespace imsd::engine {
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 {
std::string remoteIp;
int remotePort = 0;
@ -156,16 +157,20 @@ export namespace imsd::engine {
// UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made
// reliable only when the caller Requires 100rel) and arm the ring
// timeout. An offer we cannot serve — no audio line, or no AMR-WB
// (the media plane is AMR-WB-only) — is refused with 488 up front,
// before the UI ever rings.
// timeout. An offer we cannot serve — no audio line, or none of the
// codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU) — is refused
// 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> a;
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
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)));
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;
}
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.octetAlign = leg.octetAlign;
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);
// session timers: echo the caller's Session-Expires; with no
// refresher stated, make the caller (uac) the refresher — its