engine/sdp: CODECS override for the codecs we offer and accept

KPN's interconnect gateway transcodes every caller up: a G.711-only
fixed-line INVITE reached the phone offering PCMA, PCMU, AMR and AMR-WB
(bench call 2026-09-08), so the AMR-WB-only build rang and the narrowband
path — the one the Telia field report hit with 488 — cannot be reached on
the air through KPN by any caller.

CODECS=<list> (comma-separated over AMR-WB, AMR/AMR-NB, PCMA, PCMU) makes
the list both the codecs offered on an outgoing call and the acceptance
preference for an inbound offer, in that order: CODECS=PCMA takes G.711
A-law out of the mixed offer above, CODECS=PCMA,PCMU sends a G.711-only
offer toward the network. Unset, nothing changes — the default offer
bytes and the AMR-WB > AMR > PCMA > PCMU preference stay pinned. The
daemon logs an active override at startup.
This commit is contained in:
Jorijn van der Graaf 2026-09-08 17:53:26 +02:00
commit 37ec8660b7
6 changed files with 270 additions and 67 deletions

View file

@ -210,3 +210,4 @@ GPL-3.0-only — see [LICENSE](LICENSE).
Copyright (C) 2026 Catcrafts® Copyright (C) 2026 Catcrafts®
catcrafts.net catcrafts.net
| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run |

View file

@ -571,6 +571,15 @@ public:
precond_ = EnvOr("PRECOND", "0") == "1"; precond_ = EnvOr("PRECOND", "0") == "1";
ealgOffer_ = EnvOr("EALG", "aes-cbc"); ealgOffer_ = EnvOr("EALG", "aes-cbc");
mediaBin_ = ResolveMediaBin(); mediaBin_ = ResolveMediaBin();
// CODECS: restrict + reorder the codecs we offer and accept (bench
// knob — a gateway that transcodes every caller up to AMR-WB never
// lets the narrowband path run otherwise). Empty = defaults.
codecs_ = imsd::sdp::ParseCodecList(EnvOr("CODECS", ""));
if (!codecs_.empty()) {
std::string list;
for (const auto& c : codecs_) list += (list.empty() ? "" : ",") + c;
Log(std::format("CODECS override active: {}", list));
}
// EMERGENCY_NUMBERS: comma-separated additions to the builtin // EMERGENCY_NUMBERS: comma-separated additions to the builtin
// 112/911 (a lab core's advertised short code, carrier extras). // 112/911 (a lab core's advertised short code, carrier extras).
std::string extra = EnvOr("EMERGENCY_NUMBERS", ""); std::string extra = EnvOr("EMERGENCY_NUMBERS", "");
@ -688,6 +697,7 @@ private:
int pcscfPort_ = 5060; int pcscfPort_ = 5060;
int rtpPort_ = 50004; int rtpPort_ = 50004;
bool precond_ = false; bool precond_ = false;
std::vector<std::string> codecs_; // CODECS override; empty = defaults
// registration // registration
imsd::msg::Context ctx_; imsd::msg::Context ctx_;
@ -1138,7 +1148,7 @@ private:
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; " "fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number)); "carrier-side behavior UNVERIFIED", c.number));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency); call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info); PostAdded(info);
bool ok = Execute(call_->Start()); bool ok = Execute(call_->Start());
@ -1198,7 +1208,7 @@ private:
} }
std::string uni = std::format("ims-call-{}", ++callSeq_); std::string uni = std::format("ims-call-{}", ++callSeq_);
DumpRaw("imsd-invite-in.raw", msg); DumpRaw("imsd-invite-in.raw", msg);
call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_); call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_, codecs_);
callReply_ = reply_; callReply_ = reply_;
Log(std::format("incoming call {} from {}", uni, call_->Number())); Log(std::format("incoming call {} from {}", uni, call_->Number()));
// Validate the offer BEFORE the UI hears of the call. A refused // Validate the offer BEFORE the UI hears of the call. A refused

View file

