diff --git a/README.md b/README.md index 822474b..2b631e6 100644 --- a/README.md +++ b/README.md @@ -210,3 +210,4 @@ GPL-3.0-only — see [LICENSE](LICENSE). Copyright (C) 2026 Catcrafts® 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 | diff --git a/implementations/main.cpp b/implementations/main.cpp index 43b77f1..559569a 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -571,6 +571,15 @@ public: precond_ = EnvOr("PRECOND", "0") == "1"; ealgOffer_ = EnvOr("EALG", "aes-cbc"); 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 // 112/911 (a lab core's advertised short code, carrier extras). std::string extra = EnvOr("EMERGENCY_NUMBERS", ""); @@ -688,6 +697,7 @@ private: int pcscfPort_ = 5060; int rtpPort_ = 50004; bool precond_ = false; + std::vector codecs_; // CODECS override; empty = defaults // registration imsd::msg::Context ctx_; @@ -1138,7 +1148,7 @@ private: Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " "fallback plain INVITE — stage 1, no emergency registration/PDN; " "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}; PostAdded(info); bool ok = Execute(call_->Start()); @@ -1198,7 +1208,7 @@ private: } std::string uni = std::format("ims-call-{}", ++callSeq_); 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_; Log(std::format("incoming call {} from {}", uni, call_->Number())); // Validate the offer BEFORE the UI hears of the call. A refused diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index ffa46f2..b91eef1 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -99,15 +99,20 @@ export namespace imsd::engine { public: // `emergency`: the shell classified the dialled string as an // 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 codecs = {}) : 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(); } - 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 codecs = {}) : 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"), invite_(in.invite) { number_ = imsd::sip::CallerId(invite_); @@ -148,6 +153,7 @@ export namespace imsd::engine { offer.rtpPort = rtpPort_; offer.sessionId = rng_.UInt(1000000, 9999999); offer.precond = precond_; + offer.codecs = codecs_; std::string sdp = imsd::sdp::BuildOffer(offer); std::vector a; 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 // reliable only when the caller Requires 100rel) and arm the ring // 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 + // codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU; the CODECS + // 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 // gateway want" without a raw SIP dump. std::vector OnInvite() { @@ -318,6 +325,7 @@ export namespace imsd::engine { offer.sessionId = rng_.UInt(1000000, 9999999); offer.precond = precond_; offer.currRemote = "sendrecv"; + offer.codecs = codecs_; ans = imsd::sdp::BuildOffer(offer); } // RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires, @@ -429,6 +437,7 @@ export namespace imsd::engine { std::string uni_, number_; int rtpPort_; bool precond_; + std::vector codecs_; // CODECS override; empty = defaults Direction direction_ = Direction::Outgoing; CallState state_ = CallState::Dialing; std::string reason_ = "outgoing"; @@ -532,7 +541,7 @@ export namespace imsd::engine { } MediaLeg ParseAnswer() const { - imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_); + imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_, codecs_); MediaLeg leg; leg.remoteIp = ans.ip; leg.remotePort = ans.port < 0 ? 0 : ans.port; diff --git a/interfaces/Imsd-Sdp.cppm b/interfaces/Imsd-Sdp.cppm index 471e294..723720d 100644 --- a/interfaces/Imsd-Sdp.cppm +++ b/interfaces/Imsd-Sdp.cppm @@ -5,15 +5,18 @@ /* 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 -with an 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 -parser extracts only what the media plane needs: remote address/port, the -negotiated payload type, the codec and octet-align mode — it reads answers -to our offers and inbound offers alike (same fields either way). The codecs -it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) 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 +The offer is by default an AMR-WB (octet-aligned) + AMR + telephone-event +shape with an 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 parser extracts only what the media plane needs: remote +address/port, the negotiated payload type, the codec and octet-align mode — it +reads answers to our offers and inbound offers alike (same fields either way). +The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) +and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a +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 octet-align mode (RFC 4867 requires both directions to use one mode). Pure std C++. @@ -49,20 +52,52 @@ 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 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 ParseCodecList(std::string_view spec) { + std::vector 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(std::toupper(static_cast(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 { std::string local; // connection address (v4 or v6) int rtpPort = 0; std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes bool precond = false; // IR.92 QoS preconditions block std::string currRemote = "none"; // a=curr:qos remote + std::vector codecs; // offered codecs in order; empty = AMR-WB, AMR }; // Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the // 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 // 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) { + static const std::vector kDefaultOffer{"AMR-WB", "AMR"}; + const std::vector& 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 s; s += "v=0\r\n"; @@ -70,18 +105,33 @@ export namespace imsd::sdp { s += "s=-\r\n"; s += std::format("c=IN {} {}\r\n", ipver, o.local); s += "t=0 0\r\n"; - s += std::format("m=audio {} RTP/AVP 97 99 98 100\r\n", o.rtpPort); - s += "b=AS:41\r\n"; + std::string pts; + 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=RR:1536\r\n"; - s += "a=rtpmap:97 AMR-WB/16000/1\r\n"; - s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"; - s += "a=rtpmap:99 AMR/8000/1\r\n"; - s += "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"; - s += "a=rtpmap:98 telephone-event/16000\r\n"; - s += "a=fmtp:98 0-15\r\n"; - s += "a=rtpmap:100 telephone-event/8000\r\n"; - s += "a=fmtp:100 0-15\r\n"; + for (const auto& c : codecs) { + if (c == "AMR-WB") { + s += "a=rtpmap:97 AMR-WB/16000/1\r\n"; + s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"; + } else if (c == "AMR") { + s += "a=rtpmap:99 AMR/8000/1\r\n"; + s += "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\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=maxptime:240\r\n"; if (o.precond) { @@ -144,16 +194,16 @@ export namespace imsd::sdp { } // 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 - // because it is the media leg's native, call-proven format - // (bandwidth-efficient is supported but was the s56 static-audio + // octet-align=1 > AMR > PCMA > PCMU (kCodecs order). Octet-aligned wins + // within a codec because it is the media leg's native, call-proven + // format (bandwidth-efficient is supported but was the s56 static-audio // culprit) — when the peer offers both variants, taking the - // octet-aligned pt keeps real calls on the battle-tested path. 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). With no - // 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) { + // octet-aligned pt keeps real calls on the battle-tested path. A + // non-empty `prefs` replaces the codec order (and restricts acceptance + // to the codecs named) — the CODECS override. With no acceptable 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, std::span prefs = {}) { Answer a; 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); @@ -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); return line.contains("octet-align=1"); }; - int wbOa = -1; - int wb = -1; - int nbOa = -1; - int nb = -1; - int pcma = -1; - int pcmu = -1; + struct Seen { int first = -1; int oa = -1; }; + std::map seen; // codec name -> first pt, first octet-aligned pt for (int p : pts) { std::string name = CodecName(body, p); - if (name == "AMR-WB") { - if (wb == -1) wb = p; - if (wbOa == -1 && hasOctetAlign(p)) wbOa = p; - } else if (name == "AMR") { - 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 (std::ranges::find(kCodecs, name) == kCodecs.end()) continue; + Seen& s = seen[name]; + if (s.first == -1) s.first = p; + if (s.oa == -1 && name.starts_with("AMR") && hasOctetAlign(p)) s.oa = p; } - if (wbOa != -1 || wb != -1) { - a.payloadType = wbOa != -1 ? wbOa : wb; - a.codec = "AMR-WB"; - } else if (nbOa != -1 || nb != -1) { - a.payloadType = nbOa != -1 ? nbOa : nb; - a.codec = "AMR"; - } else if (pcma != -1) { - a.payloadType = pcma; - a.codec = "PCMA"; - } else if (pcmu != -1) { - a.payloadType = pcmu; - a.codec = "PCMU"; + auto pick = [&](std::string_view name) { + auto it = seen.find(std::string(name)); + if (it == seen.end()) return false; + a.payloadType = it->second.oa != -1 ? it->second.oa : it->second.first; + a.codec = std::string(name); + return true; + }; + bool found = false; + if (prefs.empty()) { + for (std::string_view n : kCodecs) if ((found = pick(n))) break; } else { + for (const std::string& n : prefs) if ((found = pick(n))) break; + } + if (!found) { a.payloadType = pts.front(); a.codec.clear(); } diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index b835679..bf19a76 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -772,6 +772,62 @@ int main() { 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"); return Failures; } diff --git a/tests/Sdp/main.cpp b/tests/Sdp/main.cpp index 5a70cc9..e7f68ca 100644 --- a/tests/Sdp/main.cpp +++ b/tests/Sdp/main.cpp @@ -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"); } + // ---- 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 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 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 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 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"); return Failures; }