Compare commits

..
13 changed files with 301 additions and 775 deletions

View file

@ -14,10 +14,9 @@ name: package
# Release gating is the version: pkgver comes from implementations/main.cpp, # Release gating is the version: pkgver comes from implementations/main.cpp,
# the registry answers 409 for an already-published version, and the publish # 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 # 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 # Version constant bumps. fp6-img images keep building imsd from their own
# images install the published package from the registry (pinned version, # pinned checkout of this repo (packaging/aport/); APKBUILD.binary's payload
# pinned sha256) instead of building their own, so image and 'apk upgrade' # must stay identical to that aport's, so the two pipelines cannot skew.
# carry the same binary.
# #
# Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an # Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an
# org-level secret on Catcrafts since 2026-09-02, shared with fp6-img; # org-level secret on Catcrafts since 2026-09-02, shared with fp6-img;

View file

@ -1201,32 +1201,16 @@ private:
call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_); call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_);
callReply_ = reply_; callReply_ = reply_;
Log(std::format("incoming call {} from {}", uni, call_->Number())); 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, CallInfo info{uni, call_->Number(), "incoming", "incoming", NowEpoch(), 0,
"incoming"}; "incoming"};
PostAdded(info); PostAdded(info);
Execute(actions); Execute(call_->OnInvite());
MaybeDropCall(); MaybeDropCall();
} }
// ---- action execution ------------------------------------------------- // ---- action execution -------------------------------------------------
// Returns false if a client-flow send failed (used to fail a dial's INVITE). // 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 bool Execute(const std::vector<imsd::engine::Action>& actions) {
// that was never announced (refused before ringing), so the bus never
// sees a call it cannot show.
bool Execute(const std::vector<imsd::engine::Action>& actions, bool announce = true) {
using T = imsd::engine::Action::Type; using T = imsd::engine::Action::Type;
bool sendOk = true; bool sendOk = true;
for (const auto& a : actions) { for (const auto& a : actions) {
@ -1240,13 +1224,8 @@ private:
break; break;
case T::StartMedia: StartMedia(a.media); break; case T::StartMedia: StartMedia(a.media); break;
case T::StopMedia: StopMedia(); break; case T::StopMedia: StopMedia(); break;
case T::State: case T::State: PostState(call_->Uni(), a.state, a.reason); break;
if (announce) PostState(call_->Uni(), a.state, a.reason); case T::Deleted: PostDeleted(call_->Uni()); dropCall_ = true; break;
break;
case T::Deleted:
if (announce) PostDeleted(call_->Uni());
dropCall_ = true;
break;
case T::SetDeadline: deadline_ = Mono() + a.seconds; break; case T::SetDeadline: deadline_ = Mono() + a.seconds; break;
case T::Log: Log(a.text); break; case T::Log: Log(a.text); break;
} }
@ -1278,7 +1257,6 @@ private:
setenv("AMR_MODE", EnvOr("AMR_MODE", "2").c_str(), 1); setenv("AMR_MODE", EnvOr("AMR_MODE", "2").c_str(), 1);
setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
setenv("CODEC", leg.codec.c_str(), 1);
std::vector<char*> c; std::vector<char*> c;
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str())); for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
c.push_back(nullptr); c.push_back(nullptr);

View file

