Compare commits

..
Author SHA1 Message Date
2b5de7a60b Update README.md
All checks were successful
package / package (push) Successful in 1m51s
2026-10-16 20:37:28 +00:00
547361a4d4
imsd 1.1.0
All checks were successful
package / package (push) Successful in 1m48s
2026-10-16 22:25:49 +02:00
e31887bf21
sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.

Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
2026-10-16 22:25:49 +02:00
92fba40da0
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.

Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-16 22:25:49 +02:00
29d624fc55
media: RFC 4733 telephone-event uplink, the tones from dtmf <tones> lines on stdin
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 <tones>` 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.
2026-10-16 22:25:48 +02:00
108afcce96
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".
2026-10-16 22:25:48 +02:00
78685c4b41 Update README.md
All checks were successful
package / package (push) Successful in 1m41s
2026-10-16 14:53:08 +00:00
8 changed files with 864 additions and 126 deletions

202
AGENTS.md
View file

@ -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.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-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-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; `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-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-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`. | | `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/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`. | | `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/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 out, queued by `dtmf <tones>` 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 (`<linux/qrtr.h>`), no GLib; never Start/Stop Network. | | `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 (`<linux/qrtr.h>`), no GLib; never Start/Stop Network. |
| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). | | `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`). | | `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). |
@ -282,13 +282,20 @@ What the suites pin:
keepalive discards), `ResponseOf`, refused finals with reason headers, keepalive discards), `ResponseOf`, refused finals with reason headers,
reginfo documents. A change that flips one of those tests is a behaviour reginfo documents. A change that flips one of those tests is a behaviour
change, not a cleanup. 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. own payload types and octet-align mode, the `CODECS` override.
- `Engine` — the call state machine driven through recorded network dialogs - `Engine` — the call state machine driven through recorded network dialogs
(reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer (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, 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 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`; 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 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 registration counts as gone, the holds a restart takes over, a binding the
network terminated). network terminated).
@ -427,7 +434,57 @@ Rules:
the script's keys ran on the host build (2026-10-12, exit 0), its own idle 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 (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 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); 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
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, 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 - `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` 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, on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture,
@ -439,9 +496,23 @@ Rules:
QRTR control lookup and the matching of answers by type, transaction and QRTR control lookup and the matching of answers by type, transaction and
message id are shell code (`pcscf.cpp`) no test reaches. message id are shell code (`pcscf.cpp`) no test reaches.
- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both
formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16 formats, both AMR codecs), the G.711 tables, and pins the RFC 4733 event
payload bytes, the keypad-to-event codes and every event's Q.23
frequency pair. `MIC_SRC=<file>` (raw s16
PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams 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 <tones>` 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), 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 - `imsd --session` and `imsd-ofonod --session` own their names on the session
bus for development without root, polkit or a phone (§8.3). bus for development without root, polkit or a phone (§8.3).
@ -504,8 +575,9 @@ the status snapshot live (main thread only).
Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the 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 `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 payload type as arguments, the codec and audio knobs in its environment
(§7.6, §10.2). (§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 The desktop daemons are translation layers: `imsd-dialerd` runs as the
session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in
@ -993,8 +1065,8 @@ journal says it was not handed over.
`CallMachine` owns one call's SIP dialog, outgoing or incoming, and never `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 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 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 it came in on, start/stop the media leg, hand it the tones a key press
this deadline). States and reasons are the frozen D-Bus vocabulary: 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 `dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons
`outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` / `outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` /
`refused-or-busy` / `error`. `refused-or-busy` / `error`.
@ -1007,7 +1079,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 - 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 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 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 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 gateway's G.711-only or AMR-NB offer rings and is answered at its own
payload types. payload types.
@ -1016,7 +1089,26 @@ 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; `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 a plain INVITE answered `380 Alternative Service` with an emergency body is
re-attempted as `urn:service:sos`. re-attempted as `urn:service:sos`.
- `SendDtmf` is a stub (RFC 4733 not implemented). - 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 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
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
<tones>` line per `SendDtmf` down the leg's control pipe.
- Incoming calls reach imsd only when the network's T-ADS routes the call to - 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 the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and
`+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives — `+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives —
@ -1032,7 +1124,35 @@ narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR
frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient
(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd (`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd
(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB); (`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; imsd sets it from the SDP and writes the lines
down the control pipe, §7.1): each `dtmf
<tones>` 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. 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 `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. 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` Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1`
@ -1127,6 +1247,20 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
failed: … — the resume is given up (the engine's rule): the pair is failed: … — the resume is given up (the engine's rule): the pair is
removed`). removed`).
- `audio session: …` at start and at each call's media start; `CODECS override active: …`. - `audio session: …` at start and at each call's media start; `CODECS override active: …`.
- `media <codec> pt=N octet=true|false -> [X]:P, telephone-event pt N|none` at a
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 <state>` /
`… 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: <error>`
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 - A refused REGISTER or SUBSCRIBE is logged with its status line and
any `Retry-After` / `Warning` / `Reason` / `Server` headers; any `Retry-After` / `Warning` / `Reason` / `Server` headers;
`re-REGISTER challenged: 401 …; re-authentication asked, the next `re-REGISTER challenged: 401 …; re-authentication asked, the next
@ -1257,8 +1391,6 @@ Known gaps, all inherited rather than introduced:
`mm_sim_dup_emergency_numbers()`). An upstream fix would close this. `mm_sim_dup_emergency_numbers()`). An upstream fix would close this.
Dialling an emergency number from the dialpad is unaffected — that reaches Dialling an emergency number from the dialpad is unaffected — that reaches
imsd's own classifier. 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 - Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's
`VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a `VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a
new origin but leaves the old origin's `CallAdded` handler connected, so new origin but leaves the old origin's `CallAdded` handler connected, so
@ -1398,12 +1530,12 @@ resume.
### 10.2 imsd-media (per call, set through imsd's environment) ### 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: there:
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; 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) | | `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) |
### 10.3 ims-pdn-up.sh and imsd-ofonod ### 10.3 ims-pdn-up.sh and imsd-ofonod
@ -1543,6 +1675,40 @@ the related code.
- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string
IPv6 bearer request is refused when the attach PDN was granted IPv4-only; IPv6 bearer request is refused when the attach PDN was granted IPv4-only;
a profile-indexed request is not. 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).
- **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). 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 - **pmOS's default nftables** drops inbound on `qmapmux*`; without the
protected-port rule every network-initiated request (NOTIFY, MT INVITE) protected-port rule every network-initiated request (NOTIFY, MT INVITE)
dies after ESP decapsulation and MT calls fall back to CS. dies after ESP decapsulation and MT calls fall back to CS.

