2026-07-22 22:53:28 +02:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
|
|
|
|
|
|
// lint-disable-file fixed-width-types
|
|
|
|
|
/*
|
2026-09-02 03:11:11 +02:00
|
|
|
Imsd:Sdp — SDP offer/answer building + parsing for the voice media plane.
|
2026-07-22 22:53:28 +02:00
|
|
|
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
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
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
address/port, the negotiated payload type, the codec and octet-align mode, and
|
|
|
|
|
the telephone-event payload type among the m-line's formats at that codec's
|
|
|
|
|
clock (DTMF, RFC 4733) — it
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
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
|
2026-09-02 03:11:11 +02:00
|
|
|
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++.
|
2026-07-22 22:53:28 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export module Imsd:Sdp;
|
|
|
|
|
import std;
|
|
|
|
|
|
|
|
|
|
namespace imsd::sdp {
|
|
|
|
|
|
|
|
|
|
bool Is6(std::string_view addr) { return addr.contains(':'); }
|
|
|
|
|
|
|
|
|
|
// First token (up to whitespace/CR/LF) starting at `pos`.
|
|
|
|
|
std::string_view TokenAt(std::string_view body, std::size_t pos) {
|
|
|
|
|
std::size_t end = body.find_first_of(" \t\r\n", pos);
|
|
|
|
|
return body.substr(pos, end == std::string_view::npos ? std::string_view::npos : end - pos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::optional<int> ParseInt(std::string_view s) {
|
|
|
|
|
int v = 0;
|
|
|
|
|
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
|
|
|
|
|
if (ec != std::errc{} || p != s.data() + s.size()) return std::nullopt;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First occurrence of `needle` anywhere in the body; nullopt if absent.
|
|
|
|
|
std::optional<std::size_t> Find(std::string_view body, std::string_view needle) {
|
|
|
|
|
std::size_t at = body.find(needle);
|
|
|
|
|
if (at == std::string_view::npos) return std::nullopt;
|
|
|
|
|
return at;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export namespace imsd::sdp {
|
|
|
|
|
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 22:53:28 +02:00
|
|
|
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 <this>
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
std::vector<std::string> codecs; // offered codecs in order; empty = AMR-WB, AMR
|
2026-07-22 22:53:28 +02:00
|
|
|
};
|
|
|
|
|
|
sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.
Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
2026-10-15 21:18:28 +02:00
|
|
|
// The payload type our offer gives telephone-event at a clock rate: the
|
|
|
|
|
// number an answerer sends with (RFC 3264 §6.1), whatever the answer
|
|
|
|
|
// renumbered ours to.
|
|
|
|
|
int OfferedTelephoneEventPt(int clockRate) { return clockRate == 16000 ? 98 : 100; }
|
|
|
|
|
|
2026-09-02 03:21:06 +02:00
|
|
|
// 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
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
// 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.
|
2026-07-22 22:53:28 +02:00
|
|
|
std::string BuildOffer(const Offer& o) {
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
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; };
|
2026-07-22 22:53:28 +02:00
|
|
|
std::string ipver = Is6(o.local) ? "IP6" : "IP4";
|
|
|
|
|
std::string s;
|
|
|
|
|
s += "v=0\r\n";
|
|
|
|
|
s += std::format("o=- {} {} IN {} {}\r\n", o.sessionId, o.sessionId, ipver, o.local);
|
|
|
|
|
s += "s=-\r\n";
|
|
|
|
|
s += std::format("c=IN {} {}\r\n", ipver, o.local);
|
|
|
|
|
s += "t=0 0\r\n";
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
std::string pts;
|
|
|
|
|
for (const auto& c : codecs) pts += std::format(" {}", pt(c));
|
sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.
Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
2026-10-15 21:18:28 +02:00
|
|
|
if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000));
|
|
|
|
|
if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000));
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts);
|
|
|
|
|
s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80);
|
2026-07-22 22:53:28 +02:00
|
|
|
s += "b=RS:512\r\n";
|
|
|
|
|
s += "b=RR:1536\r\n";
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
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) {
|
sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.
Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
2026-10-15 21:18:28 +02:00
|
|
|
s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000));
|
|
|
|
|
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000));
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
}
|
|
|
|
|
if (nb) {
|
sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.
Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
2026-10-15 21:18:28 +02:00
|
|
|
s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000));
|
|
|
|
|
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000));
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
}
|
2026-07-22 22:53:28 +02:00
|
|
|
s += "a=ptime:20\r\n";
|
|
|
|
|
s += "a=maxptime:240\r\n";
|
|
|
|
|
if (o.precond) {
|
|
|
|
|
s += "a=curr:qos local sendrecv\r\n";
|
|
|
|
|
s += std::format("a=curr:qos remote {}\r\n", o.currRemote);
|
|
|
|
|
s += "a=des:qos mandatory local sendrecv\r\n";
|
|
|
|
|
s += "a=des:qos mandatory remote sendrecv\r\n";
|
|
|
|
|
}
|
|
|
|
|
s += "a=sendrecv\r\n";
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Answer {
|
|
|
|
|
std::string ip; // empty = no c= line found
|
|
|
|
|
int port = -1; // -1 = no m=audio line found
|
|
|
|
|
int payloadType = -1;
|
|
|
|
|
bool octetAlign = false;
|
2026-09-02 03:11:11 +02:00
|
|
|
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
std::optional<int> dtmfPt; // telephone-event at the codec's clock rate; nullopt = no DTMF
|
2026-07-22 22:53:28 +02:00
|
|
|
};
|
|
|
|
|
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
// 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; }
|
|
|
|
|
constexpr std::string_view Avp = " RTP/AVP "; // after the m=audio port: the one profile we speak
|
|
|
|
|
|
|
|
|
|
// The m=audio line's payload types in its order; empty without the line,
|
|
|
|
|
// its port or the " RTP/AVP " literal.
|
|
|
|
|
std::vector<int> AudioFormats(std::string_view body) {
|
|
|
|
|
std::vector<int> pts;
|
|
|
|
|
auto m = Find(body, "m=audio ");
|
|
|
|
|
if (!m) return pts;
|
|
|
|
|
std::string_view portTok = TokenAt(body, *m + 8);
|
|
|
|
|
std::size_t afterPort = *m + 8 + portTok.size();
|
|
|
|
|
if (!ParseInt(portTok) || !body.substr(afterPort).starts_with(Avp)) return pts;
|
|
|
|
|
// digits + spaces up to CR/LF
|
|
|
|
|
std::size_t ptsBegin = afterPort + Avp.size();
|
|
|
|
|
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
|
|
|
|
|
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
|
|
|
|
|
for (auto part : std::views::split(ptsRun, ' '))
|
|
|
|
|
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
|
|
|
|
|
return pts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A payload type's rtpmap encoding, `NAME/CLOCK[/CHANNELS]` upper-cased
|
|
|
|
|
// (RFC 4566 §6: encoding names are case-insensitive); empty without one.
|
|
|
|
|
std::string Rtpmap(std::string_view body, int pt) {
|
|
|
|
|
const std::string key = std::format("a=rtpmap:{} ", pt);
|
|
|
|
|
auto at = Find(body, key);
|
|
|
|
|
if (!at) return "";
|
|
|
|
|
std::size_t begin = *at + key.size();
|
|
|
|
|
std::size_t end = body.find_first_of("\r\n", begin);
|
|
|
|
|
std::string enc(body.substr(begin, end == std::string_view::npos ? std::string_view::npos : end - begin));
|
|
|
|
|
for (char& c : enc)
|
|
|
|
|
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
|
|
|
|
return enc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Payload type of `telephone-event/<clockRate>` among the m=audio line's
|
|
|
|
|
// formats (DTMF, RFC 4733); nullopt when the line lists none. An rtpmap
|
|
|
|
|
// the line does not list is not negotiated: nothing is sent with a format
|
|
|
|
|
// the answer does not carry (RFC 3264 §8.3.2). The clock is matched
|
|
|
|
|
// whole: `/8000` is not `/80000`.
|
|
|
|
|
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
|
|
|
|
|
const std::string needle = std::format("TELEPHONE-EVENT/{}", clockRate);
|
|
|
|
|
for (int pt : AudioFormats(body)) {
|
|
|
|
|
const std::string enc = Rtpmap(body, pt);
|
|
|
|
|
const bool whole = enc.size() == needle.size() || (enc.size() > needle.size() && (enc[needle.size()] == '/' || enc[needle.size()] == ' '));
|
|
|
|
|
if (enc.starts_with(needle) && whole) return pt;
|
|
|
|
|
}
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 03:11:11 +02:00
|
|
|
// 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) {
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
const std::string enc = Rtpmap(body, pt);
|
|
|
|
|
if (!enc.empty()) return enc.substr(0, enc.find('/'));
|
2026-09-02 03:11:11 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 22:53:28 +02:00
|
|
|
// Payload-type preference: AMR-WB with octet-align=1 > AMR-WB > AMR with
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
// 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
|
2026-09-02 03:11:11 +02:00
|
|
|
// culprit) — when the peer offers both variants, taking the
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
// 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
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
// otherwise would defeat codec checks upstream. The telephone-event pt
|
|
|
|
|
// is the one at the chosen codec's clock (an event's rate is its rtpmap
|
|
|
|
|
// clock, RFC 4733 §2.4.1; an offer with two codec clocks lists
|
|
|
|
|
// telephone-event at each), so a tone goes out on the stream's own clock.
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
|
2026-07-22 22:53:28 +02:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
auto m = Find(body, "m=audio ");
|
|
|
|
|
if (!m) return a;
|
|
|
|
|
std::string_view portTok = TokenAt(body, *m + 8);
|
|
|
|
|
auto port = ParseInt(portTok);
|
|
|
|
|
// both the port and the " RTP/AVP " literal are required
|
|
|
|
|
std::size_t afterPort = *m + 8 + portTok.size();
|
|
|
|
|
if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
|
|
|
|
|
a.port = *port;
|
|
|
|
|
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
std::vector<int> pts = AudioFormats(body);
|
2026-07-22 22:53:28 +02:00
|
|
|
if (pts.empty()) return a;
|
|
|
|
|
|
|
|
|
|
auto hasOctetAlign = [&](int p) {
|
|
|
|
|
auto fm = Find(body, std::format("a=fmtp:{} ", p));
|
|
|
|
|
if (!fm) return false;
|
|
|
|
|
std::size_t lineEnd = body.find_first_of("\r\n", *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");
|
|
|
|
|
};
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
struct Seen { int first = -1; int oa = -1; };
|
|
|
|
|
std::map<std::string, Seen> seen; // codec name -> first pt, first octet-aligned pt
|
2026-07-22 22:53:28 +02:00
|
|
|
for (int p : pts) {
|
2026-09-02 03:11:11 +02:00
|
|
|
std::string name = CodecName(body, p);
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
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;
|
2026-07-22 22:53:28 +02:00
|
|
|
}
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
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;
|
2026-07-22 22:53:28 +02:00
|
|
|
} else {
|
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.
2026-09-08 17:53:26 +02:00
|
|
|
for (const std::string& n : prefs) if ((found = pick(n))) break;
|
|
|
|
|
}
|
|
|
|
|
if (!found) {
|
2026-07-22 22:53:28 +02:00
|
|
|
a.payloadType = pts.front();
|
|
|
|
|
a.codec.clear();
|
|
|
|
|
}
|
2026-09-02 03:11:11 +02:00
|
|
|
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec));
|
2026-07-22 22:53:28 +02:00
|
|
|
return a;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct AnswerSpec {
|
|
|
|
|
std::string local; // connection address (v4 or v6)
|
|
|
|
|
int rtpPort = 0;
|
|
|
|
|
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
|
|
|
|
|
int payloadType = 97; // the offer's pt for the chosen codec
|
2026-09-02 03:11:11 +02:00
|
|
|
bool octetAlign = true; // mirror of the offer's mode (AMR codecs only)
|
|
|
|
|
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU"
|
2026-07-22 22:53:28 +02:00
|
|
|
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-09-02 03:11:11 +02:00
|
|
|
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all),
|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.
Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
|
|
|
// telephone-event kept when the offer's m-line lists one at the codec's
|
|
|
|
|
// clock (ParseAnswer's rule). b=AS is the codec's peak payload
|
2026-09-02 03:11:11 +02:00
|
|
|
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
|
2026-07-22 22:53:28 +02:00
|
|
|
std::string BuildAnswer(const AnswerSpec& s) {
|
|
|
|
|
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
|
2026-09-02 03:11:11 +02:00
|
|
|
int rate = CodecRate(s.codec);
|
|
|
|
|
int as = s.codec == "AMR-WB" ? 41 : (s.codec == "AMR" ? 30 : 80);
|
2026-07-22 22:53:28 +02:00
|
|
|
std::string out;
|
|
|
|
|
out += "v=0\r\n";
|
|
|
|
|
out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local);
|
|
|
|
|
out += "s=-\r\n";
|
|
|
|
|
out += std::format("c=IN {} {}\r\n", ipver, s.local);
|
|
|
|
|
out += "t=0 0\r\n";
|
|
|
|
|
if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt);
|
|
|
|
|
else
|
|
|
|
|
out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType);
|
2026-09-02 03:11:11 +02:00
|
|
|
out += std::format("b=AS:{}\r\n", as);
|
2026-07-22 22:53:28 +02:00
|
|
|
out += "b=RS:512\r\n";
|
|
|
|
|
out += "b=RR:1536\r\n";
|
|
|
|
|
out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate);
|
2026-09-02 03:11:11 +02:00
|
|
|
if (s.octetAlign && s.codec.starts_with("AMR")) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType);
|
2026-07-22 22:53:28 +02:00
|
|
|
if (s.dtmfPt) {
|
|
|
|
|
out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate);
|
|
|
|
|
out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt);
|
|
|
|
|
}
|
|
|
|
|
out += "a=ptime:20\r\n";
|
|
|
|
|
out += "a=maxptime:240\r\n";
|
|
|
|
|
out += "a=sendrecv\r\n";
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
}
|