@ -3,42 +3,28 @@
// lint-disable-file fixed-width-types no-char-pointer // lint-disable-file fixed-width-types no-char-pointer
/* /*
imsd-media the RTP media leg for a userspace VoLTE call, spawned as its imsd-media the RTP/AMR-WB media leg for a userspace VoLTE call, spawned as
own process by the daemon (imsd) exactly as the Python prototype spawned 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 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 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 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 whose network BYE never reaches our SAs is the reliable teardown trigger) and
isolates a media crash from the control-plane daemon. isolates a media crash from the control-plane daemon.
Codecs (CODEC env, set by the daemon from the negotiated SDP): AMR-WB Binds the advertised local RTP port, sends uplink octet-aligned AMR-WB frames
(16 kHz; the mobile-to-mobile VoLTE codec, the default), AMR narrowband and toward the media gateway, captures the downlink, and with PLAY=1
G.711 PCMA/PCMU (8 kHz; what a PSTN gateway offers when a landline calls). reconstructs the media clock from RTP timestamps so pw-play stays real-time-
AMR frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient paced through far-end DTX silence (every missing 20 ms slot is decoded as an
(OCTET_ALIGN, mirrored from the SDP); G.711 is raw samples. The AMR codecs FT-15 NO_DATA frame for CNG/PLC). MIC=1 feeds live pw-record audio through
are dlopen'd (libvo-amrwbenc + libopencore-amrwb for WB, libopencore-amrnb libvo-amrwbenc; downlink decode is libopencore-amrwb. Both codecs are dlopen'd
for NB) so the binary has no link-time dependency on them; G.711 is a table. so the binary has no link-time dependency on them.
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.
Usage: imsd-media <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base> Usage: imsd-media <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base>
imsd-media --selftest (payload pack/depay roundtrip, both formats, imsd-media --selftest (payload pack/depay roundtrip, both formats)
both AMR codecs; G.711 table roundtrip) Env: MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP OCTET_ALIGN
Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP AUDIO_USER
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP
(OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the (OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the
daemon sets it from the negotiated SDP 1 is the default/legacy behavior. daemon sets it from the negotiated SDP 1 is the default/legacy behavior.)
AMR_MODE is the encoder mode of whichever AMR codec is active; default 2
for AMR-WB (12.65 kbit/s), 7 for AMR (12.2 kbit/s). MIC_SRC=<file> 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 <out-base>.pcm
both are test seams for driving the leg against a synthetic RTP peer
with no PipeWire and no network.)
*/ */
#include <arpa/inet.h> #include <arpa/inet.h>
@ -60,125 +46,28 @@ double Now() {
return std::chrono::duration<double>(Clock::now().time_since_epoch()).count(); return std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
} }
enum class Codec { AmrWb, AmrNb, Pcma, Pcmu }; // octet-aligned AMR-WB speech-frame byte sizes by frame type (mode).
constexpr int AmrwbBytes(int ft) {
Codec ParseCodec(std::string_view name) { switch (ft) {
if (name == "AMR") return Codec::AmrNb; case 0: return 17; case 1: return 23; case 2: return 32; case 3: return 36;
if (name == "PCMA") return Codec::Pcma; case 4: return 40; case 5: return 46; case 6: return 50; case 7: return 58;
if (name == "PCMU") return Codec::Pcmu; case 8: return 60; case 9: return 5; default: return 0;
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 // AMR-WB speech bits per frame type (RFC 4867 table 2 / TS 26.201) — the
// bandwidth-efficient format. AMR-WB: RFC 4867 table 2 / TS 26.201; AMR: // exact payload bit counts of the bandwidth-efficient format. FT 14/15
// RFC 4867 table 1 / TS 26.101 (FT 8 = SID, 9-11 = the other systems' SID // (SPEECH_LOST/NO_DATA) carry 0 bits; unknown FTs return -1 so a corrupt
// frames a gateway may forward). FT 14/15 (SPEECH_LOST/NO_DATA) carry 0 // ToC aborts the packet instead of shifting every later bit.
// bits; unknown FTs return -1 so a corrupt ToC aborts the packet instead of constexpr int AmrwbBits(int ft) {
// shifting every later bit.
constexpr int AmrBits(Codec c, int ft) {
if (c == Codec::AmrWb) {
switch (ft) { switch (ft) {
case 0: return 132; case 1: return 177; case 2: return 253; case 3: return 285; 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 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; case 8: return 477; case 9: return 40; case 14: return 0; case 15: return 0;
default: return -1; default: return -1;
} }
}
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;
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<const int> ends) {
for (std::size_t i = 0; i < ends.size(); i++)
if (val <= ends[i]) return static_cast<int>(i);
return static_cast<int>(ends.size());
}
std::uint8_t LinearToAlaw(std::int16_t pcm) {
static constexpr std::array<int, 8> 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<std::uint8_t>(0x7F ^ mask);
int aval = seg << 4;
aval |= seg < 2 ? (val >> 1) & 0x0F : (val >> seg) & 0x0F;
return static_cast<std::uint8_t>(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<std::int16_t>((a & 0x80) ? t : -t);
}
std::uint8_t LinearToUlaw(std::int16_t pcm) {
static constexpr std::array<int, 8> 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<std::uint8_t>(0x7F ^ mask);
int uval = (seg << 4) | ((val >> (seg + 1)) & 0x0F);
return static_cast<std::uint8_t>(uval ^ mask);
}
std::int16_t UlawToLinear(std::uint8_t u) {
u = static_cast<std::uint8_t>(~u);
int t = ((u & 0x0F) << 3) + UlawBias;
t <<= (u & 0x70) >> 4;
return static_cast<std::int16_t>((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) { std::string EnvOr(const char* k, const char* d) {
const char* v = std::getenv(k); const char* v = std::getenv(k);
return v ? std::string(v) : std::string(d); 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); return sizeof(sockaddr_in);
} }
// ---- AMR codecs via dlopen ------------------------------------------------ // ---- AMR-WB codecs via dlopen (vo-amrwbenc E_IF_*, opencore-amrwb D_IF_*) --
// 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.
class Encoder { class Encoder {
public: public:
bool Open(Codec c, int dtx) { bool Open() {
codec_ = c;
if (c == Codec::AmrWb) {
lib_ = dlopen("libvo-amrwbenc.so.0", RTLD_NOW); lib_ = dlopen("libvo-amrwbenc.so.0", RTLD_NOW);
if (!lib_) return false; if (!lib_) return false;
wbInit_ = reinterpret_cast<WbInitFn>(dlsym(lib_, "E_IF_init")); init_ = reinterpret_cast<InitFn>(dlsym(lib_, "E_IF_init"));
wbEnc_ = reinterpret_cast<WbEncFn>(dlsym(lib_, "E_IF_encode")); enc_ = reinterpret_cast<EncFn>(dlsym(lib_, "E_IF_encode"));
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "E_IF_exit")); exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "E_IF_exit"));
if (!wbInit_ || !wbEnc_ || !exit_) return false; if (!init_ || !enc_ || !exit_) return false;
st_ = wbInit_(); st_ = init_();
return st_ != nullptr; return st_ != nullptr;
} }
lib_ = dlopen("libopencore-amrnb.so.0", RTLD_NOW); // 320 int16 samples -> one RFC 3267 storage frame (header byte + speech).
if (!lib_) return false;
nbInit_ = reinterpret_cast<NbInitFn>(dlsym(lib_, "Encoder_Interface_init"));
nbEnc_ = reinterpret_cast<NbEncFn>(dlsym(lib_, "Encoder_Interface_Encode"));
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "Encoder_Interface_exit"));
if (!nbInit_ || !nbEnc_ || !exit_) return false;
st_ = nbInit_(dtx); // NB: DTX is an init-time choice
return st_ != nullptr;
}
// one 20 ms frame of s16 samples -> one storage frame (header byte + speech).
std::vector<std::uint8_t> Encode(std::int16_t* samples, int mode, int dtx) { std::vector<std::uint8_t> Encode(std::int16_t* samples, int mode, int dtx) {
std::uint8_t out[128]; std::uint8_t out[128];
int n = codec_ == Codec::AmrWb int n = enc_(st_, static_cast<std::int16_t>(mode), samples, out, static_cast<std::int16_t>(dtx));
? wbEnc_(st_, static_cast<std::int16_t>(mode), samples, out, static_cast<std::int16_t>(dtx))
: nbEnc_(st_, mode, samples, out, 0);
if (n <= 0) return {}; if (n <= 0) return {};
return std::vector<std::uint8_t>(out, out + n); return std::vector<std::uint8_t>(out, out + n);
} }
~Encoder() { ~Encoder() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); }
if (st_ && exit_) exit_(st_);
if (lib_) dlclose(lib_);
}
private: private:
using WbInitFn = void* (*)(); using InitFn = void* (*)();
using WbEncFn = int (*)(void*, std::int16_t, std::int16_t*, std::uint8_t*, std::int16_t); using EncFn = 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 ExitFn = void (*)(void*); using ExitFn = void (*)(void*);
Codec codec_ = Codec::AmrWb; void* lib_ = nullptr; void* st_ = nullptr;
void* lib_ = nullptr; InitFn init_ = nullptr; EncFn enc_ = nullptr; ExitFn exit_ = nullptr;
void* st_ = nullptr;
WbInitFn wbInit_ = nullptr;
WbEncFn wbEnc_ = nullptr;
NbInitFn nbInit_ = nullptr;
NbEncFn nbEnc_ = nullptr;
ExitFn exit_ = nullptr;
}; };
class Decoder { class Decoder {
public: public:
bool Open(Codec c) { bool Open() {
codec_ = c; lib_ = dlopen("libopencore-amrwb.so.0", RTLD_NOW);
bool wb = c == Codec::AmrWb;
lib_ = dlopen(wb ? "libopencore-amrwb.so.0" : "libopencore-amrnb.so.0", RTLD_NOW);
if (!lib_) return false; if (!lib_) return false;
init_ = reinterpret_cast<InitFn>(dlsym(lib_, wb ? "D_IF_init" : "Decoder_Interface_init")); init_ = reinterpret_cast<InitFn>(dlsym(lib_, "D_IF_init"));
dec_ = reinterpret_cast<DecFn>(dlsym(lib_, wb ? "D_IF_decode" : "Decoder_Interface_Decode")); dec_ = reinterpret_cast<DecFn>(dlsym(lib_, "D_IF_decode"));
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, wb ? "D_IF_exit" : "Decoder_Interface_exit")); exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "D_IF_exit"));
if (!init_ || !dec_ || !exit_) return false; if (!init_ || !dec_ || !exit_) return false;
st_ = init_(); st_ = init_();
return st_ != nullptr; return st_ != nullptr;
} }
// one storage frame ([header][speech]) -> one 20 ms frame of s16 PCM. // one storage frame ([header][speech]) -> 640 bytes of 16 kHz s16 PCM.
std::vector<std::int16_t> Decode(const std::uint8_t* frame, int len) { std::array<std::int16_t, 320> Decode(const std::uint8_t* frame, int len) {
std::vector<std::int16_t> out(static_cast<std::size_t>(FrameSamples(codec_)), 0); std::array<std::int16_t, 320> out{};
std::vector<std::uint8_t> in(frame, frame + len); std::vector<std::uint8_t> in(frame, frame + len);
dec_(st_, in.data(), out.data(), 0); dec_(st_, in.data(), out.data(), 0);
return out; return out;
} }
~Decoder() { ~Decoder() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); }
if (st_ && exit_) exit_(st_);
if (lib_) dlclose(lib_);
}
private: private:
using InitFn = void* (*)(); 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*); using ExitFn = void (*)(void*);
Codec codec_ = Codec::AmrWb; void* lib_ = nullptr; void* st_ = nullptr;
void* lib_ = nullptr; InitFn init_ = nullptr; DecFn dec_ = nullptr; ExitFn exit_ = 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 // 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. // [(storage-header-byte, speech-bytes)...], speech re-aligned to octets.
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
DepayBe(Codec c, std::span<const std::uint8_t> pl) { DepayBe(std::span<const std::uint8_t> pl) {
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> out; std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> out;
BitReader br{pl}; BitReader br{pl};
if (!br.Ok(4)) return out; if (!br.Ok(4)) return out;
@ -337,9 +189,9 @@ DepayBe(Codec c, std::span<const std::uint8_t> pl) {
if (!f) break; if (!f) break;
} }
for (auto [ft, q] : tocs) { for (auto [ft, q] : tocs) {
int bits = AmrBits(c, ft); int bits = AmrwbBits(ft);
if (bits < 0 || !br.Ok(static_cast<std::size_t>(bits))) break; if (bits < 0 || !br.Ok(static_cast<std::size_t>(bits))) break;
std::vector<std::uint8_t> speech(AmrBytes(c, ft), 0); std::vector<std::uint8_t> speech(AmrwbBytes(ft), 0);
for (int i = 0; i < bits; i++) for (int i = 0; i < bits; i++)
if (br.Take(1)) speech[i >> 3] |= 0x80 >> (i & 7); if (br.Take(1)) speech[i >> 3] |= 0x80 >> (i & 7);
out.emplace_back(static_cast<std::uint8_t>((ft << 3) | (q ? 0x04 : 0)), std::move(speech)); out.emplace_back(static_cast<std::uint8_t>((ft << 3) | (q ? 0x04 : 0)), std::move(speech));
@ -350,8 +202,8 @@ DepayBe(Codec c, std::span<const std::uint8_t> pl) {
// storage-format frame (header byte + octet-aligned speech) -> RTP payload. // storage-format frame (header byte + octet-aligned speech) -> RTP payload.
// Octet-aligned: CMR byte + the frame verbatim (the storage header doubles // Octet-aligned: CMR byte + the frame verbatim (the storage header doubles
// as a ToC byte with F=0). Bandwidth-efficient: 10 header bits + exactly // as a ToC byte with F=0). Bandwidth-efficient: 10 header bits + exactly
// AmrBits(ft) speech bits, final octet zero-padded. // AmrwbBits(ft) speech bits, final octet zero-padded.
std::vector<std::uint8_t> PayloadFromFrame(Codec c, std::span<const std::uint8_t> frame, bool octetAlign) { std::vector<std::uint8_t> PayloadFromFrame(std::span<const std::uint8_t> frame, bool octetAlign) {
if (octetAlign) { if (octetAlign) {
std::vector<std::uint8_t> pl = {0xF0}; std::vector<std::uint8_t> pl = {0xF0};
pl.insert(pl.end(), frame.begin(), frame.end()); pl.insert(pl.end(), frame.begin(), frame.end());
@ -359,7 +211,7 @@ std::vector<std::uint8_t> PayloadFromFrame(Codec c, std::span<const std::uint8_t
} }
int ft = (frame[0] >> 3) & 0x0F; int ft = (frame[0] >> 3) & 0x0F;
int q = (frame[0] >> 2) & 1; int q = (frame[0] >> 2) & 1;
int bits = AmrBits(c, ft); int bits = AmrwbBits(ft);
if (bits < 0) bits = 0; if (bits < 0) bits = 0;
std::vector<std::uint8_t> pl((10 + bits + 7) / 8, 0); std::vector<std::uint8_t> pl((10 + bits + 7) / 8, 0);
auto put = [&](int pos, int n, std::uint32_t v) { auto put = [&](int pos, int n, std::uint32_t v) {
@ -375,10 +227,10 @@ std::vector<std::uint8_t> PayloadFromFrame(Codec c, std::span<const std::uint8_t
return pl; return pl;
} }
// octet-aligned AMR de-payload: skip CMR, read ToC bytes until F=0, then // octet-aligned AMR-WB de-payload: skip CMR, read ToC bytes until F=0, then
// the speech runs. Returns [(storage-header-byte, speech-bytes)...]. // the speech runs. Returns [(storage-header-byte, speech-bytes)...].
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
DepayOctet(Codec c, std::span<const std::uint8_t> pl) { DepayOctet(std::span<const std::uint8_t> pl) {
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> out; std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> out;
if (pl.empty()) return out; if (pl.empty()) return out;
std::size_t i = 1; // skip CMR std::size_t i = 1; // skip CMR
@ -390,7 +242,7 @@ DepayOctet(Codec c, std::span<const std::uint8_t> pl) {
} }
for (std::uint8_t toc : tocs) { for (std::uint8_t toc : tocs) {
int ft = (toc >> 3) & 0x0F; int ft = (toc >> 3) & 0x0F;
int n = AmrBytes(c, ft); int n = AmrwbBytes(ft);
std::vector<std::uint8_t> speech; std::vector<std::uint8_t> speech;
if (n > 0 && i + n <= pl.size()) speech.assign(pl.begin() + i, pl.begin() + i + n); if (n > 0 && i + n <= pl.size()) speech.assign(pl.begin() + i, pl.begin() + i + n);
out.emplace_back(static_cast<std::uint8_t>(toc & 0x7C), std::move(speech)); out.emplace_back(static_cast<std::uint8_t>(toc & 0x7C), std::move(speech));
@ -415,7 +267,7 @@ std::size_t RtpPayloadOffset(std::span<const std::uint8_t> pkt) {
// is postmarketOS's standard account. `toChild` true = we write the child's // 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}. // stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}.
struct Child { pid_t pid = -1; int fd = -1; }; 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]; int pipefd[2];
if (pipe(pipefd) != 0) return {}; if (pipe(pipefd) != 0) return {};
std::vector<std::string> argv; std::vector<std::string> argv;
@ -428,8 +280,7 @@ Child SpawnPw(bool play, bool toChild, int rate) {
} }
const char* tool = play ? "pw-play" : "pw-record"; const char* tool = play ? "pw-play" : "pw-record";
const char* lat = play ? "40ms" : "20ms"; const char* lat = play ? "40ms" : "20ms";
std::string rateStr = std::to_string(rate); for (const char* a : {tool, "--raw", "--rate", "16000", "--channels", "1", "--format", "s16", "--latency", lat, "-"})
for (const char* a : {tool, "--raw", "--rate", rateStr.c_str(), "--channels", "1", "--format", "s16", "--latency", lat, "-"})
argv.emplace_back(a); argv.emplace_back(a);
pid_t pid = fork(); pid_t pid = fork();
if (pid == 0) { if (pid == 0) {
@ -464,7 +315,6 @@ bool ReadExact(int fd, std::uint8_t* buf, std::size_t n) {
struct TxState { struct TxState {
std::mutex lock; std::mutex lock;
int pt = 0; 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 ssrc = 0x5EED1234;
std::uint32_t seq = 1000; std::uint32_t seq = 1000;
std::uint32_t ts = 160000; std::uint32_t ts = 160000;
@ -488,7 +338,7 @@ void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF; hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF;
hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF; hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF;
st.seq = (st.seq + 1) & 0xFFFF; st.seq = (st.seq + 1) & 0xFFFF;
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; a1 = st.dst; l1 = st.dstLen; a2 = st.latched; l2 = st.latchedLen;
} }
std::vector<std::uint8_t> pkt(hdr, hdr + 12); std::vector<std::uint8_t> pkt(hdr, hdr + 12);
@ -505,34 +355,15 @@ void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
} }
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) { Depay(std::span<const std::uint8_t> pl, bool octetAlign) {
return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl); return octetAlign ? DepayOctet(pl) : DepayBe(pl);
} }
// What goes out every 20 ms while the mic is not feeding frames: for AMR a std::vector<std::uint8_t> SilenceFrame(int ft, bool octetAlign) {
// storage frame ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode; for // storage frame: ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode.
// G.711 one frame of digital zero.
std::vector<std::uint8_t> SilenceFrame(Codec c, int ft, bool octetAlign) {
if (!IsAmr(c)) return std::vector<std::uint8_t>(static_cast<std::size_t>(FrameSamples(c)), G711Zero(c));
std::vector<std::uint8_t> f = {static_cast<std::uint8_t>((ft << 3) | 0x04)}; std::vector<std::uint8_t> f = {static_cast<std::uint8_t>((ft << 3) | 0x04)};
f.resize(1 + static_cast<std::size_t>(AmrBytes(c, ft)), 0); f.resize(1 + AmrwbBytes(ft), 0);
return PayloadFromFrame(c, f, octetAlign); return PayloadFromFrame(f, octetAlign);
}
// G.711 uplink: one 20 ms frame of samples -> one payload of companded bytes.
std::vector<std::uint8_t> G711Payload(Codec c, std::span<const std::int16_t> pcm) {
std::vector<std::uint8_t> 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<std::int16_t> G711DecodeFrame(Codec c, std::span<const std::uint8_t> bytes) {
std::vector<std::int16_t> pcm(bytes.size());
for (std::size_t i = 0; i < bytes.size(); i++)
pcm[i] = G711Decode(c, bytes[i]);
return pcm;
} }
std::atomic<bool> Quit{false}; std::atomic<bool> Quit{false};
@ -541,64 +372,31 @@ void OnSig(int) { Quit.store(true); }
// playout queue item // playout queue item
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; }; struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; };
// --selftest: pack->depay roundtrip of every frame type, both payload } // namespace
// 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 main(int argc, char** argv) {
int SelfTest() { if (argc == 2 && std::string_view(argv[1]) == "--selftest") {
for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { // pack->depay roundtrip of every frame type, both payload formats.
for (int ft = 0; ft < 16; ft++) { // BE carries exactly AmrwbBits(ft) bits, so the pattern's padding
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. // bits in the last speech byte must be zero for equality to hold.
std::vector<std::uint8_t> frame = {static_cast<std::uint8_t>((ft << 3) | 0x04)}; for (int ft : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) {
for (int i = 0; i < AmrBytes(c, ft); i++) std::vector<std::uint8_t> frame = {
static_cast<std::uint8_t>((ft << 3) | 0x04)};
int bits = AmrwbBits(ft);
for (int i = 0; i < AmrwbBytes(ft); i++)
frame.push_back(static_cast<std::uint8_t>(0xA5 + i * 31)); frame.push_back(static_cast<std::uint8_t>(0xA5 + i * 31));
if (bits % 8) frame.back() &= static_cast<std::uint8_t>(0xFF << (8 - bits % 8)); if (bits % 8) frame.back() &= static_cast<std::uint8_t>(0xFF << (8 - bits % 8));
for (bool oa : {true, false}) { 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())) { 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; return 1;
} }
} }
} }
}
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<std::int16_t>(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::int16_t>(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"); std::println("selftest OK");
return 0; return 0;
} }
} // namespace
int main(int argc, char** argv) {
if (argc == 2 && std::string_view(argv[1]) == "--selftest") return SelfTest();
if (argc < 8) { if (argc < 8) {
std::println(std::cerr, "usage: imsd-media local rtp_port remote_ip remote_port pt secs out"); std::println(std::cerr, "usage: imsd-media local rtp_port remote_ip remote_port pt secs out");
return 2; return 2;
@ -611,35 +409,25 @@ int main(int argc, char** argv) {
double secs = std::atof(argv[6]); double secs = std::atof(argv[6]);
std::string out = argv[7]; std::string out = argv[7];
Codec codec = ParseCodec(EnvOr("CODEC", "AMR-WB"));
bool amr = IsAmr(codec);
int rate = CodecRate(codec);
auto frameSamples = static_cast<std::size_t>(FrameSamples(codec));
bool mic = EnvBool("MIC", false); bool mic = EnvBool("MIC", false);
bool play = EnvBool("PLAY", 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 gain = std::atof(EnvOr("GAIN", "1.0").c_str());
double playGain = std::atof(EnvOr("PLAY_GAIN", "1.0").c_str()); double playGain = std::atof(EnvOr("PLAY_GAIN", "1.0").c_str());
int dtx = EnvBool("DTX", false) ? 1 : 0; int dtx = EnvBool("DTX", false) ? 1 : 0;
bool octetAlign = EnvBool("OCTET_ALIGN", true); bool octetAlign = EnvBool("OCTET_ALIGN", true);
double mediaTimeout = std::atof(EnvOr("MEDIA_TIMEOUT", "6.0").c_str()); double mediaTimeout = std::atof(EnvOr("MEDIA_TIMEOUT", "6.0").c_str());
bool rtpDump = EnvBool("RTP_DUMP", false); bool rtpDump = EnvBool("RTP_DUMP", false);
std::string micSrc = EnvOr("MIC_SRC", "");
bool pcmDump = EnvBool("PCM_DUMP", false);
constexpr int ExitMediaTimeout = 3; constexpr int ExitMediaTimeout = 3;
constexpr int PrimeFrames = 8; constexpr int PrimeFrames = 8;
constexpr int MaxFill = 25; constexpr int MaxFill = 25;
constexpr std::size_t PlayqMax = 256; constexpr std::size_t PlayqMax = 256;
int sock = socket(Is6(local) ? AF_INET6 : AF_INET, SOCK_DGRAM, 0); int sock = socket(Is6(local) ? AF_INET6 : AF_INET, SOCK_DGRAM, 0);
if (sock < 0) { if (sock < 0) { std::println(std::cerr, "imsd-media: socket failed"); return 1; }
std::println(std::cerr, "imsd-media: socket failed");
return 1;
}
int one = 1; int one = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
sockaddr_storage bindA; sockaddr_storage bindA; socklen_t bindL = MakeAddr(local, rtpPort, bindA);
socklen_t bindL = MakeAddr(local, rtpPort, bindA);
if (bind(sock, reinterpret_cast<sockaddr*>(&bindA), bindL) != 0) { if (bind(sock, reinterpret_cast<sockaddr*>(&bindA), bindL) != 0) {
std::println(std::cerr, "imsd-media: bind [{}]:{} failed", local, rtpPort); std::println(std::cerr, "imsd-media: bind [{}]:{} failed", local, rtpPort);
return 1; return 1;
@ -649,98 +437,63 @@ int main(int argc, char** argv) {
TxState st; TxState st;
st.pt = pt; st.pt = pt;
st.tsStep = static_cast<std::uint32_t>(frameSamples);
st.dstLen = MakeAddr(rIp, rPort, st.dst); st.dstLen = MakeAddr(rIp, rPort, st.dst);
st.latched = st.dst; st.latched = st.dst; st.latchedLen = st.dstLen;
st.latchedLen = st.dstLen;
signal(SIGTERM, OnSig); signal(SIGTERM, OnSig);
signal(SIGINT, OnSig); signal(SIGINT, OnSig);
signal(SIGPIPE, SIG_IGN); signal(SIGPIPE, SIG_IGN);
// ---- mic uplink thread: pw-record (or the MIC_SRC file, real-time paced) // ---- mic uplink thread
// through the codec's encoder, one RTP packet per 20 ms frame
std::atomic<bool> stop{false}; std::atomic<bool> stop{false};
std::jthread micThread; std::jthread micThread;
if (mic) { if (mic) {
micThread = std::jthread([&] { micThread = std::jthread([&] {
Encoder enc; Encoder enc;
if (amr && !enc.Open(codec, dtx)) { if (!enc.Open()) {
std::println(std::cerr, "imsd-media: mic: {} encoder unavailable; silence fallback", CodecName(codec)); std::println(std::cerr, "imsd-media: mic: encoder unavailable; silence fallback");
return; return;
} }
int fd = -1; Child rec = SpawnPw(false, /*toChild=*/false);
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; if (rec.pid < 0) return;
fd = rec.fd; std::uint8_t raw[640];
pid = rec.pid;
}
std::vector<std::uint8_t> raw(frameSamples * 2);
std::vector<std::int16_t> samples(frameSamples);
auto next = Clock::now();
while (!stop.load()) { while (!stop.load()) {
if (!ReadExact(fd, raw.data(), raw.size())) { if (!ReadExact(rec.fd, raw, 640)) {
std::println(std::cerr, "imsd-media: mic: {} EOF; silence fallback", pid > 0 ? "pw-record" : "MIC_SRC"); std::println(std::cerr, "imsd-media: mic: pw-record EOF; silence fallback");
break; break;
} }
if (pid < 0) { std::int16_t samples[320];
// a file delivers instantly; pace it like a microphone std::memcpy(samples, raw, 640);
next += std::chrono::milliseconds(20);
std::this_thread::sleep_until(next);
}
std::memcpy(samples.data(), raw.data(), raw.size());
if (gain != 1.0) if (gain != 1.0)
for (auto& sample : samples) { for (int i = 0; i < 320; i++) {
int v = static_cast<int>(sample * gain); int v = static_cast<int>(samples[i] * gain);
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); samples[i] = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
} }
std::vector<std::uint8_t> payload; std::vector<std::uint8_t> frame = enc.Encode(samples, amrMode, dtx);
if (amr) {
std::vector<std::uint8_t> frame = enc.Encode(samples.data(), amrMode, dtx);
if (frame.empty()) continue; if (frame.empty()) continue;
payload = PayloadFromFrame(codec, frame, octetAlign);
} else {
payload = G711Payload(codec, samples);
}
st.micOn.store(true); st.micOn.store(true);
{ std::scoped_lock g(st.lock); st.txMic++; } { std::scoped_lock g(st.lock); st.txMic++; }
RtpSend(sock, st, payload); RtpSend(sock, st, PayloadFromFrame(frame, octetAlign));
} }
st.micOn.store(false); st.micOn.store(false);
close(fd); close(rec.fd);
if (pid > 0) { kill(rec.pid, SIGKILL);
kill(pid, SIGKILL); waitpid(rec.pid, nullptr, 0);
waitpid(pid, nullptr, 0);
}
}); });
} }
// ---- downlink playout (decode + RTP-timestamp clock reconstruction). // ---- downlink playout (decode + RTP-timestamp clock reconstruction)
// Runs for pw-play (PLAY=1) and/or the PCM dump (PCM_DUMP=1).
Decoder dec; Decoder dec;
Child playCh; Child playCh;
bool playOn = false; bool playOn = false;
bool decodeOn = false;
if (play || pcmDump) {
if (!amr || dec.Open(codec)) {
if (play) { if (play) {
playCh = SpawnPw(true, /*toChild=*/true, rate); if (dec.Open()) {
playCh = SpawnPw(true, /*toChild=*/true);
playOn = playCh.pid >= 0; playOn = playCh.pid >= 0;
}
decodeOn = playOn || pcmDump;
} else { } 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::mutex qlock;
std::condition_variable qcv; std::condition_variable qcv;
std::deque<PktItem> playq; std::deque<PktItem> playq;
@ -749,11 +502,9 @@ int main(int argc, char** argv) {
std::uint64_t late = 0; std::uint64_t late = 0;
std::uint64_t qdrop = 0; std::uint64_t qdrop = 0;
std::jthread playThread; std::jthread playThread;
if (decodeOn) { if (playOn) {
playThread = std::jthread([&] { playThread = std::jthread([&] {
auto writePcm = [&](std::span<const std::int16_t> pcm) { auto writePcm = [&](std::span<const std::int16_t> pcm) {
if (pcmFile) std::fwrite(pcm.data(), 2, pcm.size(), pcmFile);
if (!playOn) return true;
std::size_t bytes = pcm.size() * 2; std::size_t bytes = pcm.size() * 2;
const char* p = reinterpret_cast<const char*>(pcm.data()); const char* p = reinterpret_cast<const char*>(pcm.data());
std::size_t off = 0; std::size_t off = 0;
@ -764,20 +515,14 @@ int main(int argc, char** argv) {
} }
return true; return true;
}; };
auto applyGain = [&](std::vector<std::int16_t>& pcm) { auto applyGain = [&](std::array<std::int16_t, 320>& pcm) {
if (playGain == 1.0) return; if (playGain == 1.0) return;
for (auto& sample : pcm) { for (auto& s : pcm) {
int v = static_cast<int>(sample * playGain); int v = static_cast<int>(s * playGain);
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); s = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
} }
}; };
// Decoder state is time-ordered: the CNG fill for a gap must be std::array<std::int16_t, 320> zero{};
// decoded BEFORE the frames that follow the gap, so depay first,
// decode in playout order.
auto decodeFrame = [&](std::span<const std::uint8_t> f) {
return amr ? dec.Decode(f.data(), static_cast<int>(f.size())) : G711DecodeFrame(codec, f);
};
std::vector<std::int16_t> zero(frameSamples, 0);
std::optional<std::uint32_t> expect; std::optional<std::uint32_t> expect;
for (;;) { for (;;) {
PktItem item; PktItem item;
@ -788,18 +533,7 @@ int main(int argc, char** argv) {
item = std::move(playq.front()); item = std::move(playq.front());
playq.pop_front(); playq.pop_front();
} }
std::vector<std::vector<std::uint8_t>> frames; auto frames = Depay(item.payload, octetAlign);
if (amr) {
for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) {
std::vector<std::uint8_t> 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<std::ptrdiff_t>(off), item.payload.begin() + static_cast<std::ptrdiff_t>(off + frameSamples));
}
if (frames.empty()) continue; if (frames.empty()) continue;
int fill = 0; int fill = 0;
if (!expect) { if (!expect) {
@ -807,39 +541,36 @@ int main(int argc, char** argv) {
if (!writePcm(zero)) return; if (!writePcm(zero)) return;
} else { } else {
std::uint32_t diff = (item.ts - *expect) & 0xFFFFFFFF; std::uint32_t diff = (item.ts - *expect) & 0xFFFFFFFF;
if (diff >= 0x80000000u) { if (diff >= 0x80000000u) { late++; continue; }
late++; fill = static_cast<int>(diff / 320);
continue;
}
fill = static_cast<int>(diff / frameSamples);
if (fill > MaxFill) fill = 0; if (fill > MaxFill) fill = 0;
} }
for (int i = 0; i < fill; i++) { for (int i = 0; i < fill; i++) {
cng++; cng++;
std::uint8_t nodata = 0x7C; std::uint8_t nodata = 0x7C;
std::vector<std::int16_t> pcm = amr ? dec.Decode(&nodata, 1) : zero; auto pcm = dec.Decode(&nodata, 1);
applyGain(pcm); applyGain(pcm);
if (!writePcm(pcm)) return; if (!writePcm(pcm)) return;
} }
for (auto& f : frames) { for (auto& [hdr, speech] : frames) {
rxPlayed++; rxPlayed++;
std::vector<std::int16_t> pcm = decodeFrame(f); std::vector<std::uint8_t> f = {hdr};
f.insert(f.end(), speech.begin(), speech.end());
auto pcm = dec.Decode(f.data(), static_cast<int>(f.size()));
applyGain(pcm); applyGain(pcm);
if (!writePcm(pcm)) return; if (!writePcm(pcm)) return;
} }
expect = (item.ts + static_cast<std::uint32_t>(frameSamples * frames.size())) & 0xFFFFFFFF; expect = (item.ts + 320 * static_cast<std::uint32_t>(frames.size())) & 0xFFFFFFFF;
} }
}); });
} }
// ---- main recv loop // ---- main recv loop
std::vector<std::uint8_t> silence = SilenceFrame(codec, 0, octetAlign); std::vector<std::uint8_t> silence = SilenceFrame(0, octetAlign);
for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst
std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr; std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr;
double t0 = Now(); double t0 = Now(), lastTx = 0, lastRx = Now();
double lastTx = 0;
double lastRx = Now();
std::uint64_t rx = 0; std::uint64_t rx = 0;
std::uint64_t rxBytes = 0; std::uint64_t rxBytes = 0;
bool gotMedia = false; bool gotMedia = false;
@ -857,8 +588,7 @@ int main(int argc, char** argv) {
lastTx = now; lastTx = now;
} }
std::uint8_t buf[65535]; std::uint8_t buf[65535];
sockaddr_storage src; sockaddr_storage src; socklen_t srcLen = sizeof src;
socklen_t srcLen = sizeof src;
ssize_t n = recvfrom(sock, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &srcLen); ssize_t n = recvfrom(sock, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &srcLen);
if (n <= 0) continue; if (n <= 0) continue;
lastRx = Now(); lastRx = Now();
@ -896,10 +626,7 @@ int main(int argc, char** argv) {
std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) | std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) |
(buf[5] << 16) | (buf[6] << 8) | buf[7]; (buf[5] << 16) | (buf[6] << 8) | buf[7];
std::scoped_lock g(qlock); std::scoped_lock g(qlock);
if (playq.size() >= PlayqMax) { if (playq.size() >= PlayqMax) { playq.pop_front(); qdrop++; }
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)});
qcv.notify_one(); qcv.notify_one();
} }
@ -910,7 +637,6 @@ int main(int argc, char** argv) {
if (micThread.joinable()) micThread.join(); if (micThread.joinable()) micThread.join();
if (playThread.joinable()) playThread.join(); if (playThread.joinable()) playThread.join();
if (dump) std::fclose(dump); if (dump) std::fclose(dump);
if (pcmFile) std::fclose(pcmFile);
if (playOn) { if (playOn) {
close(playCh.fd); close(playCh.fd);
int status; int status;
@ -926,14 +652,14 @@ int main(int argc, char** argv) {
// .stats sidecar (tiny; always written) // .stats sidecar (tiny; always written)
if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) {
std::print(sf, std::print(sf,
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " "{{\"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, "
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " "\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, " "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, "
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc,
rIp, rPort, pt, mic, play, amrMode, mediaEnded); rIp, rPort, pt, mic, play, amrMode, mediaEnded);
std::fclose(sf); std::fclose(sf);
} }
std::println("imsd-media: codec={} tx={} tx_mic={} 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; return mediaEnded ? ExitMediaTimeout : 0;
} }

View file

@ -52,8 +52,7 @@ export namespace imsd::engine {
return d == Direction::Incoming ? "incoming" : "outgoing"; return d == Direction::Incoming ? "incoming" : "outgoing";
} }
// Parsed SDP answer needed to bring up the media leg. `codec` is one of // Parsed SDP answer needed to bring up the media leg.
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
struct MediaLeg { struct MediaLeg {
std::string remoteIp; std::string remoteIp;
int remotePort = 0; int remotePort = 0;
@ -157,20 +156,16 @@ export namespace imsd::engine {
// UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made // UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made
// reliable only when the caller Requires 100rel) and arm the ring // reliable only when the caller Requires 100rel) and arm the ring
// timeout. An offer we cannot serve — no audio line, or none of the // timeout. An offer we cannot serve — no audio line, or no AMR-WB
// codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU) — is refused // (the media plane is AMR-WB-only) — is refused with 488 up front,
// with 488 up front, before the UI ever rings; the log line names // before the UI ever rings.
// what WAS offered, so a field journal answers "which codec did the
// gateway want" without a raw SIP dump.
std::vector<Action> OnInvite() { std::vector<Action> OnInvite() {
std::vector<Action> a; std::vector<Action> a;
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a; if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
MediaLeg leg = ParseAnswer(); 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))); a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag)));
std::string why = !leg.Valid() ? "no usable audio line" Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent"));
: std::format("no playable codec, offered: {}", imsd::sdp::OfferedCodecs(answerSdp_));
Append(a, Finish("error", std::format("incoming offer unusable ({}); 488 sent", why)));
return a; return a;
} }
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag))); 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.payloadType = leg.payloadType;
spec.octetAlign = leg.octetAlign; spec.octetAlign = leg.octetAlign;
spec.codec = leg.codec; spec.codec = leg.codec;
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec)); spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), 16000);
localSdp_ = imsd::sdp::BuildAnswer(spec); localSdp_ = imsd::sdp::BuildAnswer(spec);
// session timers: echo the caller's Session-Expires; with no // session timers: echo the caller's Session-Expires; with no
// refresher stated, make the caller (uac) the refresher — its // refresher stated, make the caller (uac) the refresher — its