@ -99,15 +99,20 @@ export namespace imsd::engine {
public: public:
// `emergency`: the shell classified the dialled string as an // `emergency`: the shell classified the dialled string as an
// emergency number — the first attempt targets urn:service:sos. // emergency number — the first attempt targets urn:service:sos.
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond, bool emergency = false) // `codecs`: the daemon's CODECS override — the codecs offered on an
// outgoing call and accepted from an inbound offer, in preference
// order; empty = the sdp module's defaults.
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond, bool emergency = false,
std::vector<std::string> codecs = {})
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)), : ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)),
rtpPort_(rtpPort), precond_(precond), attemptSos_(emergency) { rtpPort_(rtpPort), precond_(precond), codecs_(std::move(codecs)), attemptSos_(emergency) {
ResetDialog(); ResetDialog();
} }
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort) CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort,
std::vector<std::string> codecs = {})
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), rtpPort_(rtpPort), : ctx_(ctx), rng_(rng), uni_(std::move(uni)), rtpPort_(rtpPort),
precond_(false), direction_(Direction::Incoming), precond_(false), codecs_(std::move(codecs)), direction_(Direction::Incoming),
state_(CallState::Incoming), reason_("incoming"), state_(CallState::Incoming), reason_("incoming"),
invite_(in.invite) { invite_(in.invite) {
number_ = imsd::sip::CallerId(invite_); number_ = imsd::sip::CallerId(invite_);
@ -148,6 +153,7 @@ export namespace imsd::engine {
offer.rtpPort = rtpPort_; offer.rtpPort = rtpPort_;
offer.sessionId = rng_.UInt(1000000, 9999999); offer.sessionId = rng_.UInt(1000000, 9999999);
offer.precond = precond_; offer.precond = precond_;
offer.codecs = codecs_;
std::string sdp = imsd::sdp::BuildOffer(offer); std::string sdp = imsd::sdp::BuildOffer(offer);
std::vector<Action> a; std::vector<Action> a;
a.push_back(Send(imsd::msg::BuildInvite(ctx_, d_, sdp, precond_))); a.push_back(Send(imsd::msg::BuildInvite(ctx_, d_, sdp, precond_)));
@ -158,8 +164,9 @@ 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 none of the // timeout. An offer we cannot serve — no audio line, or none of the
// codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU) — is refused // codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU; the CODECS
// with 488 up front, before the UI ever rings; the log line names // override narrows that set) — 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 // what WAS offered, so a field journal answers "which codec did the
// gateway want" without a raw SIP dump. // gateway want" without a raw SIP dump.
std::vector<Action> OnInvite() { std::vector<Action> OnInvite() {
@ -318,6 +325,7 @@ export namespace imsd::engine {
offer.sessionId = rng_.UInt(1000000, 9999999); offer.sessionId = rng_.UInt(1000000, 9999999);
offer.precond = precond_; offer.precond = precond_;
offer.currRemote = "sendrecv"; offer.currRemote = "sendrecv";
offer.codecs = codecs_;
ans = imsd::sdp::BuildOffer(offer); ans = imsd::sdp::BuildOffer(offer);
} }
// RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires, // RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires,
@ -429,6 +437,7 @@ export namespace imsd::engine {
std::string uni_, number_; std::string uni_, number_;
int rtpPort_; int rtpPort_;
bool precond_; bool precond_;
std::vector<std::string> codecs_; // CODECS override; empty = defaults
Direction direction_ = Direction::Outgoing; Direction direction_ = Direction::Outgoing;
CallState state_ = CallState::Dialing; CallState state_ = CallState::Dialing;
std::string reason_ = "outgoing"; std::string reason_ = "outgoing";
@ -532,7 +541,7 @@ export namespace imsd::engine {
} }
MediaLeg ParseAnswer() const { MediaLeg ParseAnswer() const {
imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_); imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_, codecs_);
MediaLeg leg; MediaLeg leg;
leg.remoteIp = ans.ip; leg.remoteIp = ans.ip;
leg.remotePort = ans.port < 0 ? 0 : ans.port; leg.remotePort = ans.port < 0 ? 0 : ans.port;

View file

@ -5,15 +5,18 @@
/* /*
Imsd:Sdp — SDP offer/answer building + parsing for the voice media plane. Imsd:Sdp — SDP offer/answer building + parsing for the voice media plane.
The offer is a fixed AMR-WB (octet-aligned) + AMR + telephone-event shape The offer is by default an AMR-WB (octet-aligned) + AMR + telephone-event
with an optional GSMA IR.92 QoS-precondition block; it is what commercial IMS shape with an optional GSMA IR.92 QoS-precondition block; it is what
cores expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The commercial IMS cores expect from a VoLTE UE, and its exact bytes are pinned by
parser extracts only what the media plane needs: remote address/port, the tests/Sdp. The parser extracts only what the media plane needs: remote
negotiated payload type, the codec and octet-align mode — it reads answers address/port, the negotiated payload type, the codec and octet-align mode — it
to our offers and inbound offers alike (same fields either way). The codecs reads answers to our offers and inbound offers alike (same fields either way).
it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) and The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband)
G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
landline calls. BuildAnswer is the terminating side: it answers an inbound landline calls. An explicit codec list (the daemon's CODECS override) replaces
both the offered set and the acceptance preference: it is how the narrowband
path is exercised against a network whose gateway transcodes every caller up
to AMR-WB. BuildAnswer is the terminating side: it answers an inbound
offer with the offer's own payload type numbers (RFC 3264) and mirrors its 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 octet-align mode (RFC 4867 requires both directions to use one mode). Pure
std C++. std C++.
@ -49,20 +52,52 @@ namespace imsd::sdp {
export namespace imsd::sdp { export namespace imsd::sdp {
// The codec names imsd-media plays, in the default acceptance order:
// wideband beats narrowband, AMR beats G.711 (a mobile-to-mobile call
// must never land on a G.711 leg just because the gateway listed it).
constexpr std::array<std::string_view, 4> kCodecs{"AMR-WB", "AMR", "PCMA", "PCMU"};
// A codec preference list from its textual form ("PCMA, amr-nb"):
// case-insensitive, spaces ignored, AMR-NB accepted for AMR, unknown
// names and repeats dropped. Empty in = empty out = the defaults.
std::vector<std::string> ParseCodecList(std::string_view spec) {
std::vector<std::string> out;
for (auto part : std::views::split(spec, ',')) {
std::string n;
for (char c : std::string_view(part))
if (c != ' ' && c != '\t') n += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (n == "AMR-NB") n = "AMR";
if (std::ranges::find(kCodecs, n) == kCodecs.end()) continue;
if (std::ranges::find(out, n) == out.end()) out.push_back(n);
}
return out;
}
struct Offer { struct Offer {
std::string local; // connection address (v4 or v6) std::string local; // connection address (v4 or v6)
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
bool precond = false; // IR.92 QoS preconditions block bool precond = false; // IR.92 QoS preconditions block
std::string currRemote = "none"; // a=curr:qos remote <this> std::string currRemote = "none"; // a=curr:qos remote <this>
std::vector<std::string> codecs; // offered codecs in order; empty = AMR-WB, AMR
}; };
// Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the // Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the
// 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a // 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a
// call we place TO a landline no longer depends on the far side // call we place TO a landline no longer depends on the far side
// transcoding up to wideband — each with telephone-event at its own // transcoding up to wideband — each with telephone-event at its own
// clock. Byte layout is pinned by tests/Sdp. // clock. Byte layout is pinned by tests/Sdp. An explicit `codecs` list
// offers exactly those, in that order, with fixed payload types (97
// AMR-WB, 99 AMR, 8 PCMA, 0 PCMU) and telephone-event only at the clocks
// in use. G.711 is not offered by default: a VoLTE UE offering PCMA
// deviates from stock shapes for no gain.
std::string BuildOffer(const Offer& o) { std::string BuildOffer(const Offer& o) {
static const std::vector<std::string> kDefaultOffer{"AMR-WB", "AMR"};
const std::vector<std::string>& codecs = o.codecs.empty() ? kDefaultOffer : o.codecs;
bool wb = std::ranges::find(codecs, "AMR-WB") != codecs.end();
bool amr = std::ranges::find(codecs, "AMR") != codecs.end();
bool nb = codecs.size() > (wb ? 1u : 0u); // anything besides AMR-WB is 8 kHz
auto pt = [](const std::string& c) { return c == "AMR-WB" ? 97 : c == "AMR" ? 99 : c == "PCMA" ? 8 : 0; };
std::string ipver = Is6(o.local) ? "IP6" : "IP4"; std::string ipver = Is6(o.local) ? "IP6" : "IP4";
std::string s; std::string s;
s += "v=0\r\n"; s += "v=0\r\n";
@ -70,18 +105,33 @@ export namespace imsd::sdp {
s += "s=-\r\n"; s += "s=-\r\n";
s += std::format("c=IN {} {}\r\n", ipver, o.local); s += std::format("c=IN {} {}\r\n", ipver, o.local);
s += "t=0 0\r\n"; s += "t=0 0\r\n";
s += std::format("m=audio {} RTP/AVP 97 99 98 100\r\n", o.rtpPort); std::string pts;
s += "b=AS:41\r\n"; for (const auto& c : codecs) pts += std::format(" {}", pt(c));
if (wb) pts += " 98";
if (nb) pts += " 100";
s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts);
s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80);
s += "b=RS:512\r\n"; s += "b=RS:512\r\n";
s += "b=RR:1536\r\n"; s += "b=RR:1536\r\n";
s += "a=rtpmap:97 AMR-WB/16000/1\r\n"; for (const auto& c : codecs) {
s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"; if (c == "AMR-WB") {
s += "a=rtpmap:99 AMR/8000/1\r\n"; s += "a=rtpmap:97 AMR-WB/16000/1\r\n";
s += "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"; s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n";
s += "a=rtpmap:98 telephone-event/16000\r\n"; } else if (c == "AMR") {
s += "a=fmtp:98 0-15\r\n"; s += "a=rtpmap:99 AMR/8000/1\r\n";
s += "a=rtpmap:100 telephone-event/8000\r\n"; s += "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n";
s += "a=fmtp:100 0-15\r\n"; } else {
s += std::format("a=rtpmap:{} {}/8000/1\r\n", pt(c), c);
}
}
if (wb) {
s += "a=rtpmap:98 telephone-event/16000\r\n";
s += "a=fmtp:98 0-15\r\n";
}
if (nb) {
s += "a=rtpmap:100 telephone-event/8000\r\n";
s += "a=fmtp:100 0-15\r\n";
}
s += "a=ptime:20\r\n"; s += "a=ptime:20\r\n";
s += "a=maxptime:240\r\n"; s += "a=maxptime:240\r\n";
if (o.precond) { if (o.precond) {
@ -144,16 +194,16 @@ export namespace imsd::sdp {
} }
// 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 > PCMA > PCMU. Octet-aligned wins within a codec // octet-align=1 > AMR > PCMA > PCMU (kCodecs order). Octet-aligned wins
// because it is the media leg's native, call-proven format // within a codec because it is the media leg's native, call-proven
// (bandwidth-efficient is supported but was the s56 static-audio // format (bandwidth-efficient is supported but was the s56 static-audio
// culprit) — when the peer offers both variants, taking the // culprit) — when the peer offers both variants, taking the
// octet-aligned pt keeps real calls on the battle-tested path. Wideband // octet-aligned pt keeps real calls on the battle-tested path. A
// beats narrowband, AMR beats G.711 (a mobile-to-mobile call must never // non-empty `prefs` replaces the codec order (and restricts acceptance
// land on a G.711 leg just because the gateway listed it). With no // to the codecs named) — the CODECS override. With no acceptable codec
// playable codec at all, the first pt is used and codec comes back EMPTY // at all, the first pt is used and codec comes back EMPTY — pretending
// — pretending otherwise would defeat codec checks upstream. // otherwise would defeat codec checks upstream.
Answer ParseAnswer(std::string_view body) { Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
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);
else if (auto at4 = Find(body, "c=IN IP4 ")) a.ip = TokenAt(body, *at4 + 9); else if (auto at4 = Find(body, "c=IN IP4 ")) a.ip = TokenAt(body, *at4 + 9);
@ -184,39 +234,29 @@ export namespace imsd::sdp {
std::string_view line = body.substr(*fm, lineEnd == std::string_view::npos ? std::string_view::npos : lineEnd - *fm); std::string_view line = body.substr(*fm, lineEnd == std::string_view::npos ? std::string_view::npos : lineEnd - *fm);
return line.contains("octet-align=1"); return line.contains("octet-align=1");
}; };
int wbOa = -1; struct Seen { int first = -1; int oa = -1; };
int wb = -1; std::map<std::string, Seen> seen; // codec name -> first pt, first octet-aligned pt
int nbOa = -1;
int nb = -1;
int pcma = -1;
int pcmu = -1;
for (int p : pts) { for (int p : pts) {
std::string name = CodecName(body, p); std::string name = CodecName(body, p);
if (name == "AMR-WB") { if (std::ranges::find(kCodecs, name) == kCodecs.end()) continue;
if (wb == -1) wb = p; Seen& s = seen[name];
if (wbOa == -1 && hasOctetAlign(p)) wbOa = p; if (s.first == -1) s.first = p;
} else if (name == "AMR") { if (s.oa == -1 && name.starts_with("AMR") && hasOctetAlign(p)) s.oa = p;
if (nb == -1) nb = 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) { auto pick = [&](std::string_view name) {
a.payloadType = wbOa != -1 ? wbOa : wb; auto it = seen.find(std::string(name));
a.codec = "AMR-WB"; if (it == seen.end()) return false;
} else if (nbOa != -1 || nb != -1) { a.payloadType = it->second.oa != -1 ? it->second.oa : it->second.first;
a.payloadType = nbOa != -1 ? nbOa : nb; a.codec = std::string(name);
a.codec = "AMR"; return true;
} else if (pcma != -1) { };
a.payloadType = pcma; bool found = false;
a.codec = "PCMA"; if (prefs.empty()) {
} else if (pcmu != -1) { for (std::string_view n : kCodecs) if ((found = pick(n))) break;
a.payloadType = pcmu;
a.codec = "PCMU";
} else { } else {
for (const std::string& n : prefs) if ((found = pick(n))) break;
}
if (!found) {
a.payloadType = pts.front(); a.payloadType = pts.front();
a.codec.clear(); a.codec.clear();
} }

View file

@ -772,6 +772,62 @@ int main() {
Check(m.Terminated(), "abandoned machine is terminated"); Check(m.Terminated(), "abandoned machine is terminated");
} }
// ---- CODECS override, terminating side: the transcoding gateway's
// mixed offer (KPN 2026-09-08) answered with G.711 A-law because the
// list says so — the on-air narrowband bench path
{
imsd::util::Rng rng(51);
std::string sdp =
"v=0\r\no=LucentPCSF 1 1 IN IP6 2001:db8::309\r\ns=-\r\n"
"c=IN IP6 2001:db8::309\r\nt=0 0\r\n"
"m=audio 41166 RTP/AVP 8 0 100 96 101 13 106\r\n"
"a=rtpmap:8 PCMA/8000\r\na=rtpmap:0 PCMU/8000\r\n"
"a=rtpmap:100 AMR/8000\r\na=fmtp:100 mode-change-period=2\r\n"
"a=rtpmap:96 AMR-WB/16000\r\na=fmtp:96 mode-change-period=2\r\n"
"a=rtpmap:101 telephone-event/8000\r\na=fmtp:101 0-15\r\n"
"a=rtpmap:106 telephone-event/16000\r\na=fmtp:106 0-15\r\n"
"a=sendrecv\r\na=ptime:20\r\na=maxptime:60\r\n";
CallMachine m(c, rng, "ims-call-20", IncomingInvite{MtInviteSdp("mt-kpn-pcma", sdp)}, 50004, {"PCMA"});
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "CODECS=PCMA: mixed 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 8 101\r\n"), "CODECS=PCMA: answer is PCMA + narrowband DTMF at the offer's pts");
Check(ok && ok->text.contains("a=rtpmap:8 PCMA/8000/1\r\n") && !ok->text.contains("AMR"), "CODECS=PCMA: no AMR in the answer");
const Action* med = Find(aa, Action::Type::StartMedia);
Check(med && med->media.codec == "PCMA" && med->media.payloadType == 8 && !med->media.octetAlign, "CODECS=PCMA: media leg starts G.711 A-law");
// same offer, CODECS=AMR: narrowband AMR, bandwidth-efficient as offered
imsd::util::Rng rng2(52);
CallMachine m2(c, rng2, "ims-call-21", IncomingInvite{MtInviteSdp("mt-kpn-amr", sdp)}, 50004, {"AMR"});
m2.OnInvite();
auto aa2 = m2.OnAccept();
const Action* ok2 = Responded(aa2, "SIP/2.0 200 OK");
Check(ok2 && ok2->text.contains("m=audio 50004 RTP/AVP 100 101\r\n") && !ok2->text.contains("octet-align"), "CODECS=AMR: answer is AMR-NB BE + narrowband DTMF");
const Action* med2 = Find(aa2, Action::Type::StartMedia);
Check(med2 && med2->media.codec == "AMR" && med2->media.payloadType == 100 && !med2->media.octetAlign, "CODECS=AMR: media leg starts AMR-NB BE");
// same offer, no override: unchanged — AMR-WB
imsd::util::Rng rng3(53);
CallMachine m3(c, rng3, "ims-call-22", IncomingInvite{MtInviteSdp("mt-kpn-default", sdp)}, 50004);
m3.OnInvite();
auto aa3 = m3.OnAccept();
const Action* med3 = Find(aa3, Action::Type::StartMedia);
Check(med3 && med3->media.codec == "AMR-WB" && med3->media.payloadType == 96, "no override: the same offer still lands on AMR-WB");
}
// ---- CODECS override, originating side: the INVITE offers only what the
// list names (G.711 = a landline-shaped offer toward the network)
{
imsd::util::Rng rng(54);
CallMachine m(c, rng, "ims-call-23", "0783690354", 50004, false, false, {"PCMA", "PCMU"});
auto start = m.Start();
const Action* inv = Sent(start, "INVITE ");
Check(inv && inv->text.contains("m=audio 50004 RTP/AVP 8 0 100\r\n"), "CODECS=PCMA,PCMU: INVITE offers G.711 only");
Check(inv && !inv->text.contains("AMR"), "CODECS=PCMA,PCMU: no AMR in the offer");
Check(inv && inv->text.contains("a=rtpmap:100 telephone-event/8000\r\n") && !inv->text.contains("telephone-event/16000"), "CODECS=PCMA,PCMU: narrowband DTMF only");
}
if (Failures == 0) std::println("Engine: all tests passed"); if (Failures == 0) std::println("Engine: all tests passed");
return Failures; return Failures;
} }

View file

@ -294,6 +294,93 @@ int main() {
Check(sdp.contains("a=rtpmap:0 PCMU/8000/1\r\n") && !sdp.contains("fmtp:0"), "PCMU answer has no fmtp"); Check(sdp.contains("a=rtpmap:0 PCMU/8000/1\r\n") && !sdp.contains("fmtp:0"), "PCMU answer has no fmtp");
} }
// ---- CODECS override: the offer of a real interconnect gateway that
// transcodes up (KPN, 2026-09-08: a G.711-only caller arrives with
// AMR-WB BE added). Default picks AMR-WB; a preference list picks
// what it names, in its order, and nothing outside it.
{
std::string kpn =
"c=IN IP6 2001:db8::309\r\n"
"m=audio 41166 RTP/AVP 8 0 100 96 101 13 106\r\n"
"a=rtpmap:8 PCMA/8000\r\na=rtpmap:0 PCMU/8000\r\n"
"a=rtpmap:100 AMR/8000\r\na=fmtp:100 mode-change-period=2\r\n"
"a=rtpmap:96 AMR-WB/16000\r\na=fmtp:96 mode-change-period=2\r\n"
"a=rtpmap:101 telephone-event/8000\r\na=fmtp:101 0-15\r\n"
"a=rtpmap:106 telephone-event/16000\r\na=fmtp:106 0-15\r\n"
"a=sendrecv\r\na=ptime:20\r\na=maxptime:60\r\n";
Answer d = ParseAnswer(kpn);
Check(d.payloadType == 96 && d.codec == "AMR-WB" && !d.octetAlign, "transcoding gateway: default takes AMR-WB BE");
std::vector<std::string> pcma{"PCMA"};
Answer a = ParseAnswer(kpn, pcma);
Check(a.payloadType == 8 && a.codec == "PCMA" && !a.octetAlign, "CODECS=PCMA takes G.711 A-law out of a mixed offer");
std::vector<std::string> ulawFirst{"PCMU", "PCMA"};
Answer u = ParseAnswer(kpn, ulawFirst);
Check(u.payloadType == 0 && u.codec == "PCMU", "CODECS order wins over the offer's order");
std::vector<std::string> nb{"AMR"};
Answer n = ParseAnswer(kpn, nb);
Check(n.payloadType == 100 && n.codec == "AMR" && !n.octetAlign, "CODECS=AMR takes narrowband AMR, BE as offered");
std::vector<std::string> wb{"AMR-WB"};
Answer w = ParseAnswer(kpn, wb);
Check(w.payloadType == 96 && w.codec == "AMR-WB", "CODECS=AMR-WB matches the default here");
Answer g = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 0 101\r\n" "a=rtpmap:101 telephone-event/8000\r\n", wb);
Check(g.codec.empty() && g.payloadType == 8, "CODECS=AMR-WB refuses a G.711-only offer (codec empty, first pt)");
Check(g.port == 5000 && g.ip == "192.0.2.1", "address/port still parsed when no codec is acceptable");
}
// ---- ParseCodecList: case, spaces, AMR-NB alias, unknowns, repeats
{
auto l = ParseCodecList("pcma, amr-nb ,AMR-WB,g729,PCMA,,PCMU");
Check(l.size() == 4 && l[0] == "PCMA" && l[1] == "AMR" && l[2] == "AMR-WB" && l[3] == "PCMU", "codec list normalised: PCMA,AMR,AMR-WB,PCMU");
Check(ParseCodecList("").empty(), "empty spec = no override");
Check(ParseCodecList("opus, g722").empty(), "only unknown names = no override");
}
// ---- BuildOffer with an explicit codec list: G.711 only (landline
// shape, for MO tests against a transcoding network)
{
std::string sdp = BuildOffer({
.local = "192.0.2.1",
.rtpPort = 22222,
.sessionId = 1234567,
.codecs = {"PCMA", "PCMU"},
});
std::string expected =
"v=0\r\n"
"o=- 1234567 1234567 IN IP4 192.0.2.1\r\n"
"s=-\r\n"
"c=IN IP4 192.0.2.1\r\n"
"t=0 0\r\n"
"m=audio 22222 RTP/AVP 8 0 100\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:0 PCMU/8000/1\r\n"
"a=rtpmap:100 telephone-event/8000\r\n"
"a=fmtp:100 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
Check(sdp == expected, "G.711-only offer byte-exact");
}
// ---- BuildOffer, AMR narrowband only: pt 99, narrowband DTMF only, AS 30
{
std::string sdp = BuildOffer({.local = "2001:db8::1", .rtpPort = 50004, .sessionId = 7, .codecs = {"AMR"}});
Check(sdp.contains("m=audio 50004 RTP/AVP 99 100\r\n"), "AMR-only offer m-line");
Check(sdp.contains("b=AS:30\r\n"), "AMR-only offer bandwidth");
Check(sdp.contains("a=rtpmap:99 AMR/8000/1\r\na=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"), "AMR-only offer rtpmap+fmtp");
Check(!sdp.contains("16000"), "no wideband telephone-event without AMR-WB");
}
// ---- BuildOffer, explicit default order == the pinned default bytes
{
Offer o{.local = "2001:db8::1", .rtpPort = 50004, .sessionId = 7};
Offer e = o;
e.codecs = {"AMR-WB", "AMR"};
Check(BuildOffer(o) == BuildOffer(e), "explicit AMR-WB,AMR list is byte-identical to the default offer");
}
if (Failures == 0) std::println("Sdp: all tests passed"); if (Failures == 0) std::println("Sdp: all tests passed");
return Failures; return Failures;
} }