diff --git a/AGENTS.md b/AGENTS.md index c64ca94..b13c406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ PipeWire. The verified device/carrier combinations are in the README. | `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. | | `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). | | `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | -| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | +| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs (the codec, its payload type and mode, and the telephone-event payload type the m-line lists at that codec's clock), `BuildAnswer` for the terminating side, the `CODECS` override. | | `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | | `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). | | `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | @@ -282,13 +282,18 @@ What the suites pin: keepalive discards), `ResponseOf`, refused finals with reason headers, reginfo documents. A change that flips one of those tests is a behaviour change, not a cleanup. -- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's +- `Sdp` — the offer's exact bytes, the parser (the telephone-event payload + type at the chosen codec's clock among it), `BuildAnswer` at the offer's own payload types and octet-align mode, the `CODECS` override. - `Engine` — the call state machine driven through recorded network dialogs (reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer races, busy/error finals, the terminating side's 100/180/200/486/487/488, G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the - emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle + emergency chain, DTMF — sent to the leg on an active call that negotiated + telephone-event, dropped with the reason before the answer, after the + call, without the negotiation, for a character that is no tone or for a + string past `DtmfMaxTones`) and + `RegRecovery` (thresholds, backoffs, the throttle schedule, Retry-After, which waits a Dial may cut short, when the old registration counts as gone, the holds a restart takes over, a binding the network terminated). @@ -427,7 +432,46 @@ Rules: the script's keys ran on the host build (2026-10-12, exit 0), its own idle (nothing to register to), the discovery-failed exit 1, `main()`'s removal of a pair the holds wrote off and `imsd-ofonod` starting without waiting - for imsd on the dev phone (2026-10-12). + for imsd on the dev phone (2026-10-12). DTMF ran on the dev phone (KPN, + 2026-10-14 to 16, the fp6 journal's `2026-10-14-dtmf-bench-1` to + `2026-10-16-dtmf-bench-17-heard`): outgoing calls to voicemail over the bus + (AMR-WB, telephone-event 98 — three tones, a string with a character + that is no tone, a wrong call uni: the two drops' lines; benches 1, 5 + to 9 and 11 to 16), 13 keys from GNOME Calls' keypad on a call to a stock phone the + network answered narrowband (AMR 99, telephone-event 100), heard there + (bench 2, the first sender; 28 keys from Plasma Dialer's keypad on two + such calls heard there on the one-sender shape, bench 17); on incoming + calls from that phone (the offer's 111 at 16 kHz) + 10 and 4 keys sent from the keypad (benches 3 and 4); on Plasma Mobile, + 11 keys from Plasma Dialer's keypad on an incoming call and 3 on an + outgoing narrowband one, through `imsd-dialerd` (bench 10). Every event of + ours in benches 1 to 3 and 5 to 17 is in the RFC 4733 shape on the wire + with the clock shared with the audio, and so are three of bench 4's + four: the third carried a mic frame after its first packet (seq 1761 + between 1760 and 1762) — the race of the sender's first shape, a thread + of its own beside the mic's, which the sender's commit closes by having + the mic thread send the event packets in its own frames' slots (one + event of bench 4's four raced, none of the 23 live-mic events of benches + 2 and 3; the loopback's mic is a file, paced like a microphone, and has + not raced it). That first shape also stamped the audio a slot early or + late per event against the capture clock on the AMR-WB calls measured — + there pw-record delivers frames in bursts of two (§13) and the thread's + 140 ms window took six to eight of them: the two tones of `2#`, 10 slots + apart by construction, are 11, 9 and 10 slots apart in benches 5, 12 and + 13, AMR-WB voicemail calls (`analysis-v2-bench-{5,12,13}.txt` beside + bench 14) — which the one-sender shape of bench 14 cannot (10); on a + narrowband call the first shape's stamping was not measured. + Not reached on hardware: + a network answering without telephone-event (KPN echoes it; an answer that + keeps the rtpmap off its m-line is pinned in `tests/Sdp`), the drops + before the answer and after the call and the `DtmfMaxTones` bound + (these three pinned in `tests/Engine`), the letters A–D and lower case + (pinned in `tests/Engine` and the selftest), an inbound offer without + telephone-event at the codec's clock (pinned in `tests/Sdp`), the shell's + refusal of a line over PIPE_BUF and its `not sent: no control pipe` line + on a call with no media leg (nothing pins these two), a `pipe()` failure, a write to a + dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, and + G.711 (the loopback only). - `imsd-pcscf` ran on the dev phone (KPN) on 2026-10-11 against the live ims bearer: the `--mux 1 --profile 2 -v` sequence, a plain run, and `OutOfCall` on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture, @@ -513,8 +557,9 @@ the status snapshot live (main thread only). Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the `imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and -payload type as arguments and the codec and audio knobs in its environment -(§7.6, §10.2). +payload type as arguments, the codec and audio knobs in its environment +(§7.6, §10.2) and its stdin on a pipe from imsd, the control pipe the DTMF +tones travel down (§7.6). The desktop daemons are translation layers: `imsd-dialerd` runs as the session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in @@ -1002,8 +1047,8 @@ journal says it was not handed over. `CallMachine` owns one call's SIP dialog, outgoing or incoming, and never touches a socket, a clock or a subprocess: each event returns `Action`s for the shell (send this on the client flow, answer that request on the channel -it came in on, start/stop the media leg, emit this D-Bus state change, arm -this deadline). States and reasons are the frozen D-Bus vocabulary: +it came in on, start/stop the media leg, hand it the tones a key press +sent, emit this D-Bus state change, arm this deadline). States and reasons are the frozen D-Bus vocabulary: `dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons `outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / `refused-or-busy` / `error`. @@ -1016,7 +1061,8 @@ this deadline). States and reasons are the frozen D-Bus vocabulary: - Incoming: an INVITE on a protected-port listener is validated before it is announced to the UI; 100 and 180 (reliable when the caller requires 100rel); `Accept` sends the 200 with the SDP answer at the offer's own - payload types and octet-align mode; a remote CANCEL gets 200 + 487; an + payload types and octet-align mode, its telephone-event echoed when the + offer's m-line lists one at the codec's clock; a remote CANCEL gets 200 + 487; an unusable offer 488 naming what was offered; ring timeout 480. A landline gateway's G.711-only or AMR-NB offer rings and is answered at its own payload types. @@ -1025,7 +1071,20 @@ this deadline). States and reasons are the frozen D-Bus vocabulary: `urn:service:sos` first, with one fallback to a plain INVITE of the digits; a plain INVITE answered `380 Alternative Service` with an emergency body is re-attempted as `urn:service:sos`. -- `SendDtmf` is a stub. +- DTMF (`SendDtmf`): the tones go out as RFC 4733 telephone-event packets + on the call's RTP stream, at the payload type the SDP negotiated for the + codec's clock (`MediaLeg::dtmfPt`: the answer's for an outgoing call, the + offer's for an incoming one). The + engine's `OnDtmf` lets them through on an active call that negotiated + telephone-event and drops them otherwise, with a journal 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 a string past + `DtmfMaxTones` (128) — then + the whole string, never part of what was pressed; letters are + upper-cased, `a` and `A` are one event). There + is no second method: no SIP INFO, and no tone played into the microphone + path. The shell writes one `dtmf + ` line per `SendDtmf` down the leg's control pipe. - Incoming calls reach imsd only when the network's T-ADS routes the call to the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — @@ -1042,8 +1101,8 @@ frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient (`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd (`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); G.711 is a table. DTMF (RFC 4733 telephone-events; `DTMF_PT` names their -payload type, unset = none; nothing in imsd supplies the lines or the -variable at this commit — a hand run or the loopback does): each `dtmf +payload type, unset = none; imsd sets it from the SDP and writes the lines +down the control pipe, §7.1): each `dtmf ` line on its stdin queues the tones, and a tone takes seven 20 ms slots of the uplink in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration @@ -1151,6 +1210,19 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: failed: … — the resume is given up (the engine's rule): the pair is removed`). - `audio session: …` at start and at each call's media start; `CODECS override active: …`. +- `media pt=N octet=true|false -> [X]:P, telephone-event pt N|none` at a + call's media start; `call X: DTMF '…' -> media leg (telephone-event pt + N)` for tones the engine lets through, `call X: DTMF '…' dropped: the call is ` / + `… dropped: no telephone-event negotiated for this call` / `… dropped: + 'c' is not a tone` / `call X: DTMF of N tones dropped: more than 128` for + tones not sent, `SendDtmf(X, '…') ignored: no such call`; `DTMF '…' not + sent: no control pipe to the media leg` on a call whose control pipe + could not be made or that has no media leg, `DTMF '…' not sent: ` + when the control pipe's write fails (`EPIPE`: the leg died under the + action; `EAGAIN`: it is alive, stopped reading, its pipe full), + `DTMF of N tones not sent: longer than the control pipe's atomic + write` (a line the engine's bound should have stopped), `media control + pipe: …; this call has no DTMF` when the pipe could not be made. - A refused REGISTER or SUBSCRIBE is logged with its status line and any `Retry-After` / `Warning` / `Reason` / `Server` headers; `re-REGISTER challenged: 401 …; re-authentication asked, the next @@ -1281,8 +1353,6 @@ Known gaps, all inherited rather than introduced: `mm_sim_dup_emergency_numbers()`). An upstream fix would close this. Dialling an emergency number from the dialpad is unaffected — that reaches imsd's own classifier. -- DTMF is forwarded to imsd, where `SendDtmf` is a stub — the same - limitation Plasma has. - Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a new origin but leaves the old origin's `CallAdded` handler connected, so @@ -1427,9 +1497,8 @@ there: | Variable | Default | Meaning | |---|---|---| -| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; not configuration | +| `CODEC`, `OCTET_ALIGN`, `DTMF_PT` | *(from the SDP)* | set by imsd from the negotiated answer (`DTMF_PT`: the telephone-event payload type at the codec's clock, empty when none was negotiated); not configuration | | `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | -| `DTMF_PT` | *(unset)* | the telephone-event payload type the leg sends with (§7.6); nothing in imsd sets it — a hand run or the loopback does | ### 10.3 ims-pdn-up.sh and imsd-ofonod @@ -1582,6 +1651,14 @@ the related code. and an event's packets leave in those bursts: the audio's own spacing, which RFC 4733 §2.5.1.2 calls the natural interval for event updates (its 50 ms recommendation is for a spacing of the sender's own). +- **DTMF on KPN (2026-10-14 to 16):** the answer to our offer echoes + telephone-event at our payload types — 98 at 16 kHz on an AMR-WB call to + voicemail, 100 at 8 kHz on a phone-to-phone call the network answered + narrowband AMR at 99 — and a stock phone's terminating INVITE offers + AMR-WB 107 with telephone-event 111; events of the sender's first shape + (100 ms, a packet every 20 ms, at least 100 ms apart) were heard on that + phone (bench 2 the first shape; bench 17 the one-sender shape — 28 keys + on two narrowband calls, every one heard there). - **pmOS's default nftables** drops inbound on `qmapmux*`; without the protected-port rule every network-initiated request (NOTIFY, MT INVITE) dies after ESP decapsulation and MT calls fall back to CS. diff --git a/implementations/main.cpp b/implementations/main.cpp index 08c5c70..7055bbc 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -31,6 +31,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include +#include #include #include #include @@ -1052,6 +1053,10 @@ public: std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Accept, uni, {}}); } + void SendDtmf(const std::string& uni, const std::string& tones) { + std::scoped_lock g(cmdLock_); + cmds_.push_back({Cmd::Kind::Dtmf, uni, tones}); + } void Stop() { { std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); } quit_.store(true); @@ -1140,7 +1145,7 @@ public: } private: - struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; + struct Cmd { enum class Kind { Dial, HangUp, Accept, Dtmf, Stop } kind; std::string uni, arg; }; // arg: the number to dial, or the tones struct Stopping {}; // the engine was told to stop while a bring-up waited // A bring-up the network deferred (a fresh-SA throttle) or refused: wait @@ -1247,6 +1252,7 @@ private: bool dropCall_ = false; bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call pid_t mediaPid_ = -1; + int mediaCtl_ = -1; // our end of the leg's control pipe (its stdin) double deadline_ = 0; // reply_ answers the inbound request being dispatched right now; // callReply_ is the call's durable response channel — refreshed from @@ -2368,8 +2374,8 @@ private: bool ClearForDial(const Cmd& c, bool emergency) { if (!call_) return true; if (!emergency) { - Log(std::format("dial({}) refused: call in progress", c.number)); - CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; + Log(std::format("dial({}) refused: call in progress", c.arg)); + CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0}; PostAdded(bad); PostState(c.uni, "terminated", "error"); PostDeleted(c.uni); @@ -2385,7 +2391,7 @@ private: void HandleCmd(const Cmd& c) { if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Dial) { - bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); + bool emergency = imsd::msg::IsEmergency(c.arg, emergencyExtra_); if (!ClearForDial(c, emergency)) return; // a dead client flow fails the INVITE instantly — give the // recovery path one immediate shot first: user action cuts the @@ -2404,9 +2410,9 @@ private: if (emergency) 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, codecs_); - CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; + "carrier-side behavior UNVERIFIED", c.arg)); + call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_); + CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0}; PostAdded(info); bool ok = Execute(call_->Start()); if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); } @@ -2416,6 +2422,9 @@ private: } else if (c.kind == Cmd::Kind::Accept) { if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); } else Log(std::format("Accept({}) ignored: no such ringing call", c.uni)); + } else if (c.kind == Cmd::Kind::Dtmf) { + if (call_ && call_->Uni() == c.uni) Execute(call_->OnDtmf(c.arg)); + else Log(std::format("SendDtmf({}, '{}') ignored: no such call", c.uni, c.arg)); } } @@ -2515,6 +2524,7 @@ private: break; case T::StartMedia: StartMedia(a.media); break; case T::StopMedia: StopMedia(); break; + case T::SendDtmf: SendDtmfToMedia(a.text); break; case T::State: if (announce) PostState(call_->Uni(), a.state, a.reason); break; @@ -2541,7 +2551,7 @@ private: void StartMedia(const imsd::engine::MediaLeg& leg) { if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; } - Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort)); + Log(std::format("media {} pt={} octet={} -> [{}]:{}, telephone-event pt {}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort, leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none"))); if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_}); else RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); @@ -2555,8 +2565,21 @@ private: session = ResolveAudioSession(); Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : "")); } + // the control pipe: imsd-media reads `dtmf ` lines on its + // stdin (§7.6); our end closes with the leg, no other child inherits + // it, and it never blocks the engine thread — a leg that stopped + // reading fails the write (EAGAIN once the pipe is full) instead + int ctl[2] = {-1, -1}; + if (pipe(ctl) != 0) { + Log(std::format("media control pipe: {}; this call has no DTMF", std::strerror(errno))); + } else { + fcntl(ctl[0], F_SETFD, FD_CLOEXEC); + fcntl(ctl[1], F_SETFD, FD_CLOEXEC); + fcntl(ctl[1], F_SETFL, O_NONBLOCK); + } pid_t pid = fork(); if (pid == 0) { + if (ctl[0] >= 0) dup2(ctl[0], STDIN_FILENO); // dup2 clears the close-on-exec if (geteuid() == 0) { // no session is said with an empty value: imsd-media reads a // missing variable as a caller older than 0.3.10 @@ -2570,16 +2593,42 @@ private: setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); setenv("CODEC", leg.codec.c_str(), 1); + setenv("DTMF_PT", leg.dtmfPt ? std::to_string(*leg.dtmfPt).c_str() : "", 1); std::vector c; for (auto& s : argv) c.push_back(const_cast(s.c_str())); c.push_back(nullptr); execvp(c[0], c.data()); _exit(127); } + if (ctl[0] >= 0) close(ctl[0]); + if (pid < 0 && ctl[1] >= 0) { close(ctl[1]); ctl[1] = -1; } mediaPid_ = pid; + mediaCtl_ = ctl[1]; + } + + // The engine's SendDtmf action: one `dtmf ` line down the + // control pipe, whole or not at all — a write of at most PIPE_BUF bytes + // to a pipe is atomic, and the engine's DtmfMaxTones keeps the line + // under it (a longer line is refused here too, never half-written to + // be joined with the next). The leg may have died since the action was + // decided (its exit is reaped on this thread, after the actions ran): + // then the write fails with EPIPE — SIGPIPE is ignored in main() — and + // the tones are lost with the call, which the reap ends. + void SendDtmfToMedia(const std::string& tones) { + if (mediaCtl_ < 0) { Log(std::format("DTMF '{}' not sent: no control pipe to the media leg", tones)); return; } + const std::string line = std::format("dtmf {}\n", tones); + if (line.size() > PIPE_BUF) { Log(std::format("DTMF of {} tones not sent: longer than the control pipe's atomic write", tones.size())); return; } + const ssize_t n = write(mediaCtl_, line.data(), line.size()); + if (n != static_cast(line.size())) Log(std::format("DTMF '{}' not sent: {}", tones, n < 0 ? std::strerror(errno) : "short write to the media leg")); + } + + void CloseMediaCtl() { + if (mediaCtl_ >= 0) close(mediaCtl_); + mediaCtl_ = -1; } void StopMedia() { + CloseMediaCtl(); if (mediaPid_ <= 0) return; kill(mediaPid_, SIGTERM); for (int i = 0; i < 100; i++) { @@ -2599,6 +2648,7 @@ private: if (r == 0) return; // still running int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; mediaPid_ = -1; // reaped; don't re-terminate + CloseMediaCtl(); Execute(call_->OnMediaExit(code)); MaybeDropCall(); } @@ -2676,7 +2726,10 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar* return; } if (m == "SendDtmf") { - Log("SendDtmf accepted but not yet sent (TODO RFC4733)"); + const gchar* uni = nullptr; + const gchar* tones = nullptr; + g_variant_get(params, "(&s&s)", &uni, &tones); + TheEngine->SendDtmf(uni ? uni : "", tones ? tones : ""); g_dbus_method_invocation_return_value(inv, nullptr); return; } @@ -2740,6 +2793,11 @@ int main(int argc, char** argv) { std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)"); return 1; } + // A write to the media leg's control pipe after the leg died must fail + // with EPIPE, not end the daemon (the client flow's sends carry + // MSG_NOSIGNAL; an inbound connection's reply is a plain write and is + // covered by this too). + signal(SIGPIPE, SIG_IGN); // The bring-up found nothing to do (IMSD_IDLE= in /run/imsd.env — the // script's arms: no SIM, a refused ims PDN): say so and diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 52a4e1f..40ecdc7 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -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 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 \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 OnDtmf(std::string_view tones) { + std::vector 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(std::toupper(static_cast(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 pracked_; std::string answerSdp_; + std::optional 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; diff --git a/interfaces/Imsd-Sdp.cppm b/interfaces/Imsd-Sdp.cppm index 723720d..46e74e5 100644 --- a/interfaces/Imsd-Sdp.cppm +++ b/interfaces/Imsd-Sdp.cppm @@ -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 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 AudioFormats(std::string_view body) { + std::vector 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(std::toupper(static_cast(c))); + return enc; + } + + // Payload type of `telephone-event/` 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 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(std::toupper(static_cast(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 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 pts; - for (auto part : std::views::split(ptsRun, ' ')) - if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v); + std::vector 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/` in a body's rtpmap lines - // (DTMF, RFC 4733); nullopt when the offer doesn't carry one. - std::optional 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 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"; diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 610dd79..db27971 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -11,7 +11,10 @@ // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // what was offered), a landline gateway's G.711-only and AMR-NB offers // ring and are answered at the offer's own payload types, ring timeout -> -// 480, BYE both ways. Then RegRecovery, the registration recovery policy: +// 480, BYE both ways. DTMF: the tones reach the media leg on an active +// call that negotiated telephone-event, and are dropped — with the reason +// — before the answer, after the call, without the negotiation or with a +// character that is no tone. Then RegRecovery, the registration recovery policy: // the refusal and silence thresholds, local refusals, the reconnect and // fresh backoffs, the throttle schedule, Retry-After, which waits a Dial // may cut short, when the old registration counts as gone, the holds a @@ -90,6 +93,21 @@ namespace { "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n" "t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid); } + // A 200 OK around an arbitrary SDP answer, Content-Length from it — a + // message the shell's framing would deliver whole. + std::string R200Sdp(std::string_view callid, std::string_view sdp) { + return std::format( + "SIP/2.0 200 OK\r\n" + "Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n" + "From: ;tag=it\r\n" + "To: ;tag=remote7\r\n" + "Call-ID: {}\r\n" + "CSeq: 1 INVITE\r\n" + "Contact: \r\n" + "Content-Type: application/sdp\r\n" + "Content-Length: {}\r\n\r\n{}", callid, sdp.size(), sdp); + } + constexpr std::string_view SdpHead = "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\nt=0 0\r\n"; std::string Final(std::string_view callid, int code, std::string_view text) { return std::format( "SIP/2.0 {} {}\r\n" @@ -209,6 +227,7 @@ int main() { Check(media && media->media.remotePort == 6004, "media remote port"); Check(media && media->media.payloadType == 97, "media pt"); Check(media && media->media.codec == "AMR-WB", "media codec"); + Check(media && !media->media.dtmfPt, "an answer without telephone-event: the leg has no DTMF pt"); const Action* active = Find(a200, Action::Type::State); Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted"); Check(m.State() == CallState::Active, "state active"); @@ -220,6 +239,12 @@ int main() { Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action"); Check(m.State() == CallState::Active, "state still active after the retransmit"); + // DTMF on a call whose answer negotiated no telephone-event: dropped, said why + auto adt = m.OnDtmf("1"); + Check(!Has(adt, Action::Type::SendDtmf), "no telephone-event negotiated: no SendDtmf"); + const Action* dtLog = Find(adt, Action::Type::Log); + Check(dtLog && dtLog->text == "call ims-call-1: DTMF '1' dropped: no telephone-event negotiated for this call", "...and the line says so"); + // media leg exits code 3 (downlink dried up) -> BYE + terminated auto amx = m.OnMediaExit(3); Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE"); @@ -230,6 +255,43 @@ int main() { Check(m.Terminated(), "terminated"); } + // ---- DTMF, outgoing: the answer echoes our telephone-event/16000 at pt + // 98; the tones go to the leg once active, in upper case, and are + // dropped before the answer, with a character that is no tone, + // and after the call + { + imsd::util::Rng rng(43); + CallMachine m(c, rng, "ims-call-1d", "1233", 50004, false); + m.Start(); + auto early = m.OnDtmf("1"); + Check(!Has(early, Action::Type::SendDtmf), "DTMF while dialing: not sent"); + const Action* earlyLog = Find(early, Action::Type::Log); + Check(earlyLog && earlyLog->text == "call ims-call-1d: DTMF '1' dropped: the call is dialing", "...the line names the state"); + auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 98 100\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:98 telephone-event/16000\r\na=rtpmap:100 telephone-event/8000\r\n", SdpHead))); + const Action* media = Find(a200, Action::Type::StartMedia); + Check(media && media->media.dtmfPt == 98, "the leg carries the answer's telephone-event pt at the codec's clock (98, not the narrowband 100)"); + Check(m.State() == CallState::Active, "active"); + auto sent = m.OnDtmf("12#*a"); + const Action* dt = Find(sent, Action::Type::SendDtmf); + Check(dt && dt->text == "12#*A", "active + negotiated: SendDtmf with the tones, letters upper-cased"); + const Action* dtLog = Find(sent, Action::Type::Log); + Check(dtLog && dtLog->text == "call ims-call-1d: DTMF '12#*A' -> media leg (telephone-event pt 98)", "...and the line names the pt"); + auto bad = m.OnDtmf("1x"); + Check(!Has(bad, Action::Type::SendDtmf), "a character that is no tone: nothing sent, not even the '1'"); + const Action* badLog = Find(bad, Action::Type::Log); + Check(badLog && badLog->text == "call ims-call-1d: DTMF '1x' dropped: 'x' is not a tone", "...the line names the character"); + Check(m.OnDtmf("").empty(), "an empty string: nothing, silently"); + const std::string atMost(DtmfMaxTones, '1'); + auto most = m.OnDtmf(atMost); + const Action* maxDt = Find(most, Action::Type::SendDtmf); + Check(maxDt && maxDt->text == atMost, "a string of DtmfMaxTones tones goes"); + auto tooMany = m.OnDtmf(std::format("{}1", atMost)); + Check(!Has(tooMany, Action::Type::SendDtmf) && Find(tooMany, Action::Type::Log) && Find(tooMany, Action::Type::Log)->text == std::format("call ims-call-1d: DTMF of {} tones dropped: more than {}", DtmfMaxTones + 1, DtmfMaxTones), "one more: dropped, the line names the bound"); + m.OnHangup(); + auto late = m.OnDtmf("5"); + Check(!Has(late, Action::Type::SendDtmf) && Find(late, Action::Type::Log) && Find(late, Action::Type::Log)->text.ends_with("the call is terminated"), "DTMF after the call: dropped, the line names the state"); + } + // ---- remote BYE mid-call { imsd::util::Rng rng(7); @@ -368,6 +430,11 @@ int main() { Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt"); Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align"); Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event"); + const Action* mtMedia = Find(aa, Action::Type::StartMedia); + Check(mtMedia && mtMedia->media.dtmfPt == 105, "incoming: the leg carries the offer's telephone-event pt"); + auto mtDtmf = m.OnDtmf("9"); + const Action* mtDt = Find(mtDtmf, Action::Type::SendDtmf); + Check(mtDt && mtDt->text == "9", "incoming, accepted: the tones go to the leg"); Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes"); Check(ok && ok->text.contains("Contact: nb = {"AMR"}; + Check(ParseAnswer(both, nb).dtmfPt == 106, "...and the narrowband one when AMR is chosen"); + Check(TelephoneEventPt(both, 8000) == 106, "listed on the m-line, the narrowband telephone-event pt is found"); + Check(!ParseAnswer(offer, nb).dtmfPt, "no codec we play chosen: no dtmfPt either"); + Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n").dtmfPt, "no telephone-event in the answer: dtmfPt empty"); + Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt, "the answer dropped telephone-event from its m-line and kept the rtpmap: not negotiated (RFC 3264 §8.3.2: nothing is sent with a format the answer does not list)"); + Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt == 101, "G.711 answer with telephone-event/8000: dtmfPt 101"); + Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/16000\r\n").dtmfPt, "telephone-event at the wrong clock for the codec is not the call's"); + Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 TELEPHONE-EVENT/8000\r\n").dtmfPt == 101, "the name in another case (RFC 4566: case-insensitive)"); + Check(TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 Telephone-Event/16000/1\r\n", 16000) == 98, "mixed case, with a channel count"); + Check(!TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 telephone-event/80000\r\n", 8000), "the clock is matched whole: /80000 is not /8000"); Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt"); Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt"); }