View file

@ -6,28 +6,19 @@
Userspace IMS/VoLTE daemon for mainline Linux phones. Userspace IMS/VoLTE daemon for mainline Linux phones.
Most Linux phones drive the modem's internal IMS engine. Most Linux phones drive the modem's internal IMS engine.
imsd implements the whole IMS client in userspace instead: imsd implements the whole IMS client in userspace instead wired into
SIP registration with USIM AKA authentication over kernel
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh 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. can place and receive real VoLTE calls on a commercial network.
## Status Calls verified by community members to be fully working:
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:
| Device | OS | Carrier | | Device | OS | Carrier |
|---|---|---| |---|---|---|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | KPN NL |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telekom Deutschland GER |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Phonero |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | | 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 ## What it needs

View file

@ -31,6 +31,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live
#include <gio/gio.h> #include <gio/gio.h>
#include <glib-unix.h> #include <glib-unix.h>
#include <ifaddrs.h> #include <ifaddrs.h>
#include <limits.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <poll.h> #include <poll.h>
#include <pthread.h> #include <pthread.h>
@ -48,7 +49,7 @@ import ImsdBus;
namespace { namespace {
// ---- static config (env-overridable, same knobs as imsd.py) --------------- // ---- 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* BusName = "net.catcrafts.IMS1";
constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1";
constexpr const char* Iface = "net.catcrafts.IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1";
@ -1052,6 +1053,10 @@ public:
std::scoped_lock g(cmdLock_); std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::Accept, uni, {}}); 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() { void Stop() {
{ std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); } { std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); }
quit_.store(true); quit_.store(true);
@ -1140,7 +1145,7 @@ public:
} }
private: 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 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 // A bring-up the network deferred (a fresh-SA throttle) or refused: wait
@ -1247,6 +1252,7 @@ private:
bool dropCall_ = false; bool dropCall_ = false;
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
pid_t mediaPid_ = -1; pid_t mediaPid_ = -1;
int mediaCtl_ = -1; // our end of the leg's control pipe (its stdin)
double deadline_ = 0; double deadline_ = 0;
// reply_ answers the inbound request being dispatched right now; // reply_ answers the inbound request being dispatched right now;
// callReply_ is the call's durable response channel — refreshed from // callReply_ is the call's durable response channel — refreshed from
@ -2368,8 +2374,8 @@ private:
bool ClearForDial(const Cmd& c, bool emergency) { bool ClearForDial(const Cmd& c, bool emergency) {
if (!call_) return true; if (!call_) return true;
if (!emergency) { if (!emergency) {
Log(std::format("dial({}) refused: call in progress", c.number)); Log(std::format("dial({}) refused: call in progress", c.arg));
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0};
PostAdded(bad); PostAdded(bad);
PostState(c.uni, "terminated", "error"); PostState(c.uni, "terminated", "error");
PostDeleted(c.uni); PostDeleted(c.uni);
@ -2385,7 +2391,7 @@ private:
void HandleCmd(const Cmd& c) { void HandleCmd(const Cmd& c) {
if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; }
if (c.kind == Cmd::Kind::Dial) { 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; if (!ClearForDial(c, emergency)) return;
// a dead client flow fails the INVITE instantly — give the // a dead client flow fails the INVITE instantly — give the
// recovery path one immediate shot first: user action cuts the // recovery path one immediate shot first: user action cuts the
@ -2404,9 +2410,9 @@ private:
if (emergency) if (emergency)
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; " "fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number)); "carrier-side behavior UNVERIFIED", c.arg));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_); call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info); PostAdded(info);
bool ok = Execute(call_->Start()); bool ok = Execute(call_->Start());
if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); } if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); }
@ -2416,6 +2422,9 @@ private:
} else if (c.kind == Cmd::Kind::Accept) { } else if (c.kind == Cmd::Kind::Accept) {
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); } if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); }
else Log(std::format("Accept({}) ignored: no such ringing call", c.uni)); 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; break;
case T::StartMedia: StartMedia(a.media); break; case T::StartMedia: StartMedia(a.media); break;
case T::StopMedia: StopMedia(); break; case T::StopMedia: StopMedia(); break;
case T::SendDtmf: SendDtmfToMedia(a.text); break;
case T::State: case T::State:
if (announce) PostState(call_->Uni(), a.state, a.reason); if (announce) PostState(call_->Uni(), a.state, a.reason);
break; break;
@ -2541,7 +2551,9 @@ private:
void StartMedia(const imsd::engine::MediaLeg& leg) { void StartMedia(const imsd::engine::MediaLeg& leg) {
if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; } 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"),
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_}); if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_});
else else
RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_});
@ -2555,8 +2567,21 @@ private:
session = ResolveAudioSession(); session = ResolveAudioSession();
Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : "")); Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : ""));
} }
// the control pipe: imsd-media reads `dtmf <tones>` 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(); pid_t pid = fork();
if (pid == 0) { if (pid == 0) {
if (ctl[0] >= 0) dup2(ctl[0], STDIN_FILENO); // dup2 clears the close-on-exec
if (geteuid() == 0) { if (geteuid() == 0) {
// no session is said with an empty value: imsd-media reads a // no session is said with an empty value: imsd-media reads a
// missing variable as a caller older than 0.3.10 // missing variable as a caller older than 0.3.10
@ -2570,16 +2595,43 @@ private:
setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
setenv("CODEC", leg.codec.c_str(), 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<char*> c; std::vector<char*> c;
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str())); for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
c.push_back(nullptr); c.push_back(nullptr);
execvp(c[0], c.data()); execvp(c[0], c.data());
_exit(127); _exit(127);
} }
if (ctl[0] >= 0) close(ctl[0]);
if (pid < 0 && ctl[1] >= 0) { close(ctl[1]); ctl[1] = -1; }
mediaPid_ = pid; mediaPid_ = pid;
mediaCtl_ = ctl[1];
}
// The engine's SendDtmf action: one `dtmf <tones>` 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<ssize_t>(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() { void StopMedia() {
CloseMediaCtl();
if (mediaPid_ <= 0) return; if (mediaPid_ <= 0) return;
kill(mediaPid_, SIGTERM); kill(mediaPid_, SIGTERM);
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
@ -2599,6 +2651,7 @@ private:
if (r == 0) return; // still running if (r == 0) return; // still running
int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
mediaPid_ = -1; // reaped; don't re-terminate mediaPid_ = -1; // reaped; don't re-terminate
CloseMediaCtl();
Execute(call_->OnMediaExit(code)); Execute(call_->OnMediaExit(code));
MaybeDropCall(); MaybeDropCall();
} }
@ -2676,7 +2729,10 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*
return; return;
} }
if (m == "SendDtmf") { 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); g_dbus_method_invocation_return_value(inv, nullptr);
return; return;
} }
@ -2740,6 +2796,11 @@ int main(int argc, char** argv) {
std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)"); std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)");
return 1; 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 // 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 // script's arms: no SIM, a refused ims PDN): say so and