View file

@ -3,20 +3,17 @@
// lint-disable-file fixed-width-types // 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 The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an
with an optional GSMA IR.92 QoS-precondition block; it is what commercial IMS optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores
cores expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The 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 parser extracts only what the media plane needs: remote address/port, the
negotiated payload type, the codec and octet-align mode — it reads answers negotiated payload type, and octet-align mode — it reads answers to our
to our offers and inbound offers alike (same fields either way). The codecs offers and inbound offers alike (same fields either way). BuildAnswer is the
it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) and terminating side: it answers an inbound offer with the offer's own payload
G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a type numbers (RFC 3264) and mirrors its octet-align mode (RFC 4867 requires
landline calls. BuildAnswer is the terminating side: it answers an inbound both directions to use one mode). Pure std C++.
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; export module Imsd:Sdp;
@ -57,11 +54,7 @@ export namespace imsd::sdp {
std::string currRemote = "none"; // a=curr:qos remote <this> std::string currRemote = "none"; // a=curr:qos remote <this>
}; };
// Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the // AMR-WB (octet-aligned) offer. Byte layout is pinned by tests/Sdp.
// 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.
std::string BuildOffer(const Offer& o) { std::string BuildOffer(const Offer& o) {
std::string ipver = Is6(o.local) ? "IP6" : "IP4"; std::string ipver = Is6(o.local) ? "IP6" : "IP4";
std::string s; std::string s;
@ -70,18 +63,14 @@ export namespace imsd::sdp {
s += "s=-\r\n"; s += "s=-\r\n";
s += std::format("c=IN {} {}\r\n", ipver, o.local); s += std::format("c=IN {} {}\r\n", ipver, o.local);
s += "t=0 0\r\n"; 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=AS:41\r\n";
s += "b=RS:512\r\n"; s += "b=RS:512\r\n";
s += "b=RR:1536\r\n"; s += "b=RR:1536\r\n";
s += "a=rtpmap:97 AMR-WB/16000/1\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=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=rtpmap:98 telephone-event/16000\r\n";
s += "a=fmtp:98 0-15\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=ptime:20\r\n";
s += "a=maxptime:240\r\n"; s += "a=maxptime:240\r\n";
if (o.precond) { if (o.precond) {
@ -99,60 +88,18 @@ export namespace imsd::sdp {
int port = -1; // -1 = no m=audio line found int port = -1; // -1 = no m=audio line found
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play) std::string codec = "AMR-WB";
}; };
// 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<char>(std::toupper(static_cast<unsigned char>(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 <port> <proto> <pt>...
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 // 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 // octet-align=1 > AMR. Octet-aligned wins within a codec because it is
// because it is the media leg's native, call-proven format // the media leg's native, call-proven format (bandwidth-efficient is
// (bandwidth-efficient is supported but was the s56 static-audio // supported but was the s56 static-audio culprit) — when the peer offers
// culprit) — when the peer offers both variants, taking the // both variants, taking the octet-aligned pt keeps real calls on the
// octet-aligned pt keeps real calls on the battle-tested path. Wideband // battle-tested path. With no rtpmap match at all, the first pt is used
// beats narrowband, AMR beats G.711 (a mobile-to-mobile call must never // and codec comes back EMPTY — the body named no AMR variant (e.g. a
// land on a G.711 leg just because the gateway listed it). With no // PCMA offer), and pretending otherwise would defeat codec checks
// playable codec at all, the first pt is used and codec comes back EMPTY // upstream.
// — pretending otherwise would defeat codec checks upstream.
Answer ParseAnswer(std::string_view body) { Answer ParseAnswer(std::string_view body) {
Answer a; Answer a;
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9); if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
@ -188,20 +135,20 @@ export namespace imsd::sdp {
int wb = -1; int wb = -1;
int nbOa = -1; int nbOa = -1;
int nb = -1; int nb = -1;
int pcma = -1;
int pcmu = -1;
for (int p : pts) { for (int p : pts) {
std::string name = CodecName(body, p); std::string key = std::format("a=rtpmap:{} ", p);
if (name == "AMR-WB") { 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 (wb == -1) wb = p;
if (wbOa == -1 && hasOctetAlign(p)) wbOa = p; if (wbOa == -1 && hasOctetAlign(p)) wbOa = p;
} else if (name == "AMR") { } else if (name.contains("AMR")) {
if (nb == -1) nb = p; if (nb == -1) nb = p;
if (nbOa == -1 && hasOctetAlign(p)) nbOa = 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) { if (wbOa != -1 || wb != -1) {
@ -210,17 +157,11 @@ export namespace imsd::sdp {
} else if (nbOa != -1 || nb != -1) { } else if (nbOa != -1 || nb != -1) {
a.payloadType = nbOa != -1 ? nbOa : nb; a.payloadType = nbOa != -1 ? nbOa : nb;
a.codec = "AMR"; a.codec = "AMR";
} else if (pcma != -1) {
a.payloadType = pcma;
a.codec = "PCMA";
} else if (pcmu != -1) {
a.payloadType = pcmu;
a.codec = "PCMU";
} else { } else {
a.payloadType = pts.front(); a.payloadType = pts.front();
a.codec.clear(); a.codec.clear();
} }
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType); a.octetAlign = hasOctetAlign(a.payloadType);
return a; return a;
} }
@ -248,24 +189,18 @@ export namespace imsd::sdp {
int rtpPort = 0; int rtpPort = 0;
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
int payloadType = 97; // the offer's pt for the chosen codec int payloadType = 97; // the offer's pt for the chosen codec
bool octetAlign = true; // mirror of the offer's mode (AMR codecs only) bool octetAlign = true; // mirror of the offer's mode
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" std::string codec = "AMR-WB"; // "AMR-WB" or "AMR"
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
// SDP answer to an inbound audio offer: single codec at the offer's own // SDP answer to an inbound audio offer: single codec at the offer's own
// payload type, octet-align echoed only when the offer used it (absence // payload type, octet-align echoed only when the offer used it (absence
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all), // = bandwidth-efficient, RFC 4867 §8.1), telephone-event kept when
// telephone-event kept when offered. b=AS is the codec's peak payload // offered. Byte layout pinned by tests/Sdp.
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
std::string BuildAnswer(const AnswerSpec& s) { std::string BuildAnswer(const AnswerSpec& s) {
std::string ipver = Is6(s.local) ? "IP6" : "IP4"; std::string ipver = Is6(s.local) ? "IP6" : "IP4";
int rate = CodecRate(s.codec); int rate = s.codec == "AMR-WB" ? 16000 : 8000;
int as = s.codec == "AMR-WB" ? 41 : (s.codec == "AMR" ? 30 : 80);
std::string out; std::string out;
out += "v=0\r\n"; out += "v=0\r\n";
out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local); 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); if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt);
else else
out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType); 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=RS:512\r\n";
out += "b=RR:1536\r\n"; out += "b=RR:1536\r\n";
out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate); 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) { if (s.dtmfPt) {
out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate); out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate);
out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt); out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt);

