Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1987275a54 | |||
| d5aaf57e49 | |||
| a064b5deff | |||
| 649923d36f | |||
| daaba2d657 |
13 changed files with 775 additions and 301 deletions
|
|
@ -14,9 +14,10 @@ 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. fp6-img images keep building imsd from their own
|
# Version constant bumps. This is the only producer of the imsd apk: fp6-img
|
||||||
# pinned checkout of this repo (packaging/aport/); APKBUILD.binary's payload
|
# images install the published package from the registry (pinned version,
|
||||||
# must stay identical to that aport's, so the two pipelines cannot skew.
|
# pinned sha256) instead of building their own, so image and 'apk upgrade'
|
||||||
|
# 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;
|
||||||
|
|
|
||||||
|
|
@ -1201,16 +1201,32 @@ 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(call_->OnInvite());
|
Execute(actions);
|
||||||
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).
|
||||||
bool Execute(const std::vector<imsd::engine::Action>& actions) {
|
// `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<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) {
|
||||||
|
|
@ -1224,8 +1240,13 @@ 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: PostState(call_->Uni(), a.state, a.reason); break;
|
case T::State:
|
||||||
case T::Deleted: PostDeleted(call_->Uni()); dropCall_ = true; break;
|
if (announce) PostState(call_->Uni(), a.state, a.reason);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -1257,6 +1278,7 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,42 @@
|
||||||
|
|
||||||
// lint-disable-file fixed-width-types no-char-pointer
|
// lint-disable-file fixed-width-types no-char-pointer
|
||||||
/*
|
/*
|
||||||
imsd-media — the RTP/AMR-WB media leg for a userspace VoLTE call, spawned as
|
imsd-media — the RTP media leg for a userspace VoLTE call, spawned as its
|
||||||
its own process by the daemon (imsd) exactly as the Python prototype spawned
|
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.
|
||||||
|
|
||||||
Binds the advertised local RTP port, sends uplink octet-aligned AMR-WB frames
|
Codecs (CODEC env, set by the daemon from the negotiated SDP): AMR-WB
|
||||||
toward the media gateway, captures the downlink, and — with PLAY=1 —
|
(16 kHz; the mobile-to-mobile VoLTE codec, the default), AMR narrowband and
|
||||||
reconstructs the media clock from RTP timestamps so pw-play stays real-time-
|
G.711 PCMA/PCMU (8 kHz; what a PSTN gateway offers when a landline calls).
|
||||||
paced through far-end DTX silence (every missing 20 ms slot is decoded as an
|
AMR frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient
|
||||||
FT-15 NO_DATA frame for CNG/PLC). MIC=1 feeds live pw-record audio through
|
(OCTET_ALIGN, mirrored from the SDP); G.711 is raw samples. The AMR codecs
|
||||||
libvo-amrwbenc; downlink decode is libopencore-amrwb. Both codecs are dlopen'd
|
are dlopen'd (libvo-amrwbenc + libopencore-amrwb for WB, libopencore-amrnb
|
||||||
so the binary has no link-time dependency on them.
|
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.
|
||||||
|
|
||||||
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,
|
||||||
Env: MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP OCTET_ALIGN
|
both AMR codecs; G.711 table roundtrip)
|
||||||
AUDIO_USER
|
Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP
|
||||||
|
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>
|
||||||
|
|
@ -46,28 +60,125 @@ double Now() {
|
||||||
return std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
|
return std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
|
||||||
}
|
}
|
||||||
|
|
||||||
// octet-aligned AMR-WB speech-frame byte sizes by frame type (mode).
|
enum class Codec { AmrWb, AmrNb, Pcma, Pcmu };
|
||||||
constexpr int AmrwbBytes(int ft) {
|
|
||||||
switch (ft) {
|
|
||||||
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
|
Codec ParseCodec(std::string_view name) {
|
||||||
// exact payload bit counts of the bandwidth-efficient format. FT 14/15
|
if (name == "AMR") return Codec::AmrNb;
|
||||||
// (SPEECH_LOST/NO_DATA) carry 0 bits; unknown FTs return -1 so a corrupt
|
if (name == "PCMA") return Codec::Pcma;
|
||||||
// ToC aborts the packet instead of shifting every later bit.
|
if (name == "PCMU") return Codec::Pcmu;
|
||||||
constexpr int AmrwbBits(int ft) {
|
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) {
|
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);
|
||||||
|
|
@ -97,62 +208,99 @@ socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) {
|
||||||
return sizeof(sockaddr_in);
|
return sizeof(sockaddr_in);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- AMR-WB codecs via dlopen (vo-amrwbenc E_IF_*, opencore-amrwb D_IF_*) --
|
// ---- 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.
|
||||||
|
|
||||||
class Encoder {
|
class Encoder {
|
||||||
public:
|
public:
|
||||||
bool Open() {
|
bool Open(Codec c, int dtx) {
|
||||||
|
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;
|
||||||
init_ = reinterpret_cast<InitFn>(dlsym(lib_, "E_IF_init"));
|
wbInit_ = reinterpret_cast<WbInitFn>(dlsym(lib_, "E_IF_init"));
|
||||||
enc_ = reinterpret_cast<EncFn>(dlsym(lib_, "E_IF_encode"));
|
wbEnc_ = reinterpret_cast<WbEncFn>(dlsym(lib_, "E_IF_encode"));
|
||||||
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "E_IF_exit"));
|
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "E_IF_exit"));
|
||||||
if (!init_ || !enc_ || !exit_) return false;
|
if (!wbInit_ || !wbEnc_ || !exit_) return false;
|
||||||
st_ = init_();
|
st_ = wbInit_();
|
||||||
return st_ != nullptr;
|
return st_ != nullptr;
|
||||||
}
|
}
|
||||||
// 320 int16 samples -> one RFC 3267 storage frame (header byte + speech).
|
lib_ = dlopen("libopencore-amrnb.so.0", RTLD_NOW);
|
||||||
|
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 = enc_(st_, static_cast<std::int16_t>(mode), samples, out, static_cast<std::int16_t>(dtx));
|
int n = codec_ == Codec::AmrWb
|
||||||
|
? 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() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); }
|
~Encoder() {
|
||||||
|
if (st_ && exit_) exit_(st_);
|
||||||
|
if (lib_) dlclose(lib_);
|
||||||
|
}
|
||||||
private:
|
private:
|
||||||
using InitFn = void* (*)();
|
using WbInitFn = void* (*)();
|
||||||
using EncFn = int (*)(void*, std::int16_t, std::int16_t*, std::uint8_t*, std::int16_t);
|
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 ExitFn = void (*)(void*);
|
using ExitFn = void (*)(void*);
|
||||||
void* lib_ = nullptr; void* st_ = nullptr;
|
Codec codec_ = Codec::AmrWb;
|
||||||
InitFn init_ = nullptr; EncFn enc_ = nullptr; ExitFn exit_ = nullptr;
|
void* lib_ = 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() {
|
bool Open(Codec c) {
|
||||||
lib_ = dlopen("libopencore-amrwb.so.0", RTLD_NOW);
|
codec_ = c;
|
||||||
|
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_, "D_IF_init"));
|
init_ = reinterpret_cast<InitFn>(dlsym(lib_, wb ? "D_IF_init" : "Decoder_Interface_init"));
|
||||||
dec_ = reinterpret_cast<DecFn>(dlsym(lib_, "D_IF_decode"));
|
dec_ = reinterpret_cast<DecFn>(dlsym(lib_, wb ? "D_IF_decode" : "Decoder_Interface_Decode"));
|
||||||
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "D_IF_exit"));
|
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, wb ? "D_IF_exit" : "Decoder_Interface_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]) -> 640 bytes of 16 kHz s16 PCM.
|
// one storage frame ([header][speech]) -> one 20 ms frame of s16 PCM.
|
||||||
std::array<std::int16_t, 320> Decode(const std::uint8_t* frame, int len) {
|
std::vector<std::int16_t> Decode(const std::uint8_t* frame, int len) {
|
||||||
std::array<std::int16_t, 320> out{};
|
std::vector<std::int16_t> out(static_cast<std::size_t>(FrameSamples(codec_)), 0);
|
||||||
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() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); }
|
~Decoder() {
|
||||||
|
if (st_ && exit_) exit_(st_);
|
||||||
|
if (lib_) dlclose(lib_);
|
||||||
|
}
|
||||||
private:
|
private:
|
||||||
using InitFn = void* (*)();
|
using InitFn = void* (*)();
|
||||||
using DecFn = void (*)(void*, std::uint8_t*, std::int16_t*, int);
|
using DecFn = void (*)(void*, const std::uint8_t*, std::int16_t*, int);
|
||||||
using ExitFn = void (*)(void*);
|
using ExitFn = void (*)(void*);
|
||||||
void* lib_ = nullptr; void* st_ = nullptr;
|
Codec codec_ = Codec::AmrWb;
|
||||||
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
|
// MSB-first bit cursor over an RTP payload (bandwidth-efficient AMR-WB is
|
||||||
|
|
@ -170,10 +318,10 @@ struct BitReader {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// bandwidth-efficient AMR-WB de-payload. Same contract as DepayOctet:
|
// bandwidth-efficient AMR 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(std::span<const std::uint8_t> pl) {
|
DepayBe(Codec c, 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;
|
||||||
|
|
@ -189,9 +337,9 @@ DepayBe(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 = AmrwbBits(ft);
|
int bits = AmrBits(c, 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(AmrwbBytes(ft), 0);
|
std::vector<std::uint8_t> speech(AmrBytes(c, 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));
|
||||||
|
|
@ -202,8 +350,8 @@ DepayBe(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
|
||||||
// AmrwbBits(ft) speech bits, final octet zero-padded.
|
// AmrBits(ft) speech bits, final octet zero-padded.
|
||||||
std::vector<std::uint8_t> PayloadFromFrame(std::span<const std::uint8_t> frame, bool octetAlign) {
|
std::vector<std::uint8_t> PayloadFromFrame(Codec c, 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());
|
||||||
|
|
@ -211,7 +359,7 @@ std::vector<std::uint8_t> PayloadFromFrame(std::span<const std::uint8_t> frame,
|
||||||
}
|
}
|
||||||
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 = AmrwbBits(ft);
|
int bits = AmrBits(c, 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) {
|
||||||
|
|
@ -227,10 +375,10 @@ std::vector<std::uint8_t> PayloadFromFrame(std::span<const std::uint8_t> frame,
|
||||||
return pl;
|
return pl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// octet-aligned AMR-WB de-payload: skip CMR, read ToC bytes until F=0, then
|
// octet-aligned AMR 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(std::span<const std::uint8_t> pl) {
|
DepayOctet(Codec c, 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
|
||||||
|
|
@ -242,7 +390,7 @@ DepayOctet(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 = AmrwbBytes(ft);
|
int n = AmrBytes(c, 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));
|
||||||
|
|
@ -267,7 +415,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) {
|
Child SpawnPw(bool play, bool toChild, int rate) {
|
||||||
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;
|
||||||
|
|
@ -280,7 +428,8 @@ Child SpawnPw(bool play, bool toChild) {
|
||||||
}
|
}
|
||||||
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";
|
||||||
for (const char* a : {tool, "--raw", "--rate", "16000", "--channels", "1", "--format", "s16", "--latency", lat, "-"})
|
std::string rateStr = std::to_string(rate);
|
||||||
|
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) {
|
||||||
|
|
@ -315,6 +464,7 @@ 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;
|
||||||
|
|
@ -338,7 +488,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 + 320) & 0xFFFFFFFF;
|
st.ts = (st.ts + st.tsStep) & 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);
|
||||||
|
|
@ -355,15 +505,34 @@ 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(std::span<const std::uint8_t> pl, bool octetAlign) {
|
Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) {
|
||||||
return octetAlign ? DepayOctet(pl) : DepayBe(pl);
|
return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::uint8_t> SilenceFrame(int ft, bool octetAlign) {
|
// 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.
|
// storage frame ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode; for
|
||||||
|
// 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 + AmrwbBytes(ft), 0);
|
f.resize(1 + static_cast<std::size_t>(AmrBytes(c, ft)), 0);
|
||||||
return PayloadFromFrame(f, octetAlign);
|
return PayloadFromFrame(c, 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};
|
||||||
|
|
@ -372,31 +541,64 @@ 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; };
|
||||||
|
|
||||||
} // namespace
|
// --selftest: pack->depay roundtrip of every frame type, both payload
|
||||||
|
// formats, both AMR codecs; G.711 digital zero, idempotence over the whole
|
||||||
int main(int argc, char** argv) {
|
// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR.
|
||||||
if (argc == 2 && std::string_view(argv[1]) == "--selftest") {
|
int SelfTest() {
|
||||||
// pack->depay roundtrip of every frame type, both payload formats.
|
for (Codec c : {Codec::AmrWb, Codec::AmrNb}) {
|
||||||
// BE carries exactly AmrwbBits(ft) bits, so the pattern's padding
|
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.
|
// 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<std::uint8_t> frame = {static_cast<std::uint8_t>((ft << 3) | 0x04)};
|
||||||
std::vector<std::uint8_t> frame = {
|
for (int i = 0; i < AmrBytes(c, ft); i++)
|
||||||
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(PayloadFromFrame(frame, oa), oa);
|
auto got = Depay(c, PayloadFromFrame(c, 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={}", ft, oa);
|
std::println(std::cerr, "selftest FAIL {} ft={} oa={}", CodecName(c), 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;
|
||||||
|
|
@ -409,25 +611,35 @@ 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", "2").c_str());
|
int amrMode = std::atoi(EnvOr("AMR_MODE", codec == Codec::AmrWb ? "2" : "7").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) { 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;
|
int one = 1;
|
||||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
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<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;
|
||||||
|
|
@ -437,63 +649,98 @@ 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.latchedLen = st.dstLen;
|
st.latched = st.dst;
|
||||||
|
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
|
// ---- 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
|
||||||
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 (!enc.Open()) {
|
if (amr && !enc.Open(codec, dtx)) {
|
||||||
std::println(std::cerr, "imsd-media: mic: encoder unavailable; silence fallback");
|
std::println(std::cerr, "imsd-media: mic: {} encoder unavailable; silence fallback", CodecName(codec));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Child rec = SpawnPw(false, /*toChild=*/false);
|
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;
|
if (rec.pid < 0) return;
|
||||||
std::uint8_t raw[640];
|
fd = rec.fd;
|
||||||
|
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(rec.fd, raw, 640)) {
|
if (!ReadExact(fd, raw.data(), raw.size())) {
|
||||||
std::println(std::cerr, "imsd-media: mic: pw-record EOF; silence fallback");
|
std::println(std::cerr, "imsd-media: mic: {} EOF; silence fallback", pid > 0 ? "pw-record" : "MIC_SRC");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
std::int16_t samples[320];
|
if (pid < 0) {
|
||||||
std::memcpy(samples, raw, 640);
|
// a file delivers instantly; pace it like a microphone
|
||||||
if (gain != 1.0)
|
next += std::chrono::milliseconds(20);
|
||||||
for (int i = 0; i < 320; i++) {
|
std::this_thread::sleep_until(next);
|
||||||
int v = static_cast<int>(samples[i] * gain);
|
|
||||||
samples[i] = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
|
|
||||||
}
|
}
|
||||||
std::vector<std::uint8_t> frame = enc.Encode(samples, amrMode, dtx);
|
std::memcpy(samples.data(), raw.data(), raw.size());
|
||||||
|
if (gain != 1.0)
|
||||||
|
for (auto& sample : samples) {
|
||||||
|
int v = static_cast<int>(sample * gain);
|
||||||
|
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
|
||||||
|
}
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
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, PayloadFromFrame(frame, octetAlign));
|
RtpSend(sock, st, payload);
|
||||||
}
|
}
|
||||||
st.micOn.store(false);
|
st.micOn.store(false);
|
||||||
close(rec.fd);
|
close(fd);
|
||||||
kill(rec.pid, SIGKILL);
|
if (pid > 0) {
|
||||||
waitpid(rec.pid, nullptr, 0);
|
kill(pid, SIGKILL);
|
||||||
|
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) {
|
||||||
if (dec.Open()) {
|
playCh = SpawnPw(true, /*toChild=*/true, rate);
|
||||||
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");
|
std::println(std::cerr, "imsd-media: play: {} decoder unavailable; capture only", CodecName(codec));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
|
@ -502,9 +749,11 @@ 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 (playOn) {
|
if (decodeOn) {
|
||||||
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;
|
||||||
|
|
@ -515,14 +764,20 @@ int main(int argc, char** argv) {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
auto applyGain = [&](std::array<std::int16_t, 320>& pcm) {
|
auto applyGain = [&](std::vector<std::int16_t>& pcm) {
|
||||||
if (playGain == 1.0) return;
|
if (playGain == 1.0) return;
|
||||||
for (auto& s : pcm) {
|
for (auto& sample : pcm) {
|
||||||
int v = static_cast<int>(s * playGain);
|
int v = static_cast<int>(sample * playGain);
|
||||||
s = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
|
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
std::array<std::int16_t, 320> zero{};
|
// 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<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;
|
||||||
|
|
@ -533,7 +788,18 @@ int main(int argc, char** argv) {
|
||||||
item = std::move(playq.front());
|
item = std::move(playq.front());
|
||||||
playq.pop_front();
|
playq.pop_front();
|
||||||
}
|
}
|
||||||
auto frames = Depay(item.payload, octetAlign);
|
std::vector<std::vector<std::uint8_t>> frames;
|
||||||
|
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) {
|
||||||
|
|
@ -541,36 +807,39 @@ 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) { late++; continue; }
|
if (diff >= 0x80000000u) {
|
||||||
fill = static_cast<int>(diff / 320);
|
late++;
|
||||||
|
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;
|
||||||
auto pcm = dec.Decode(&nodata, 1);
|
std::vector<std::int16_t> pcm = amr ? dec.Decode(&nodata, 1) : zero;
|
||||||
applyGain(pcm);
|
applyGain(pcm);
|
||||||
if (!writePcm(pcm)) return;
|
if (!writePcm(pcm)) return;
|
||||||
}
|
}
|
||||||
for (auto& [hdr, speech] : frames) {
|
for (auto& f : frames) {
|
||||||
rxPlayed++;
|
rxPlayed++;
|
||||||
std::vector<std::uint8_t> f = {hdr};
|
std::vector<std::int16_t> pcm = decodeFrame(f);
|
||||||
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 + 320 * static_cast<std::uint32_t>(frames.size())) & 0xFFFFFFFF;
|
expect = (item.ts + static_cast<std::uint32_t>(frameSamples * frames.size())) & 0xFFFFFFFF;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- main recv loop
|
// ---- main recv loop
|
||||||
std::vector<std::uint8_t> silence = SilenceFrame(0, octetAlign);
|
std::vector<std::uint8_t> silence = SilenceFrame(codec, 0, octetAlign);
|
||||||
for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst
|
for (int i = 0; i < 5; i++) 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(), lastTx = 0, lastRx = Now();
|
double t0 = 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;
|
||||||
|
|
@ -588,7 +857,8 @@ int main(int argc, char** argv) {
|
||||||
lastTx = now;
|
lastTx = now;
|
||||||
}
|
}
|
||||||
std::uint8_t buf[65535];
|
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<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();
|
||||||
|
|
@ -626,7 +896,10 @@ 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) { playq.pop_front(); qdrop++; }
|
if (playq.size() >= PlayqMax) {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
@ -637,6 +910,7 @@ 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;
|
||||||
|
|
@ -652,14 +926,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,
|
||||||
"{{\"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, "
|
"{{\"codec\": \"{}\", \"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\": {}}}",
|
||||||
st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc,
|
CodecName(codec), 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: 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);
|
std::println("imsd-media: codec={} tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded);
|
||||||
return mediaEnded ? ExitMediaTimeout : 0;
|
return mediaEnded ? ExitMediaTimeout : 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ 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.
|
// 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).
|
||||||
struct MediaLeg {
|
struct MediaLeg {
|
||||||
std::string remoteIp;
|
std::string remoteIp;
|
||||||
int remotePort = 0;
|
int remotePort = 0;
|
||||||
|
|
@ -156,16 +157,20 @@ 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 no AMR-WB
|
// timeout. An offer we cannot serve — no audio line, or none of the
|
||||||
// (the media plane is AMR-WB-only) — is refused with 488 up front,
|
// codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU) — is refused
|
||||||
// before the UI ever rings.
|
// 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.
|
||||||
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 != "AMR-WB") {
|
if (!leg.Valid() || leg.codec.empty()) {
|
||||||
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)));
|
||||||
Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent"));
|
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)));
|
||||||
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)));
|
||||||
|
|
@ -196,7 +201,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_), 16000);
|
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec));
|
||||||
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
||||||
// session timers: echo the caller's Session-Expires; with no
|
// session timers: echo the caller's Session-Expires; with no
|
||||||
// refresher stated, make the caller (uac) the refresher — its
|
// refresher stated, make the caller (uac) the refresher — its
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,20 @@
|
||||||
|
|
||||||
// lint-disable-file fixed-width-types
|
// lint-disable-file fixed-width-types
|
||||||
/*
|
/*
|
||||||
Imsd:Sdp — AMR-WB SDP offer/answer building + parsing.
|
Imsd:Sdp — SDP offer/answer building + parsing for the voice media plane.
|
||||||
|
|
||||||
The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an
|
The offer is a fixed AMR-WB (octet-aligned) + AMR + telephone-event shape
|
||||||
optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores
|
with an optional GSMA IR.92 QoS-precondition block; it is what commercial IMS
|
||||||
expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The
|
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
|
parser extracts only what the media plane needs: remote address/port, the
|
||||||
negotiated payload type, and octet-align mode — it reads answers to our
|
negotiated payload type, the codec and octet-align mode — it reads answers
|
||||||
offers and inbound offers alike (same fields either way). BuildAnswer is the
|
to our offers and inbound offers alike (same fields either way). The codecs
|
||||||
terminating side: it answers an inbound offer with the offer's own payload
|
it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) and
|
||||||
type numbers (RFC 3264) and mirrors its octet-align mode (RFC 4867 requires
|
G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
|
||||||
both directions to use one mode). Pure std C++.
|
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++.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export module Imsd:Sdp;
|
export module Imsd:Sdp;
|
||||||
|
|
@ -54,7 +57,11 @@ export namespace imsd::sdp {
|
||||||
std::string currRemote = "none"; // a=curr:qos remote <this>
|
std::string currRemote = "none"; // a=curr:qos remote <this>
|
||||||
};
|
};
|
||||||
|
|
||||||
// AMR-WB (octet-aligned) offer. Byte layout is pinned by tests/Sdp.
|
// 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.
|
||||||
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;
|
||||||
|
|
@ -63,14 +70,18 @@ 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 98\r\n", o.rtpPort);
|
s += std::format("m=audio {} RTP/AVP 97 99 98 100\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) {
|
||||||
|
|
@ -88,18 +99,60 @@ 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";
|
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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. Octet-aligned wins within a codec because it is
|
// octet-align=1 > AMR > PCMA > PCMU. Octet-aligned wins within a codec
|
||||||
// the media leg's native, call-proven format (bandwidth-efficient is
|
// because it is the media leg's native, call-proven format
|
||||||
// supported but was the s56 static-audio culprit) — when the peer offers
|
// (bandwidth-efficient is supported but was the s56 static-audio
|
||||||
// both variants, taking the octet-aligned pt keeps real calls on the
|
// culprit) — when the peer offers both variants, taking the
|
||||||
// battle-tested path. With no rtpmap match at all, the first pt is used
|
// octet-aligned pt keeps real calls on the battle-tested path. Wideband
|
||||||
// and codec comes back EMPTY — the body named no AMR variant (e.g. a
|
// beats narrowband, AMR beats G.711 (a mobile-to-mobile call must never
|
||||||
// PCMA offer), and pretending otherwise would defeat codec checks
|
// land on a G.711 leg just because the gateway listed it). With no
|
||||||
// upstream.
|
// playable codec at all, the first pt is used and codec comes back EMPTY
|
||||||
|
// — 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);
|
||||||
|
|
@ -135,20 +188,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 key = std::format("a=rtpmap:{} ", p);
|
std::string name = CodecName(body, p);
|
||||||
auto at = Find(body, key);
|
if (name == "AMR-WB") {
|
||||||
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.contains("AMR")) {
|
} else if (name == "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) {
|
||||||
|
|
@ -157,11 +210,17 @@ 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 = hasOctetAlign(a.payloadType);
|
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,18 +248,24 @@ 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
|
bool octetAlign = true; // mirror of the offer's mode (AMR codecs only)
|
||||||
std::string codec = "AMR-WB"; // "AMR-WB" or "AMR"
|
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU"
|
||||||
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), telephone-event kept when
|
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all),
|
||||||
// offered. Byte layout pinned by tests/Sdp.
|
// 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.
|
||||||
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 = s.codec == "AMR-WB" ? 16000 : 8000;
|
int rate = CodecRate(s.codec);
|
||||||
|
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);
|
||||||
|
|
@ -210,11 +275,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", s.codec == "AMR-WB" ? 41 : 30);
|
out += std::format("b=AS:{}\r\n", as);
|
||||||
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) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType);
|
if (s.octetAlign && s.codec.starts_with("AMR")) 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);
|
||||||
|
|
|
||||||
|
|
@ -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 come
|
# produced by packaging/make-bin-tarball.sh; the desktop/preset files live
|
||||||
# from packaging/aport/ (copy them next to this file). The package payload
|
# next to it in packaging/ (copy them beside this file). Since 2026-09-02
|
||||||
# must stay identical to packaging/aport/APKBUILD's, or an apk upgrade
|
# this is the only imsd packaging: fp6-img images install the apk this
|
||||||
# across the two pipelines would add/strip files on users' phones.
|
# builds from the registry rather than building their own.
|
||||||
pkgname=imsd
|
pkgname=imsd
|
||||||
pkgver=0.3.1
|
pkgver=0.3.1
|
||||||
pkgrel=0
|
pkgrel=0
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
# 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
|
|
||||||
"
|
|
||||||
|
|
@ -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/aport/org.kde.modem.daemon.desktop" \
|
cp "$SRC/packaging/org.kde.modem.daemon.desktop" \
|
||||||
"$SRC/packaging/aport/80-imsd.preset" "$PKG/"
|
"$SRC/packaging/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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@
|
||||||
// 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, ring
|
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
|
||||||
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
|
// 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.
|
||||||
import std;
|
import std;
|
||||||
import Imsd;
|
import Imsd;
|
||||||
|
|
||||||
|
|
@ -479,26 +481,109 @@ int main() {
|
||||||
Check(m.Terminated(), "terminated after remote CANCEL");
|
Check(m.Terminated(), "terminated after remote CANCEL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- offer we cannot serve (no AMR-WB): 488 before ringing
|
// 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: <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\r\na=rtpmap:8 PCMA/8000\r\n";
|
"m=audio 27864 RTP/AVP 8 0 101\r\na=rtpmap:101 telephone-event/8000\r\na=ptime:20\r\n";
|
||||||
std::string inv = std::format(
|
CallMachine m(c, rng, "ims-call-11", IncomingInvite{MtInviteSdp("mt-pcma", sdp)}, 50004);
|
||||||
"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, "non-AMR-WB offer refused with 488");
|
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 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
|
||||||
|
|
|
||||||
|
|
@ -96,14 +96,18 @@ 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 98\r\n"
|
"m=audio 50004 RTP/AVP 97 99 98 100\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";
|
||||||
|
|
@ -129,21 +133,25 @@ 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: 323\r\n"
|
"Content-Length: 466\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 98\r\n"
|
"m=audio 50004 RTP/AVP 97 99 98 100\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";
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,18 @@ 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 98\r\n"
|
"m=audio 22222 RTP/AVP 97 99 98 100\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"
|
||||||
|
|
@ -126,12 +130,68 @@ 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");
|
||||||
}
|
}
|
||||||
// ---- no rtpmap at all -> first payload type, bandwidth-efficient
|
// ---- G.711 by static payload type, no rtpmap at all (RFC 3551 §6):
|
||||||
|
// 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 8 0\r\n");
|
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, "no rtpmap -> first payload type");
|
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");
|
||||||
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
|
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
|
||||||
Check(a.codec.empty(), "no AMR rtpmap -> codec unknown, not assumed");
|
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");
|
||||||
}
|
}
|
||||||
// ---- degenerate body
|
// ---- degenerate body
|
||||||
{
|
{
|
||||||
|
|
@ -198,6 +258,41 @@ 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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue