From 78685c4b41f31d558fa079e01c793d377e86f089 Mon Sep 17 00:00:00 2001 From: jorijnvdgraaf Date: Fri, 16 Oct 2026 14:53:08 +0000 Subject: [PATCH 1/7] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 826b4bb..941d418 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Verified by community members to be fully working: | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | | The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | +| The Fairphone (Gen. 6) | postmarketOS, Linux 7.2.0 | o2-uk | ## What it needs From 108afcce96f55ae7cdc6a666876544842fe89b7c Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 16 Oct 2026 22:25:48 +0200 Subject: [PATCH 2/7] README: trim the intro, drop the status section The intro keeps one sentence; what works is the device and carrier table, under "Calls verified by community members to be fully working". --- README.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 941d418..6dbda5b 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,11 @@ Userspace IMS/VoLTE daemon for mainline Linux phones. Most Linux phones drive the modem's internal IMS engine. -imsd implements the whole IMS client in userspace instead: -SIP registration with USIM AKA authentication over kernel -IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into +imsd implements the whole IMS client in userspace instead wired into PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh can place and receive real VoLTE calls on a commercial network. -## Status - -Call-capable, both directions: outgoing calls place, answer, carry full-duplex -AMR-WB audio and tear down. Incoming calls ring, answer and carry media the -same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI. -Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting, -USSD. - -Verified by community members to be fully working: +Calls verified by community members to be fully working: | Device | OS | Carrier | |---|---|---| From 29d624fc5592b75b7441afad3760a37cf175df4b Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 14 Oct 2026 15:05:24 +0200 Subject: [PATCH 3/7] media: RFC 4733 telephone-event uplink, the tones from `dtmf ` lines on stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf ` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series. --- AGENTS.md | 51 +++++++- implementations/media.cpp | 256 ++++++++++++++++++++++++++++++++++---- 2 files changed, 278 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cf5274..c64ca94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ PipeWire. The verified device/carrier combinations are in the README. | `interfaces/Imsd-Qmi.cppm` | `imsd::qmi` — the QMI WDS request bytes `imsd-pcscf` sends (Bind Mux Data Port, Set IP Family, Get Packet Service Status, Get Current Settings, Get Profile Settings), the message/TLV parser, the P-CSCF list readers (0x22 flag, 0x23 IPv4, 0x24 FQDN, 0x2E IPv6 — the last one libqmi does not model). | | `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | | `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. | -| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg. Pure std + POSIX, dlopen's the AMR codecs. | +| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events, queued by `dtmf ` lines on its stdin). Pure std + POSIX, dlopen's the AMR codecs. | | `implementations/pcscf.cpp` | `imsd-pcscf`, the P-CSCF reader: the AF_QIPCRTR socket, the WDS service lookup and the Bind Mux Data Port / Set IP Family / Get Current Settings sequence around `Imsd:Qmi` (plus Get Profile Settings with `--profile`, Get Packet Service Status with `-v`). std + POSIX + the Linux QRTR UAPI (``), no GLib; never Start/Stop Network. | | `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | | `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | @@ -439,9 +439,18 @@ Rules: QRTR control lookup and the matching of answers by type, transaction and message id are shell code (`pcscf.cpp`) no test reaches. - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both - formats, both AMR codecs) and the G.711 tables. `MIC_SRC=` (raw s16 + formats, both AMR codecs), the G.711 tables, and pins the RFC 4733 event + payload bytes and the keypad-to-event codes. `MIC_SRC=` (raw s16 PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams - for driving the leg against a synthetic RTP peer without a phone. + for driving the leg against a synthetic RTP peer without a phone; the fp6 + journal's `utilities/media_loopback.py` is that peer, and its `--dtmf` + arm writes a `dtmf ` line to the leg's stdin and checks the events + on the wire (one timestamp per event, the marker, the growing duration, + the end bit three times, the shared sequence and timestamp space with the + audio; with the mic file, or without a mic — `--no-mic`, the silence tick + carrying the events) — the sender's shape is proven there and on the dev phone's wire + (the fp6 journal's dtmf benches: their pcaps go through the same checks); + what a gateway does with the events is proven only by a call to one. - `imsd --session` and `imsd-ofonod --session` own their names on the session bus for development without root, polkit or a phone (§8.3). @@ -1016,7 +1025,7 @@ 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 (RFC 4733 not implemented). +- `SendDtmf` is a stub. - 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 — @@ -1032,7 +1041,22 @@ narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR 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. **Exit code 3 means the downlink dried up** for +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 +` 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 +growing a slot at a time, the end bit on the fifth packet) and two in +which the end packet goes out again (RFC 4733 §2.5.1.4) — then three +slots of audio before the next tone; the audio's own sender carries the +events — per captured frame (a silence tick without a mic) the event +packet goes in the frame's place, one packet per slot — so events and +audio share the stream's sequence and timestamp space (RFC 4733 §2.1), the +clock stays the capture's, and the audio after an event is stamped as if +it had run through it; a line of more than 4096 bytes is +dropped through its end and the next line goes. **Exit +code 3 means the downlink dried up** for `MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on carriers whose BYE never reaches our SAs is the reliable teardown trigger. Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1` @@ -1398,13 +1422,14 @@ resume. ### 10.2 imsd-media (per call, set through imsd's environment) -The README's second table. Two pairs are not configuration and are left out +The README's second table. These are not configuration and are left out there: | Variable | Default | Meaning | |---|---|---| | `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; 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 @@ -1543,6 +1568,20 @@ the related code. - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string IPv6 bearer request is refused when the attach PDN was granted IPv4-only; a profile-indexed request is not. +- **pw-record delivers 20 ms frames in bursts** — of two on an AMR-WB + call (1.8 frames per burst on average, the bursts ~37 ms apart; the dev + phone, 2026-10-15/16: of the uplink's inter-packet gaps 42–45 % are + under 4 ms and 50–52 % are 30–40 ms, 3–8 % lie between and under 1 % + above — the fp6 journal's `2026-10-16-dtmf-bench-14/uplink-cadence.txt` + over benches 4, 5, 13 and 14, counted by the `cadence.py` beside it), + of three or four on a narrowband one (3.3 on average, ~68 ms apart; + 70 % under 4 ms, 28 % over 40 ms — `2026-10-16-dtmf-bench-17-heard/uplink-cadence-narrowband.txt`, + bench 17's two calls; the burst sizes of all six legs in `burst-sizes.txt` + beside it). A sender on a clock of its own cannot count the frames a + 140 ms window holds, so the events ride the frames' own slots (§7.6), + 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). - **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/media.cpp b/implementations/media.cpp index 7f37fa7..777e81c 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -26,11 +26,20 @@ DTX silence (every missing 20 ms slot is decoded as a NO_DATA frame for CNG/PLC; zeros for G.711). MIC=1 feeds live pw-record audio through the encoder. +DTMF goes out as RFC 4733 telephone-event packets at the payload type the +SDP negotiated for the codec's clock (DTMF_PT; unset = none): stdin is the +daemon's control pipe, and each `dtmf ` line queues its tones (0-9, +*, #, A-D). 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) and two in which its +end packet goes out again — then three slots of audio run before the next +tone. + Usage: imsd-media imsd-media --selftest (payload pack/depay roundtrip, both formats, - both AMR codecs; G.711 table roundtrip) + both AMR codecs; G.711 table roundtrip; the + telephone-event payload and codes) Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP -OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP +OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT (OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the daemon sets it from the negotiated SDP — 1 is the default/legacy behavior. AMR_MODE is the encoder mode of whichever AMR codec is active; default 2 @@ -45,6 +54,7 @@ with no PipeWire and no network.) #include #include #include +#include #include #include #include @@ -471,8 +481,13 @@ Child SpawnPw(bool play, bool toChild, int rate) { if (toChild) { dup2(pipefd[0], STDIN_FILENO); } else { dup2(pipefd[1], STDOUT_FILENO); } close(pipefd[0]); close(pipefd[1]); - int devnull = open("/dev/null", O_WRONLY); - if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } + int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, STDERR_FILENO); + // our stdin is the daemon's control pipe: not pw-record's to hold + if (!toChild) dup2(devnull, STDIN_FILENO); + close(devnull); + } std::vector cargv; for (auto& s : argv) cargv.push_back(const_cast(s.c_str())); cargv.push_back(nullptr); @@ -507,19 +522,32 @@ struct TxState { sockaddr_storage latched{}; socklen_t latchedLen = 0; std::uint64_t tx = 0; std::uint64_t txMic = 0; + std::uint64_t txDtmf = 0; std::atomic micOn{false}; }; -void RtpSend(int sock, TxState& st, std::span payload) { +// One packet on the uplink, audio or event. Every packet stands in one +// 20 ms slot of the stream's single timestamp base (RFC 4733 §2.1: events +// share the audio's sequence and timestamp space), so the next slot's +// timestamp advances whichever kind went out; an audio frame is stamped +// with its slot (`ts` empty), an event packet with the event's start +// (`ts` given — constant over the event, §2.5.1.2). Returns the timestamp +// used. +std::uint32_t RtpSendPacket(int sock, TxState& st, int pt, bool marker, std::optional ts, std::span payload) { std::uint8_t hdr[12]; - sockaddr_storage a1, a2; socklen_t l1, l2; + sockaddr_storage a1; + sockaddr_storage a2; + socklen_t l1; + socklen_t l2; + std::uint32_t stamp; { std::scoped_lock g(st.lock); + stamp = ts.value_or(st.ts); hdr[0] = 0x80; - hdr[1] = static_cast(st.pt & 0x7F); + hdr[1] = static_cast((marker ? 0x80 : 0) | (pt & 0x7F)); hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF; - hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF; - hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 0xFF; + hdr[4] = (stamp >> 24) & 0xFF; hdr[5] = (stamp >> 16) & 0xFF; + hdr[6] = (stamp >> 8) & 0xFF; hdr[7] = stamp & 0xFF; hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF; hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF; st.seq = (st.seq + 1) & 0xFFFF; @@ -537,8 +565,104 @@ void RtpSend(int sock, TxState& st, std::span payload) { sendTo(a1, l1); // avoid double-send when latched == dst if (l2 && (l2 != l1 || std::memcmp(&a1, &a2, l1) != 0)) sendTo(a2, l2); + return stamp; } +// an audio frame: the stream's payload type, stamped with its slot +void RtpSend(int sock, TxState& st, std::span payload) { + RtpSendPacket(sock, st, st.pt, false, std::nullopt, payload); +} + +// ---- DTMF: RFC 4733 telephone-event packets on the uplink ----------------- +// A tone is one event of five slots (100 ms): an event packet in each in +// place of the audio frame, the duration growing by a slot, the marker bit +// on the first, the end bit on the fifth, which goes out three times, the +// repeats in two slots of their own (§2.5.1.4) — then three slots of audio +// before the next tone. The gateway's detector sees a 100 ms tone and a +// 100 ms gap: what a keypad press produces, and Q.24's 40 ms minimums +// with margin. The audio's own senders carry the events — per slot, a +// captured frame in the mic thread or a silence tick in the main loop +// without a mic (and the latch burst), `DtmfSender::SendSlot` sends +// either the event packet the slot carries or the frame, under one lock — +// so one packet leaves per slot, no frame can land inside an event +// whichever thread holds the slot, and the stream's clock stays the +// capture's; the slots an event took advanced it like frames +// (RtpSendPacket), so the audio that follows is stamped as if it had run +// through it. +constexpr int DtmfToneSlots = 5; +constexpr int DtmfEndRepeats = 2; +constexpr int DtmfGapSlots = 3; +constexpr std::uint8_t DtmfVolume = 10; // -10 dBm0 (§2.3.4: 0 to -63) + +// The event code of a keypad character (§3.2: 0-9, * = 10, # = 11, A-D = +// 12-15, either case); nullopt for anything else. +std::optional DtmfEvent(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c == '*') return 10; + if (c == '#') return 11; + if (c >= 'A' && c <= 'D') return 12 + (c - 'A'); + if (c >= 'a' && c <= 'd') return 12 + (c - 'a'); + return std::nullopt; +} + +// §2.3: event, E | R | volume, the duration in timestamp units, big-endian. +std::array DtmfPayload(int event, bool end, std::uint16_t duration) { + return {static_cast(event), + static_cast((end ? 0x80 : 0) | DtmfVolume), + static_cast(duration >> 8), + static_cast(duration & 0xFF)}; +} + +// The tones the control pipe queued, paid out one slot at a time by the +// audio's own senders: one sender per slot, so no frame can land inside an +// event and the stream's clock is the capture's. +class DtmfSender { +public: + void Queue(std::string_view tones) { + std::scoped_lock g(lock_); + for (char c : tones) + if (auto ev = DtmfEvent(c)) queue_.push_back(*ev); + } + // Called by the audio's sender for every uplink slot, with the frame + // it would send: under one lock the slot is decided and sent — the + // event packet the slot carries (returns true: the frame stays unsent), + // or the frame (false; an empty frame, a DTX gap, sends nothing). One + // decision and one send per slot, so no frame can land inside an event + // whichever thread holds the slot. + bool SendSlot(int sock, TxState& st, int pt, std::span frame) { + std::scoped_lock g(lock_); + if (gap_ > 0) { + gap_--; + } else if (event_ >= 0 || !queue_.empty()) { + if (event_ < 0) { + event_ = queue_.front(); + queue_.pop_front(); + slot_ = 0; + start_.reset(); + } + slot_++; + const int reported = std::min(slot_, DtmfToneSlots); + const auto payload = DtmfPayload(event_, slot_ >= DtmfToneSlots, static_cast(reported * st.tsStep)); + start_ = RtpSendPacket(sock, st, pt, slot_ == 1, start_, payload); + { std::scoped_lock s(st.lock); st.txDtmf++; } + if (slot_ == DtmfToneSlots + DtmfEndRepeats) { + event_ = -1; + gap_ = DtmfGapSlots; + } + return true; + } + if (!frame.empty()) RtpSend(sock, st, frame); + return false; + } +private: + std::mutex lock_; + std::deque queue_; + int event_ = -1; // the event on the wire; -1 = none + int slot_ = 0; // its packets sent so far + int gap_ = 0; // slots of audio owed before the next event + std::optional start_; // the event's timestamp +}; + std::vector>> Depay(Codec c, std::span pl, bool octetAlign) { return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl); @@ -578,7 +702,9 @@ struct PktItem { std::uint32_t ts; std::vector payload; }; // --selftest: pack->depay roundtrip of every frame type, both payload // formats, both AMR codecs; G.711 digital zero, idempotence over the whole -// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR. +// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR; +// the RFC 4733 telephone-event payload bytes and every keypad character's +// event code. int SelfTest() { for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { for (int ft = 0; ft < 16; ft++) { @@ -603,6 +729,26 @@ int SelfTest() { std::println(std::cerr, "selftest FAIL G.711 digital zero"); return 1; } + // RFC 4733 §2.3: event, E | R | volume (-10 dBm0), duration big-endian + if (DtmfPayload(1, false, 160) != std::array{0x01, 0x0A, 0x00, 0xA0} + || DtmfPayload(11, true, 1600) != std::array{0x0B, 0x8A, 0x06, 0x40}) { + std::println(std::cerr, "selftest FAIL telephone-event payload"); + return 1; + } + // §3.2: every keypad character's event code, the letters in either case, + // and no code for anything else + for (const char* keys : {"0123456789*#ABCD", "0123456789*#abcd"}) { + for (int ev = 0; ev < 16; ev++) { + if (DtmfEvent(keys[ev]) != ev) { + std::println(std::cerr, "selftest FAIL telephone-event code of '{}'", keys[ev]); + return 1; + } + } + } + if (DtmfEvent('E') || DtmfEvent('e') || DtmfEvent(' ') || DtmfEvent('\n')) { + std::println(std::cerr, "selftest FAIL telephone-event codes"); + return 1; + } for (Codec c : {Codec::Pcma, Codec::Pcmu}) { for (int v = -32768; v <= 32767; v++) { std::uint8_t b = G711Encode(c, static_cast(v)); @@ -661,6 +807,7 @@ int main(int argc, char** argv) { bool rtpDump = EnvBool("RTP_DUMP", false); std::string micSrc = EnvOr("MIC_SRC", ""); bool pcmDump = EnvBool("PCM_DUMP", false); + const int dtmfPt = std::atoi(EnvOr("DTMF_PT", "0").c_str()); constexpr int ExitMediaTimeout = 3; constexpr int PrimeFrames = 8; constexpr int MaxFill = 25; @@ -693,8 +840,13 @@ int main(int argc, char** argv) { signal(SIGINT, OnSig); signal(SIGPIPE, SIG_IGN); + // ---- DTMF: the tones the control pipe (below) queues; the audio's + // senders pay them out slot by slot + DtmfSender dtmf; + // ---- mic uplink thread: pw-record (or the MIC_SRC file, real-time paced) - // through the codec's encoder, one RTP packet per 20 ms frame + // through the codec's encoder, one RTP packet per 20 ms frame — or the + // event packet whose slot it is std::atomic stop{false}; std::jthread micThread; if (mic) { @@ -731,23 +883,23 @@ int main(int argc, char** argv) { next += std::chrono::milliseconds(20); std::this_thread::sleep_until(next); } + st.micOn.store(true); std::memcpy(samples.data(), raw.data(), raw.size()); if (gain != 1.0) for (auto& sample : samples) { int v = static_cast(sample * gain); sample = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); } - std::vector payload; + std::vector payload; // empty: a DTX gap, nothing to send in the slot if (amr) { std::vector frame = enc.Encode(samples.data(), amrMode, dtx); - if (frame.empty()) continue; - payload = PayloadFromFrame(codec, frame, octetAlign); + if (!frame.empty()) payload = PayloadFromFrame(codec, frame, octetAlign); } else { payload = G711Payload(codec, samples); } - st.micOn.store(true); + // the slot's packet: the event's, or this frame + if (dtmf.SendSlot(sock, st, dtmfPt, payload) || payload.empty()) continue; { std::scoped_lock g(st.lock); st.txMic++; } - RtpSend(sock, st, payload); } st.micOn.store(false); close(fd); @@ -867,9 +1019,65 @@ int main(int argc, char** argv) { }); } + // ---- the control pipe that queues the tones: the daemon's + // `dtmf ` lines on stdin. No pipe (a hand run, /dev/null) ends + // the reader at once; the leg runs on. + std::jthread ctlThread([&] { + // a hand run in the background of a job-control shell: a read of + // its terminal would stop the whole process (SIGTTIN); blocked for + // this thread, the read fails with EIO and the reader ends + sigset_t ttin; + sigemptyset(&ttin); + sigaddset(&ttin, SIGTTIN); + pthread_sigmask(SIG_BLOCK, &ttin, nullptr); + std::string pending; + bool discarding = false; // an overlong line is dropped through its end + char buf[256]; + while (!stop.load()) { + pollfd pfd{STDIN_FILENO, POLLIN, 0}; + if (poll(&pfd, 1, 100) <= 0) continue; + const ssize_t n = read(STDIN_FILENO, buf, sizeof buf); + if (n <= 0) return; + std::string_view in(buf, static_cast(n)); + if (discarding) { + const std::size_t end = in.find('\n'); + if (end == std::string_view::npos) continue; + in.remove_prefix(end + 1); + discarding = false; + } + pending.append(in); + // a line of more than 4096 bytes with no end in sight: dropped + // through its end; the next line goes + if (pending.size() > 4096 && pending.find('\n') == std::string::npos) { + std::println(std::cerr, "imsd-media: control line of {} bytes without an end dropped", pending.size()); + pending.clear(); + discarding = true; + continue; + } + std::size_t nl; + while ((nl = pending.find('\n')) != std::string::npos) { + const std::string line = pending.substr(0, nl); + pending.erase(0, nl + 1); + // the same bound on a line whose end came in the read that + // took it past 4096 bytes + if (nl > 4096) { + std::println(std::cerr, "imsd-media: control line of {} bytes dropped", nl); + continue; + } + if (!line.starts_with("dtmf ")) { + std::println(std::cerr, "imsd-media: control line ignored: {}", line); + } else if (dtmfPt > 0) { + dtmf.Queue(std::string_view(line).substr(5)); + } else { + std::println(std::cerr, "imsd-media: {} dropped: no telephone-event payload type (DTMF_PT)", line); + } + } + } + }); + // ---- main recv loop std::vector silence = SilenceFrame(codec, 0, octetAlign); - for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst + for (int i = 0; i < 5; i++) dtmf.SendSlot(sock, st, dtmfPt, silence); // latch burst, the one path std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr; double t0 = Now(); @@ -888,7 +1096,8 @@ int main(int argc, char** argv) { break; } if (now - lastTx >= 0.02 && !st.micOn.load()) { - RtpSend(sock, st, silence); + // no mic: the slot's packet is the event's, or a silence frame + dtmf.SendSlot(sock, st, dtmfPt, silence); lastTx = now; } std::uint8_t buf[65535]; @@ -942,6 +1151,7 @@ int main(int argc, char** argv) { stop.store(true); qcv.notify_all(); + if (ctlThread.joinable()) ctlThread.join(); if (micThread.joinable()) micThread.join(); if (playThread.joinable()) playThread.join(); if (dump) std::fclose(dump); @@ -961,14 +1171,14 @@ int main(int argc, char** argv) { // .stats sidecar (tiny; always written) if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { std::print(sf, - "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " + "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, " "\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " - "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, " + "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"mic\": {}, " "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", - CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, - rIp, rPort, pt, mic, play, amrMode, mediaEnded); + CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, + rIp, rPort, pt, dtmfPt, mic, play, amrMode, mediaEnded); std::fclose(sf); } - std::println("imsd-media: codec={} tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); + std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); return mediaEnded ? ExitMediaTimeout : 0; } From 92fba40da06c75f16b6e2d2fa353c0ce6f1eb2f2 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 14 Oct 2026 15:05:24 +0200 Subject: [PATCH 4/7] sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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). --- AGENTS.md | 109 ++++++++++++++++++++++++++++++------ implementations/main.cpp | 76 ++++++++++++++++++++++--- interfaces/Imsd-Engine.cppm | 59 ++++++++++++++++++- interfaces/Imsd-Sdp.cppm | 109 ++++++++++++++++++++++-------------- tests/Engine/main.cpp | 69 ++++++++++++++++++++++- tests/Sdp/main.cpp | 16 +++++- 6 files changed, 365 insertions(+), 73 deletions(-) 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"); } From e31887bf217aff0c65bcf45f3de2eb6685c6ea88 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 15 Oct 2026 21:18:28 +0200 Subject: [PATCH 5/7] sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 82 ++++++++++++++++++----- implementations/main.cpp | 5 +- implementations/media.cpp | 129 ++++++++++++++++++++++++++++++++---- interfaces/Imsd-Engine.cppm | 17 ++++- interfaces/Imsd-Sdp.cppm | 17 +++-- tests/Engine/main.cpp | 31 +++++++++ tests/Sdp/main.cpp | 1 + 7 files changed, 244 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b13c406..a01b265 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 (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-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions; `OfferedTelephoneEventPt` names its telephone-event numbers), 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`. | @@ -48,7 +48,7 @@ PipeWire. The verified device/carrier combinations are in the README. | `interfaces/Imsd-Qmi.cppm` | `imsd::qmi` — the QMI WDS request bytes `imsd-pcscf` sends (Bind Mux Data Port, Set IP Family, Get Packet Service Status, Get Current Settings, Get Profile Settings), the message/TLV parser, the P-CSCF list readers (0x22 flag, 0x23 IPv4, 0x24 FQDN, 0x2E IPv6 — the last one libqmi does not model). | | `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | | `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. | -| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events, queued by `dtmf ` lines on its stdin). Pure std + POSIX, dlopen's the AMR codecs. | +| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events out, queued by `dtmf ` lines on its stdin; the far end's events in, played as tones). Pure std + POSIX, dlopen's the AMR codecs. | | `implementations/pcscf.cpp` | `imsd-pcscf`, the P-CSCF reader: the AF_QIPCRTR socket, the WDS service lookup and the Bind Mux Data Port / Set IP Family / Get Current Settings sequence around `Imsd:Qmi` (plus Get Profile Settings with `--profile`, Get Packet Service Status with `-v`). std + POSIX + the Linux QRTR UAPI (``), no GLib; never Start/Stop Network. | | `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | | `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). | @@ -292,7 +292,9 @@ What the suites pin: 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 + string past `DtmfMaxTones`; the receive payload type: our offer's on a + renumbered answer, the answer's when the audio took ours, the echoed + offer's on an incoming call) 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 @@ -444,7 +446,12 @@ Rules: 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 + outgoing narrowband one, through `imsd-dialerd` (bench 10); the stock + phone's key presses heard as tones (on GNOME Calls: bench 3's twelve on + the renderer before the gap fill, bench 4's four on it with the gap + fill; on Plasma Mobile: bench 10's ten on an incoming wideband call and + two on an outgoing narrowband one, every received event rendered by the + leg's own count). 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 @@ -470,8 +477,14 @@ Rules: 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). + dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, G.711 + (the loopback only), a received event with a code past D (dropped + unrendered) or one whose packet reports more than two seconds the clock + has not covered (that packet skipped — a long key press renders slot by + slot), a lost first packet of an event (the renderer starts where the + clock stands), and an answer that renumbers telephone-event or gives the + audio our number (`DTMF_RX_PT` — KPN echoes our numbers; pinned in + `tests/Engine` and by the loopback's collision arm). - `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, @@ -484,7 +497,8 @@ Rules: message id are shell code (`pcscf.cpp`) no test reaches. - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both formats, both AMR codecs), the G.711 tables, and pins the RFC 4733 event - payload bytes and the keypad-to-event codes. `MIC_SRC=` (raw s16 + payload bytes, the keypad-to-event codes and every event's Q.23 + frequency pair. `MIC_SRC=` (raw s16 PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams for driving the leg against a synthetic RTP peer without a phone; the fp6 journal's `utilities/media_loopback.py` is that peer, and its `--dtmf` @@ -492,9 +506,13 @@ Rules: on the wire (one timestamp per event, the marker, the growing duration, the end bit three times, the shared sequence and timestamp space with the audio; with the mic file, or without a mic — `--no-mic`, the silence tick - carrying the events) — the sender's shape is proven there and on the dev phone's wire - (the fp6 journal's dtmf benches: their pcaps go through the same checks); - what a gateway does with the events is proven only by a call to one. + carrying the events), and its `--dtmf-rx` arm has the peer send events and checks the + decoded downlink for each tone's two frequencies where the event sat, the + stream intact around it — the sender's shape is proven there and on the + dev phone's wire (the fp6 journal's dtmf benches: their pcaps go through + the same checks), the renderer's there — and on the dev phone by ear and + by the leg's count of events rendered (bench 10); what a gateway does + with the events is proven only by a call to one. - `imsd --session` and `imsd-ofonod --session` own their names on the session bus for development without root, polkit or a phone (§8.3). @@ -1074,7 +1092,13 @@ sent, emit this D-Bus state change, arm this deadline). States and reasons are t - 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 + offer's for an incoming one; the leg takes the far end's events at that + number and at `MediaLeg::dtmfRxPt`, `DTMF_RX_PT` — our own SDP's at that + clock: our offer's on a call we placed (the answerer sends with the + offer's numbers, RFC 3264 §6.1), the echoed offer's on one we answered + (the offerer with the answer's, §5.1); the answer's instead when + the answer gave the audio our offer's number, which is then the leg's + audio number — never at the audio's). 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 @@ -1114,8 +1138,21 @@ packet goes in the frame's place, one packet per slot — so events and audio share the stream's sequence and timestamp space (RFC 4733 §2.1), the clock stays the capture's, and the audio after an event is stamped as if it had run through it; a line of more than 4096 bytes is -dropped through its end and the next line goes. **Exit -code 3 means the downlink dried up** for +dropped through its end and the next line goes. The far end's events — +taken at `DTMF_RX_PT` (our offer's 98 or 100 on a call we placed, the +number an answerer sends with, RFC 3264 §6.1, whatever the answer +renumbered ours to; the offer's number we echoed on a call we answered; +the answer's telephone-event number instead when the answer gave the +audio our offer's number, which is then the leg's audio number) and at +`DTMF_PT`, never at the audio's — are played as the tones they stand for +(ITU-T Q.23's frequency pairs, at the packet's volume), in the playout, in +step with the audio clock: the gap before the event's start (a stock +sender's DTX silence often precedes a key press — 0 to 7 slots on the dev +phone, 2026-10-15) is filled as before an audio frame, each packet reports +the event's duration so far and what the clock has not covered is +synthesized, so a retransmitted end packet adds nothing and a lost update +is caught up by the next; a far end whose audio resumes past the event +(the stock phone's does, §13) meets the clock with the usual fill. **Exit code 3 means the downlink dried up** for `MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on carriers whose BYE never reaches our SAs is the reliable teardown trigger. Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1` @@ -1211,7 +1248,8 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `: 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 + call's media start (`… pt N (theirs M)` when `DTMF_RX_PT` differs from + the one we send with); `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 @@ -1497,7 +1535,7 @@ there: | Variable | Default | Meaning | |---|---|---| -| `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 | +| `CODEC`, `OCTET_ALIGN`, `DTMF_PT`, `DTMF_RX_PT` | *(from the SDP)* | set by imsd from the SDP (`DTMF_PT`: the telephone-event payload type at the codec's clock the leg sends with, empty when none was negotiated; `DTMF_RX_PT`: a second number the leg takes events at — our own SDP's at that clock, or the answer's when the answer gave the audio our offer's number, §7.6); not configuration | | `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) | ### 10.3 ims-pdn-up.sh and imsd-ofonod @@ -1658,7 +1696,19 @@ the related code. 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). + on two narrowband calls, every one heard there). On + its own AMR-WB calls to us (telephone-event 111 at 16 kHz) that phone + sends one event of 20 packets per key press (340 ms, -10 dBm0, an update + every 20 ms, the end packet three times), its audio resuming one to eight + slots past the event's end and the event preceded by a DTX gap of up to + seven slots (the fp6 journal's benches 2 to 4 and bench 10's incoming + call, their `far-end-events-*` files); on a call we placed that the + network answered narrowband (telephone-event 100 at 8 kHz) its two key + presses arrived as events of 22 and 19 packets (400 and 340 ms), the + audio resuming 15.8 and 19 slots (316 and 380 ms) past their ends, the + first off the stream's 20 ms timestamp grid (bench 10's outgoing call) — + whether that shape is the phone's or the network's interworking, + nothing on disk says. - **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 7055bbc..a4723e5 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -2551,7 +2551,9 @@ 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={} -> [{}]:{}, telephone-event pt {}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort, leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none"))); + 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"), + leg.dtmfRxPt && leg.dtmfRxPt != leg.dtmfPt ? std::format(" (theirs {})", *leg.dtmfRxPt) : std::string())); if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_}); else RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); @@ -2594,6 +2596,7 @@ private: 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); + setenv("DTMF_RX_PT", leg.dtmfRxPt ? std::to_string(*leg.dtmfRxPt).c_str() : "", 1); std::vector c; for (auto& s : argv) c.push_back(const_cast(s.c_str())); c.push_back(nullptr); diff --git a/implementations/media.cpp b/implementations/media.cpp index 777e81c..3d24966 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -32,14 +32,17 @@ daemon's control pipe, and each `dtmf ` line queues its tones (0-9, *, #, A-D). 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) and two in which its end packet goes out again — then three slots of audio run before the next -tone. +tone. The far end's events — taken at DTMF_RX_PT (default DTMF_PT) and +at DTMF_PT, never at the audio's payload type — are played as the tones +they stand for, in step with the audio clock. Usage: imsd-media imsd-media --selftest (payload pack/depay roundtrip, both formats, both AMR codecs; G.711 table roundtrip; the - telephone-event payload and codes) + telephone-event payload, codes and Q.23 + frequency pairs) Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP -OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT +OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT DTMF_RX_PT (OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the daemon sets it from the negotiated SDP — 1 is the default/legacy behavior. AMR_MODE is the encoder mode of whichever AMR codec is active; default 2 @@ -613,6 +616,28 @@ std::array DtmfPayload(int event, bool end, std::uint16_t durat static_cast(duration & 0xFF)}; } +// The tone an event stands for (ITU-T Q.23 / RFC 4733 §3.2): the keypad's +// row and column frequencies of events 0-15; nullopt for any other event. +struct DtmfPair { int low; int high; }; +std::optional DtmfFrequencies(int event) { + static constexpr std::array lows = {697, 770, 852, 941}; + static constexpr std::array highs = {1209, 1336, 1477, 1633}; + // keypad rows 1 2 3 A / 4 5 6 B / 7 8 9 C / * 0 # D, indexed by event + static constexpr std::array, 16> cell = {{ + {3, 1}, {0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, + {2, 1}, {2, 2}, {3, 0}, {3, 2}, {0, 3}, {1, 3}, {2, 3}, {3, 3}}}; + if (event < 0 || event > 15) return std::nullopt; + const auto [row, col] = cell[static_cast(event)]; + return DtmfPair{lows[static_cast(row)], highs[static_cast(col)]}; +} + +// Sample `k` of a tone at `rate`: the two sines at `amp` each. +std::int16_t DtmfSample(DtmfPair f, std::uint32_t k, int rate, double amp) { + const double t = static_cast(k) / rate; + const double v = amp * (std::sin(2 * std::numbers::pi * f.low * t) + std::sin(2 * std::numbers::pi * f.high * t)); + return static_cast(std::lround(std::clamp(v, -32768.0, 32767.0))); +} + // The tones the control pipe queued, paid out one slot at a time by the // audio's own senders: one sender per slot, so no frame can land inside an // event and the stream's clock is the capture's. @@ -697,14 +722,14 @@ std::vector G711DecodeFrame(Codec c, std::span std::atomic Quit{false}; void OnSig(int) { Quit.store(true); } -// playout queue item -struct PktItem { std::uint32_t ts; std::vector payload; }; +// playout queue item: an audio packet, or (`event`) a telephone-event one +struct PktItem { std::uint32_t ts; std::vector payload; bool event = false; }; // --selftest: pack->depay roundtrip of every frame type, both payload // formats, both AMR codecs; G.711 digital zero, idempotence over the whole // 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR; -// the RFC 4733 telephone-event payload bytes and every keypad character's -// event code. +// the RFC 4733 telephone-event payload bytes, every keypad character's +// event code, every event's Q.23 frequency pair and no tone past D. int SelfTest() { for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { for (int ft = 0; ft < 16; ft++) { @@ -749,6 +774,22 @@ int SelfTest() { std::println(std::cerr, "selftest FAIL telephone-event codes"); return 1; } + // Q.23: every event's pair — rows 697/770/852/941 Hz for 1 2 3 A, 4 5 6 B, + // 7 8 9 C, * 0 # D; columns 1209/1336/1477/1633 Hz — and no tone past D + static constexpr std::array, 16> q23 = {{ + {941, 1336}, {697, 1209}, {697, 1336}, {697, 1477}, {770, 1209}, {770, 1336}, {770, 1477}, {852, 1209}, + {852, 1336}, {852, 1477}, {941, 1209}, {941, 1477}, {697, 1633}, {770, 1633}, {852, 1633}, {941, 1633}}}; + for (int ev = 0; ev < 16; ev++) { + const auto f = DtmfFrequencies(ev); + if (!f || f->low != q23[static_cast(ev)].first || f->high != q23[static_cast(ev)].second) { + std::println(std::cerr, "selftest FAIL telephone-event tone {}", ev); + return 1; + } + } + if (DtmfFrequencies(16) || DtmfFrequencies(-1)) { + std::println(std::cerr, "selftest FAIL telephone-event tones past D"); + return 1; + } for (Codec c : {Codec::Pcma, Codec::Pcmu}) { for (int v = -32768; v <= 32767; v++) { std::uint8_t b = G711Encode(c, static_cast(v)); @@ -808,6 +849,7 @@ int main(int argc, char** argv) { std::string micSrc = EnvOr("MIC_SRC", ""); bool pcmDump = EnvBool("PCM_DUMP", false); const int dtmfPt = std::atoi(EnvOr("DTMF_PT", "0").c_str()); + const int dtmfRxPt = std::atoi(EnvOr("DTMF_RX_PT", EnvOr("DTMF_PT", "0").c_str()).c_str()); constexpr int ExitMediaTimeout = 3; constexpr int PrimeFrames = 8; constexpr int MaxFill = 25; @@ -935,6 +977,8 @@ int main(int argc, char** argv) { std::uint64_t cng = 0; std::uint64_t late = 0; std::uint64_t qdrop = 0; + std::uint64_t rxDtmf = 0; // telephone-event packets from the far end + std::uint64_t tones = 0; // events of theirs rendered as a tone std::jthread playThread; if (decodeOn) { playThread = std::jthread([&] { @@ -966,6 +1010,7 @@ int main(int argc, char** argv) { }; std::vector zero(frameSamples, 0); std::optional expect; + std::optional toneTs; // the event being rendered, by its start stamp for (;;) { PktItem item; { @@ -975,6 +1020,55 @@ int main(int argc, char** argv) { item = std::move(playq.front()); playq.pop_front(); } + if (item.event) { + // The far end's key press, played as the tone it stands + // for (§2.5.2.2 leaves rendering to the receiver). The + // gap before the event's start — a stock sender's DTX + // silence often precedes a key press (dev phone, + // 2026-10-15: 0 to 7 slots) — is filled as before an + // audio frame, so the tone starts where the event does. + // Each packet reports the event's duration so far from + // its start stamp; what the clock has not covered yet is + // synthesized — a retransmitted end packet adds nothing, + // a lost update is caught up by the next. A far end + // whose audio resumes past the event (the stock phone's + // does) meets the clock with the usual fill. + if (item.payload.size() < 4) continue; + const auto freqs = DtmfFrequencies(item.payload[0]); + if (!freqs) continue; + const std::uint32_t dur = (static_cast(item.payload[2]) << 8) | item.payload[3]; + const double amp = 16383.0 * std::pow(10.0, -(item.payload[1] & 0x3F) / 20.0); + if (!expect) { + for (int i = 0; i < PrimeFrames; i++) + if (!writePcm(zero)) return; + expect = item.ts; + } + const std::uint32_t before = (item.ts - *expect) & 0xFFFFFFFF; + if (before < 0x80000000u) { + int fill = static_cast(before / frameSamples); + if (fill > MaxFill) fill = 0; + for (int i = 0; i < fill; i++) { + cng++; + std::uint8_t nodata = 0x7C; + std::vector pcm = amr ? dec.Decode(&nodata, 1) : zero; + applyGain(pcm); + if (!writePcm(pcm)) return; + } + expect = item.ts; + } + const std::uint32_t end = (item.ts + dur) & 0xFFFFFFFF; + const std::uint32_t ahead = (end - *expect) & 0xFFFFFFFF; + if (ahead == 0 || ahead > static_cast(2 * rate)) continue; // covered, or not a tone's length + if (!toneTs || *toneTs != item.ts) { toneTs = item.ts; tones++; } + const std::uint32_t from = (*expect - item.ts) & 0xFFFFFFFF; + std::vector pcm(ahead); + for (std::uint32_t i = 0; i < ahead; i++) + pcm[i] = DtmfSample(*freqs, from + i, rate, amp); + applyGain(pcm); + if (!writePcm(pcm)) return; + expect = end; + continue; + } std::vector> frames; if (amr) { for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) { @@ -1135,7 +1229,14 @@ int main(int argc, char** argv) { std::fwrite(lb, 1, 4, dump); std::fwrite(buf, 1, static_cast(n), dump); } - if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) { + // the far end's events arrive at the payload type our own SDP gave + // telephone-event (DTMF_RX_PT), or at the one we send with when the + // answerer numbers for itself — never at the audio's: a frame is + // audio first + const int rxPt = n >= 12 ? buf[1] & 0x7F : -1; + const bool isEvent = n >= 12 && rxPt != pt && ((dtmfRxPt > 0 && rxPt == dtmfRxPt) || (dtmfPt > 0 && rxPt == dtmfPt)); + if (isEvent) rxDtmf++; + if (playThread.joinable() && n >= 12 && (rxPt == pt || isEvent)) { std::size_t off = RtpPayloadOffset(std::span(buf, static_cast(n))); std::uint32_t pktTs = (static_cast(buf[4]) << 24) | (buf[5] << 16) | (buf[6] << 8) | buf[7]; @@ -1144,7 +1245,7 @@ int main(int argc, char** argv) { playq.pop_front(); qdrop++; } - playq.push_back({pktTs, std::vector(buf + off, buf + n)}); + playq.push_back({pktTs, std::vector(buf + off, buf + n), isEvent}); qcv.notify_one(); } } @@ -1172,13 +1273,13 @@ int main(int argc, char** argv) { if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { std::print(sf, "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, " - "\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " - "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"mic\": {}, " + "\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " + "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, " "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", - CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, - rIp, rPort, pt, dtmfPt, mic, play, amrMode, mediaEnded); + CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc, + rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded); std::fclose(sf); } - std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); + std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} rx_dtmf={} tones={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, rxDtmf, tones, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); return mediaEnded ? ExitMediaTimeout : 0; } diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 40ecdc7..7c1d4ce 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -283,7 +283,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. + // (RFC 4733) the leg sends with — the peer's SDP's; nullopt = none + // negotiated, no DTMF on this call. `dtmfRxPt` is a second number the + // leg takes events at: our own SDP's at that clock — our offer's + // number on a call we placed (an answerer sends with the offer's + // numbers, RFC 3264 §6.1, and may renumber ours in the answer), the + // offer's we echoed on a call we answered; the answer's telephone-event + // number instead when the answer gave the audio our offer's number, + // which is then the leg's audio number. The leg takes events at + // either number, never at the audio's. struct MediaLeg { std::string remoteIp; int remotePort = 0; @@ -291,6 +299,7 @@ export namespace imsd::engine { bool octetAlign = false; std::string codec = "AMR-WB"; std::optional dtmfPt; + std::optional dtmfRxPt; bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } }; @@ -831,6 +840,12 @@ export namespace imsd::engine { leg.octetAlign = ans.octetAlign; leg.codec = ans.codec; leg.dtmfPt = ans.dtmfPt; + // the second number the leg takes events at: our offer's on a call + // we placed — the answer's instead when the answer gave the audio + // that very number, which is then the leg's audio number + const int offered = imsd::sdp::OfferedTelephoneEventPt(imsd::sdp::CodecRate(leg.codec)); + leg.dtmfRxPt = !leg.dtmfPt ? std::nullopt + : std::optional(direction_ == Direction::Outgoing && offered != leg.payloadType ? offered : *leg.dtmfPt); return leg; } diff --git a/interfaces/Imsd-Sdp.cppm b/interfaces/Imsd-Sdp.cppm index 46e74e5..1c77dc1 100644 --- a/interfaces/Imsd-Sdp.cppm +++ b/interfaces/Imsd-Sdp.cppm @@ -84,6 +84,11 @@ export namespace imsd::sdp { std::vector codecs; // offered codecs in order; empty = AMR-WB, AMR }; + // 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; } + // 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 @@ -109,8 +114,8 @@ export namespace imsd::sdp { s += "t=0 0\r\n"; std::string pts; for (const auto& c : codecs) pts += std::format(" {}", pt(c)); - if (wb) pts += " 98"; - if (nb) pts += " 100"; + if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000)); + if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000)); s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts); s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80); s += "b=RS:512\r\n"; @@ -127,12 +132,12 @@ export namespace imsd::sdp { } } if (wb) { - s += "a=rtpmap:98 telephone-event/16000\r\n"; - s += "a=fmtp:98 0-15\r\n"; + s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000)); + s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000)); } if (nb) { - s += "a=rtpmap:100 telephone-event/8000\r\n"; - s += "a=fmtp:100 0-15\r\n"; + s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000)); + s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000)); } s += "a=ptime:20\r\n"; s += "a=maxptime:240\r\n"; diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index db27971..75e1555 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -270,6 +270,7 @@ int main() { 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(media && media->media.dtmfRxPt == 98, "outgoing: the far end sends at our offer's number for that clock"); Check(m.State() == CallState::Active, "active"); auto sent = m.OnDtmf("12#*a"); const Action* dt = Find(sent, Action::Type::SendDtmf); @@ -292,6 +293,35 @@ int main() { 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"); } + // ---- DTMF, outgoing, the answer renumbered telephone-event (RFC 3264 + // allows it): we send with its number, the far end with our offer's + { + imsd::util::Rng rng(44); + CallMachine m(c, rng, "ims-call-1e", "1233", 50004, false); + m.Start(); + auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 101\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:101 telephone-event/16000\r\n", SdpHead))); + const Action* media = Find(a200, Action::Type::StartMedia); + Check(media && media->media.dtmfPt == 101 && media->media.dtmfRxPt == 98, "renumbered answer: send with 101, listen on our 98"); + // a narrowband answer: AMR at 99 with telephone-event/8000 at 102 + imsd::util::Rng rng2(45); + CallMachine n(c, rng2, "ims-call-1f", "1233", 50004, false); + n.Start(); + auto b200 = n.OnInviteResponse(R200Sdp(n.CallId(), std::format("{}m=audio 6004 RTP/AVP 99 102\r\na=rtpmap:99 AMR/8000/1\r\na=rtpmap:102 telephone-event/8000\r\n", SdpHead))); + const Action* nbMedia = Find(b200, Action::Type::StartMedia); + Check(nbMedia && nbMedia->media.codec == "AMR" && nbMedia->media.dtmfPt == 102 && nbMedia->media.dtmfRxPt == 100, "narrowband answer: send with its 102, listen on our offer's 100"); + // an answer numbering for itself that gives the audio our offer's + // telephone-event number: the receive number is the answer's, never + // the audio's (every downlink frame would be taken for an event) + imsd::util::Rng rng3(46); + CallMachine o(c, rng3, "ims-call-1g", "1233", 50004, false); + o.Start(); + auto c200 = o.OnInviteResponse(R200Sdp(o.CallId(), std::format("{}m=audio 6004 RTP/AVP 100 101\r\na=rtpmap:100 AMR/8000/1\r\na=rtpmap:101 telephone-event/8000\r\n", SdpHead))); + const Action* colMedia = Find(c200, Action::Type::StartMedia); + Check(colMedia && colMedia->media.payloadType == 100 && colMedia->media.dtmfPt == 101 && colMedia->media.dtmfRxPt == 101, "the audio at our telephone-event number: listen on the answer's 101, not the audio's 100"); + auto again = m.OnInviteResponse(R200(m.CallId())); + Check(!Find(again, Action::Type::StartMedia), "a retransmitted 200 starts no second leg"); + } + // ---- remote BYE mid-call { imsd::util::Rng rng(7); @@ -432,6 +462,7 @@ int main() { 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"); + Check(mtMedia && mtMedia->media.dtmfRxPt == 105, "incoming: the far end sends at the number we echoed"); 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"); diff --git a/tests/Sdp/main.cpp b/tests/Sdp/main.cpp index 36ef893..25e1c5d 100644 --- a/tests/Sdp/main.cpp +++ b/tests/Sdp/main.cpp @@ -206,6 +206,7 @@ int main() { "a=rtpmap:104 AMR-WB/16000/1\r\n" "a=rtpmap:105 telephone-event/16000\r\n" "a=rtpmap:106 telephone-event/8000\r\n"; + Check(OfferedTelephoneEventPt(16000) == 98 && OfferedTelephoneEventPt(8000) == 100, "our offer's telephone-event numbers per clock"); Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate"); Check(!TelephoneEventPt(offer, 8000), "an rtpmap the m-line does not list (106 among 104 105) is not negotiated"); Check(ParseAnswer(offer).dtmfPt == 105, "the answer's dtmfPt is the one at the chosen codec's clock (AMR-WB: 16000)"); From 547361a4d476014adccf66170adea19d762bc9e8 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 15 Oct 2026 21:51:13 +0200 Subject: [PATCH 6/7] imsd 1.1.0 --- implementations/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/implementations/main.cpp b/implementations/main.cpp index a4723e5..6af25b2 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -49,7 +49,7 @@ import ImsdBus; namespace { // ---- static config (env-overridable, same knobs as imsd.py) --------------- -constexpr const char* Version = "1.0.0"; +constexpr const char* Version = "1.1.0"; constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1"; From 2b5de7a60b3d9ecedab5fa34da40b2d3c8b26cab Mon Sep 17 00:00:00 2001 From: jorijnvdgraaf Date: Fri, 16 Oct 2026 20:37:28 +0000 Subject: [PATCH 7/7] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6dbda5b..b2f35de 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ Calls verified by community members to be fully working: | Device | OS | Carrier | |---|---|---| -| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | -| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | -| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | -| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | -| The Fairphone (Gen. 6) | postmarketOS, Linux 7.2.0 | o2-uk | +| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | KPN NL | +| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telekom Deutschland GER | +| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Phonero | +| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telia Norge | +| The Fairphone (Gen. 6) | Nura, Linux 7.2.0 | o2-uk | ## What it needs