View file

@ -5,10 +5,10 @@
# README's "Cross-compiling" section into a proper apk — used by this repo's # 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 # package CI (packaging/build-package.sh, which also seds pkgver from
# implementations/main.cpp) and runnable by hand. The source tarball is # implementations/main.cpp) and runnable by hand. The source tarball is
# produced by packaging/make-bin-tarball.sh; the desktop/preset files live # produced by packaging/make-bin-tarball.sh; the desktop/preset files come
# next to it in packaging/ (copy them beside this file). Since 2026-09-02 # from packaging/aport/ (copy them next to this file). The package payload
# this is the only imsd packaging: fp6-img images install the apk this # must stay identical to packaging/aport/APKBUILD's, or an apk upgrade
# builds from the registry rather than building their own. # across the two pipelines would add/strip files on users' phones.
pkgname=imsd pkgname=imsd
pkgver=0.3.1 pkgver=0.3.1
pkgrel=0 pkgrel=0

81
packaging/aport/APKBUILD Normal file
View file

@ -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 <jorijnvdgraaf@catcrafts.net>"
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
"

View file

@ -88,8 +88,8 @@ PKG="$HOME/pkg"
rm -rf "$PKG" rm -rf "$PKG"
mkdir -p "$PKG" mkdir -p "$PKG"
cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD" cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD"
cp "$SRC/packaging/org.kde.modem.daemon.desktop" \ cp "$SRC/packaging/aport/org.kde.modem.daemon.desktop" \
"$SRC/packaging/80-imsd.preset" "$PKG/" "$SRC/packaging/aport/80-imsd.preset" "$PKG/"
mv "imsd-$VER.tar.gz" "$PKG/" mv "imsd-$VER.tar.gz" "$PKG/"
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD" sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
# a throwaway signing key: phones trust the registry-signed APKINDEX, not # a throwaway signing key: phones trust the registry-signed APKINDEX, not

