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).
This commit is contained in:
parent
29d624fc55
commit
92fba40da0
6 changed files with 365 additions and 73 deletions
|
|
@ -18,8 +18,10 @@ That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx
|
|||
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the
|
||||
emergency attempt chain (urn:service:sos first for a classified number, one
|
||||
fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade
|
||||
the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE,
|
||||
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair —
|
||||
the other way), the terminating (UAS) side: 100/180 on an inbound INVITE,
|
||||
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — and
|
||||
DTMF, which reaches the media leg only on an active call whose SDP
|
||||
negotiated telephone-event, as the tones a keypad has —
|
||||
unit-testable against recorded network dialogs with no I/O (tests/Engine).
|
||||
|
||||
State/reason strings are the frozen D-Bus vocabulary the dialer maps on
|
||||
|
|
@ -280,12 +282,15 @@ export namespace imsd::engine {
|
|||
|
||||
// Parsed SDP answer needed to bring up the media leg. `codec` is one of
|
||||
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
|
||||
// `dtmfPt` is the telephone-event payload type at the codec's clock
|
||||
// (RFC 4733); nullopt = none negotiated, no DTMF on this call.
|
||||
struct MediaLeg {
|
||||
std::string remoteIp;
|
||||
int remotePort = 0;
|
||||
int payloadType = -1;
|
||||
bool octetAlign = false;
|
||||
std::string codec = "AMR-WB";
|
||||
std::optional<int> dtmfPt;
|
||||
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
|
||||
};
|
||||
|
||||
|
|
@ -295,6 +300,7 @@ export namespace imsd::engine {
|
|||
SendResponse, // answer the just-handled inbound request on its origin channel
|
||||
StartMedia, // bring up the media leg described by `media`
|
||||
StopMedia, // tear the media leg down
|
||||
SendDtmf, // hand `text` (the tones) to the running media leg
|
||||
State, // emit CallStateChanged(uni, `state`, `reason`)
|
||||
Deleted, // emit CallDeleted(uni); drop this machine
|
||||
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
|
||||
|
|
@ -308,6 +314,11 @@ export namespace imsd::engine {
|
|||
double seconds = 0;
|
||||
};
|
||||
|
||||
// The most tones one SendDtmf may carry (a keypad sends one; a dialer's
|
||||
// string is a short sequence): the leg's `dtmf <tones>\n` line stays
|
||||
// within one atomic pipe write, POSIX's 512-byte PIPE_BUF floor.
|
||||
inline constexpr std::size_t DtmfMaxTones = 128;
|
||||
|
||||
// ring/answer budget, and how long to wait for a 487 after CANCEL — the
|
||||
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
|
||||
inline constexpr double InviteTimeout = 90.0;
|
||||
|
|
@ -435,7 +446,7 @@ export namespace imsd::engine {
|
|||
spec.payloadType = leg.payloadType;
|
||||
spec.octetAlign = leg.octetAlign;
|
||||
spec.codec = leg.codec;
|
||||
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec));
|
||||
spec.dtmfPt = leg.dtmfPt;
|
||||
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
||||
// session timers: echo the caller's Session-Expires; with no
|
||||
// refresher stated, make the caller (uac) the refresher — its
|
||||
|
|
@ -608,6 +619,45 @@ export namespace imsd::engine {
|
|||
return a;
|
||||
}
|
||||
|
||||
// The user pressed keys: the tones go to the media leg as RFC 4733
|
||||
// events on an active call whose SDP negotiated telephone-event at
|
||||
// the codec's clock; otherwise they are dropped with a line that
|
||||
// says why — there is no second method (no SIP INFO).
|
||||
// Upper and lower case letters are one event; anything but 0-9, *,
|
||||
// # and A-D drops the whole string rather than sending part of
|
||||
// what was pressed, and so does a string past DtmfMaxTones — a
|
||||
// keypad sends one key, a dialer's string a short sequence; the
|
||||
// bound keeps the leg's control line within one atomic pipe write
|
||||
// (PIPE_BUF), so a line is sent whole or not at all.
|
||||
std::vector<Action> OnDtmf(std::string_view tones) {
|
||||
std::vector<Action> a;
|
||||
if (tones.size() > DtmfMaxTones) {
|
||||
a.push_back(LogAct(std::format("call {}: DTMF of {} tones dropped: more than {}", uni_, tones.size(), DtmfMaxTones)));
|
||||
return a;
|
||||
}
|
||||
std::string sent;
|
||||
for (char c : tones) {
|
||||
const char u = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
if (!((u >= '0' && u <= '9') || u == '*' || u == '#' || (u >= 'A' && u <= 'D'))) {
|
||||
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: '{}' is not a tone", uni_, tones, c)));
|
||||
return a;
|
||||
}
|
||||
sent += u;
|
||||
}
|
||||
if (sent.empty()) return a;
|
||||
if (state_ != CallState::Active) {
|
||||
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: the call is {}", uni_, sent, engine::StateStr(state_))));
|
||||
return a;
|
||||
}
|
||||
if (!dtmfPt_) {
|
||||
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: no telephone-event negotiated for this call", uni_, sent)));
|
||||
return a;
|
||||
}
|
||||
a.push_back(LogAct(std::format("call {}: DTMF '{}' -> media leg (telephone-event pt {})", uni_, sent, *dtmfPt_)));
|
||||
a.push_back(Action{Action::Type::SendDtmf, sent});
|
||||
return a;
|
||||
}
|
||||
|
||||
// The shell is abandoning this machine (an emergency dial preempts
|
||||
// it while it waits on a CANCEL's 487): terminate bookkeeping only —
|
||||
// the CANCEL already sent stands, and the late final for this
|
||||
|
|
@ -687,6 +737,7 @@ export namespace imsd::engine {
|
|||
bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline
|
||||
std::set<std::string> pracked_;
|
||||
std::string answerSdp_;
|
||||
std::optional<int> dtmfPt_; // the running leg's telephone-event pt, set at media start
|
||||
|
||||
// Fresh dialog identity for the current attempt. A fallback INVITE
|
||||
// after a final response is a new transaction AND a clean new dialog
|
||||
|
|
@ -779,6 +830,7 @@ export namespace imsd::engine {
|
|||
leg.payloadType = ans.payloadType;
|
||||
leg.octetAlign = ans.octetAlign;
|
||||
leg.codec = ans.codec;
|
||||
leg.dtmfPt = ans.dtmfPt;
|
||||
return leg;
|
||||
}
|
||||
|
||||
|
|
@ -820,6 +872,7 @@ export namespace imsd::engine {
|
|||
return a;
|
||||
}
|
||||
Action StartMediaAct(const MediaLeg& leg) {
|
||||
dtmfPt_ = leg.dtmfPt;
|
||||
Action a{Action::Type::StartMedia};
|
||||
a.media = leg;
|
||||
return a;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ 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
|
||||
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
|
||||
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
|
||||
|
|
@ -150,22 +152,68 @@ export namespace imsd::sdp {
|
|||
int payloadType = -1;
|
||||
bool octetAlign = false;
|
||||
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
|
||||
std::optional<int> dtmfPt; // telephone-event at the codec's clock rate; nullopt = no DTMF
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// The encoding name an offer gives a payload type: its a=rtpmap line,
|
||||
// upper-cased (RFC 4566 §6: encoding names are case-insensitive), else
|
||||
// the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry
|
||||
// no rtpmap at all). Empty when neither applies.
|
||||
std::string CodecName(std::string_view body, int pt) {
|
||||
std::string key = std::format("a=rtpmap:{} ", pt);
|
||||
if (auto at = Find(body, key)) {
|
||||
std::size_t nameBegin = *at + key.size();
|
||||
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
|
||||
std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin));
|
||||
for (char& c : name)
|
||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return name;
|
||||
}
|
||||
const std::string enc = Rtpmap(body, pt);
|
||||
if (!enc.empty()) return enc.substr(0, enc.find('/'));
|
||||
if (pt == 0) return "PCMU";
|
||||
if (pt == 8) return "PCMA";
|
||||
return "";
|
||||
|
|
@ -202,7 +250,10 @@ export namespace imsd::sdp {
|
|||
// 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.
|
||||
// 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.
|
||||
Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
|
||||
Answer a;
|
||||
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
|
||||
|
|
@ -214,17 +265,10 @@ export namespace imsd::sdp {
|
|||
auto port = ParseInt(portTok);
|
||||
// both the port and the " RTP/AVP " literal are required
|
||||
std::size_t afterPort = *m + 8 + portTok.size();
|
||||
constexpr std::string_view Avp = " RTP/AVP ";
|
||||
if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
|
||||
a.port = *port;
|
||||
|
||||
// payload-type list: 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);
|
||||
std::vector<int> pts;
|
||||
for (auto part : std::views::split(ptsRun, ' '))
|
||||
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
|
||||
std::vector<int> pts = AudioFormats(body);
|
||||
if (pts.empty()) return a;
|
||||
|
||||
auto hasOctetAlign = [&](int p) {
|
||||
|
|
@ -261,28 +305,10 @@ export namespace imsd::sdp {
|
|||
a.codec.clear();
|
||||
}
|
||||
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
|
||||
if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec));
|
||||
return a;
|
||||
}
|
||||
|
||||
// Payload type of `telephone-event/<clockRate>` in a body's rtpmap lines
|
||||
// (DTMF, RFC 4733); nullopt when the offer doesn't carry one.
|
||||
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
|
||||
std::string needle = std::format("telephone-event/{}", clockRate);
|
||||
std::size_t pos = 0;
|
||||
while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) {
|
||||
std::size_t ptBegin = pos + 9;
|
||||
std::size_t lineEnd = body.find_first_of("\r\n", ptBegin);
|
||||
std::size_t sp = body.find(' ', ptBegin);
|
||||
if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) {
|
||||
auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin));
|
||||
std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1);
|
||||
if (pt && name.starts_with(needle)) return pt;
|
||||
}
|
||||
pos = ptBegin;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
struct AnswerSpec {
|
||||
std::string local; // connection address (v4 or v6)
|
||||
int rtpPort = 0;
|
||||
|
|
@ -293,14 +319,11 @@ export namespace imsd::sdp {
|
|||
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
|
||||
};
|
||||
|
||||
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
|
||||
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
|
||||
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
|
||||
|
||||
// SDP answer to an inbound audio offer: single codec at the offer's own
|
||||
// payload type, octet-align echoed only when the offer used it (absence
|
||||
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all),
|
||||
// telephone-event kept when offered. b=AS is the codec's peak payload
|
||||
// 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
|
||||
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
|
||||
std::string BuildAnswer(const AnswerSpec& s) {
|
||||
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
|
||||
|
|
|
|||
Loading…
Reference in a new issue