sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and were dropped with every packet that is not the audio's. The playout now takes them: the gap before an event's start (a stock sender's DTX silence often precedes a key press) is filled as before an audio frame, each packet reports the event's duration so far from its start stamp, and what the clock has not covered yet is synthesized as the event's Q.23 frequency pair at the packet's volume, so a retransmitted end packet adds nothing, a lost update is caught up by the next, and a far end whose audio resumes past the event (the stock phone's does) meets the clock with the usual fill. An answer may renumber telephone-event from what we offered (RFC 3264: the answerer sends with the offer's numbers, §6.1, and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT to send with, DTMF_RX_PT to take events at as well — our own SDP's number: the offer's 98 or 100 on a call we placed, the offer's number we echoed on one we answered; when the answer gave the audio our offer's number, that number is the leg's audio and DTMF_RX_PT is the answer's telephone-event number instead — and takes events at either number, never at the audio's. --selftest pins the frequency table; the stats gain rx_dtmf, tones and dtmf_rx_pt. Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass); the loopback's --dtmf-rx arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both, plain and collision runs beside it); the dev phone's dtmf benches ran in builds of this series.
This commit is contained in:
parent
92fba40da0
commit
e31887bf21
7 changed files with 244 additions and 38 deletions
82
AGENTS.md
82
AGENTS.md
|
|
@ -40,7 +40,7 @@ PipeWire. The verified device/carrier combinations are in the README.
|
|||
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. |
|
||||
| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). |
|
||||
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
|
||||
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs (the codec, its payload type and mode, and the telephone-event payload type the m-line lists at that codec's clock), `BuildAnswer` for the terminating side, the `CODECS` override. |
|
||||
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions; `OfferedTelephoneEventPt` names its telephone-event numbers), the parser the media plane needs (the codec, its payload type and mode, and the telephone-event payload type the m-line lists at that codec's clock), `BuildAnswer` for the terminating side, the `CODECS` override. |
|
||||
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
|
||||
| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). |
|
||||
| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. |
|
||||
|
|
@ -48,7 +48,7 @@ PipeWire. The verified device/carrier combinations are in the README.
|
|||
| `interfaces/Imsd-Qmi.cppm` | `imsd::qmi` — the QMI WDS request bytes `imsd-pcscf` sends (Bind Mux Data Port, Set IP Family, Get Packet Service Status, Get Current Settings, Get Profile Settings), the message/TLV parser, the P-CSCF list readers (0x22 flag, 0x23 IPv4, 0x24 FQDN, 0x2E IPv6 — the last one libqmi does not model). |
|
||||
| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. |
|
||||
| `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. |
|
||||
| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events, queued by `dtmf <tones>` lines on its stdin). Pure std + POSIX, dlopen's the AMR codecs. |
|
||||
| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events out, queued by `dtmf <tones>` lines on its stdin; the far end's events in, played as tones). Pure std + POSIX, dlopen's the AMR codecs. |
|
||||
| `implementations/pcscf.cpp` | `imsd-pcscf`, the P-CSCF reader: the AF_QIPCRTR socket, the WDS service lookup and the Bind Mux Data Port / Set IP Family / Get Current Settings sequence around `Imsd:Qmi` (plus Get Profile Settings with `--profile`, Get Packet Service Status with `-v`). std + POSIX + the Linux QRTR UAPI (`<linux/qrtr.h>`), no GLib; never Start/Stop Network. |
|
||||
| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). |
|
||||
| `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). |
|
||||
|
|
@ -292,7 +292,9 @@ What the suites pin:
|
|||
emergency chain, DTMF — sent to the leg on an active call that negotiated
|
||||
telephone-event, dropped with the reason before the answer, after the
|
||||
call, without the negotiation, for a character that is no tone or for a
|
||||
string past `DtmfMaxTones`) and
|
||||
string past `DtmfMaxTones`; the receive payload type: our offer's on a
|
||||
renumbered answer, the answer's when the audio took ours, the echoed
|
||||
offer's on an incoming call) and
|
||||
`RegRecovery` (thresholds, backoffs, the throttle
|
||||
schedule, Retry-After, which waits a Dial may cut short, when the old
|
||||
registration counts as gone, the holds a restart takes over, a binding the
|
||||
|
|
@ -444,7 +446,12 @@ Rules:
|
|||
calls from that phone (the offer's 111 at 16 kHz)
|
||||
10 and 4 keys sent from the keypad (benches 3 and 4); on Plasma Mobile,
|
||||
11 keys from Plasma Dialer's keypad on an incoming call and 3 on an
|
||||
outgoing narrowband one, through `imsd-dialerd` (bench 10). Every event of
|
||||
outgoing narrowband one, through `imsd-dialerd` (bench 10); the stock
|
||||
phone's key presses heard as tones (on GNOME Calls: bench 3's twelve on
|
||||
the renderer before the gap fill, bench 4's four on it with the gap
|
||||
fill; on Plasma Mobile: bench 10's ten on an incoming wideband call and
|
||||
two on an outgoing narrowband one, every received event rendered by the
|
||||
leg's own count). Every event of
|
||||
ours in benches 1 to 3 and 5 to 17 is in the RFC 4733 shape on the wire
|
||||
with the clock shared with the audio, and so are three of bench 4's
|
||||
four: the third carried a mic frame after its first packet (seq 1761
|
||||
|
|
@ -470,8 +477,14 @@ Rules:
|
|||
telephone-event at the codec's clock (pinned in `tests/Sdp`), the shell's
|
||||
refusal of a line over PIPE_BUF and its `not sent: no control pipe` line
|
||||
on a call with no media leg (nothing pins these two), a `pipe()` failure, a write to a
|
||||
dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, and
|
||||
G.711 (the loopback only).
|
||||
dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, G.711
|
||||
(the loopback only), a received event with a code past D (dropped
|
||||
unrendered) or one whose packet reports more than two seconds the clock
|
||||
has not covered (that packet skipped — a long key press renders slot by
|
||||
slot), a lost first packet of an event (the renderer starts where the
|
||||
clock stands), and an answer that renumbers telephone-event or gives the
|
||||
audio our number (`DTMF_RX_PT` — KPN echoes our numbers; pinned in
|
||||
`tests/Engine` and by the loopback's collision arm).
|
||||
- `imsd-pcscf` ran on the dev phone (KPN) on 2026-10-11 against the live ims
|
||||
bearer: the `--mux 1 --profile 2 -v` sequence, a plain run, and `OutOfCall`
|
||||
on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture,
|
||||
|
|
@ -484,7 +497,8 @@ Rules:
|
|||
message id are shell code (`pcscf.cpp`) no test reaches.
|
||||
- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both
|
||||
formats, both AMR codecs), the G.711 tables, and pins the RFC 4733 event
|
||||
payload bytes and the keypad-to-event codes. `MIC_SRC=<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
|
||||
for driving the leg against a synthetic RTP peer without a phone; the fp6
|
||||
journal's `utilities/media_loopback.py` is that peer, and its `--dtmf`
|
||||
|
|
@ -492,9 +506,13 @@ Rules:
|
|||
on the wire (one timestamp per event, the marker, the growing duration,
|
||||
the end bit three times, the shared sequence and timestamp space with the
|
||||
audio; with the mic file, or without a mic — `--no-mic`, the silence tick
|
||||
carrying the events) — the sender's shape is proven there and on the dev phone's wire
|
||||
(the fp6 journal's dtmf benches: their pcaps go through the same checks);
|
||||
what a gateway does with the events is proven only by a call to one.
|
||||
carrying the events), and its `--dtmf-rx` arm has the peer send events and checks the
|
||||
decoded downlink for each tone's two frequencies where the event sat, the
|
||||
stream intact around it — the sender's shape is proven there and on the
|
||||
dev phone's wire (the fp6 journal's dtmf benches: their pcaps go through
|
||||
the same checks), the renderer's there — and on the dev phone by ear and
|
||||
by the leg's count of events rendered (bench 10); what a gateway does
|
||||
with the events is proven only by a call to one.
|
||||
- `imsd --session` and `imsd-ofonod --session` own their names on the session
|
||||
bus for development without root, polkit or a phone (§8.3).
|
||||
|
||||
|
|
@ -1074,7 +1092,13 @@ sent, emit this D-Bus state change, arm this deadline). States and reasons are t
|
|||
- DTMF (`SendDtmf`): the tones go out as RFC 4733 telephone-event packets
|
||||
on the call's RTP stream, at the payload type the SDP negotiated for the
|
||||
codec's clock (`MediaLeg::dtmfPt`: the answer's for an outgoing call, the
|
||||
offer's for an incoming one). The
|
||||
offer's for an incoming one; the leg takes the far end's events at that
|
||||
number and at `MediaLeg::dtmfRxPt`, `DTMF_RX_PT` — our own SDP's at that
|
||||
clock: our offer's on a call we placed (the answerer sends with the
|
||||
offer's numbers, RFC 3264 §6.1), the echoed offer's on one we answered
|
||||
(the offerer with the answer's, §5.1); the answer's instead when
|
||||
the answer gave the audio our offer's number, which is then the leg's
|
||||
audio number — never at the audio's). The
|
||||
engine's `OnDtmf` lets them through on an active call that negotiated
|
||||
telephone-event and drops them otherwise, with a journal line that says
|
||||
why (before the answer, after the call, no telephone-event at the codec's
|
||||
|
|
@ -1114,8 +1138,21 @@ packet goes in the frame's place, one packet per slot — so events and
|
|||
audio share the stream's sequence and timestamp space (RFC 4733 §2.1), the
|
||||
clock stays the capture's, and the audio after an event is stamped as if
|
||||
it had run through it; a line of more than 4096 bytes is
|
||||
dropped through its end and the next line goes. **Exit
|
||||
code 3 means the downlink dried up** for
|
||||
dropped through its end and the next line goes. The far end's events —
|
||||
taken at `DTMF_RX_PT` (our offer's 98 or 100 on a call we placed, the
|
||||
number an answerer sends with, RFC 3264 §6.1, whatever the answer
|
||||
renumbered ours to; the offer's number we echoed on a call we answered;
|
||||
the answer's telephone-event number instead when the answer gave the
|
||||
audio our offer's number, which is then the leg's audio number) and at
|
||||
`DTMF_PT`, never at the audio's — are played as the tones they stand for
|
||||
(ITU-T Q.23's frequency pairs, at the packet's volume), in the playout, in
|
||||
step with the audio clock: the gap before the event's start (a stock
|
||||
sender's DTX silence often precedes a key press — 0 to 7 slots on the dev
|
||||
phone, 2026-10-15) is filled as before an audio frame, each packet reports
|
||||
the event's duration so far and what the clock has not covered is
|
||||
synthesized, so a retransmitted end packet adds nothing and a lost update
|
||||
is caught up by the next; a far end whose audio resumes past the event
|
||||
(the stock phone's does, §13) meets the clock with the usual fill. **Exit code 3 means the downlink dried up** for
|
||||
`MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on
|
||||
carriers whose BYE never reaches our SAs is the reliable teardown trigger.
|
||||
Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1`
|
||||
|
|
@ -1211,7 +1248,8 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
|
|||
removed`).
|
||||
- `audio session: …` at start and at each call's media start; `CODECS override active: …`.
|
||||
- `media <codec> pt=N octet=true|false -> [X]:P, telephone-event pt N|none` at a
|
||||
call's media start; `call X: DTMF '…' -> media leg (telephone-event pt
|
||||
call's media start (`… pt N (theirs M)` when `DTMF_RX_PT` differs from
|
||||
the one we send with); `call X: DTMF '…' -> media leg (telephone-event pt
|
||||
N)` for tones the engine lets through, `call X: DTMF '…' dropped: the call is <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
|
||||
|
|
@ -1497,7 +1535,7 @@ there:
|
|||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `CODEC`, `OCTET_ALIGN`, `DTMF_PT` | *(from the SDP)* | set by imsd from the negotiated answer (`DTMF_PT`: the telephone-event payload type at the codec's clock, empty when none was negotiated); not configuration |
|
||||
| `CODEC`, `OCTET_ALIGN`, `DTMF_PT`, `DTMF_RX_PT` | *(from the SDP)* | set by imsd from the SDP (`DTMF_PT`: the telephone-event payload type at the codec's clock the leg sends with, empty when none was negotiated; `DTMF_RX_PT`: a second number the leg takes events at — our own SDP's at that clock, or the answer's when the answer gave the audio our offer's number, §7.6); not configuration |
|
||||
| `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) |
|
||||
|
||||
### 10.3 ims-pdn-up.sh and imsd-ofonod
|
||||
|
|
@ -1658,7 +1696,19 @@ the related code.
|
|||
AMR-WB 107 with telephone-event 111; events of the sender's first shape
|
||||
(100 ms, a packet every 20 ms, at least 100 ms apart) were heard on that
|
||||
phone (bench 2 the first shape; bench 17 the one-sender shape — 28 keys
|
||||
on two narrowband calls, every one heard there).
|
||||
on two narrowband calls, every one heard there). On
|
||||
its own AMR-WB calls to us (telephone-event 111 at 16 kHz) that phone
|
||||
sends one event of 20 packets per key press (340 ms, -10 dBm0, an update
|
||||
every 20 ms, the end packet three times), its audio resuming one to eight
|
||||
slots past the event's end and the event preceded by a DTX gap of up to
|
||||
seven slots (the fp6 journal's benches 2 to 4 and bench 10's incoming
|
||||
call, their `far-end-events-*` files); on a call we placed that the
|
||||
network answered narrowband (telephone-event 100 at 8 kHz) its two key
|
||||
presses arrived as events of 22 and 19 packets (400 and 340 ms), the
|
||||
audio resuming 15.8 and 19 slots (316 and 380 ms) past their ends, the
|
||||
first off the stream's 20 ms timestamp grid (bench 10's outgoing call) —
|
||||
whether that shape is the phone's or the network's interworking,
|
||||
nothing on disk says.
|
||||
- **pmOS's default nftables** drops inbound on `qmapmux*`; without the
|
||||
protected-port rule every network-initiated request (NOTIFY, MT INVITE)
|
||||
dies after ESP decapsulation and MT calls fall back to CS.
|
||||
|
|
|
|||
|
|
@ -2551,7 +2551,9 @@ private:
|
|||
|
||||
void StartMedia(const imsd::engine::MediaLeg& leg) {
|
||||
if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; }
|
||||
Log(std::format("media {} pt={} octet={} -> [{}]:{}, telephone-event pt {}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort, leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none")));
|
||||
Log(std::format("media {} pt={} octet={} -> [{}]:{}, telephone-event pt {}{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort,
|
||||
leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none"),
|
||||
leg.dtmfRxPt && leg.dtmfRxPt != leg.dtmfPt ? std::format(" (theirs {})", *leg.dtmfRxPt) : std::string()));
|
||||
if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_});
|
||||
else
|
||||
RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_});
|
||||
|
|
@ -2594,6 +2596,7 @@ private:
|
|||
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
|
||||
setenv("CODEC", leg.codec.c_str(), 1);
|
||||
setenv("DTMF_PT", leg.dtmfPt ? std::to_string(*leg.dtmfPt).c_str() : "", 1);
|
||||
setenv("DTMF_RX_PT", leg.dtmfRxPt ? std::to_string(*leg.dtmfRxPt).c_str() : "", 1);
|
||||
std::vector<char*> c;
|
||||
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
|
||||
c.push_back(nullptr);
|
||||
|
|
|
|||
|
|
@ -32,14 +32,17 @@ 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.
|
||||
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>
|
||||
imsd-media --selftest (payload pack/depay roundtrip, both formats,
|
||||
both AMR codecs; G.711 table roundtrip; the
|
||||
telephone-event payload and codes)
|
||||
telephone-event payload, codes and Q.23
|
||||
frequency pairs)
|
||||
Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP
|
||||
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT
|
||||
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT DTMF_RX_PT
|
||||
(OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the
|
||||
daemon sets it from the negotiated SDP — 1 is the default/legacy behavior.
|
||||
AMR_MODE is the encoder mode of whichever AMR codec is active; default 2
|
||||
|
|
@ -613,6 +616,28 @@ std::array<std::uint8_t, 4> DtmfPayload(int event, bool end, std::uint16_t durat
|
|||
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.
|
||||
|
|
@ -697,14 +722,14 @@ std::vector<std::int16_t> G711DecodeFrame(Codec c, std::span<const std::uint8_t>
|
|||
std::atomic<bool> Quit{false};
|
||||
void OnSig(int) { Quit.store(true); }
|
||||
|
||||
// playout queue item
|
||||
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; };
|
||||
// playout queue item: an audio packet, or (`event`) a telephone-event one
|
||||
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; bool event = false; };
|
||||
|
||||
// --selftest: pack->depay roundtrip of every frame type, both payload
|
||||
// formats, both AMR codecs; G.711 digital zero, idempotence over the whole
|
||||
// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR;
|
||||
// the RFC 4733 telephone-event payload bytes and every keypad character's
|
||||
// event code.
|
||||
// the RFC 4733 telephone-event payload bytes, every keypad character's
|
||||
// event code, every event's Q.23 frequency pair and no tone past D.
|
||||
int SelfTest() {
|
||||
for (Codec c : {Codec::AmrWb, Codec::AmrNb}) {
|
||||
for (int ft = 0; ft < 16; ft++) {
|
||||
|
|
@ -749,6 +774,22 @@ int SelfTest() {
|
|||
std::println(std::cerr, "selftest FAIL telephone-event codes");
|
||||
return 1;
|
||||
}
|
||||
// Q.23: every event's pair — rows 697/770/852/941 Hz for 1 2 3 A, 4 5 6 B,
|
||||
// 7 8 9 C, * 0 # D; columns 1209/1336/1477/1633 Hz — and no tone past D
|
||||
static constexpr std::array<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 (int v = -32768; v <= 32767; v++) {
|
||||
std::uint8_t b = G711Encode(c, static_cast<std::int16_t>(v));
|
||||
|
|
@ -808,6 +849,7 @@ int main(int argc, char** argv) {
|
|||
std::string micSrc = EnvOr("MIC_SRC", "");
|
||||
bool pcmDump = EnvBool("PCM_DUMP", false);
|
||||
const int dtmfPt = std::atoi(EnvOr("DTMF_PT", "0").c_str());
|
||||
const int dtmfRxPt = std::atoi(EnvOr("DTMF_RX_PT", EnvOr("DTMF_PT", "0").c_str()).c_str());
|
||||
constexpr int ExitMediaTimeout = 3;
|
||||
constexpr int PrimeFrames = 8;
|
||||
constexpr int MaxFill = 25;
|
||||
|
|
@ -935,6 +977,8 @@ int main(int argc, char** argv) {
|
|||
std::uint64_t cng = 0;
|
||||
std::uint64_t late = 0;
|
||||
std::uint64_t qdrop = 0;
|
||||
std::uint64_t rxDtmf = 0; // telephone-event packets from the far end
|
||||
std::uint64_t tones = 0; // events of theirs rendered as a tone
|
||||
std::jthread playThread;
|
||||
if (decodeOn) {
|
||||
playThread = std::jthread([&] {
|
||||
|
|
@ -966,6 +1010,7 @@ int main(int argc, char** argv) {
|
|||
};
|
||||
std::vector<std::int16_t> zero(frameSamples, 0);
|
||||
std::optional<std::uint32_t> expect;
|
||||
std::optional<std::uint32_t> toneTs; // the event being rendered, by its start stamp
|
||||
for (;;) {
|
||||
PktItem item;
|
||||
{
|
||||
|
|
@ -975,6 +1020,55 @@ int main(int argc, char** argv) {
|
|||
item = std::move(playq.front());
|
||||
playq.pop_front();
|
||||
}
|
||||
if (item.event) {
|
||||
// The far end's key press, played as the tone it stands
|
||||
// for (§2.5.2.2 leaves rendering to the receiver). The
|
||||
// gap before the event's start — a stock sender's DTX
|
||||
// silence often precedes a key press (dev phone,
|
||||
// 2026-10-15: 0 to 7 slots) — is filled as before an
|
||||
// audio frame, so the tone starts where the event does.
|
||||
// Each packet reports the event's duration so far from
|
||||
// its start stamp; what the clock has not covered yet is
|
||||
// synthesized — a retransmitted end packet adds nothing,
|
||||
// a lost update is caught up by the next. A far end
|
||||
// whose audio resumes past the event (the stock phone's
|
||||
// does) meets the clock with the usual fill.
|
||||
if (item.payload.size() < 4) continue;
|
||||
const auto freqs = DtmfFrequencies(item.payload[0]);
|
||||
if (!freqs) continue;
|
||||
const std::uint32_t dur = (static_cast<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;
|
||||
if (amr) {
|
||||
for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) {
|
||||
|
|
@ -1135,7 +1229,14 @@ int main(int argc, char** argv) {
|
|||
std::fwrite(lb, 1, 4, dump);
|
||||
std::fwrite(buf, 1, static_cast<std::size_t>(n), dump);
|
||||
}
|
||||
if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) {
|
||||
// the far end's events arrive at the payload type our own SDP gave
|
||||
// telephone-event (DTMF_RX_PT), or at the one we send with when the
|
||||
// answerer numbers for itself — never at the audio's: a frame is
|
||||
// audio first
|
||||
const int rxPt = n >= 12 ? buf[1] & 0x7F : -1;
|
||||
const bool isEvent = n >= 12 && rxPt != pt && ((dtmfRxPt > 0 && rxPt == dtmfRxPt) || (dtmfPt > 0 && rxPt == dtmfPt));
|
||||
if (isEvent) rxDtmf++;
|
||||
if (playThread.joinable() && n >= 12 && (rxPt == pt || isEvent)) {
|
||||
std::size_t off = RtpPayloadOffset(std::span(buf, static_cast<std::size_t>(n)));
|
||||
std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) |
|
||||
(buf[5] << 16) | (buf[6] << 8) | buf[7];
|
||||
|
|
@ -1144,7 +1245,7 @@ int main(int argc, char** argv) {
|
|||
playq.pop_front();
|
||||
qdrop++;
|
||||
}
|
||||
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n)});
|
||||
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n), isEvent});
|
||||
qcv.notify_one();
|
||||
}
|
||||
}
|
||||
|
|
@ -1172,13 +1273,13 @@ int main(int argc, char** argv) {
|
|||
if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) {
|
||||
std::print(sf,
|
||||
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, "
|
||||
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
|
||||
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"mic\": {}, "
|
||||
"\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
|
||||
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, "
|
||||
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
|
||||
CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc,
|
||||
rIp, rPort, pt, dtmfPt, mic, play, amrMode, mediaEnded);
|
||||
CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc,
|
||||
rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded);
|
||||
std::fclose(sf);
|
||||
}
|
||||
std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded);
|
||||
std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} rx_dtmf={} tones={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, rxDtmf, tones, cng, late, qdrop, rxBytes, firstSrc, mediaEnded);
|
||||
return mediaEnded ? ExitMediaTimeout : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,15 @@ export namespace imsd::engine {
|
|||
// Parsed SDP answer needed to bring up the media leg. `codec` is one of
|
||||
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
|
||||
// `dtmfPt` is the telephone-event payload type at the codec's clock
|
||||
// (RFC 4733); nullopt = none negotiated, no DTMF on this call.
|
||||
// (RFC 4733) the leg sends with — the peer's SDP's; nullopt = none
|
||||
// negotiated, no DTMF on this call. `dtmfRxPt` is a second number the
|
||||
// leg takes events at: our own SDP's at that clock — our offer's
|
||||
// number on a call we placed (an answerer sends with the offer's
|
||||
// numbers, RFC 3264 §6.1, and may renumber ours in the answer), the
|
||||
// offer's we echoed on a call we answered; the answer's telephone-event
|
||||
// number instead when the answer gave the audio our offer's number,
|
||||
// which is then the leg's audio number. The leg takes events at
|
||||
// either number, never at the audio's.
|
||||
struct MediaLeg {
|
||||
std::string remoteIp;
|
||||
int remotePort = 0;
|
||||
|
|
@ -291,6 +299,7 @@ export namespace imsd::engine {
|
|||
bool octetAlign = false;
|
||||
std::string codec = "AMR-WB";
|
||||
std::optional<int> dtmfPt;
|
||||
std::optional<int> dtmfRxPt;
|
||||
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
|
||||
};
|
||||
|
||||
|
|
@ -831,6 +840,12 @@ export namespace imsd::engine {
|
|||
leg.octetAlign = ans.octetAlign;
|
||||
leg.codec = ans.codec;
|
||||
leg.dtmfPt = ans.dtmfPt;
|
||||
// the second number the leg takes events at: our offer's on a call
|
||||
// we placed — the answer's instead when the answer gave the audio
|
||||
// that very number, which is then the leg's audio number
|
||||
const int offered = imsd::sdp::OfferedTelephoneEventPt(imsd::sdp::CodecRate(leg.codec));
|
||||
leg.dtmfRxPt = !leg.dtmfPt ? std::nullopt
|
||||
: std::optional<int>(direction_ == Direction::Outgoing && offered != leg.payloadType ? offered : *leg.dtmfPt);
|
||||
return leg;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ export namespace imsd::sdp {
|
|||
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
|
||||
// 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a
|
||||
// call we place TO a landline no longer depends on the far side
|
||||
|
|
@ -109,8 +114,8 @@ export namespace imsd::sdp {
|
|||
s += "t=0 0\r\n";
|
||||
std::string pts;
|
||||
for (const auto& c : codecs) pts += std::format(" {}", pt(c));
|
||||
if (wb) pts += " 98";
|
||||
if (nb) pts += " 100";
|
||||
if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000));
|
||||
if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000));
|
||||
s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts);
|
||||
s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80);
|
||||
s += "b=RS:512\r\n";
|
||||
|
|
@ -127,12 +132,12 @@ export namespace imsd::sdp {
|
|||
}
|
||||
}
|
||||
if (wb) {
|
||||
s += "a=rtpmap:98 telephone-event/16000\r\n";
|
||||
s += "a=fmtp:98 0-15\r\n";
|
||||
s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000));
|
||||
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000));
|
||||
}
|
||||
if (nb) {
|
||||
s += "a=rtpmap:100 telephone-event/8000\r\n";
|
||||
s += "a=fmtp:100 0-15\r\n";
|
||||
s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000));
|
||||
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000));
|
||||
}
|
||||
s += "a=ptime:20\r\n";
|
||||
s += "a=maxptime:240\r\n";
|
||||
|
|
|
|||
|
|
@ -270,6 +270,7 @@ int main() {
|
|||
auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 98 100\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:98 telephone-event/16000\r\na=rtpmap:100 telephone-event/8000\r\n", SdpHead)));
|
||||
const Action* media = Find(a200, Action::Type::StartMedia);
|
||||
Check(media && media->media.dtmfPt == 98, "the leg carries the answer's telephone-event pt at the codec's clock (98, not the narrowband 100)");
|
||||
Check(media && media->media.dtmfRxPt == 98, "outgoing: the far end sends at our offer's number for that clock");
|
||||
Check(m.State() == CallState::Active, "active");
|
||||
auto sent = m.OnDtmf("12#*a");
|
||||
const Action* dt = Find(sent, Action::Type::SendDtmf);
|
||||
|
|
@ -292,6 +293,35 @@ int main() {
|
|||
Check(!Has(late, Action::Type::SendDtmf) && Find(late, Action::Type::Log) && Find(late, Action::Type::Log)->text.ends_with("the call is terminated"), "DTMF after the call: dropped, the line names the state");
|
||||
}
|
||||
|
||||
// ---- DTMF, outgoing, the answer renumbered telephone-event (RFC 3264
|
||||
// allows it): we send with its number, the far end with our offer's
|
||||
{
|
||||
imsd::util::Rng rng(44);
|
||||
CallMachine m(c, rng, "ims-call-1e", "1233", 50004, false);
|
||||
m.Start();
|
||||
auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 101\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:101 telephone-event/16000\r\n", SdpHead)));
|
||||
const Action* media = Find(a200, Action::Type::StartMedia);
|
||||
Check(media && media->media.dtmfPt == 101 && media->media.dtmfRxPt == 98, "renumbered answer: send with 101, listen on our 98");
|
||||
// a narrowband answer: AMR at 99 with telephone-event/8000 at 102
|
||||
imsd::util::Rng rng2(45);
|
||||
CallMachine n(c, rng2, "ims-call-1f", "1233", 50004, false);
|
||||
n.Start();
|
||||
auto b200 = n.OnInviteResponse(R200Sdp(n.CallId(), std::format("{}m=audio 6004 RTP/AVP 99 102\r\na=rtpmap:99 AMR/8000/1\r\na=rtpmap:102 telephone-event/8000\r\n", SdpHead)));
|
||||
const Action* nbMedia = Find(b200, Action::Type::StartMedia);
|
||||
Check(nbMedia && nbMedia->media.codec == "AMR" && nbMedia->media.dtmfPt == 102 && nbMedia->media.dtmfRxPt == 100, "narrowband answer: send with its 102, listen on our offer's 100");
|
||||
// an answer numbering for itself that gives the audio our offer's
|
||||
// telephone-event number: the receive number is the answer's, never
|
||||
// the audio's (every downlink frame would be taken for an event)
|
||||
imsd::util::Rng rng3(46);
|
||||
CallMachine o(c, rng3, "ims-call-1g", "1233", 50004, false);
|
||||
o.Start();
|
||||
auto c200 = o.OnInviteResponse(R200Sdp(o.CallId(), std::format("{}m=audio 6004 RTP/AVP 100 101\r\na=rtpmap:100 AMR/8000/1\r\na=rtpmap:101 telephone-event/8000\r\n", SdpHead)));
|
||||
const Action* colMedia = Find(c200, Action::Type::StartMedia);
|
||||
Check(colMedia && colMedia->media.payloadType == 100 && colMedia->media.dtmfPt == 101 && colMedia->media.dtmfRxPt == 101, "the audio at our telephone-event number: listen on the answer's 101, not the audio's 100");
|
||||
auto again = m.OnInviteResponse(R200(m.CallId()));
|
||||
Check(!Find(again, Action::Type::StartMedia), "a retransmitted 200 starts no second leg");
|
||||
}
|
||||
|
||||
// ---- remote BYE mid-call
|
||||
{
|
||||
imsd::util::Rng rng(7);
|
||||
|
|
@ -432,6 +462,7 @@ int main() {
|
|||
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
|
||||
const Action* mtMedia = Find(aa, Action::Type::StartMedia);
|
||||
Check(mtMedia && mtMedia->media.dtmfPt == 105, "incoming: the leg carries the offer's telephone-event pt");
|
||||
Check(mtMedia && mtMedia->media.dtmfRxPt == 105, "incoming: the far end sends at the number we echoed");
|
||||
auto mtDtmf = m.OnDtmf("9");
|
||||
const Action* mtDt = Find(mtDtmf, Action::Type::SendDtmf);
|
||||
Check(mtDt && mtDt->text == "9", "incoming, accepted: the tones go to the leg");
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ int main() {
|
|||
"a=rtpmap:104 AMR-WB/16000/1\r\n"
|
||||
"a=rtpmap:105 telephone-event/16000\r\n"
|
||||
"a=rtpmap:106 telephone-event/8000\r\n";
|
||||
Check(OfferedTelephoneEventPt(16000) == 98 && OfferedTelephoneEventPt(8000) == 100, "our offer's telephone-event numbers per clock");
|
||||
Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate");
|
||||
Check(!TelephoneEventPt(offer, 8000), "an rtpmap the m-line does not list (106 among 104 105) is not negotiated");
|
||||
Check(ParseAnswer(offer).dtmfPt == 105, "the answer's dtmfPt is the one at the chosen codec's clock (AMR-WB: 16000)");
|
||||
|
|
|
|||
Loading…
Reference in a new issue