View file

@ -8,10 +8,8 @@
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180 // after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200 // on the INVITE (reliable when the caller requires 100rel), Accept -> 200
// with the SDP answer at the offer's payload types + media, ACK silence, // with the SDP answer at the offer's payload types + media, ACK silence,
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
// what was offered), a landline gateway's G.711-only and AMR-NB offers // timeout -> 480, BYE both ways. Pure reducer, no I/O.
// ring and are answered at the offer's own payload types, ring timeout ->
// 480, BYE both ways. Pure reducer, no I/O.
import std; import std;
import Imsd; import Imsd;
@ -481,109 +479,26 @@ int main() {
Check(m.Terminated(), "terminated after remote CANCEL"); Check(m.Terminated(), "terminated after remote CANCEL");
} }
// an inbound INVITE around an arbitrary offer (the gateway shapes below) // ---- offer we cannot serve (no AMR-WB): 488 before ringing
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: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\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.
{ {
imsd::util::Rng rng(29); imsd::util::Rng rng(29);
std::string sdp = std::string sdp =
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n" "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" "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"; "m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n";
CallMachine m(c, rng, "ims-call-11", IncomingInvite{MtInviteSdp("mt-pcma", sdp)}, 50004); std::string inv = std::format(
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\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(); 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 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488");
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 180 Ringing") == nullptr, "no 180 for a 488"); Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
Check(m.Terminated(), "terminated on 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 // ---- ring timeout: 480, missed call

View file

@ -96,18 +96,14 @@ namespace {
"s=-\r\n" "s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n" "c=IN IP6 2001:db8::db43\r\n"
"t=0 0\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=AS:41\r\n"
"b=RS:512\r\n" "b=RS:512\r\n"
"b=RR:1536\r\n" "b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\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=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=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\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=ptime:20\r\n"
"a=maxptime:240\r\n" "a=maxptime:240\r\n"
"a=sendrecv\r\n"; "a=sendrecv\r\n";
@ -133,25 +129,21 @@ namespace {
"Session-Expires: 1800;refresher=uac\r\n" "Session-Expires: 1800;refresher=uac\r\n"
"Min-SE: 90\r\n" "Min-SE: 90\r\n"
"Content-Type: application/sdp\r\n" "Content-Type: application/sdp\r\n"
"Content-Length: 466\r\n" "Content-Length: 323\r\n"
"\r\n" "\r\n"
"v=0\r\n" "v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n" "o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n" "s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n" "c=IN IP6 2001:db8::db43\r\n"
"t=0 0\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=AS:41\r\n"
"b=RS:512\r\n" "b=RS:512\r\n"
"b=RR:1536\r\n" "b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\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=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=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\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=ptime:20\r\n"
"a=maxptime:240\r\n" "a=maxptime:240\r\n"
"a=sendrecv\r\n"; "a=sendrecv\r\n";

View file

@ -37,18 +37,14 @@ int main() {
"s=-\r\n" "s=-\r\n"
"c=IN IP6 2001:db8:29e9:a05f::1\r\n" "c=IN IP6 2001:db8:29e9:a05f::1\r\n"
"t=0 0\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=AS:41\r\n"
"b=RS:512\r\n" "b=RS:512\r\n"
"b=RR:1536\r\n" "b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\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=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=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\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=ptime:20\r\n"
"a=maxptime:240\r\n" "a=maxptime:240\r\n"
"a=curr:qos local sendrecv\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.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
Check(a.octetAlign, "octet-align seen for AMR too"); Check(a.octetAlign, "octet-align seen for AMR too");
} }
// ---- G.711 by static payload type, no rtpmap at all (RFC 3551 §6): // ---- no rtpmap at all -> first payload type, bandwidth-efficient
// the shape a PSTN gateway sends for a landline caller
{ {
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 0 8\r\n"); 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, "PCMA preferred over an earlier PCMU"); Check(a.payloadType == 8, "no rtpmap -> first payload type");
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");
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient"); Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
Check(a.codec.empty(), "G729-only -> codec unknown, not assumed"); Check(a.codec.empty(), "no AMR rtpmap -> 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");
} }
// ---- degenerate body // ---- degenerate body
{ {
@ -258,41 +198,6 @@ int main() {
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none"); Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth"); 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"); if (Failures == 0) std::println("Sdp: all tests passed");
return Failures; return Failures;