Compare commits
8 changed files with 126 additions and 864 deletions
202
AGENTS.md
202
AGENTS.md
|
|
@ -40,7 +40,7 @@ PipeWire. The verified device/carrier combinations are in the README.
|
||||||
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. |
|
| `interfaces/Imsd.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; `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-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-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, 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/media.cpp` | `imsd-media`, the per-call RTP leg. 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,20 +282,13 @@ 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 (the telephone-event payload
|
- `Sdp` — the offer's exact bytes, the parser, `BuildAnswer` at the offer's
|
||||||
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, DTMF — sent to the leg on an active call that negotiated
|
emergency chain) and `RegRecovery` (thresholds, backoffs, the throttle
|
||||||
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).
|
||||||
|
|
@ -434,57 +427,7 @@ 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). DTMF ran on the dev phone (KPN,
|
for imsd on the dev phone (2026-10-12).
|
||||||
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,
|
||||||
|
|
@ -496,23 +439,9 @@ 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), the G.711 tables, and pins the RFC 4733 event
|
formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16
|
||||||
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; the fp6
|
for driving the leg against a synthetic RTP peer without a phone.
|
||||||
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).
|
||||||
|
|
||||||
|
|
@ -575,9 +504,8 @@ 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, the codec and audio knobs in its environment
|
payload type as arguments and the codec and audio knobs in its environment
|
||||||
(§7.6, §10.2) and its stdin on a pipe from imsd, the control pipe the DTMF
|
(§7.6, §10.2).
|
||||||
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
|
||||||
|
|
@ -1065,8 +993,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, hand it the tones a key press
|
it came in on, start/stop the media leg, emit this D-Bus state change, arm
|
||||||
sent, emit this D-Bus state change, arm this deadline). States and reasons are the frozen D-Bus vocabulary:
|
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`.
|
||||||
|
|
@ -1079,8 +1007,7 @@ sent, emit this D-Bus state change, arm this deadline). States and reasons are t
|
||||||
- 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, its telephone-event echoed when the
|
payload types and octet-align mode; a remote CANCEL gets 200 + 487; an
|
||||||
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.
|
||||||
|
|
@ -1089,26 +1016,7 @@ sent, emit this D-Bus state change, arm this deadline). States and reasons are t
|
||||||
`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`.
|
||||||
- DTMF (`SendDtmf`): the tones go out as RFC 4733 telephone-event packets
|
- `SendDtmf` is a stub (RFC 4733 not implemented).
|
||||||
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 —
|
||||||
|
|
@ -1124,35 +1032,7 @@ 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. DTMF (RFC 4733 telephone-events; `DTMF_PT` names their
|
G.711 is a table. **Exit code 3 means the downlink dried up** for
|
||||||
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`
|
||||||
|
|
@ -1247,20 +1127,6 @@ 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
|
||||||
|
|
@ -1391,6 +1257,8 @@ 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
|
||||||
|
|
@ -1530,12 +1398,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. These are not configuration and are left out
|
The README's second table. Two pairs are not configuration and are left out
|
||||||
there:
|
there:
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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 |
|
| `CODEC`, `OCTET_ALIGN` | *(from the SDP)* | set by imsd from the negotiated answer; 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
|
||||||
|
|
@ -1675,40 +1543,6 @@ 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.
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -6,19 +6,28 @@
|
||||||
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 wired into
|
imsd implements the whole IMS client in userspace instead:
|
||||||
|
SIP registration with USIM AKA authentication over kernel
|
||||||
|
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
|
||||||
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.
|
||||||
|
|
||||||
Calls verified by community members to be fully working:
|
## Status
|
||||||
|
|
||||||
|
Call-capable, both directions: outgoing calls place, answer, carry full-duplex
|
||||||
|
AMR-WB audio and tear down. Incoming calls ring, answer and carry media the
|
||||||
|
same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI.
|
||||||
|
Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting,
|
||||||
|
USSD.
|
||||||
|
|
||||||
|
Verified by community members to be fully working:
|
||||||
|
|
||||||
| Device | OS | Carrier |
|
| Device | OS | Carrier |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | KPN NL |
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL |
|
||||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telekom Deutschland GER |
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER |
|
||||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Phonero |
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero |
|
||||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telia Norge |
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
|
||||||
| The Fairphone (Gen. 6) | Nura, Linux 7.2.0 | o2-uk |
|
|
||||||
|
|
||||||
## What it needs
|
## What it needs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ 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>
|
||||||
|
|
@ -49,7 +48,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.1.0";
|
constexpr const char* Version = "1.0.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";
|
||||||
|
|
@ -1053,10 +1052,6 @@ 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);
|
||||||
|
|
@ -1145,7 +1140,7 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Cmd { enum class Kind { Dial, HangUp, Accept, Dtmf, Stop } kind; std::string uni, arg; }; // arg: the number to dial, or the tones
|
struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; };
|
||||||
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
|
||||||
|
|
@ -1252,7 +1247,6 @@ 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
|
||||||
|
|
@ -2374,8 +2368,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.arg));
|
Log(std::format("dial({}) refused: call in progress", c.number));
|
||||||
CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0};
|
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
|
||||||
PostAdded(bad);
|
PostAdded(bad);
|
||||||
PostState(c.uni, "terminated", "error");
|
PostState(c.uni, "terminated", "error");
|
||||||
PostDeleted(c.uni);
|
PostDeleted(c.uni);
|
||||||
|
|
@ -2391,7 +2385,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.arg, emergencyExtra_);
|
bool emergency = imsd::msg::IsEmergency(c.number, 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
|
||||||
|
|
@ -2410,9 +2404,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.arg));
|
"carrier-side behavior UNVERIFIED", c.number));
|
||||||
call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_);
|
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_);
|
||||||
CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0};
|
CallInfo info{c.uni, c.number, "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")); }
|
||||||
|
|
@ -2422,9 +2416,6 @@ 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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2524,7 +2515,6 @@ 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;
|
||||||
|
|
@ -2551,9 +2541,7 @@ 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={} -> [{}]:{}, telephone-event pt {}{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort,
|
Log(std::format("media {} pt={} octet={} -> [{}]:{}", 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_});
|
||||||
|
|
@ -2567,21 +2555,8 @@ 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
|
||||||
|
|
@ -2595,43 +2570,16 @@ 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++) {
|
||||||
|
|
@ -2651,7 +2599,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
@ -2729,10 +2676,7 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (m == "SendDtmf") {
|
if (m == "SendDtmf") {
|
||||||
const gchar* uni = nullptr;
|
Log("SendDtmf accepted but not yet sent (TODO RFC4733)");
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -2796,11 +2740,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -26,23 +26,11 @@ 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; the
|
both AMR codecs; G.711 table roundtrip)
|
||||||
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 DTMF_PT DTMF_RX_PT
|
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP
|
||||||
(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
|
||||||
|
|
@ -57,7 +45,6 @@ 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>
|
||||||
|
|
@ -484,13 +471,8 @@ 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_RDWR);
|
int devnull = open("/dev/null", O_WRONLY);
|
||||||
if (devnull >= 0) {
|
if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); }
|
||||||
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);
|
||||||
|
|
@ -525,32 +507,19 @@ 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};
|
||||||
};
|
};
|
||||||
|
|
||||||
// One packet on the uplink, audio or event. Every packet stands in one
|
void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
|
||||||
// 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;
|
sockaddr_storage a1, a2; socklen_t l1, l2;
|
||||||
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>((marker ? 0x80 : 0) | (pt & 0x7F));
|
hdr[1] = static_cast<std::uint8_t>(st.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] = (stamp >> 24) & 0xFF; hdr[5] = (stamp >> 16) & 0xFF;
|
hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF;
|
||||||
hdr[6] = (stamp >> 8) & 0xFF; hdr[7] = stamp & 0xFF;
|
hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 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;
|
||||||
|
|
@ -568,126 +537,8 @@ std::uint32_t RtpSendPacket(int sock, TxState& st, int pt, bool marker, std::opt
|
||||||
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);
|
||||||
|
|
@ -722,14 +573,12 @@ 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: an audio packet, or (`event`) a telephone-event one
|
// playout queue item
|
||||||
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; bool event = false; };
|
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; };
|
||||||
|
|
||||||
// --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++) {
|
||||||
|
|
@ -754,42 +603,6 @@ 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));
|
||||||
|
|
@ -848,8 +661,6 @@ 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;
|
||||||
|
|
@ -882,13 +693,8 @@ 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 — or the
|
// through the codec's encoder, one RTP packet per 20 ms frame
|
||||||
// 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) {
|
||||||
|
|
@ -925,23 +731,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; // empty: a DTX gap, nothing to send in the slot
|
std::vector<std::uint8_t> payload;
|
||||||
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()) payload = PayloadFromFrame(codec, frame, octetAlign);
|
if (frame.empty()) continue;
|
||||||
|
payload = PayloadFromFrame(codec, frame, octetAlign);
|
||||||
} else {
|
} else {
|
||||||
payload = G711Payload(codec, samples);
|
payload = G711Payload(codec, samples);
|
||||||
}
|
}
|
||||||
// the slot's packet: the event's, or this frame
|
st.micOn.store(true);
|
||||||
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);
|
||||||
|
|
@ -977,8 +783,6 @@ 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([&] {
|
||||||
|
|
@ -1010,7 +814,6 @@ 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;
|
||||||
{
|
{
|
||||||
|
|
@ -1020,55 +823,6 @@ 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)) {
|
||||||
|
|
@ -1113,65 +867,9 @@ 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++) dtmf.SendSlot(sock, st, dtmfPt, silence); // latch burst, the one path
|
for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -1190,8 +888,7 @@ int main(int argc, char** argv) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (now - lastTx >= 0.02 && !st.micOn.load()) {
|
if (now - lastTx >= 0.02 && !st.micOn.load()) {
|
||||||
// no mic: the slot's packet is the event's, or a silence frame
|
RtpSend(sock, st, silence);
|
||||||
dtmf.SendSlot(sock, st, dtmfPt, silence);
|
|
||||||
lastTx = now;
|
lastTx = now;
|
||||||
}
|
}
|
||||||
std::uint8_t buf[65535];
|
std::uint8_t buf[65535];
|
||||||
|
|
@ -1229,14 +926,7 @@ 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);
|
||||||
}
|
}
|
||||||
// the far end's events arrive at the payload type our own SDP gave
|
if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) {
|
||||||
// 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];
|
||||||
|
|
@ -1245,14 +935,13 @@ 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), isEvent});
|
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n)});
|
||||||
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);
|
||||||
|
|
@ -1272,14 +961,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\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, "
|
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, "
|
||||||
"\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
|
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
|
||||||
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, "
|
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, "
|
||||||
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
|
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
|
||||||
CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc,
|
CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc,
|
||||||
rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded);
|
rIp, rPort, pt, mic, play, amrMode, mediaEnded);
|
||||||
std::fclose(sf);
|
std::fclose(sf);
|
||||||
}
|
}
|
||||||
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);
|
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);
|
||||||
return mediaEnded ? ExitMediaTimeout : 0;
|
return mediaEnded ? ExitMediaTimeout : 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,8 @@ 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), the terminating (UAS) side: 100/180 on an inbound INVITE,
|
the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE,
|
||||||
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — and
|
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair —
|
||||||
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
|
||||||
|
|
@ -282,24 +280,12 @@ 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; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -309,7 +295,6 @@ 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()
|
||||||
|
|
@ -323,11 +308,6 @@ 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;
|
||||||
|
|
@ -455,7 +435,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 = leg.dtmfPt;
|
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec));
|
||||||
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
|
||||||
|
|
@ -628,45 +608,6 @@ 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
|
||||||
|
|
@ -746,7 +687,6 @@ 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
|
||||||
|
|
@ -839,13 +779,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -887,7 +820,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,7 @@ 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, and
|
address/port, the negotiated payload type, the codec and octet-align mode — it
|
||||||
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
|
||||||
|
|
@ -84,11 +82,6 @@ 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
|
||||||
|
|
@ -114,8 +107,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 += std::format(" {}", OfferedTelephoneEventPt(16000));
|
if (wb) pts += " 98";
|
||||||
if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000));
|
if (nb) pts += " 100";
|
||||||
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";
|
||||||
|
|
@ -132,12 +125,12 @@ export namespace imsd::sdp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (wb) {
|
if (wb) {
|
||||||
s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000));
|
s += "a=rtpmap:98 telephone-event/16000\r\n";
|
||||||
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000));
|
s += "a=fmtp:98 0-15\r\n";
|
||||||
}
|
}
|
||||||
if (nb) {
|
if (nb) {
|
||||||
s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000));
|
s += "a=rtpmap:100 telephone-event/8000\r\n";
|
||||||
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000));
|
s += "a=fmtp:100 0-15\r\n";
|
||||||
}
|
}
|
||||||
s += "a=ptime:20\r\n";
|
s += "a=ptime:20\r\n";
|
||||||
s += "a=maxptime:240\r\n";
|
s += "a=maxptime:240\r\n";
|
||||||
|
|
@ -157,68 +150,22 @@ 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) {
|
||||||
const std::string enc = Rtpmap(body, pt);
|
std::string key = std::format("a=rtpmap:{} ", pt);
|
||||||
if (!enc.empty()) return enc.substr(0, enc.find('/'));
|
if (auto at = Find(body, key)) {
|
||||||
|
std::size_t nameBegin = *at + key.size();
|
||||||
|
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
|
||||||
|
std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin));
|
||||||
|
for (char& c : name)
|
||||||
|
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
return name;
|
||||||
|
}
|
||||||
if (pt == 0) return "PCMU";
|
if (pt == 0) return "PCMU";
|
||||||
if (pt == 8) return "PCMA";
|
if (pt == 8) return "PCMA";
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -255,10 +202,7 @@ 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. The telephone-event pt
|
// otherwise would defeat codec checks upstream.
|
||||||
// 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);
|
||||||
|
|
@ -270,10 +214,17 @@ 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;
|
||||||
|
|
||||||
std::vector<int> pts = AudioFormats(body);
|
// payload-type list: digits + spaces up to CR/LF
|
||||||
|
std::size_t ptsBegin = afterPort + Avp.size();
|
||||||
|
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
|
||||||
|
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
|
||||||
|
std::vector<int> pts;
|
||||||
|
for (auto part : std::views::split(ptsRun, ' '))
|
||||||
|
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
|
||||||
if (pts.empty()) return a;
|
if (pts.empty()) return a;
|
||||||
|
|
||||||
auto hasOctetAlign = [&](int p) {
|
auto hasOctetAlign = [&](int p) {
|
||||||
|
|
@ -310,10 +261,28 @@ 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;
|
||||||
|
|
@ -324,11 +293,14 @@ 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 the offer's m-line lists one at the codec's
|
// telephone-event kept when offered. b=AS is the codec's peak payload
|
||||||
// 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";
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,7 @@
|
||||||
// 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. DTMF: the tones reach the media leg on an active
|
// 480, BYE both ways. Then RegRecovery, the registration recovery policy:
|
||||||
// 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
|
||||||
|
|
@ -93,21 +90,6 @@ 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"
|
||||||
|
|
@ -227,7 +209,6 @@ 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");
|
||||||
|
|
@ -239,12 +220,6 @@ 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");
|
||||||
|
|
@ -255,73 +230,6 @@ 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);
|
||||||
|
|
@ -460,12 +368,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -206,23 +206,8 @@ 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), "an rtpmap the m-line does not list (106 among 104 105) is not negotiated");
|
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt");
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue