diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 783fa74..9db1013 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,10 +14,9 @@ name: package # Release gating is the version: pkgver comes from implementations/main.cpp, # the registry answers 409 for an already-published version, and the publish # step treats that as "nothing to do" — so pushes only release when the -# Version constant bumps. This is the only producer of the imsd apk: fp6-img -# images install the published package from the registry (pinned version, -# pinned sha256) instead of building their own, so image and 'apk upgrade' -# carry the same binary. +# Version constant bumps. fp6-img images keep building imsd from their own +# pinned checkout of this repo (packaging/aport/); APKBUILD.binary's payload +# must stay identical to that aport's, so the two pipelines cannot skew. # # Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an # org-level secret on Catcrafts since 2026-09-02, shared with fp6-img; diff --git a/implementations/main.cpp b/implementations/main.cpp index 43b77f1..0ee64c4 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1201,32 +1201,16 @@ private: call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_); callReply_ = reply_; Log(std::format("incoming call {} from {}", uni, call_->Number())); - // Validate the offer BEFORE the UI hears of the call. A refused - // INVITE (488: nothing we can play) is answered and dropped without - // ever announcing it — announcing first produced a 24 ms - // added/terminated/deleted burst that left Plasma Dialer stuck as a - // lock-screen overlay with no call to show (field report 2026-09-01). - // The journal keeps the record: caller and reason are logged above - // and by the engine's teardown line. - auto actions = call_->OnInvite(); - if (call_->Terminated()) { - Execute(actions, /*announce=*/false); - MaybeDropCall(); - return; - } CallInfo info{uni, call_->Number(), "incoming", "incoming", NowEpoch(), 0, "incoming"}; PostAdded(info); - Execute(actions); + Execute(call_->OnInvite()); MaybeDropCall(); } // ---- action execution ------------------------------------------------- // Returns false if a client-flow send failed (used to fail a dial's INVITE). - // `announce` false suppresses the D-Bus state/deleted events — for a call - // that was never announced (refused before ringing), so the bus never - // sees a call it cannot show. - bool Execute(const std::vector& actions, bool announce = true) { + bool Execute(const std::vector& actions) { using T = imsd::engine::Action::Type; bool sendOk = true; for (const auto& a : actions) { @@ -1240,13 +1224,8 @@ private: break; case T::StartMedia: StartMedia(a.media); break; case T::StopMedia: StopMedia(); break; - case T::State: - if (announce) PostState(call_->Uni(), a.state, a.reason); - break; - case T::Deleted: - if (announce) PostDeleted(call_->Uni()); - dropCall_ = true; - break; + case T::State: PostState(call_->Uni(), a.state, a.reason); break; + case T::Deleted: PostDeleted(call_->Uni()); dropCall_ = true; break; case T::SetDeadline: deadline_ = Mono() + a.seconds; break; case T::Log: Log(a.text); break; } @@ -1278,7 +1257,6 @@ private: setenv("AMR_MODE", EnvOr("AMR_MODE", "2").c_str(), 1); setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); - setenv("CODEC", leg.codec.c_str(), 1); std::vector c; for (auto& s : argv) c.push_back(const_cast(s.c_str())); c.push_back(nullptr); diff --git a/implementations/media.cpp b/implementations/media.cpp index 1dffd0a..14bf2e3 100644 --- a/implementations/media.cpp +++ b/implementations/media.cpp @@ -3,42 +3,28 @@ // lint-disable-file fixed-width-types no-char-pointer /* -imsd-media — the RTP media leg for a userspace VoLTE call, spawned as its -own process by the daemon (imsd) exactly as the Python prototype spawned +imsd-media — the RTP/AMR-WB media leg for a userspace VoLTE call, spawned as +its own process by the daemon (imsd) exactly as the Python prototype spawned rtpcap.py: one media leg per call, argv-configured, torn down on SIGTERM or when the downlink dries up. Keeping it a separate process preserves the far-end-hangup contract (exit code 3 = downlink RTP stopped, which on carriers whose network BYE never reaches our SAs is the reliable teardown trigger) and isolates a media crash from the control-plane daemon. -Codecs (CODEC env, set by the daemon from the negotiated SDP): AMR-WB -(16 kHz; the mobile-to-mobile VoLTE codec, the default), AMR narrowband and -G.711 PCMA/PCMU (8 kHz; what a PSTN gateway offers when a landline calls). -AMR frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient -(OCTET_ALIGN, mirrored from the SDP); G.711 is raw samples. The AMR codecs -are dlopen'd (libvo-amrwbenc + libopencore-amrwb for WB, libopencore-amrnb -for NB) so the binary has no link-time dependency on them; G.711 is a table. - -Binds the advertised local RTP port, sends uplink frames toward the media -gateway, captures the downlink, and — with PLAY=1 — reconstructs the media -clock from RTP timestamps so pw-play stays real-time-paced through far-end -DTX silence (every missing 20 ms slot is decoded as a NO_DATA frame for -CNG/PLC; zeros for G.711). MIC=1 feeds live pw-record audio through the -encoder. +Binds the advertised local RTP port, sends uplink octet-aligned AMR-WB frames +toward the media gateway, captures the downlink, and — with PLAY=1 — +reconstructs the media clock from RTP timestamps so pw-play stays real-time- +paced through far-end DTX silence (every missing 20 ms slot is decoded as an +FT-15 NO_DATA frame for CNG/PLC). MIC=1 feeds live pw-record audio through +libvo-amrwbenc; downlink decode is libopencore-amrwb. Both codecs are dlopen'd +so the binary has no link-time dependency on them. Usage: imsd-media - imsd-media --selftest (payload pack/depay roundtrip, both formats, - both AMR codecs; G.711 table roundtrip) -Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP -OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP + imsd-media --selftest (payload pack/depay roundtrip, both formats) +Env: MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP OCTET_ALIGN +AUDIO_USER (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 -for AMR-WB (12.65 kbit/s), 7 for AMR (12.2 kbit/s). MIC_SRC= feeds -raw s16 PCM at the codec rate through the encoder instead of pw-record, -paced in real time; PCM_DUMP=1 writes the decoded downlink to .pcm -— both are test seams for driving the leg against a synthetic RTP peer -with no PipeWire and no network.) +daemon sets it from the negotiated SDP — 1 is the default/legacy behavior.) */ #include @@ -60,125 +46,28 @@ double Now() { return std::chrono::duration(Clock::now().time_since_epoch()).count(); } -enum class Codec { AmrWb, AmrNb, Pcma, Pcmu }; - -Codec ParseCodec(std::string_view name) { - if (name == "AMR") return Codec::AmrNb; - if (name == "PCMA") return Codec::Pcma; - if (name == "PCMU") return Codec::Pcmu; - return Codec::AmrWb; -} -std::string_view CodecName(Codec c) { - switch (c) { - case Codec::AmrWb: return "AMR-WB"; - case Codec::AmrNb: return "AMR"; - case Codec::Pcma: return "PCMA"; - case Codec::Pcmu: return "PCMU"; - } - return "AMR-WB"; -} -bool IsAmr(Codec c) { return c == Codec::AmrWb || c == Codec::AmrNb; } -// Sample rate = RTP clock rate; one 20 ms frame is 320 or 160 samples. -int CodecRate(Codec c) { return c == Codec::AmrWb ? 16000 : 8000; } -int FrameSamples(Codec c) { return CodecRate(c) / 50; } - -// AMR speech bits per frame type — the exact payload bit counts of the -// bandwidth-efficient format. AMR-WB: RFC 4867 table 2 / TS 26.201; AMR: -// RFC 4867 table 1 / TS 26.101 (FT 8 = SID, 9-11 = the other systems' SID -// frames a gateway may forward). FT 14/15 (SPEECH_LOST/NO_DATA) carry 0 -// bits; unknown FTs return -1 so a corrupt ToC aborts the packet instead of -// shifting every later bit. -constexpr int AmrBits(Codec c, int ft) { - if (c == Codec::AmrWb) { - switch (ft) { - case 0: return 132; case 1: return 177; case 2: return 253; case 3: return 285; - case 4: return 317; case 5: return 365; case 6: return 397; case 7: return 461; - case 8: return 477; case 9: return 40; case 14: return 0; case 15: return 0; - default: return -1; - } - } +// octet-aligned AMR-WB speech-frame byte sizes by frame type (mode). +constexpr int AmrwbBytes(int ft) { switch (ft) { - case 0: return 95; case 1: return 103; case 2: return 118; case 3: return 134; - case 4: return 148; case 5: return 159; case 6: return 204; case 7: return 244; - case 8: return 39; case 9: return 43; case 10: return 38; case 11: return 37; - case 15: return 0; + case 0: return 17; case 1: return 23; case 2: return 32; case 3: return 36; + case 4: return 40; case 5: return 46; case 6: return 50; case 7: return 58; + case 8: return 60; case 9: return 5; default: return 0; + } +} + +// AMR-WB speech bits per frame type (RFC 4867 table 2 / TS 26.201) — the +// exact payload bit counts of the bandwidth-efficient format. FT 14/15 +// (SPEECH_LOST/NO_DATA) carry 0 bits; unknown FTs return -1 so a corrupt +// ToC aborts the packet instead of shifting every later bit. +constexpr int AmrwbBits(int ft) { + switch (ft) { + case 0: return 132; case 1: return 177; case 2: return 253; case 3: return 285; + case 4: return 317; case 5: return 365; case 6: return 397; case 7: return 461; + case 8: return 477; case 9: return 40; case 14: return 0; case 15: return 0; default: return -1; } } -// octet-aligned speech-frame byte size by frame type: the bits rounded up -// (AMR-WB: 17 23 32 36 40 46 50 58 60, SID 5; AMR: 12 13 15 17 19 20 26 31, -// SID 5). 0 for frame types that carry no speech. -constexpr int AmrBytes(Codec c, int ft) { - int bits = AmrBits(c, ft); - return bits <= 0 ? 0 : (bits + 7) / 8; -} - -// ---- G.711 (ITU-T, the classic Sun g711.c formulation) -------------------- -// 16-bit linear <-> 8-bit companded. Byte 0xD5 (A-law) / 0xFF (mu-law) is -// digital zero. Idempotent: encoding a decoded sample gives the same byte. - -constexpr int UlawBias = 0x84; -constexpr int UlawClip = 8159; - -int SegmentOf(int val, std::span ends) { - for (std::size_t i = 0; i < ends.size(); i++) - if (val <= ends[i]) return static_cast(i); - return static_cast(ends.size()); -} - -std::uint8_t LinearToAlaw(std::int16_t pcm) { - static constexpr std::array ends = {0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF}; - int val = pcm >> 3; // 13-bit magnitude space - int mask = 0xD5; - if (val < 0) { - mask = 0x55; - val = -val - 1; - } - int seg = SegmentOf(val, ends); - if (seg >= 8) return static_cast(0x7F ^ mask); - int aval = seg << 4; - aval |= seg < 2 ? (val >> 1) & 0x0F : (val >> seg) & 0x0F; - return static_cast(aval ^ mask); -} - -std::int16_t AlawToLinear(std::uint8_t a) { - a ^= 0x55; - int t = (a & 0x0F) << 4; - int seg = (a & 0x70) >> 4; - if (seg == 0) t += 8; - else if (seg == 1) t += 0x108; - else t = (t + 0x108) << (seg - 1); - return static_cast((a & 0x80) ? t : -t); -} - -std::uint8_t LinearToUlaw(std::int16_t pcm) { - static constexpr std::array ends = {0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF}; - int val = pcm >> 2; // 14-bit magnitude space - int mask = 0xFF; - if (val < 0) { - val = -val; - mask = 0x7F; - } - if (val > UlawClip) val = UlawClip; - val += UlawBias >> 2; - int seg = SegmentOf(val, ends); - if (seg >= 8) return static_cast(0x7F ^ mask); - int uval = (seg << 4) | ((val >> (seg + 1)) & 0x0F); - return static_cast(uval ^ mask); -} - -std::int16_t UlawToLinear(std::uint8_t u) { - u = static_cast(~u); - int t = ((u & 0x0F) << 3) + UlawBias; - t <<= (u & 0x70) >> 4; - return static_cast((u & 0x80) ? (UlawBias - t) : (t - UlawBias)); -} - -std::uint8_t G711Encode(Codec c, std::int16_t pcm) { return c == Codec::Pcma ? LinearToAlaw(pcm) : LinearToUlaw(pcm); } -std::int16_t G711Decode(Codec c, std::uint8_t b) { return c == Codec::Pcma ? AlawToLinear(b) : UlawToLinear(b); } -std::uint8_t G711Zero(Codec c) { return c == Codec::Pcma ? 0xD5 : 0xFF; } - std::string EnvOr(const char* k, const char* d) { const char* v = std::getenv(k); return v ? std::string(v) : std::string(d); @@ -208,99 +97,62 @@ socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) { return sizeof(sockaddr_in); } -// ---- AMR codecs via dlopen ------------------------------------------------ -// AMR-WB: vo-amrwbenc E_IF_* (encode), opencore-amrwb D_IF_* (decode). -// AMR: opencore-amrnb Encoder_Interface_* / Decoder_Interface_*. -// Both families speak the RFC 4867 §5.3 storage format: [header byte][speech], -// header = (FT << 3) | (Q << 2) — exactly the octet-aligned ToC byte with F=0. +// ---- AMR-WB codecs via dlopen (vo-amrwbenc E_IF_*, opencore-amrwb D_IF_*) -- class Encoder { public: - bool Open(Codec c, int dtx) { - codec_ = c; - if (c == Codec::AmrWb) { - lib_ = dlopen("libvo-amrwbenc.so.0", RTLD_NOW); - if (!lib_) return false; - wbInit_ = reinterpret_cast(dlsym(lib_, "E_IF_init")); - wbEnc_ = reinterpret_cast(dlsym(lib_, "E_IF_encode")); - exit_ = reinterpret_cast(dlsym(lib_, "E_IF_exit")); - if (!wbInit_ || !wbEnc_ || !exit_) return false; - st_ = wbInit_(); - return st_ != nullptr; - } - lib_ = dlopen("libopencore-amrnb.so.0", RTLD_NOW); + bool Open() { + lib_ = dlopen("libvo-amrwbenc.so.0", RTLD_NOW); if (!lib_) return false; - nbInit_ = reinterpret_cast(dlsym(lib_, "Encoder_Interface_init")); - nbEnc_ = reinterpret_cast(dlsym(lib_, "Encoder_Interface_Encode")); - exit_ = reinterpret_cast(dlsym(lib_, "Encoder_Interface_exit")); - if (!nbInit_ || !nbEnc_ || !exit_) return false; - st_ = nbInit_(dtx); // NB: DTX is an init-time choice + init_ = reinterpret_cast(dlsym(lib_, "E_IF_init")); + enc_ = reinterpret_cast(dlsym(lib_, "E_IF_encode")); + exit_ = reinterpret_cast(dlsym(lib_, "E_IF_exit")); + if (!init_ || !enc_ || !exit_) return false; + st_ = init_(); return st_ != nullptr; } - // one 20 ms frame of s16 samples -> one storage frame (header byte + speech). + // 320 int16 samples -> one RFC 3267 storage frame (header byte + speech). std::vector Encode(std::int16_t* samples, int mode, int dtx) { std::uint8_t out[128]; - int n = codec_ == Codec::AmrWb - ? wbEnc_(st_, static_cast(mode), samples, out, static_cast(dtx)) - : nbEnc_(st_, mode, samples, out, 0); + int n = enc_(st_, static_cast(mode), samples, out, static_cast(dtx)); if (n <= 0) return {}; return std::vector(out, out + n); } - ~Encoder() { - if (st_ && exit_) exit_(st_); - if (lib_) dlclose(lib_); - } + ~Encoder() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); } private: - using WbInitFn = void* (*)(); - using WbEncFn = int (*)(void*, std::int16_t, std::int16_t*, std::uint8_t*, std::int16_t); - using NbInitFn = void* (*)(int); - using NbEncFn = int (*)(void*, int, const std::int16_t*, std::uint8_t*, int); + using InitFn = void* (*)(); + using EncFn = int (*)(void*, std::int16_t, std::int16_t*, std::uint8_t*, std::int16_t); using ExitFn = void (*)(void*); - Codec codec_ = Codec::AmrWb; - void* lib_ = nullptr; - void* st_ = nullptr; - WbInitFn wbInit_ = nullptr; - WbEncFn wbEnc_ = nullptr; - NbInitFn nbInit_ = nullptr; - NbEncFn nbEnc_ = nullptr; - ExitFn exit_ = nullptr; + void* lib_ = nullptr; void* st_ = nullptr; + InitFn init_ = nullptr; EncFn enc_ = nullptr; ExitFn exit_ = nullptr; }; class Decoder { public: - bool Open(Codec c) { - codec_ = c; - bool wb = c == Codec::AmrWb; - lib_ = dlopen(wb ? "libopencore-amrwb.so.0" : "libopencore-amrnb.so.0", RTLD_NOW); + bool Open() { + lib_ = dlopen("libopencore-amrwb.so.0", RTLD_NOW); if (!lib_) return false; - init_ = reinterpret_cast(dlsym(lib_, wb ? "D_IF_init" : "Decoder_Interface_init")); - dec_ = reinterpret_cast(dlsym(lib_, wb ? "D_IF_decode" : "Decoder_Interface_Decode")); - exit_ = reinterpret_cast(dlsym(lib_, wb ? "D_IF_exit" : "Decoder_Interface_exit")); + init_ = reinterpret_cast(dlsym(lib_, "D_IF_init")); + dec_ = reinterpret_cast(dlsym(lib_, "D_IF_decode")); + exit_ = reinterpret_cast(dlsym(lib_, "D_IF_exit")); if (!init_ || !dec_ || !exit_) return false; st_ = init_(); return st_ != nullptr; } - // one storage frame ([header][speech]) -> one 20 ms frame of s16 PCM. - std::vector Decode(const std::uint8_t* frame, int len) { - std::vector out(static_cast(FrameSamples(codec_)), 0); + // one storage frame ([header][speech]) -> 640 bytes of 16 kHz s16 PCM. + std::array Decode(const std::uint8_t* frame, int len) { + std::array out{}; std::vector in(frame, frame + len); dec_(st_, in.data(), out.data(), 0); return out; } - ~Decoder() { - if (st_ && exit_) exit_(st_); - if (lib_) dlclose(lib_); - } + ~Decoder() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); } private: using InitFn = void* (*)(); - using DecFn = void (*)(void*, const std::uint8_t*, std::int16_t*, int); + using DecFn = void (*)(void*, std::uint8_t*, std::int16_t*, int); using ExitFn = void (*)(void*); - Codec codec_ = Codec::AmrWb; - void* lib_ = nullptr; - void* st_ = nullptr; - InitFn init_ = nullptr; - DecFn dec_ = nullptr; - ExitFn exit_ = nullptr; + void* lib_ = nullptr; void* st_ = nullptr; + InitFn init_ = nullptr; DecFn dec_ = nullptr; ExitFn exit_ = nullptr; }; // MSB-first bit cursor over an RTP payload (bandwidth-efficient AMR-WB is @@ -318,10 +170,10 @@ struct BitReader { } }; -// bandwidth-efficient AMR de-payload. Same contract as DepayOctet: +// bandwidth-efficient AMR-WB de-payload. Same contract as DepayOctet: // [(storage-header-byte, speech-bytes)...], speech re-aligned to octets. std::vector>> -DepayBe(Codec c, std::span pl) { +DepayBe(std::span pl) { std::vector>> out; BitReader br{pl}; if (!br.Ok(4)) return out; @@ -337,9 +189,9 @@ DepayBe(Codec c, std::span pl) { if (!f) break; } for (auto [ft, q] : tocs) { - int bits = AmrBits(c, ft); + int bits = AmrwbBits(ft); if (bits < 0 || !br.Ok(static_cast(bits))) break; - std::vector speech(AmrBytes(c, ft), 0); + std::vector speech(AmrwbBytes(ft), 0); for (int i = 0; i < bits; i++) if (br.Take(1)) speech[i >> 3] |= 0x80 >> (i & 7); out.emplace_back(static_cast((ft << 3) | (q ? 0x04 : 0)), std::move(speech)); @@ -350,8 +202,8 @@ DepayBe(Codec c, std::span pl) { // storage-format frame (header byte + octet-aligned speech) -> RTP payload. // Octet-aligned: CMR byte + the frame verbatim (the storage header doubles // as a ToC byte with F=0). Bandwidth-efficient: 10 header bits + exactly -// AmrBits(ft) speech bits, final octet zero-padded. -std::vector PayloadFromFrame(Codec c, std::span frame, bool octetAlign) { +// AmrwbBits(ft) speech bits, final octet zero-padded. +std::vector PayloadFromFrame(std::span frame, bool octetAlign) { if (octetAlign) { std::vector pl = {0xF0}; pl.insert(pl.end(), frame.begin(), frame.end()); @@ -359,7 +211,7 @@ std::vector PayloadFromFrame(Codec c, std::span> 3) & 0x0F; int q = (frame[0] >> 2) & 1; - int bits = AmrBits(c, ft); + int bits = AmrwbBits(ft); if (bits < 0) bits = 0; std::vector pl((10 + bits + 7) / 8, 0); auto put = [&](int pos, int n, std::uint32_t v) { @@ -375,10 +227,10 @@ std::vector PayloadFromFrame(Codec c, std::span>> -DepayOctet(Codec c, std::span pl) { +DepayOctet(std::span pl) { std::vector>> out; if (pl.empty()) return out; std::size_t i = 1; // skip CMR @@ -390,7 +242,7 @@ DepayOctet(Codec c, std::span pl) { } for (std::uint8_t toc : tocs) { int ft = (toc >> 3) & 0x0F; - int n = AmrBytes(c, ft); + int n = AmrwbBytes(ft); std::vector speech; if (n > 0 && i + n <= pl.size()) speech.assign(pl.begin() + i, pl.begin() + i + n); out.emplace_back(static_cast(toc & 0x7C), std::move(speech)); @@ -415,7 +267,7 @@ std::size_t RtpPayloadOffset(std::span pkt) { // is postmarketOS's standard account. `toChild` true = we write the child's // stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}. struct Child { pid_t pid = -1; int fd = -1; }; -Child SpawnPw(bool play, bool toChild, int rate) { +Child SpawnPw(bool play, bool toChild) { int pipefd[2]; if (pipe(pipefd) != 0) return {}; std::vector argv; @@ -428,8 +280,7 @@ Child SpawnPw(bool play, bool toChild, int rate) { } const char* tool = play ? "pw-play" : "pw-record"; const char* lat = play ? "40ms" : "20ms"; - std::string rateStr = std::to_string(rate); - for (const char* a : {tool, "--raw", "--rate", rateStr.c_str(), "--channels", "1", "--format", "s16", "--latency", lat, "-"}) + for (const char* a : {tool, "--raw", "--rate", "16000", "--channels", "1", "--format", "s16", "--latency", lat, "-"}) argv.emplace_back(a); pid_t pid = fork(); if (pid == 0) { @@ -464,7 +315,6 @@ bool ReadExact(int fd, std::uint8_t* buf, std::size_t n) { struct TxState { std::mutex lock; int pt = 0; - std::uint32_t tsStep = 320; // samples per 20 ms frame at the codec's clock std::uint32_t ssrc = 0x5EED1234; std::uint32_t seq = 1000; std::uint32_t ts = 160000; @@ -488,7 +338,7 @@ void RtpSend(int sock, TxState& st, std::span payload) { hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF; hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF; st.seq = (st.seq + 1) & 0xFFFF; - st.ts = (st.ts + st.tsStep) & 0xFFFFFFFF; + st.ts = (st.ts + 320) & 0xFFFFFFFF; a1 = st.dst; l1 = st.dstLen; a2 = st.latched; l2 = st.latchedLen; } std::vector pkt(hdr, hdr + 12); @@ -505,34 +355,15 @@ void RtpSend(int sock, TxState& st, std::span payload) { } std::vector>> -Depay(Codec c, std::span pl, bool octetAlign) { - return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl); +Depay(std::span pl, bool octetAlign) { + return octetAlign ? DepayOctet(pl) : DepayBe(pl); } -// What goes out every 20 ms while the mic is not feeding frames: for AMR a -// storage frame ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode; for -// G.711 one frame of digital zero. -std::vector SilenceFrame(Codec c, int ft, bool octetAlign) { - if (!IsAmr(c)) return std::vector(static_cast(FrameSamples(c)), G711Zero(c)); +std::vector SilenceFrame(int ft, bool octetAlign) { + // storage frame: ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode. std::vector f = {static_cast((ft << 3) | 0x04)}; - f.resize(1 + static_cast(AmrBytes(c, ft)), 0); - return PayloadFromFrame(c, f, octetAlign); -} - -// G.711 uplink: one 20 ms frame of samples -> one payload of companded bytes. -std::vector G711Payload(Codec c, std::span pcm) { - std::vector pl(pcm.size()); - for (std::size_t i = 0; i < pcm.size(); i++) - pl[i] = G711Encode(c, pcm[i]); - return pl; -} - -// G.711 downlink: one frame's worth of companded bytes -> PCM. -std::vector G711DecodeFrame(Codec c, std::span bytes) { - std::vector pcm(bytes.size()); - for (std::size_t i = 0; i < bytes.size(); i++) - pcm[i] = G711Decode(c, bytes[i]); - return pcm; + f.resize(1 + AmrwbBytes(ft), 0); + return PayloadFromFrame(f, octetAlign); } std::atomic Quit{false}; @@ -541,64 +372,31 @@ void OnSig(int) { Quit.store(true); } // playout queue item struct PktItem { std::uint32_t ts; std::vector payload; }; -// --selftest: pack->depay roundtrip of every frame type, both payload -// formats, both AMR codecs; G.711 digital zero, idempotence over the whole -// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR. -int SelfTest() { - for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { - for (int ft = 0; ft < 16; ft++) { - int bits = AmrBits(c, ft); - if (bits <= 0) continue; - // BE carries exactly AmrBits(ft) bits, so the pattern's padding - // bits in the last speech byte must be zero for equality to hold. - std::vector frame = {static_cast((ft << 3) | 0x04)}; - for (int i = 0; i < AmrBytes(c, ft); i++) +} // namespace + +int main(int argc, char** argv) { + if (argc == 2 && std::string_view(argv[1]) == "--selftest") { + // pack->depay roundtrip of every frame type, both payload formats. + // BE carries exactly AmrwbBits(ft) bits, so the pattern's padding + // bits in the last speech byte must be zero for equality to hold. + for (int ft : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) { + std::vector frame = { + static_cast((ft << 3) | 0x04)}; + int bits = AmrwbBits(ft); + for (int i = 0; i < AmrwbBytes(ft); i++) frame.push_back(static_cast(0xA5 + i * 31)); if (bits % 8) frame.back() &= static_cast(0xFF << (8 - bits % 8)); for (bool oa : {true, false}) { - auto got = Depay(c, PayloadFromFrame(c, frame, oa), oa); + auto got = Depay(PayloadFromFrame(frame, oa), oa); if (got.size() != 1 || got[0].first != frame[0] || !std::equal(got[0].second.begin(), got[0].second.end(), frame.begin() + 1, frame.end())) { - std::println(std::cerr, "selftest FAIL {} ft={} oa={}", CodecName(c), ft, oa); + std::println(std::cerr, "selftest FAIL ft={} oa={}", ft, oa); return 1; } } } + std::println("selftest OK"); + return 0; } - if (LinearToAlaw(0) != 0xD5 || LinearToUlaw(0) != 0xFF) { - std::println(std::cerr, "selftest FAIL G.711 digital zero"); - return 1; - } - for (Codec c : {Codec::Pcma, Codec::Pcmu}) { - for (int v = -32768; v <= 32767; v++) { - std::uint8_t b = G711Encode(c, static_cast(v)); - if (G711Encode(c, G711Decode(c, b)) != b) { - std::println(std::cerr, "selftest FAIL {} not idempotent at {}", CodecName(c), v); - return 1; - } - } - double sig = 0; - double err = 0; - for (int i = 0; i < 8000; i++) { - double x = 10000.0 * std::sin(2 * std::numbers::pi * 1000.0 * i / 8000.0); - auto sample = static_cast(std::lround(x)); - std::int16_t d = G711Decode(c, G711Encode(c, sample)); - sig += x * x; - err += (x - d) * (x - d); - } - double snr = 10 * std::log10(sig / err); - if (snr < 30) { - std::println(std::cerr, "selftest FAIL {} sine SNR {:.1f} dB", CodecName(c), snr); - return 1; - } - } - std::println("selftest OK"); - return 0; -} - -} // namespace - -int main(int argc, char** argv) { - if (argc == 2 && std::string_view(argv[1]) == "--selftest") return SelfTest(); if (argc < 8) { std::println(std::cerr, "usage: imsd-media local rtp_port remote_ip remote_port pt secs out"); return 2; @@ -611,35 +409,25 @@ int main(int argc, char** argv) { double secs = std::atof(argv[6]); std::string out = argv[7]; - Codec codec = ParseCodec(EnvOr("CODEC", "AMR-WB")); - bool amr = IsAmr(codec); - int rate = CodecRate(codec); - auto frameSamples = static_cast(FrameSamples(codec)); bool mic = EnvBool("MIC", false); bool play = EnvBool("PLAY", false); - int amrMode = std::atoi(EnvOr("AMR_MODE", codec == Codec::AmrWb ? "2" : "7").c_str()); + int amrMode = std::atoi(EnvOr("AMR_MODE", "2").c_str()); double gain = std::atof(EnvOr("GAIN", "1.0").c_str()); double playGain = std::atof(EnvOr("PLAY_GAIN", "1.0").c_str()); int dtx = EnvBool("DTX", false) ? 1 : 0; bool octetAlign = EnvBool("OCTET_ALIGN", true); double mediaTimeout = std::atof(EnvOr("MEDIA_TIMEOUT", "6.0").c_str()); bool rtpDump = EnvBool("RTP_DUMP", false); - std::string micSrc = EnvOr("MIC_SRC", ""); - bool pcmDump = EnvBool("PCM_DUMP", false); constexpr int ExitMediaTimeout = 3; constexpr int PrimeFrames = 8; constexpr int MaxFill = 25; constexpr std::size_t PlayqMax = 256; int sock = socket(Is6(local) ? AF_INET6 : AF_INET, SOCK_DGRAM, 0); - if (sock < 0) { - std::println(std::cerr, "imsd-media: socket failed"); - return 1; - } + if (sock < 0) { std::println(std::cerr, "imsd-media: socket failed"); return 1; } int one = 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); - sockaddr_storage bindA; - socklen_t bindL = MakeAddr(local, rtpPort, bindA); + sockaddr_storage bindA; socklen_t bindL = MakeAddr(local, rtpPort, bindA); if (bind(sock, reinterpret_cast(&bindA), bindL) != 0) { std::println(std::cerr, "imsd-media: bind [{}]:{} failed", local, rtpPort); return 1; @@ -649,98 +437,63 @@ int main(int argc, char** argv) { TxState st; st.pt = pt; - st.tsStep = static_cast(frameSamples); st.dstLen = MakeAddr(rIp, rPort, st.dst); - st.latched = st.dst; - st.latchedLen = st.dstLen; + st.latched = st.dst; st.latchedLen = st.dstLen; signal(SIGTERM, OnSig); signal(SIGINT, OnSig); signal(SIGPIPE, SIG_IGN); - // ---- 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 + // ---- mic uplink thread std::atomic stop{false}; std::jthread micThread; if (mic) { micThread = std::jthread([&] { Encoder enc; - if (amr && !enc.Open(codec, dtx)) { - std::println(std::cerr, "imsd-media: mic: {} encoder unavailable; silence fallback", CodecName(codec)); + if (!enc.Open()) { + std::println(std::cerr, "imsd-media: mic: encoder unavailable; silence fallback"); return; } - int fd = -1; - pid_t pid = -1; - if (!micSrc.empty()) { - fd = open(micSrc.c_str(), O_RDONLY); - if (fd < 0) { - std::println(std::cerr, "imsd-media: mic: cannot open MIC_SRC {}", micSrc); - return; - } - } else { - Child rec = SpawnPw(false, /*toChild=*/false, rate); - if (rec.pid < 0) return; - fd = rec.fd; - pid = rec.pid; - } - std::vector raw(frameSamples * 2); - std::vector samples(frameSamples); - auto next = Clock::now(); + Child rec = SpawnPw(false, /*toChild=*/false); + if (rec.pid < 0) return; + std::uint8_t raw[640]; while (!stop.load()) { - if (!ReadExact(fd, raw.data(), raw.size())) { - std::println(std::cerr, "imsd-media: mic: {} EOF; silence fallback", pid > 0 ? "pw-record" : "MIC_SRC"); + if (!ReadExact(rec.fd, raw, 640)) { + std::println(std::cerr, "imsd-media: mic: pw-record EOF; silence fallback"); break; } - if (pid < 0) { - // a file delivers instantly; pace it like a microphone - next += std::chrono::milliseconds(20); - std::this_thread::sleep_until(next); - } - std::memcpy(samples.data(), raw.data(), raw.size()); + std::int16_t samples[320]; + std::memcpy(samples, raw, 640); if (gain != 1.0) - for (auto& sample : samples) { - int v = static_cast(sample * gain); - sample = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); + for (int i = 0; i < 320; i++) { + int v = static_cast(samples[i] * gain); + samples[i] = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); } - std::vector payload; - if (amr) { - std::vector frame = enc.Encode(samples.data(), amrMode, dtx); - if (frame.empty()) continue; - payload = PayloadFromFrame(codec, frame, octetAlign); - } else { - payload = G711Payload(codec, samples); - } + std::vector frame = enc.Encode(samples, amrMode, dtx); + if (frame.empty()) continue; st.micOn.store(true); { std::scoped_lock g(st.lock); st.txMic++; } - RtpSend(sock, st, payload); + RtpSend(sock, st, PayloadFromFrame(frame, octetAlign)); } st.micOn.store(false); - close(fd); - if (pid > 0) { - kill(pid, SIGKILL); - waitpid(pid, nullptr, 0); - } + close(rec.fd); + kill(rec.pid, SIGKILL); + waitpid(rec.pid, nullptr, 0); }); } - // ---- downlink playout (decode + RTP-timestamp clock reconstruction). - // Runs for pw-play (PLAY=1) and/or the PCM dump (PCM_DUMP=1). + // ---- downlink playout (decode + RTP-timestamp clock reconstruction) Decoder dec; Child playCh; bool playOn = false; - bool decodeOn = false; - if (play || pcmDump) { - if (!amr || dec.Open(codec)) { - if (play) { - playCh = SpawnPw(true, /*toChild=*/true, rate); - playOn = playCh.pid >= 0; - } - decodeOn = playOn || pcmDump; + if (play) { + if (dec.Open()) { + playCh = SpawnPw(true, /*toChild=*/true); + playOn = playCh.pid >= 0; } else { - std::println(std::cerr, "imsd-media: play: {} decoder unavailable; capture only", CodecName(codec)); + std::println(std::cerr, "imsd-media: play: decoder unavailable; capture only"); } } - std::FILE* pcmFile = decodeOn && pcmDump ? std::fopen((std::format("{}.pcm", out)).c_str(), "wb") : nullptr; std::mutex qlock; std::condition_variable qcv; std::deque playq; @@ -749,11 +502,9 @@ int main(int argc, char** argv) { std::uint64_t late = 0; std::uint64_t qdrop = 0; std::jthread playThread; - if (decodeOn) { + if (playOn) { playThread = std::jthread([&] { auto writePcm = [&](std::span pcm) { - if (pcmFile) std::fwrite(pcm.data(), 2, pcm.size(), pcmFile); - if (!playOn) return true; std::size_t bytes = pcm.size() * 2; const char* p = reinterpret_cast(pcm.data()); std::size_t off = 0; @@ -764,20 +515,14 @@ int main(int argc, char** argv) { } return true; }; - auto applyGain = [&](std::vector& pcm) { + auto applyGain = [&](std::array& pcm) { if (playGain == 1.0) return; - for (auto& sample : pcm) { - int v = static_cast(sample * playGain); - sample = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); + for (auto& s : pcm) { + int v = static_cast(s * playGain); + s = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); } }; - // Decoder state is time-ordered: the CNG fill for a gap must be - // decoded BEFORE the frames that follow the gap, so depay first, - // decode in playout order. - auto decodeFrame = [&](std::span f) { - return amr ? dec.Decode(f.data(), static_cast(f.size())) : G711DecodeFrame(codec, f); - }; - std::vector zero(frameSamples, 0); + std::array zero{}; std::optional expect; for (;;) { PktItem item; @@ -788,18 +533,7 @@ int main(int argc, char** argv) { item = std::move(playq.front()); playq.pop_front(); } - std::vector> frames; - if (amr) { - for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) { - std::vector f = {hdr}; - f.insert(f.end(), speech.begin(), speech.end()); - frames.push_back(std::move(f)); - } - } else { - // whole frames back to back (a gateway may pack 2 at ptime 40) - for (std::size_t off = 0; off + frameSamples <= item.payload.size(); off += frameSamples) - frames.emplace_back(item.payload.begin() + static_cast(off), item.payload.begin() + static_cast(off + frameSamples)); - } + auto frames = Depay(item.payload, octetAlign); if (frames.empty()) continue; int fill = 0; if (!expect) { @@ -807,39 +541,36 @@ int main(int argc, char** argv) { if (!writePcm(zero)) return; } else { std::uint32_t diff = (item.ts - *expect) & 0xFFFFFFFF; - if (diff >= 0x80000000u) { - late++; - continue; - } - fill = static_cast(diff / frameSamples); + if (diff >= 0x80000000u) { late++; continue; } + fill = static_cast(diff / 320); if (fill > MaxFill) fill = 0; } for (int i = 0; i < fill; i++) { cng++; std::uint8_t nodata = 0x7C; - std::vector pcm = amr ? dec.Decode(&nodata, 1) : zero; + auto pcm = dec.Decode(&nodata, 1); applyGain(pcm); if (!writePcm(pcm)) return; } - for (auto& f : frames) { + for (auto& [hdr, speech] : frames) { rxPlayed++; - std::vector pcm = decodeFrame(f); + std::vector f = {hdr}; + f.insert(f.end(), speech.begin(), speech.end()); + auto pcm = dec.Decode(f.data(), static_cast(f.size())); applyGain(pcm); if (!writePcm(pcm)) return; } - expect = (item.ts + static_cast(frameSamples * frames.size())) & 0xFFFFFFFF; + expect = (item.ts + 320 * static_cast(frames.size())) & 0xFFFFFFFF; } }); } // ---- main recv loop - std::vector silence = SilenceFrame(codec, 0, octetAlign); + std::vector silence = SilenceFrame(0, octetAlign); 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; - double t0 = Now(); - double lastTx = 0; - double lastRx = Now(); + double t0 = Now(), lastTx = 0, lastRx = Now(); std::uint64_t rx = 0; std::uint64_t rxBytes = 0; bool gotMedia = false; @@ -857,8 +588,7 @@ int main(int argc, char** argv) { lastTx = now; } std::uint8_t buf[65535]; - sockaddr_storage src; - socklen_t srcLen = sizeof src; + sockaddr_storage src; socklen_t srcLen = sizeof src; ssize_t n = recvfrom(sock, buf, sizeof buf, 0, reinterpret_cast(&src), &srcLen); if (n <= 0) continue; lastRx = Now(); @@ -896,10 +626,7 @@ int main(int argc, char** argv) { std::uint32_t pktTs = (static_cast(buf[4]) << 24) | (buf[5] << 16) | (buf[6] << 8) | buf[7]; std::scoped_lock g(qlock); - if (playq.size() >= PlayqMax) { - playq.pop_front(); - qdrop++; - } + if (playq.size() >= PlayqMax) { playq.pop_front(); qdrop++; } playq.push_back({pktTs, std::vector(buf + off, buf + n)}); qcv.notify_one(); } @@ -910,7 +637,6 @@ int main(int argc, char** argv) { if (micThread.joinable()) micThread.join(); if (playThread.joinable()) playThread.join(); if (dump) std::fclose(dump); - if (pcmFile) std::fclose(pcmFile); if (playOn) { close(playCh.fd); int status; @@ -926,14 +652,14 @@ int main(int argc, char** argv) { // .stats sidecar (tiny; always written) if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { std::print(sf, - "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " + "{{\"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " "\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, " "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", - CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, + st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, rIp, rPort, pt, mic, play, amrMode, mediaEnded); std::fclose(sf); } - std::println("imsd-media: codec={} tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); + std::println("imsd-media: tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); return mediaEnded ? ExitMediaTimeout : 0; } diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index ffa46f2..163e1b0 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -52,8 +52,7 @@ export namespace imsd::engine { return d == Direction::Incoming ? "incoming" : "outgoing"; } - // 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). + // Parsed SDP answer needed to bring up the media leg. struct MediaLeg { std::string remoteIp; int remotePort = 0; @@ -157,20 +156,16 @@ export namespace imsd::engine { // UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made // reliable only when the caller Requires 100rel) and arm the ring - // timeout. An offer we cannot serve — no audio line, or none of the - // codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU) — is refused - // with 488 up front, before the UI ever rings; the log line names - // what WAS offered, so a field journal answers "which codec did the - // gateway want" without a raw SIP dump. + // timeout. An offer we cannot serve — no audio line, or no AMR-WB + // (the media plane is AMR-WB-only) — is refused with 488 up front, + // before the UI ever rings. std::vector OnInvite() { std::vector a; if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a; MediaLeg leg = ParseAnswer(); - if (!leg.Valid() || leg.codec.empty()) { + if (!leg.Valid() || leg.codec != "AMR-WB") { a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag))); - std::string why = !leg.Valid() ? "no usable audio line" - : std::format("no playable codec, offered: {}", imsd::sdp::OfferedCodecs(answerSdp_)); - Append(a, Finish("error", std::format("incoming offer unusable ({}); 488 sent", why))); + Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent")); return a; } a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag))); @@ -201,7 +196,7 @@ export namespace imsd::engine { spec.payloadType = leg.payloadType; spec.octetAlign = leg.octetAlign; spec.codec = leg.codec; - spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec)); + spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), 16000); localSdp_ = imsd::sdp::BuildAnswer(spec); // session timers: echo the caller's Session-Expires; with no // refresher stated, make the caller (uac) the refresher — its diff --git a/interfaces/Imsd-Sdp.cppm b/interfaces/Imsd-Sdp.cppm index 471e294..ed99cd2 100644 --- a/interfaces/Imsd-Sdp.cppm +++ b/interfaces/Imsd-Sdp.cppm @@ -3,20 +3,17 @@ // lint-disable-file fixed-width-types /* -Imsd:Sdp — SDP offer/answer building + parsing for the voice media plane. +Imsd:Sdp — AMR-WB SDP offer/answer building + parsing. -The offer is a fixed AMR-WB (octet-aligned) + AMR + telephone-event shape -with an optional GSMA IR.92 QoS-precondition block; it is what commercial IMS -cores expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The +The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an +optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores +expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The parser extracts only what the media plane needs: remote address/port, the -negotiated payload type, the codec and octet-align mode — it reads answers -to our offers and inbound offers alike (same fields either way). The codecs -it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) and -G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a -landline calls. BuildAnswer is the terminating side: it answers an inbound -offer with the offer's own payload type numbers (RFC 3264) and mirrors its -octet-align mode (RFC 4867 requires both directions to use one mode). Pure -std C++. +negotiated payload type, and octet-align mode — it reads answers to our +offers and inbound offers alike (same fields either way). BuildAnswer is the +terminating side: it answers an inbound offer with the offer's own payload +type numbers (RFC 3264) and mirrors its octet-align mode (RFC 4867 requires +both directions to use one mode). Pure std C++. */ export module Imsd:Sdp; @@ -57,11 +54,7 @@ export namespace imsd::sdp { std::string currRemote = "none"; // a=curr:qos remote }; - // 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 - // transcoding up to wideband — each with telephone-event at its own - // clock. Byte layout is pinned by tests/Sdp. + // AMR-WB (octet-aligned) offer. Byte layout is pinned by tests/Sdp. std::string BuildOffer(const Offer& o) { std::string ipver = Is6(o.local) ? "IP6" : "IP4"; std::string s; @@ -70,18 +63,14 @@ export namespace imsd::sdp { s += "s=-\r\n"; s += std::format("c=IN {} {}\r\n", ipver, o.local); s += "t=0 0\r\n"; - s += std::format("m=audio {} RTP/AVP 97 99 98 100\r\n", o.rtpPort); + s += std::format("m=audio {} RTP/AVP 97 98\r\n", o.rtpPort); s += "b=AS:41\r\n"; s += "b=RS:512\r\n"; s += "b=RR:1536\r\n"; s += "a=rtpmap:97 AMR-WB/16000/1\r\n"; s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"; - s += "a=rtpmap:99 AMR/8000/1\r\n"; - s += "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"; s += "a=rtpmap:98 telephone-event/16000\r\n"; s += "a=fmtp:98 0-15\r\n"; - s += "a=rtpmap:100 telephone-event/8000\r\n"; - s += "a=fmtp:100 0-15\r\n"; s += "a=ptime:20\r\n"; s += "a=maxptime:240\r\n"; if (o.precond) { @@ -99,60 +88,18 @@ export namespace imsd::sdp { int port = -1; // -1 = no m=audio line found int payloadType = -1; bool octetAlign = false; - std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play) + std::string codec = "AMR-WB"; }; - // The encoding name an offer gives a payload type: its a=rtpmap line, - // upper-cased (RFC 4566 §6: encoding names are case-insensitive), else - // the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry - // no rtpmap at all). Empty when neither applies. - std::string CodecName(std::string_view body, int pt) { - std::string key = std::format("a=rtpmap:{} ", pt); - if (auto at = Find(body, key)) { - std::size_t nameBegin = *at + key.size(); - std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin); - std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin)); - for (char& c : name) - c = static_cast(std::toupper(static_cast(c))); - return name; - } - if (pt == 0) return "PCMU"; - if (pt == 8) return "PCMA"; - return ""; - } - - // The offer's audio payload types with their names, "8 PCMA, 96 AMR, - // 101 telephone-event" — for the log line of a refused offer, so a - // field journal says WHAT the gateway offered without a raw SIP dump. - std::string OfferedCodecs(std::string_view body) { - auto m = Find(body, "m=audio "); - if (!m) return "no m=audio line"; - std::size_t lineEnd = body.find_first_of("\r\n", *m); - std::string_view line = body.substr(*m, lineEnd == std::string_view::npos ? std::string_view::npos : lineEnd - *m); - std::string out; - int field = 0; - for (auto part : std::views::split(line, ' ')) { - std::string_view tok(part); - if (field++ < 3 || tok.empty()) continue; // m=audio ... - auto pt = ParseInt(tok); - if (!pt) continue; - std::string name = CodecName(body, *pt); - if (!out.empty()) out += ", "; - out += name.empty() ? std::format("{}", *pt) : std::format("{} {}", *pt, name); - } - return out.empty() ? std::string(line) : out; - } - // Payload-type preference: AMR-WB with octet-align=1 > AMR-WB > AMR with - // octet-align=1 > AMR > PCMA > PCMU. Octet-aligned wins within a codec - // because it is the media leg's native, call-proven format - // (bandwidth-efficient is supported but was the s56 static-audio - // culprit) — when the peer offers both variants, taking the - // octet-aligned pt keeps real calls on the battle-tested path. Wideband - // beats narrowband, AMR beats G.711 (a mobile-to-mobile call must never - // land on a G.711 leg just because the gateway listed it). With no - // playable codec at all, the first pt is used and codec comes back EMPTY - // — pretending otherwise would defeat codec checks upstream. + // octet-align=1 > AMR. Octet-aligned wins within a codec because it is + // the media leg's native, call-proven format (bandwidth-efficient is + // supported but was the s56 static-audio culprit) — when the peer offers + // both variants, taking the octet-aligned pt keeps real calls on the + // battle-tested path. With no rtpmap match at all, the first pt is used + // and codec comes back EMPTY — the body named no AMR variant (e.g. a + // PCMA offer), and pretending otherwise would defeat codec checks + // upstream. Answer ParseAnswer(std::string_view body) { Answer a; if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9); @@ -188,20 +135,20 @@ export namespace imsd::sdp { int wb = -1; int nbOa = -1; int nb = -1; - int pcma = -1; - int pcmu = -1; for (int p : pts) { - std::string name = CodecName(body, p); - if (name == "AMR-WB") { + std::string key = std::format("a=rtpmap:{} ", p); + auto at = Find(body, key); + if (!at) continue; + // codec name: up to '/', CR or LF + std::size_t nameBegin = *at + key.size(); + std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin); + std::string_view name = body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin); + if (name.contains("AMR-WB")) { if (wb == -1) wb = p; if (wbOa == -1 && hasOctetAlign(p)) wbOa = p; - } else if (name == "AMR") { + } else if (name.contains("AMR")) { if (nb == -1) nb = p; if (nbOa == -1 && hasOctetAlign(p)) nbOa = p; - } else if (name == "PCMA") { - if (pcma == -1) pcma = p; - } else if (name == "PCMU") { - if (pcmu == -1) pcmu = p; } } if (wbOa != -1 || wb != -1) { @@ -210,17 +157,11 @@ export namespace imsd::sdp { } else if (nbOa != -1 || nb != -1) { a.payloadType = nbOa != -1 ? nbOa : nb; a.codec = "AMR"; - } else if (pcma != -1) { - a.payloadType = pcma; - a.codec = "PCMA"; - } else if (pcmu != -1) { - a.payloadType = pcmu; - a.codec = "PCMU"; } else { a.payloadType = pts.front(); a.codec.clear(); } - a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType); + a.octetAlign = hasOctetAlign(a.payloadType); return a; } @@ -248,24 +189,18 @@ export namespace imsd::sdp { int rtpPort = 0; std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes int payloadType = 97; // the offer's pt for the chosen codec - bool octetAlign = true; // mirror of the offer's mode (AMR codecs only) - std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" + bool octetAlign = true; // mirror of the offer's mode + std::string codec = "AMR-WB"; // "AMR-WB" or "AMR" std::optional dtmfPt; // the offer's telephone-event pt, if any }; - // Sample rate of a codec we play: AMR-WB is 16 kHz, everything else - // (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate. - int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; } - // SDP answer to an inbound audio offer: single codec at the offer's own // payload type, octet-align echoed only when the offer used it (absence - // = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all), - // telephone-event kept when offered. b=AS is the codec's peak payload - // rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp. + // = bandwidth-efficient, RFC 4867 §8.1), telephone-event kept when + // offered. Byte layout pinned by tests/Sdp. std::string BuildAnswer(const AnswerSpec& s) { std::string ipver = Is6(s.local) ? "IP6" : "IP4"; - int rate = CodecRate(s.codec); - int as = s.codec == "AMR-WB" ? 41 : (s.codec == "AMR" ? 30 : 80); + int rate = s.codec == "AMR-WB" ? 16000 : 8000; std::string out; out += "v=0\r\n"; out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local); @@ -275,11 +210,11 @@ export namespace imsd::sdp { if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt); else out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType); - out += std::format("b=AS:{}\r\n", as); + out += std::format("b=AS:{}\r\n", s.codec == "AMR-WB" ? 41 : 30); out += "b=RS:512\r\n"; out += "b=RR:1536\r\n"; out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate); - if (s.octetAlign && s.codec.starts_with("AMR")) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType); + if (s.octetAlign) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType); if (s.dtmfPt) { out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate); out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt); diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary index 0043af0..e874d5d 100644 --- a/packaging/APKBUILD.binary +++ b/packaging/APKBUILD.binary @@ -5,10 +5,10 @@ # README's "Cross-compiling" section into a proper apk — used by this repo's # package CI (packaging/build-package.sh, which also seds pkgver from # implementations/main.cpp) and runnable by hand. The source tarball is -# produced by packaging/make-bin-tarball.sh; the desktop/preset files live -# next to it in packaging/ (copy them beside this file). Since 2026-09-02 -# this is the only imsd packaging: fp6-img images install the apk this -# builds from the registry rather than building their own. +# produced by packaging/make-bin-tarball.sh; the desktop/preset files come +# from packaging/aport/ (copy them next to this file). The package payload +# must stay identical to packaging/aport/APKBUILD's, or an apk upgrade +# across the two pipelines would add/strip files on users' phones. pkgname=imsd pkgver=0.3.1 pkgrel=0 diff --git a/packaging/80-imsd.preset b/packaging/aport/80-imsd.preset similarity index 100% rename from packaging/80-imsd.preset rename to packaging/aport/80-imsd.preset diff --git a/packaging/aport/APKBUILD b/packaging/aport/APKBUILD new file mode 100644 index 0000000..7912d38 --- /dev/null +++ b/packaging/aport/APKBUILD @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# The canonical apk aport for imsd, owned by this repository since +# 2026-09-01 (transferred from fp6-img, which now consumes this directory: +# its build.sh copies packaging/aport/ into the pmaports overlay, pins +# _commit to a reviewed commit, and generates the source tarball with +# git-archive — the Forgejo instance serves no source archives). +# packaging/APKBUILD{,.binary} are the older standalone variants. +maintainer="Jorijn van der Graaf " +pkgname=imsd +pkgver=0.3.1 +pkgrel=0 +pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" +url="https://forgejo.catcrafts.net/Catcrafts/imsd" +# other arches: nothing wrong known, just never run there +arch="aarch64 x86_64" +license="GPL-3.0-only" +# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire — +# none of which abuild's .so auto-scan can see +depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools" +# clang/libc++ C++26-modules build (upstream Makefile); llvm-runtimes ships +# the libc++ std module sources the build precompiles +makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgconf" +# the versioned provides both satisfies soc-qcom-modem's 81voltd dependency +# and excludes the real package: 81voltd serves the modem firmware's own +# ims-PDN requests, which races imsd for the PDN and flaps it (a new prefix +# every ~2.5 min) — two IMS stacks cannot share one PDN. Installing imsd is +# an explicit choice to hand the IMS PDN to userspace. +provides="81voltd=$pkgver-r$pkgrel" +# no OpenRC service yet: the unit's PDN-bring-up/env-file sequencing is only +# tested under systemd; an initd is welcome once someone can verify one +subpackages="$pkgname-systemd" +# _commit is pinned by the consuming CI, which also drops the git-archive +# tarball (prefix imsd/) next to this APKBUILD. The skel override hides +# kde-telephony's modem daemon autostart for the account created at install — +# imsd-dialerd owns those session D-Bus names instead. +_commit="REPLACED_BY_CI" +source=" + imsd-$_commit.tar.gz + org.kde.modem.daemon.desktop + 80-imsd.preset +" +builddir="$srcdir/$pkgname" + +build() { + make +} + +check() { + make check +} + +package() { + make install DESTDIR="$pkgdir" + install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ + "$pkgdir"/etc/skel/.config/autostart/org.kde.modem.daemon.desktop + # enabled by preset: the unit is a no-op until /etc/imsd.env exists, and + # VoLTE surviving reboots must not depend on a manual systemctl enable + install -Dm644 "$srcdir"/80-imsd.preset \ + "$pkgdir"/usr/lib/systemd/system-preset/80-imsd.preset + mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants + ln -s /usr/lib/systemd/system/imsd.service \ + "$pkgdir"/etc/systemd/system/multi-user.target.wants/imsd.service + # ...but only actually start once the carrier config exists, so + # unconfigured systems don't boot into a failing unit + mkdir -p "$pkgdir"/usr/lib/systemd/system/imsd.service.d + printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ + > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf +} + +systemd() { + install_if="$pkgname=$pkgver-r$pkgrel systemd" + + amove usr/lib/systemd/system +} + +sha512sums=" +REPLACED_BY_CI imsd-REPLACED_BY_CI.tar.gz +REPLACED_BY_CI org.kde.modem.daemon.desktop +REPLACED_BY_CI 80-imsd.preset +" diff --git a/packaging/org.kde.modem.daemon.desktop b/packaging/aport/org.kde.modem.daemon.desktop similarity index 100% rename from packaging/org.kde.modem.daemon.desktop rename to packaging/aport/org.kde.modem.daemon.desktop diff --git a/packaging/build-package.sh b/packaging/build-package.sh index e035173..9b130aa 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -88,8 +88,8 @@ PKG="$HOME/pkg" rm -rf "$PKG" mkdir -p "$PKG" cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD" -cp "$SRC/packaging/org.kde.modem.daemon.desktop" \ - "$SRC/packaging/80-imsd.preset" "$PKG/" +cp "$SRC/packaging/aport/org.kde.modem.daemon.desktop" \ + "$SRC/packaging/aport/80-imsd.preset" "$PKG/" mv "imsd-$VER.tar.gz" "$PKG/" sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD" # a throwaway signing key: phones trust the registry-signed APKINDEX, not diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index b835679..0e6ce1d 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -8,10 +8,8 @@ // after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180 // on the INVITE (reliable when the caller requires 100rel), Accept -> 200 // with the SDP answer at the offer's payload types + media, ACK silence, -// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming -// what was offered), a landline gateway's G.711-only and AMR-NB offers -// ring and are answered at the offer's own payload types, ring timeout -> -// 480, BYE both ways. Pure reducer, no I/O. +// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring +// timeout -> 480, BYE both ways. Pure reducer, no I/O. import std; import Imsd; @@ -481,109 +479,26 @@ int main() { Check(m.Terminated(), "terminated after remote CANCEL"); } - // an inbound INVITE around an arbitrary offer (the gateway shapes below) - auto MtInviteSdp = [](std::string_view callid, std::string_view sdp) { - return std::format( - "INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n" - "From: ;tag=caller1\r\n" - "To: \r\n" - "Call-ID: {}\r\nCSeq: 1 INVITE\r\n" - "Contact: \r\n" - "Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}", - callid, sdp.size(), sdp); - }; - - // ---- a landline caller: the PSTN gateway offers G.711 only, by static - // payload type with no rtpmap (the shape that used to be 488'd). It - // rings, and Accept answers PCMA at the offer's pt with narrowband DTMF. + // ---- offer we cannot serve (no AMR-WB): 488 before ringing { imsd::util::Rng rng(29); std::string sdp = "v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n" "c=IN IP6 2001:db8::30c\r\nt=0 0\r\n" - "m=audio 27864 RTP/AVP 8 0 101\r\na=rtpmap:101 telephone-event/8000\r\na=ptime:20\r\n"; - CallMachine m(c, rng, "ims-call-11", IncomingInvite{MtInviteSdp("mt-pcma", sdp)}, 50004); + "m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n"; + std::string inv = std::format( + "INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n" + "From: ;tag=caller1\r\n" + "To: \r\n" + "Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n" + "Contact: \r\n" + "Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}", + sdp.size(), sdp); + CallMachine m(c, rng, "ims-call-11", IncomingInvite{inv}, 50004); auto ai = m.OnInvite(); - Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") == nullptr, "G.711 offer is not refused"); - Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "G.711 offer rings"); - Check(!m.Terminated(), "G.711 call alive after INVITE"); - auto aa = m.OnAccept(); - const Action* ok = Responded(aa, "SIP/2.0 200 OK"); - Check(ok != nullptr, "Accept answers 200"); - Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 8 101\r\n"), "answer keeps the offer's PCMA + DTMF pts"); - Check(ok && ok->text.contains("a=rtpmap:8 PCMA/8000/1\r\n"), "answer names PCMA"); - Check(ok && ok->text.contains("a=rtpmap:101 telephone-event/8000\r\n"), "DTMF at the narrowband clock"); - Check(ok && !ok->text.contains("octet-align"), "no octet-align for G.711"); - const Action* med = Find(aa, Action::Type::StartMedia); - Check(med && med->media.codec == "PCMA" && med->media.payloadType == 8 && !med->media.octetAlign, "media leg starts PCMA pt 8"); - Check(med && med->media.remoteIp == "2001:db8::30c" && med->media.remotePort == 27864, "media leg targets the gateway"); - } - - // ---- a gateway offering narrowband AMR (octet-aligned) + G.711: AMR wins - { - imsd::util::Rng rng(31); - std::string sdp = - "v=0\r\no=- 6 6 IN IP6 2001:db8::30c\r\ns=-\r\n" - "c=IN IP6 2001:db8::30c\r\nt=0 0\r\n" - "m=audio 27866 RTP/AVP 8 100 110\r\n" - "a=rtpmap:100 AMR/8000/1\r\na=fmtp:100 octet-align=1;mode-set=0,2,4,7\r\n" - "a=rtpmap:110 telephone-event/8000\r\n"; - CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInviteSdp("mt-amrnb", sdp)}, 50004); - auto ai = m.OnInvite(); - Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "AMR-NB offer rings"); - auto aa = m.OnAccept(); - const Action* ok = Responded(aa, "SIP/2.0 200 OK"); - Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 100 110\r\n"), "answer picks AMR over PCMA"); - Check(ok && ok->text.contains("a=rtpmap:100 AMR/8000/1\r\n") && ok->text.contains("a=fmtp:100 octet-align=1\r\n"), "answer mirrors octet-aligned AMR"); - const Action* med = Find(aa, Action::Type::StartMedia); - Check(med && med->media.codec == "AMR" && med->media.payloadType == 100 && med->media.octetAlign, "media leg starts octet-aligned AMR pt 100"); - } - - // ---- a mobile caller offering everything: still AMR-WB (no regression) - { - imsd::util::Rng rng(33); - std::string sdp = - "v=0\r\no=- 7 7 IN IP6 2001:db8::310\r\ns=-\r\n" - "c=IN IP6 2001:db8::310\r\nt=0 0\r\n" - "m=audio 27868 RTP/AVP 116 107 8 100 111 110 13\r\n" - "a=rtpmap:116 AMR-WB/16000/1\r\na=fmtp:116 mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:107 AMR-WB/16000/1\r\na=fmtp:107 octet-align=1;mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:100 AMR/8000/1\r\na=rtpmap:111 telephone-event/16000\r\n" - "a=rtpmap:110 telephone-event/8000\r\na=rtpmap:13 CN/8000\r\n"; - CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInviteSdp("mt-mobile", sdp)}, 50004); - m.OnInvite(); - auto aa = m.OnAccept(); - const Action* ok = Responded(aa, "SIP/2.0 200 OK"); - Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 107 111\r\n"), "mobile caller answered with octet-aligned AMR-WB + wideband DTMF"); - const Action* med = Find(aa, Action::Type::StartMedia); - Check(med && med->media.codec == "AMR-WB" && med->media.payloadType == 107, "media leg stays AMR-WB for a mobile caller"); - } - - // ---- offer we cannot serve (nothing we play): 488 before ringing, and - // the log names what was offered - { - imsd::util::Rng rng(35); - std::string sdp = - "v=0\r\no=- 8 8 IN IP6 2001:db8::30c\r\ns=-\r\n" - "c=IN IP6 2001:db8::30c\r\nt=0 0\r\n" - "m=audio 27864 RTP/AVP 18 101\r\na=rtpmap:18 G729/8000\r\na=rtpmap:101 telephone-event/8000\r\n"; - CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInviteSdp("mt-g729", sdp)}, 50004); - auto ai = m.OnInvite(); - Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "G729-only offer refused with 488"); + Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488"); Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488"); Check(m.Terminated(), "terminated on 488"); - bool named = false; - for (const auto& x : ai) - if (x.type == Action::Type::Log && x.text.contains("offered: 18 G729, 101 TELEPHONE-EVENT")) named = true; - Check(named, "488 log names the offered codecs"); - } - // ---- no audio line at all: 488 too - { - imsd::util::Rng rng(37); - CallMachine m(c, rng, "ims-call-15", IncomingInvite{MtInviteSdp("mt-noaudio", "v=0\r\no=- 9 9 IN IP6 2001:db8::30c\r\ns=-\r\nc=IN IP6 2001:db8::30c\r\nt=0 0\r\n")}, 50004); - auto ai = m.OnInvite(); - Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "offer without m=audio refused with 488"); - Check(m.Terminated(), "terminated on 488 (no audio line)"); } // ---- ring timeout: 480, missed call diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp index 3ee9ba2..7d308f1 100644 --- a/tests/Messages/main.cpp +++ b/tests/Messages/main.cpp @@ -96,18 +96,14 @@ namespace { "s=-\r\n" "c=IN IP6 2001:db8::db43\r\n" "t=0 0\r\n" - "m=audio 50004 RTP/AVP 97 99 98 100\r\n" + "m=audio 50004 RTP/AVP 97 98\r\n" "b=AS:41\r\n" "b=RS:512\r\n" "b=RR:1536\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n" "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:99 AMR/8000/1\r\n" - "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n" "a=rtpmap:98 telephone-event/16000\r\n" "a=fmtp:98 0-15\r\n" - "a=rtpmap:100 telephone-event/8000\r\n" - "a=fmtp:100 0-15\r\n" "a=ptime:20\r\n" "a=maxptime:240\r\n" "a=sendrecv\r\n"; @@ -133,25 +129,21 @@ namespace { "Session-Expires: 1800;refresher=uac\r\n" "Min-SE: 90\r\n" "Content-Type: application/sdp\r\n" - "Content-Length: 466\r\n" + "Content-Length: 323\r\n" "\r\n" "v=0\r\n" "o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n" "s=-\r\n" "c=IN IP6 2001:db8::db43\r\n" "t=0 0\r\n" - "m=audio 50004 RTP/AVP 97 99 98 100\r\n" + "m=audio 50004 RTP/AVP 97 98\r\n" "b=AS:41\r\n" "b=RS:512\r\n" "b=RR:1536\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n" "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:99 AMR/8000/1\r\n" - "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n" "a=rtpmap:98 telephone-event/16000\r\n" "a=fmtp:98 0-15\r\n" - "a=rtpmap:100 telephone-event/8000\r\n" - "a=fmtp:100 0-15\r\n" "a=ptime:20\r\n" "a=maxptime:240\r\n" "a=sendrecv\r\n"; diff --git a/tests/Sdp/main.cpp b/tests/Sdp/main.cpp index 5a70cc9..2770c16 100644 --- a/tests/Sdp/main.cpp +++ b/tests/Sdp/main.cpp @@ -37,18 +37,14 @@ int main() { "s=-\r\n" "c=IN IP6 2001:db8:29e9:a05f::1\r\n" "t=0 0\r\n" - "m=audio 22222 RTP/AVP 97 99 98 100\r\n" + "m=audio 22222 RTP/AVP 97 98\r\n" "b=AS:41\r\n" "b=RS:512\r\n" "b=RR:1536\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n" "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:99 AMR/8000/1\r\n" - "a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n" "a=rtpmap:98 telephone-event/16000\r\n" "a=fmtp:98 0-15\r\n" - "a=rtpmap:100 telephone-event/8000\r\n" - "a=fmtp:100 0-15\r\n" "a=ptime:20\r\n" "a=maxptime:240\r\n" "a=curr:qos local sendrecv\r\n" @@ -130,68 +126,12 @@ int main() { Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback"); Check(a.octetAlign, "octet-align seen for AMR too"); } - // ---- G.711 by static payload type, no rtpmap at all (RFC 3551 §6): - // the shape a PSTN gateway sends for a landline caller + // ---- no rtpmap at all -> first payload type, bandwidth-efficient { - Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 0 8\r\n"); - Check(a.payloadType == 8, "PCMA preferred over an earlier PCMU"); - Check(a.codec == "PCMA", "static pt 8 -> PCMA without an rtpmap line"); - Check(!a.octetAlign, "G.711 never octet-aligned"); - } - { - Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 0\r\n"); - Check(a.payloadType == 0 && a.codec == "PCMU", "static pt 0 -> PCMU"); - } - // ---- G.711 with an rtpmap, lower-case name (RFC 4566: case-insensitive) - { - Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:8 pcma/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n"); - Check(a.payloadType == 8 && a.codec == "PCMA", "lower-case rtpmap name still recognised"); - } - { - Answer a = ParseAnswer("c=IN IP6 2001:db8::1\r\n" "m=audio 5000 RTP/AVP 96\r\n" "a=rtpmap:96 amr-wb/16000/1\r\n" "a=fmtp:96 octet-align=1\r\n"); - Check(a.payloadType == 96 && a.codec == "AMR-WB" && a.octetAlign, "lower-case amr-wb recognised, octet-align kept"); - } - // ---- narrowband AMR beats G.711 (a gateway listing both) - { - Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 100 101\r\n" "a=rtpmap:100 AMR/8000/1\r\n" "a=fmtp:100 octet-align=1\r\n" "a=rtpmap:101 telephone-event/8000\r\n"); - Check(a.payloadType == 100 && a.codec == "AMR", "AMR preferred over PCMA listed first"); - Check(a.octetAlign, "AMR octet-align seen"); - } - // ---- wideband beats everything: KPN's real mobile-origin list - // (AMR-WB BE 116, AMR-WB OA 107, PCMA 8, AMR 100, DTMF 111/110, CN 13) - { - Answer a = ParseAnswer( - "c=IN IP6 2001:db8::310\r\n" - "m=audio 5000 RTP/AVP 116 107 8 100 111 110 13\r\n" - "a=rtpmap:116 AMR-WB/16000/1\r\n" - "a=fmtp:116 mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:107 AMR-WB/16000/1\r\n" - "a=fmtp:107 octet-align=1;mode-change-capability=2;max-red=0\r\n" - "a=rtpmap:100 AMR/8000/1\r\n" - "a=rtpmap:111 telephone-event/16000\r\n" - "a=rtpmap:110 telephone-event/8000\r\n" - "a=rtpmap:13 CN/8000\r\n"); - Check(a.payloadType == 107 && a.codec == "AMR-WB" && a.octetAlign, "mobile caller still lands on octet-aligned AMR-WB"); - } - // ---- nothing we play -> first payload type, codec EMPTY (the 488 case) - { - Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 18 101\r\n" "a=rtpmap:18 G729/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n"); - Check(a.payloadType == 18, "no playable codec -> first payload type"); + Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 0\r\n"); + Check(a.payloadType == 8, "no rtpmap -> first payload type"); Check(!a.octetAlign, "no fmtp -> bandwidth-efficient"); - Check(a.codec.empty(), "G729-only -> codec unknown, not assumed"); - } - - // ---- OfferedCodecs: the 488 log summary - { - Check(OfferedCodecs("m=audio 5000 RTP/AVP 18 101\r\na=rtpmap:18 G729/8000\r\na=rtpmap:101 telephone-event/8000\r\n") == "18 G729, 101 TELEPHONE-EVENT", "offered list names each pt"); - Check(OfferedCodecs("m=audio 5000 RTP/AVP 0 8\r\n") == "0 PCMU, 8 PCMA", "static G.711 types named without rtpmap"); - Check(OfferedCodecs("m=audio 5000 RTP/AVP 96\r\n") == "96", "unknown dynamic pt listed bare"); - Check(OfferedCodecs("v=0\r\n") == "no m=audio line", "no audio line says so"); - } - // ---- CodecRate - { - Check(CodecRate("AMR-WB") == 16000, "AMR-WB is 16 kHz"); - Check(CodecRate("AMR") == 8000 && CodecRate("PCMA") == 8000 && CodecRate("PCMU") == 8000, "narrowband codecs are 8 kHz"); + Check(a.codec.empty(), "no AMR rtpmap -> codec unknown, not assumed"); } // ---- degenerate body { @@ -258,41 +198,6 @@ int main() { Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none"); Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth"); } - // ---- BuildAnswer: G.711 for a landline caller — static pt, no fmtp, - // narrowband DTMF, byte-exact - { - std::string sdp = BuildAnswer({ - .local = "2001:db8:29e9:a05f::1", - .rtpPort = 50004, - .sessionId = 778, - .payloadType = 8, - .octetAlign = false, - .codec = "PCMA", - .dtmfPt = 101, - }); - std::string expected = - "v=0\r\n" - "o=- 778 778 IN IP6 2001:db8:29e9:a05f::1\r\n" - "s=-\r\n" - "c=IN IP6 2001:db8:29e9:a05f::1\r\n" - "t=0 0\r\n" - "m=audio 50004 RTP/AVP 8 101\r\n" - "b=AS:80\r\n" - "b=RS:512\r\n" - "b=RR:1536\r\n" - "a=rtpmap:8 PCMA/8000/1\r\n" - "a=rtpmap:101 telephone-event/8000\r\n" - "a=fmtp:101 0-15\r\n" - "a=ptime:20\r\n" - "a=maxptime:240\r\n" - "a=sendrecv\r\n"; - Check(sdp == expected, "PCMA answer is byte-exact"); - } - // ---- BuildAnswer never marks G.711 octet-aligned even if asked - { - std::string sdp = BuildAnswer({.local = "10.0.0.2", .rtpPort = 4000, .sessionId = 9, .payloadType = 0, .octetAlign = true, .codec = "PCMU"}); - Check(sdp.contains("a=rtpmap:0 PCMU/8000/1\r\n") && !sdp.contains("fmtp:0"), "PCMU answer has no fmtp"); - } if (Failures == 0) std::println("Sdp: all tests passed"); return Failures;