View file

@ -26,11 +26,23 @@ 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 CNG/PLC; zeros for G.711). MIC=1 feeds live pw-record audio through the
encoder. 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 <tones>` 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. 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 <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base> Usage: imsd-media <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base>
imsd-media --selftest (payload pack/depay roundtrip, both formats, 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, codes and Q.23
frequency pairs)
Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP 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 DTMF_RX_PT
(OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the (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. 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 AMR_MODE is the encoder mode of whichever AMR codec is active; default 2
@ -45,6 +57,7 @@ with no PipeWire and no network.)
#include <dlfcn.h> #include <dlfcn.h>
#include <fcntl.h> #include <fcntl.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <poll.h>
#include <pwd.h> #include <pwd.h>
#include <signal.h> #include <signal.h>
#include <sys/socket.h> #include <sys/socket.h>
@ -471,8 +484,13 @@ Child SpawnPw(bool play, bool toChild, int rate) {
if (toChild) { dup2(pipefd[0], STDIN_FILENO); } if (toChild) { dup2(pipefd[0], STDIN_FILENO); }
else { dup2(pipefd[1], STDOUT_FILENO); } else { dup2(pipefd[1], STDOUT_FILENO); }
close(pipefd[0]); close(pipefd[1]); close(pipefd[0]); close(pipefd[1]);
int devnull = open("/dev/null", O_WRONLY); int devnull = open("/dev/null", O_RDWR);
if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } 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<char*> cargv; std::vector<char*> cargv;
for (auto& s : argv) cargv.push_back(const_cast<char*>(s.c_str())); for (auto& s : argv) cargv.push_back(const_cast<char*>(s.c_str()));
cargv.push_back(nullptr); cargv.push_back(nullptr);
@ -507,19 +525,32 @@ struct TxState {
sockaddr_storage latched{}; socklen_t latchedLen = 0; sockaddr_storage latched{}; socklen_t latchedLen = 0;
std::uint64_t tx = 0; std::uint64_t tx = 0;
std::uint64_t txMic = 0; std::uint64_t txMic = 0;
std::uint64_t txDtmf = 0;
std::atomic<bool> micOn{false}; std::atomic<bool> micOn{false};
}; };
void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> 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<std::uint32_t> ts, std::span<const std::uint8_t> payload) {
std::uint8_t hdr[12]; 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); std::scoped_lock g(st.lock);
stamp = ts.value_or(st.ts);
hdr[0] = 0x80; hdr[0] = 0x80;
hdr[1] = static_cast<std::uint8_t>(st.pt & 0x7F); hdr[1] = static_cast<std::uint8_t>((marker ? 0x80 : 0) | (pt & 0x7F));
hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF; hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF;
hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF; hdr[4] = (stamp >> 24) & 0xFF; hdr[5] = (stamp >> 16) & 0xFF;
hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 0xFF; hdr[6] = (stamp >> 8) & 0xFF; hdr[7] = stamp & 0xFF;
hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF; hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF;
hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF; hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF;
st.seq = (st.seq + 1) & 0xFFFF; st.seq = (st.seq + 1) & 0xFFFF;
@ -537,8 +568,126 @@ void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
sendTo(a1, l1); sendTo(a1, l1);
// avoid double-send when latched == dst // avoid double-send when latched == dst
if (l2 && (l2 != l1 || std::memcmp(&a1, &a2, l1) != 0)) sendTo(a2, l2); 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<const std::uint8_t> 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<int> 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<std::uint8_t, 4> DtmfPayload(int event, bool end, std::uint16_t duration) {
return {static_cast<std::uint8_t>(event),
static_cast<std::uint8_t>((end ? 0x80 : 0) | DtmfVolume),
static_cast<std::uint8_t>(duration >> 8),
static_cast<std::uint8_t>(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<DtmfPair> DtmfFrequencies(int event) {
static constexpr std::array<int, 4> lows = {697, 770, 852, 941};
static constexpr std::array<int, 4> 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<std::pair<int, int>, 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<std::size_t>(event)];
return DtmfPair{lows[static_cast<std::size_t>(row)], highs[static_cast<std::size_t>(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<double>(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::int16_t>(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.
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<const std::uint8_t> 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<std::uint16_t>(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<int> 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<std::uint32_t> start_; // the event's timestamp
};
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) { Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) {
return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl); return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl);
@ -573,12 +722,14 @@ std::vector<std::int16_t> G711DecodeFrame(Codec c, std::span<const std::uint8_t>
std::atomic<bool> Quit{false}; std::atomic<bool> Quit{false};
void OnSig(int) { Quit.store(true); } void OnSig(int) { Quit.store(true); }
// playout queue item // playout queue item: an audio packet, or (`event`) a telephone-event one
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; }; struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; bool event = false; };
// --selftest: pack->depay roundtrip of every frame type, both payload // --selftest: pack->depay roundtrip of every frame type, both payload
// formats, both AMR codecs; G.711 digital zero, idempotence over the whole // 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, every keypad character's
// event code, every event's Q.23 frequency pair and no tone past D.
int SelfTest() { int SelfTest() {
for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { for (Codec c : {Codec::AmrWb, Codec::AmrNb}) {
for (int ft = 0; ft < 16; ft++) { for (int ft = 0; ft < 16; ft++) {
@ -603,6 +754,42 @@ int SelfTest() {
std::println(std::cerr, "selftest FAIL G.711 digital zero"); std::println(std::cerr, "selftest FAIL G.711 digital zero");
return 1; return 1;
} }
// RFC 4733 §2.3: event, E | R | volume (-10 dBm0), duration big-endian
if (DtmfPayload(1, false, 160) != std::array<std::uint8_t, 4>{0x01, 0x0A, 0x00, 0xA0}
|| DtmfPayload(11, true, 1600) != std::array<std::uint8_t, 4>{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;
}
// 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<std::pair<int, int>, 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<std::size_t>(ev)].first || f->high != q23[static_cast<std::size_t>(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 (Codec c : {Codec::Pcma, Codec::Pcmu}) {
for (int v = -32768; v <= 32767; v++) { for (int v = -32768; v <= 32767; v++) {
std::uint8_t b = G711Encode(c, static_cast<std::int16_t>(v)); std::uint8_t b = G711Encode(c, static_cast<std::int16_t>(v));
@ -661,6 +848,8 @@ int main(int argc, char** argv) {
bool rtpDump = EnvBool("RTP_DUMP", false); bool rtpDump = EnvBool("RTP_DUMP", false);
std::string micSrc = EnvOr("MIC_SRC", ""); std::string micSrc = EnvOr("MIC_SRC", "");
bool pcmDump = EnvBool("PCM_DUMP", false); 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 ExitMediaTimeout = 3;
constexpr int PrimeFrames = 8; constexpr int PrimeFrames = 8;
constexpr int MaxFill = 25; constexpr int MaxFill = 25;
@ -693,8 +882,13 @@ int main(int argc, char** argv) {
signal(SIGINT, OnSig); signal(SIGINT, OnSig);
signal(SIGPIPE, SIG_IGN); 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) // ---- 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<bool> stop{false}; std::atomic<bool> stop{false};
std::jthread micThread; std::jthread micThread;
if (mic) { if (mic) {
@ -731,23 +925,23 @@ int main(int argc, char** argv) {
next += std::chrono::milliseconds(20); next += std::chrono::milliseconds(20);
std::this_thread::sleep_until(next); std::this_thread::sleep_until(next);
} }
st.micOn.store(true);
std::memcpy(samples.data(), raw.data(), raw.size()); std::memcpy(samples.data(), raw.data(), raw.size());
if (gain != 1.0) if (gain != 1.0)
for (auto& sample : samples) { for (auto& sample : samples) {
int v = static_cast<int>(sample * gain); int v = static_cast<int>(sample * gain);
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
} }
std::vector<std::uint8_t> payload; std::vector<std::uint8_t> payload; // empty: a DTX gap, nothing to send in the slot
if (amr) { if (amr) {
std::vector<std::uint8_t> frame = enc.Encode(samples.data(), amrMode, dtx); std::vector<std::uint8_t> frame = enc.Encode(samples.data(), amrMode, dtx);
if (frame.empty()) continue; if (!frame.empty()) payload = PayloadFromFrame(codec, frame, octetAlign);
payload = PayloadFromFrame(codec, frame, octetAlign);
} else { } else {
payload = G711Payload(codec, samples); 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++; } { std::scoped_lock g(st.lock); st.txMic++; }
RtpSend(sock, st, payload);
} }
st.micOn.store(false); st.micOn.store(false);
close(fd); close(fd);
@ -783,6 +977,8 @@ int main(int argc, char** argv) {
std::uint64_t cng = 0; std::uint64_t cng = 0;
std::uint64_t late = 0; std::uint64_t late = 0;
std::uint64_t qdrop = 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; std::jthread playThread;
if (decodeOn) { if (decodeOn) {
playThread = std::jthread([&] { playThread = std::jthread([&] {
@ -814,6 +1010,7 @@ int main(int argc, char** argv) {
}; };
std::vector<std::int16_t> zero(frameSamples, 0); std::vector<std::int16_t> zero(frameSamples, 0);
std::optional<std::uint32_t> expect; std::optional<std::uint32_t> expect;
std::optional<std::uint32_t> toneTs; // the event being rendered, by its start stamp
for (;;) { for (;;) {
PktItem item; PktItem item;
{ {
@ -823,6 +1020,55 @@ int main(int argc, char** argv) {
item = std::move(playq.front()); item = std::move(playq.front());
playq.pop_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<std::uint32_t>(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<int>(before / frameSamples);
if (fill > MaxFill) fill = 0;
for (int i = 0; i < fill; i++) {
cng++;
std::uint8_t nodata = 0x7C;
std::vector<std::int16_t> 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<std::uint32_t>(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<std::int16_t> 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<std::vector<std::uint8_t>> frames; std::vector<std::vector<std::uint8_t>> frames;
if (amr) { if (amr) {
for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) { for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) {
@ -867,9 +1113,65 @@ int main(int argc, char** argv) {
}); });
} }
// ---- the control pipe that queues the tones: the daemon's
// `dtmf <tones>` 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<std::size_t>(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 // ---- main recv loop
std::vector<std::uint8_t> silence = SilenceFrame(codec, 0, octetAlign); std::vector<std::uint8_t> 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; std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr;
double t0 = Now(); double t0 = Now();
@ -888,7 +1190,8 @@ int main(int argc, char** argv) {
break; break;
} }
if (now - lastTx >= 0.02 && !st.micOn.load()) { 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; lastTx = now;
} }
std::uint8_t buf[65535]; std::uint8_t buf[65535];
@ -926,7 +1229,14 @@ int main(int argc, char** argv) {
std::fwrite(lb, 1, 4, dump); std::fwrite(lb, 1, 4, dump);
std::fwrite(buf, 1, static_cast<std::size_t>(n), dump); std::fwrite(buf, 1, static_cast<std::size_t>(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<std::size_t>(n))); std::size_t off = RtpPayloadOffset(std::span(buf, static_cast<std::size_t>(n)));
std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) | std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) |
(buf[5] << 16) | (buf[6] << 8) | buf[7]; (buf[5] << 16) | (buf[6] << 8) | buf[7];
@ -935,13 +1245,14 @@ int main(int argc, char** argv) {
playq.pop_front(); playq.pop_front();
qdrop++; qdrop++;
} }
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n)}); playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n), isEvent});
qcv.notify_one(); qcv.notify_one();
} }
} }
stop.store(true); stop.store(true);
qcv.notify_all(); qcv.notify_all();
if (ctlThread.joinable()) ctlThread.join();
if (micThread.joinable()) micThread.join(); if (micThread.joinable()) micThread.join();
if (playThread.joinable()) playThread.join(); if (playThread.joinable()) playThread.join();
if (dump) std::fclose(dump); if (dump) std::fclose(dump);
@ -961,14 +1272,14 @@ int main(int argc, char** argv) {
// .stats sidecar (tiny; always written) // .stats sidecar (tiny; always written)
if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) {
std::print(sf, std::print(sf,
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, "
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " "\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, " "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, "
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc,
rIp, rPort, pt, mic, play, amrMode, mediaEnded); rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded);
std::fclose(sf); 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={} 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; return mediaEnded ? ExitMediaTimeout : 0;
} }

View file

@ -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 finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the
emergency attempt chain (urn:service:sos first for a classified number, one 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 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, 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 — 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). 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 State/reason strings are the frozen D-Bus vocabulary the dialer maps on
@ -280,12 +282,24 @@ export namespace imsd::engine {
// Parsed SDP answer needed to bring up the media leg. `codec` is one of // 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). // 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) 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 { struct MediaLeg {
std::string remoteIp; std::string remoteIp;
int remotePort = 0; int remotePort = 0;
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; std::string codec = "AMR-WB";
std::optional<int> dtmfPt;
std::optional<int> dtmfRxPt;
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
}; };
@ -295,6 +309,7 @@ export namespace imsd::engine {
SendResponse, // answer the just-handled inbound request on its origin channel SendResponse, // answer the just-handled inbound request on its origin channel
StartMedia, // bring up the media leg described by `media` StartMedia, // bring up the media leg described by `media`
StopMedia, // tear the media leg down StopMedia, // tear the media leg down
SendDtmf, // hand `text` (the tones) to the running media leg
State, // emit CallStateChanged(uni, `state`, `reason`) State, // emit CallStateChanged(uni, `state`, `reason`)
Deleted, // emit CallDeleted(uni); drop this machine Deleted, // emit CallDeleted(uni); drop this machine
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline() SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
@ -308,6 +323,11 @@ export namespace imsd::engine {
double seconds = 0; double seconds = 0;
}; };
// The most tones one SendDtmf may carry (a keypad sends one; a dialer's
// string is a short sequence): the leg's `dtmf <tones>\n` line stays
// within one atomic pipe write, POSIX's 512-byte PIPE_BUF floor.
inline constexpr std::size_t DtmfMaxTones = 128;
// ring/answer budget, and how long to wait for a 487 after CANCEL — the // ring/answer budget, and how long to wait for a 487 after CANCEL — the
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants. // imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
inline constexpr double InviteTimeout = 90.0; inline constexpr double InviteTimeout = 90.0;
@ -435,7 +455,7 @@ export namespace imsd::engine {
spec.payloadType = leg.payloadType; spec.payloadType = leg.payloadType;
spec.octetAlign = leg.octetAlign; spec.octetAlign = leg.octetAlign;
spec.codec = leg.codec; 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); localSdp_ = imsd::sdp::BuildAnswer(spec);
// session timers: echo the caller's Session-Expires; with no // session timers: echo the caller's Session-Expires; with no
// refresher stated, make the caller (uac) the refresher — its // refresher stated, make the caller (uac) the refresher — its
@ -608,6 +628,45 @@ export namespace imsd::engine {
return a; return a;
} }
// The user pressed keys: the tones go to the media leg as RFC 4733
// events on an active call whose SDP negotiated telephone-event at
// the codec's clock; otherwise they are dropped with a line that
// says why — there is no second method (no SIP INFO).
// Upper and lower case letters are one event; anything but 0-9, *,
// # and A-D drops the whole string rather than sending part of
// what was pressed, and so does a string past DtmfMaxTones — a
// keypad sends one key, a dialer's string a short sequence; the
// bound keeps the leg's control line within one atomic pipe write
// (PIPE_BUF), so a line is sent whole or not at all.
std::vector<Action> OnDtmf(std::string_view tones) {
std::vector<Action> a;
if (tones.size() > DtmfMaxTones) {
a.push_back(LogAct(std::format("call {}: DTMF of {} tones dropped: more than {}", uni_, tones.size(), DtmfMaxTones)));
return a;
}
std::string sent;
for (char c : tones) {
const char u = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (!((u >= '0' && u <= '9') || u == '*' || u == '#' || (u >= 'A' && u <= 'D'))) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: '{}' is not a tone", uni_, tones, c)));
return a;
}
sent += u;
}
if (sent.empty()) return a;
if (state_ != CallState::Active) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: the call is {}", uni_, sent, engine::StateStr(state_))));
return a;
}
if (!dtmfPt_) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: no telephone-event negotiated for this call", uni_, sent)));
return a;
}
a.push_back(LogAct(std::format("call {}: DTMF '{}' -> media leg (telephone-event pt {})", uni_, sent, *dtmfPt_)));
a.push_back(Action{Action::Type::SendDtmf, sent});
return a;
}
// The shell is abandoning this machine (an emergency dial preempts // The shell is abandoning this machine (an emergency dial preempts
// it while it waits on a CANCEL's 487): terminate bookkeeping only — // it while it waits on a CANCEL's 487): terminate bookkeeping only —
// the CANCEL already sent stands, and the late final for this // the CANCEL already sent stands, and the late final for this
@ -687,6 +746,7 @@ export namespace imsd::engine {
bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline
std::set<std::string> pracked_; std::set<std::string> pracked_;
std::string answerSdp_; std::string answerSdp_;
std::optional<int> dtmfPt_; // the running leg's telephone-event pt, set at media start
// Fresh dialog identity for the current attempt. A fallback INVITE // Fresh dialog identity for the current attempt. A fallback INVITE
// after a final response is a new transaction AND a clean new dialog // after a final response is a new transaction AND a clean new dialog
@ -779,6 +839,13 @@ export namespace imsd::engine {
leg.payloadType = ans.payloadType; leg.payloadType = ans.payloadType;
leg.octetAlign = ans.octetAlign; leg.octetAlign = ans.octetAlign;
leg.codec = ans.codec; 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<int>(direction_ == Direction::Outgoing && offered != leg.payloadType ? offered : *leg.dtmfPt);
return leg; return leg;
} }
@ -820,6 +887,7 @@ export namespace imsd::engine {
return a; return a;
} }
Action StartMediaAct(const MediaLeg& leg) { Action StartMediaAct(const MediaLeg& leg) {
dtmfPt_ = leg.dtmfPt;
Action a{Action::Type::StartMedia}; Action a{Action::Type::StartMedia};
a.media = leg; a.media = leg;
return a; return a;

View file

@ -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 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 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 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). 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) 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 and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
@ -82,6 +84,11 @@ export namespace imsd::sdp {
std::vector<std::string> codecs; // offered codecs in order; empty = AMR-WB, AMR std::vector<std::string> 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 // Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the
// 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a // 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 // call we place TO a landline no longer depends on the far side
@ -107,8 +114,8 @@ export namespace imsd::sdp {
s += "t=0 0\r\n"; s += "t=0 0\r\n";
std::string pts; std::string pts;
for (const auto& c : codecs) pts += std::format(" {}", pt(c)); for (const auto& c : codecs) pts += std::format(" {}", pt(c));
if (wb) pts += " 98"; if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000));
if (nb) pts += " 100"; if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000));
s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts); 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 += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80);
s += "b=RS:512\r\n"; s += "b=RS:512\r\n";
@ -125,12 +132,12 @@ export namespace imsd::sdp {
} }
} }
if (wb) { if (wb) {
s += "a=rtpmap:98 telephone-event/16000\r\n"; s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000));
s += "a=fmtp:98 0-15\r\n"; s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000));
} }
if (nb) { if (nb) {
s += "a=rtpmap:100 telephone-event/8000\r\n"; s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000));
s += "a=fmtp:100 0-15\r\n"; s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000));
} }
s += "a=ptime:20\r\n"; s += "a=ptime:20\r\n";
s += "a=maxptime:240\r\n"; s += "a=maxptime:240\r\n";
@ -150,22 +157,68 @@ export namespace imsd::sdp {
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play) std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
std::optional<int> dtmfPt; // telephone-event at the codec's clock rate; nullopt = no DTMF
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
constexpr std::string_view Avp = " RTP/AVP "; // after the m=audio port: the one profile we speak
// The m=audio line's payload types in its order; empty without the line,
// its port or the " RTP/AVP " literal.
std::vector<int> AudioFormats(std::string_view body) {
std::vector<int> pts;
auto m = Find(body, "m=audio ");
if (!m) return pts;
std::string_view portTok = TokenAt(body, *m + 8);
std::size_t afterPort = *m + 8 + portTok.size();
if (!ParseInt(portTok) || !body.substr(afterPort).starts_with(Avp)) return pts;
// digits + spaces up to CR/LF
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
return pts;
}
// A payload type's rtpmap encoding, `NAME/CLOCK[/CHANNELS]` upper-cased
// (RFC 4566 §6: encoding names are case-insensitive); empty without one.
std::string Rtpmap(std::string_view body, int pt) {
const std::string key = std::format("a=rtpmap:{} ", pt);
auto at = Find(body, key);
if (!at) return "";
std::size_t begin = *at + key.size();
std::size_t end = body.find_first_of("\r\n", begin);
std::string enc(body.substr(begin, end == std::string_view::npos ? std::string_view::npos : end - begin));
for (char& c : enc)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return enc;
}
// Payload type of `telephone-event/<clockRate>` among the m=audio line's
// formats (DTMF, RFC 4733); nullopt when the line lists none. An rtpmap
// the line does not list is not negotiated: nothing is sent with a format
// the answer does not carry (RFC 3264 §8.3.2). The clock is matched
// whole: `/8000` is not `/80000`.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
const std::string needle = std::format("TELEPHONE-EVENT/{}", clockRate);
for (int pt : AudioFormats(body)) {
const std::string enc = Rtpmap(body, pt);
const bool whole = enc.size() == needle.size() || (enc.size() > needle.size() && (enc[needle.size()] == '/' || enc[needle.size()] == ' '));
if (enc.starts_with(needle) && whole) return pt;
}
return std::nullopt;
}
// The encoding name an offer gives a payload type: its a=rtpmap line, // The encoding name an offer gives a payload type: its a=rtpmap line,
// upper-cased (RFC 4566 §6: encoding names are case-insensitive), else // 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 // the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry
// no rtpmap at all). Empty when neither applies. // no rtpmap at all). Empty when neither applies.
std::string CodecName(std::string_view body, int pt) { std::string CodecName(std::string_view body, int pt) {
std::string key = std::format("a=rtpmap:{} ", pt); const std::string enc = Rtpmap(body, pt);
if (auto at = Find(body, key)) { if (!enc.empty()) return enc.substr(0, enc.find('/'));
std::size_t nameBegin = *at + key.size();
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin));
for (char& c : name)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return name;
}
if (pt == 0) return "PCMU"; if (pt == 0) return "PCMU";
if (pt == 8) return "PCMA"; if (pt == 8) return "PCMA";
return ""; return "";
@ -202,7 +255,10 @@ export namespace imsd::sdp {
// non-empty `prefs` replaces the codec order (and restricts acceptance // non-empty `prefs` replaces the codec order (and restricts acceptance
// to the codecs named) — the CODECS override. With no acceptable codec // to the codecs named) — the CODECS override. With no acceptable codec
// at all, the first pt is used and codec comes back EMPTY — pretending // at all, the first pt is used and codec comes back EMPTY — pretending
// otherwise would defeat codec checks upstream. // otherwise would defeat codec checks upstream. The telephone-event pt
// is the one at the chosen codec's clock (an event's rate is its rtpmap
// clock, RFC 4733 §2.4.1; an offer with two codec clocks lists
// telephone-event at each), so a tone goes out on the stream's own clock.
Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) { Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
Answer a; Answer a;
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9); if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
@ -214,17 +270,10 @@ export namespace imsd::sdp {
auto port = ParseInt(portTok); auto port = ParseInt(portTok);
// both the port and the " RTP/AVP " literal are required // both the port and the " RTP/AVP " literal are required
std::size_t afterPort = *m + 8 + portTok.size(); 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; if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
a.port = *port; a.port = *port;
// payload-type list: digits + spaces up to CR/LF std::vector<int> pts = AudioFormats(body);
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
std::vector<int> pts;
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
if (pts.empty()) return a; if (pts.empty()) return a;
auto hasOctetAlign = [&](int p) { auto hasOctetAlign = [&](int p) {
@ -261,28 +310,10 @@ export namespace imsd::sdp {
a.codec.clear(); a.codec.clear();
} }
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType); a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec));
return a; return a;
} }
// Payload type of `telephone-event/<clockRate>` in a body's rtpmap lines
// (DTMF, RFC 4733); nullopt when the offer doesn't carry one.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
std::string needle = std::format("telephone-event/{}", clockRate);
std::size_t pos = 0;
while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) {
std::size_t ptBegin = pos + 9;
std::size_t lineEnd = body.find_first_of("\r\n", ptBegin);
std::size_t sp = body.find(' ', ptBegin);
if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) {
auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin));
std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1);
if (pt && name.starts_with(needle)) return pt;
}
pos = ptBegin;
}
return std::nullopt;
}
struct AnswerSpec { struct AnswerSpec {
std::string local; // connection address (v4 or v6) std::string local; // connection address (v4 or v6)
int rtpPort = 0; int rtpPort = 0;
@ -293,14 +324,11 @@ export namespace imsd::sdp {
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
// SDP answer to an inbound audio offer: single codec at the offer's own // 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 // 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), // = 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. // rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
std::string BuildAnswer(const AnswerSpec& s) { std::string BuildAnswer(const AnswerSpec& s) {
std::string ipver = Is6(s.local) ? "IP6" : "IP4"; std::string ipver = Is6(s.local) ? "IP6" : "IP4";

View file

@ -11,7 +11,10 @@
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
// what was offered), a landline gateway's G.711-only and AMR-NB offers // 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 -> // 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 // the refusal and silence thresholds, local refusals, the reconnect and
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial // fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
// may cut short, when the old registration counts as gone, the holds a // 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" "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); "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: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\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) { std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format( return std::format(
"SIP/2.0 {} {}\r\n" "SIP/2.0 {} {}\r\n"
@ -209,6 +227,7 @@ int main() {
Check(media && media->media.remotePort == 6004, "media remote port"); Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt"); Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec"); 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); const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted"); Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active"); 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(!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"); 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 // media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3); auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE"); Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
@ -230,6 +255,73 @@ int main() {
Check(m.Terminated(), "terminated"); 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(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);
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");
}
// ---- 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 // ---- remote BYE mid-call
{ {
imsd::util::Rng rng(7); imsd::util::Rng rng(7);
@ -368,6 +460,12 @@ 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=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=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"); 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");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes"); Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact"); Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog) // the 180 and 200 carry the SAME To tag (one dialog)

View file

@ -206,8 +206,23 @@ int main() {
"a=rtpmap:104 AMR-WB/16000/1\r\n" "a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=rtpmap:105 telephone-event/16000\r\n" "a=rtpmap:105 telephone-event/16000\r\n"
"a=rtpmap:106 telephone-event/8000\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, 16000) == 105, "telephone-event pt by clock rate");
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt"); 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)");
std::string both = std::format("{}a=rtpmap:99 AMR/8000/1\r\n", offer);
both.replace(both.find("RTP/AVP 104 105"), 15, "RTP/AVP 104 99 105 106");
std::array<std::string, 1> 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(offer, 48000).has_value(), "absent clock rate -> nullopt");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt"); Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt");
} }