Repository navigation
Releases
CalloraVoipSdk 4.15.0
A line can now say which numbers reach it, a PBX can read a busy lamp, and DTMF is sent as a tone
instead of a burst. Four additions, three fixes, nothing removed or changed. SemVer: MINOR.
The question a registration account could not answer
"Which numbers reach me on this line?" Every telephone system has to answer it, and almost nothing
else can answer it for them. A trunk contract brings a list somebody can type in. A registration account
brings nothing: it sends no list, and its username is as often admin123 as a number. Until now the
only remaining source was an inbound call that had already happened.
RFC 3455 §5.1 answers it outright. The registrar lists the URIs associated with the address-of-record it
just accepted, in the 200 OK to REGISTER:
await line.RegisterAsync(ct);
foreach (var uri in line.AnnouncedAddresses)
{
Console.WriteLine(uri); // sip:+4930123456@carrier.example, tel:+4930123457, …
}An empty list means nobody said, never there are none. Carrier and IMS registrars send the header; a
CPE box on the local network generally does not. Read the other way round, a silent registrar turns into
a line nobody can reach — so every doc comment on the path says which of the two it is.
Order is preserved and duplicates are not. RFC 3455 gives the first entry a meaning: it is the default
public identity, the one the network uses when a request does not say otherwise. Sorting into a set
would throw away which number is the main one. Both sip: and tel: forms occur, often for the same
number, and both are returned as announced — what counts as a telephone number is a question about a
dial plan, and this layer does not have one.
Three details that would have been expensive to find later:
- A display name may contain a comma inside quotes. Splitting on every comma cuts
"Meier, Dana" <sip:…>
in half and produces two entries, neither of them an address. - A semicolon means two different things depending on where it sits (RFC 3261 §20): inside the angle
brackets it belongs to the URI (sip:a@b;transport=tcp), outside them it starts the header parameters.
The first version of this trimmed at the semicolon a second time and would have handed back an address
that lost its transport. - A refresh that answers without the header does not erase what the first response announced. Registrars
send it on the initial registration and not always afterwards, and an application reading "no numbers"
halfway through a session would conclude the line had lost them.
Subscriptions were built, and unreachable
SUBSCRIBE, the handle, the NOTIFY event, the refresh — all of it existed and all of it was internal.
Public now is ISipSubscription with exactly three things: which package, an event per NOTIFY, and
ending it. The internal handle stays internal; it carries the machinery, and that is nobody's business.
var subscription = await line.SubscribeAsync("dialog", "sip:42@pbx.example", expiresSeconds: 3600);
subscription.Notified += (_, e) =>
{
if (SipDialogInfo.TryParse(e.Body) is { } info)
lamp.SetBusy(info.IsBusy);
};The two documents a PBX lives on are now parsed, not passed through
dialog-info (RFC 4235) is the busy lamp. Subscribe to an extension's dialog package and every
notification says free, ringing or on a call. PIDF (RFC 3863) says who is available. Both are parsed
here for the same reason as Diversion and History-Info before them: the rules are in an RFC, and two
consumers reading the same XML are wrong in different ways.
Three traps are encoded in the types and in tests, because each of them produces a lamp that lies:
| Trap | What happens if you miss it |
|---|---|
| A partial document carries only what changed | Read as full state, it clears a lamp that should be lit — IsFullState says which one you have |
| An unrecognised state | Unknown, not free. A lamp that goes out on a word we do not know is worse than one that stays on |
early |
Counts as busy — a colleague whose phone is ringing cannot take a second call either |
Read namespace-tolerantly, because the RFC fixes the namespace and deployments do not. The price is
accepting a document that was never PIDF; it then yields no tuples and reads as "nothing known", exactly
like an empty one.
G.729: negotiable, never decodable
A carrier that offers only G.729 answers a call today with 488 Not Acceptable Here, and the call
never happens. That is the same failure a µ-law-only offer produced in Europe, one codec further along.
So it can be offered — opt-in through PreferredAudioCodecs, not on by default. This SDK carries no
G.729 implementation, which makes offering it a promise to forward the payload, not to understand
it: correct for a leg bridged to another G.729 leg, wrong for one whose audio somebody wants to hear.
It has its own payload-codec kind for that reason. "Some codec we could not transcode" and "G.729,
which we do not implement" are the same event and two very different sentences; the bridge now says the
second one, along with what to do about it.
Two fixes found by reading module by module
DTMF was sent as a burst in microseconds. Both send paths — the SIP RTP session and the bundled
WebRTC one — emitted three packets back to back, one start and two ends, all within microseconds, while
the payload claimed a duration of 160 ms. Two failures follow, and both show up at one customer and not
the next:
- A gateway that reconstructs the tone from the arrival time of the packets — what happens crossing
to analogue or ISDN, and what several IVRs do — sees a tone that never lasted, and reports no digit. - Between start and end there was nothing. One lost packet was one lost digit, while the design of the
RFC protects against exactly that: each intermediate packet repeats the event with a grown duration,
so any single one of them carries the whole tone.
Now: three start packets (marker on the very first only — repeated on the duplicates it announced three
tones), intermediate packets every 20 ms with growing duration, three end packets. All on the same RTP
timestamp, because that is the event's start time (RFC 4733 §2.5.1.2); only the duration field grows. A
rising timestamp would describe several tones in sequence rather than one. Cancelling mid-tone still
sends the end — an event without one is a stuck tone at the far end, still waiting for the digit to
finish.
One implementation for both paths. Two versions of RFC 4733 in one SDK would be two chances to get the
timing wrong, and only one of them would ever be tested.
Hold in the older style went unrecognised. Detection read the direction attribute only. RFC 2543
held a call by setting the connection address to 0.0.0.0, and RFC 3264 §8.4 records that this is what
it means; plenty of gateways and older systems still send it that way.
The failure had the dangerous shape — it was silent. The call reported itself connected while the far
end had it on hold, and the media path kept aiming at an address that leads nowhere. Two places, because
they are two different bugs with one cause:
- Detection now treats an unspecified connection address as hold, in both address families and compared
as a parsed address —0.0.0.0,::and::0are one address in three spellings, and a string
comparison catches one of them. An address on the m-section beats the session's (RFC 8866 §5.7): held
that way, only the audio stream is held, and reading the session line reported the opposite. - The RTP send path refuses the unspecified address instead of handing it to the socket. Until the far
end sends a packet for symmetric RTP to latch onto there is nothing there to hit; sending throws on
some stacks and vanishes on others, once per packet for as long as the hold lasts. A packet not sent
is not counted as sent either — the sender report otherwise claimed packets that never left, and the
far end's loss calculation hangs on that counter.
Checked and found correct in the same pass, and worth recording so nobody re-derives them: sequence
validation (RFC 3550 §A.1, correct ushort difference including cycle counting), the jitter formula in
both places, the RTCP interval including its e−1.5 compensation, the per-transaction nonce count, the
reversal of the Record-Route set, the ICE role conflict with 487 and tiebreaker, the SRTP/SRTCP replay
window, the G.722 quirk (RTP clock 8000 at a 16 kHz sample rate), and TURN permission refresh.
What the release path now proves
Three gates, each closing a way for a green run to prove nothing:
- Every published package is covered by the public-API baseline. 109 public members in the audio
packages were outside it — they could change shape between versions with nothing to review. - A release requires a green
cirun for the exact commit being tagged. The pack job runs the unit
set only; interop, FreeSWITCH, browser and soak need Docker and a Playwright container. Packages were
therefore published without any of what the README advertises having run. The work is not duplicated,
it is required to have happened. - A version must be anchored to its tag, and a version already complete on nuget.org is refused.
--skip-duplicateis right for the second attempt after three of six packages went through, and
silently wrong after a tag was deleted, corrected and set again — the run reports success while
nuget.org keeps serving the old content under the new promise.
And the interop and browser-interop jobs can no longer pass having executed zero tests: their skip
conditions are the right convenience locally and a false green in CI, so a test that never skips now
asserts the prerequisite on the runner.
Compatibil...
CalloraVoipSdk 4.14.0
A WebRTC call now connects in about a second instead of four. Five fixes in ICE and DTLS setup, one
new opt-in hook, nothing removed or changed. SemVer: MINOR.
The one that mattered
A peer that trickles its candidates sends neither candidates nor an address in its initial description —
it carries the placeholder RFC 3264 §5.1 prescribes, the unspecified address. Two separate places in the
SDK turned that into a candidate pair, each as a reasonable-looking fallback for "the description named
no a=candidate".
That pair inherits host priority, which makes it the highest-priority entry in the checklist, and nothing
can ever answer from 0.0.0.0. Regular nomination (RFC 8445 §8.1.1) waits for higher-priority pairs to
resolve before it nominates — so every real, working, already-validated pair waited for one impossible
pair to time out. From a real call:
12:03:42.303 pair 192.168.178.76:34686 validated ← usable after 215 ms
12:03:42.310 ready: …:34686, blocked by 0.0.0.0:9
… blocked by 0.0.0.0:9 (7 of 10 pairs already Succeeded)
12:03:44.452 ICE nominated pair ← +2.14 s
Seven working pairs, all waiting. Measured end to end: ~4.5 s to audio before, ~1.3 s after.
Worth recording, because it shaped the search: the delay did not scale with the number of real
candidates. Thinning them out — removing stale Docker networks, disabling TURN, dropping unreachable IPv6
targets — changed nothing measurable, which made it look like a nomination-strategy problem for far
longer than it should have.
Also fixed
Inbound DTLS is accepted from any of the peer's candidates, not only the nominated pair. A peer starts
its handshake as soon as it has a usable pair, well before nomination, so its records were dropped and it
retransmitted on a doubling timer (+406 ms, +813 ms, observed). Sending still follows the nominated pair —
that half was always right. This is how libwebrtc demultiplexes: a Connection per remote candidate, not
just the selected one. The off-path protection is unchanged: an endpoint nobody has heard from is
still refused.
An unreachable target is no longer retransmitted to. An IPv6 candidate on an IPv4 socket fails the
same way every time, yet cost three transmissions 500 ms apart and held the pair "in flight" for its whole
budget. Transient failures — a full send buffer, a momentary routing change — still get the full RFC 8445
§14 schedule.
Sending to the unspecified address is refused with a log line naming it, instead of failing at the
socket or being quietly accepted by the OS.
Two fixes that are latent today
Both are invisible in a typical deployment right now and decisive in others. Stated plainly rather than
counted as measured improvements:
| Fix | When it starts to matter |
|---|---|
| IPv4-mapped IPv6 canonicalisation in source comparison | Immediately on a dual-stack socket (::); latent while binding to an IPv4 address, because the two spellings never meet |
RemoteEndPointTranslator (below) |
Only with a TURN server on the same machine as the SDK |
New: WebRtcConfiguration.RemoteEndPointTranslator
var config = new WebRtcConfiguration
{
// Only where a TURN server runs on this machine. Default (null) compares the source as observed.
RemoteEndPointTranslator = ep => IsOurRelayInterface(ep) ? RelayPublicEndPoint(ep) : ep,
};The hairpin case: a peer that reaches this agent through a co-located TURN server shows a local
interface address on the wire, while the candidate it advertised carries the relay's public address.
Compared as observed the two never match, so the peer's own media is refused as if it came from a
stranger — a silent call with nothing in the log naming a cause.
A hook rather than a heuristic, because only a deployment knows its own topology. Three properties worth
knowing:
- it applies to the observed source only — a candidate is what the peer said about itself and is
stored verbatim; - it runs per inbound datagram on the media path, so it must be cheap;
- a translator that throws is caught and logged, and the source is used untranslated. Refusing one
hairpin peer is a local failure; opening the filter for everyone would not be.
Compatibility
Purely additive. One new public member; nothing removed or changed, verified against
PublicApi.approved.txt. Existing sessions need no configuration change — they simply connect sooner.
See CHANGELOG.md for the itemised list.
CalloraVoipSdk 4.13.1
CalloraVoipSdk 4.13.1
A WebRTC call no longer spends two seconds in its DTLS handshake. One fix, no API change, nothing to
configure — every WebRTC session connects faster. SemVer: PATCH.
The defect
The DTLS association's inbound source filter opened only once ICE had nominated a pair. But a browser
starts its handshake as soon as it has a usable candidate pair, which is well before the controlling agent
sets USE-CANDIDATE. Until nomination reached us the filter still pointed at the SDP endpoint —
0.0.0.0:9, the "no address" placeholder — so every record was dropped, the peer got no reply, and it
retransmitted on a doubling timer.
Measured in a real call between a telephone and a browser:
09:35:38.532 Dropping DTLS record … current remote is 0.0.0.0:9
09:35:39.141 Dropping DTLS record … +406 ms
09:35:39.954 Dropping DTLS record … +813 ms
The doubling intervals are the signature. Two seconds for an exchange that needs two round trips — on
every call, not just this one.
The fix
The filter now opens on a source ICE has authenticated, which is available immediately, instead of
waiting for one it has chosen.
Why the security property is unchanged
The filter exists so that an off-path sender cannot feed the handshake. A source that reaches this path is
not off-path:
- its inbound check verified
MESSAGE-INTEGRITYagainst our local ICE password; - a check that fails that verification is discarded rather than answered;
- so the sender holds the credential from our own SDP.
The fingerprint remains the authentication boundary in either case (RFC 5763 §6.7.1).
Two limits keep "authenticated" from being mistaken for "chosen":
| Limit | Why |
|---|---|
| A nomination always wins, and is never undone by a later validated source | Otherwise a candidate that merely authenticated could pull the filter off the pair ICE actually selected |
| Only the DTLS filter follows the early source — never the transport | Media keeps going to the nominated pair; the early source is trusted to start a handshake, not to receive audio |
Compatibility
No public API change — every type involved is internal, and PublicApi.approved.txt is unchanged. No
configuration, no opt-in: existing sessions simply connect sooner.
Scope
This addresses the handshake half of the delay. The time spent waiting for the browser to nominate an ICE
pair is RFC 8445 regular nomination and scales with the number of candidate pairs a host offers — a
machine with many virtual network interfaces (a developer box running Docker, for instance) gives the
browser more pairs to check. That is peer behaviour, not something the SDK can shorten.
See CHANGELOG.md for the itemised list.
CalloraVoipSdk 4.13.0
CalloraVoipSdk 4.13.0
Inbound WebRTC audio can now be buffered before it is raised, so a consumer that mixes gets a steady
cadence instead of whatever the network delivered. One new opt-in setting,
WebRtcConfiguration.AudioReceivePlayoutDelayMs, default 0 — every existing consumer is byte-identical to
4.12.0. Purely additive: one new public member, nothing removed or changed (verified against
PublicApi.approved.txt).
Highlights
A jitter buffer on the WebRTC receive path (#381)
Until now, packets arriving on a WebRTC audio track were raised the moment they came off the wire. For a
peer that forwards that is correct and should stay that way: the browser at the far end runs its own
jitter buffer (NetEQ), and a second one here would only add latency to the same job.
A peer whose consumer mixes is in the opposite position. It must produce one frame every frame interval
from whatever each source has delivered by then, and it cannot wait. Handed raw arrivals it reads a burst as
a single usable frame and the rest as silence.
Opus DTX makes that the normal case rather than the exception. A browser sends nothing at all while
nobody speaks, so the packets that follow a pause arrive together. The audible result is audio that cuts out
after every pause and returns a few seconds later — which is how this was found, in a two-party conference
between a browser and a telephone: phone to browser was clean, browser to phone stuttered.
Two things confirm the shape of the fix rather than merely the fix itself:
- The SIP path in this SDK has always worked this way.
RtpCallMediaSessionbuffers arrivals and drains
them from a playout loop. Only the WebRTC receive path did not. - Janus reached the same design for its mixing plugin: put the packet in the jitter buffer on arrival and
decode from the buffer on the participant's own cadence. A pure SFU needs no buffer; a mixer does.
Using it
var config = new WebRtcConfiguration
{
// 0 (default) raises packets on arrival — correct for forwarding.
// A mixing consumer sets a starting delay; the buffer adapts from there.
AudioReceivePlayoutDelayMs = 60,
};Buffering costs latency, which is why it is off by default: a forwarding peer would pay it for nothing.
Behaviour, for anyone enabling it
| Behaviour | Why it is that way |
|---|---|
| One buffer per audio m-line, at that track's clock rate | 48 kHz Opus and 8 kHz G.711 in one bundle would otherwise be scheduled six times off |
| The buffer resets when the SSRC changes | A new source brings its own sequence space; without the reset it reads as wild reordering — a leg that goes silent after a renegotiation and returns seconds later |
| Packets due together are released together | A burst that arrived together is due together; releasing one per interval would rebuild the backlog the buffer exists to undo |
| A gap is passed on as a gap, never concealed | This layer carries encoded RTP and does not know the codec — repeating an Opus frame is an artefact. The SIP path conceals because it delivers into G.711, where repetition is benign and a silent gap is not |
| A throwing subscriber is logged, not fatal | One bad subscriber must not end the playout loop and take every other track on the session with it |
Arrival and playout both read MonotonicClock: a wall-clock step mid-call would otherwise corrupt the
schedule.
Compatibility
Purely additive. One new public member (WebRtcConfiguration.AudioReceivePlayoutDelayMs); nothing removed or
changed, verified against PublicApi.approved.txt. With the setting left at its default of 0, sessions are
byte-identical to 4.12.0. SemVer: MINOR.
See CHANGELOG.md for the itemised list.
CalloraVoipSdk 4.12.0
CalloraVoipSdk 4.12.0
Simulcast now works when the SDK answers, and inbound calls surface their full retargeting history whichever
header the carrier used. 4.11 could negotiate simulcast only as the offerer; 4.12.0 closes the answerer
half — the common SFU topology where the client offers and the server answers. Separately, an inbound call now
exposes every address it was forwarded from, read from History-Info or Diversion alike. Purely additive —
one new public member, nothing removed or changed (verified against PublicApi.approved.txt).
Highlights
Simulcast as the answerer (#369, RFC 8853 §5.3)
Until now a simulcast offer the SDK received was never confirmed: the answer carried neither a=rid nor
a=simulcast, so a spec-compliant peer sent a single stream. 4.12.0 mirrors the offered simulcast into the
answer:
- an offered
a=simulcast:send— the common SFU topology, client offers and server answers — is confirmed
witha=simulcast:recv, and the received layers arrive tagged with their RID
(IPeerConnection.NegotiatedReceiveSimulcastRids, per-layer received frames); - an offered
a=simulcast:recvis answered witha=simulcast:sendfor the layers the app is configured to
produce.
Only the intersection is confirmed (RFC 8853 §5.1), and only when the offer carried the RID header extension
(RFC 8852) — without a per-packet label the layers cannot be demultiplexed. It is driven entirely through the
existing SimulcastLayers / SimulcastRecvLayers configuration and the existing per-layer events, so there is
no public API change. A full SDK↔SDK peer-to-peer media loopback proves both directions end to end over
DTLS-SRTP — the receive path that was previously reachable only against a real browser.
Behaviour note: a single configured RID is not simulcast — a lone a=rid is dropped (RFC 8853; Chrome
strips it and never enters simulcast). A one-layer SimulcastLayers / SimulcastRecvLayers now falls back to
a single stream instead of emitting a degenerate a=simulcast, and logs a warning at configuration time
rather than degrading silently. Configure two or more distinct RIDs, or none. Multi-layer simulcast and
non-simulcast sessions are unaffected.
The full retargeting history of an inbound call (#374)
ICall.DiversionChain lists every address a forwarded call was diverted from, oldest first — the number the
caller originally dialled at the front, the party that forwarded it to us at the back. It reads both
History-Info (RFC 4244) and Diversion (RFC 5806), which answer the same question but are ordered opposite
ways; both are normalised to one order, and the entry naming us is dropped (RFC 3261 §19.1.4 URI comparison).
Until now only the first row of Diversion was read. No carrier sends both headers consistently, so a
consumer was correct with part of the market and silently blind with the rest — and blind here means a
forwarded call is indistinguishable from a direct one, exactly the distinction a PBX routes on. ICall.Diversion
is unchanged for consumers that want precisely the first URI of the first Diversion row. Purely additive —
one line in PublicApi.approved.txt.
Compatibility
Purely additive. One new public member (ICall.DiversionChain); nothing removed or changed, verified against
PublicApi.approved.txt. Non-simulcast and single-stream sessions are byte-identical to 4.11.0. SemVer:
MINOR.
See CHANGELOG.md for the itemised list.
CalloraVoipSdk 4.11.0
CalloraVoipSdk 4.11.0
Media over a TCP/TLS TURN relay, and a WebRTC peer that survives an ICE restart — the two limits the 4.10 line called out. A purely additive minor: no public API was removed or changed (verified against the tracked PublicApi.approved.txt baseline).
✨ Highlights
📡 TCP/TLS TURN relay (#240)
The relay data path is no longer UDP-only. Add a TURN server with IceTransport.Tcp / IceTransport.Tls and a relay candidate carries media over a persistent TCP/TLS connection to the server (RFC 8656 §12 ChannelData), as a first-class ICE candidate. Media rides the transport the winning pair selects — the model libwebrtc and pjnath use — while the UDP relay keeps its in-place whole-socket switch. TLS gives a last-resort path across firewalls that allow only outbound 443.
services.AddCalloraWebRtc()
.WithTurnServer("turn.example.com", "user", "secret",
port: 5349, transport: IceTransport.Tls); // or IceTransport.Tcp🔄 ICE restart on a connected peer (#226)
IPeerConnection.CreateIceRestartOfferAsync(...) re-gathers and re-nominates connectivity in place — the DTLS association and SRTP contexts are preserved — instead of forcing a dispose-and-rebuild. A WLAN → mobile handover no longer drops the call.
📶 Receive-side simulcast (#317)
A receive-only offerer (a conference host) can ask the peer to simulcast via WebRtcConfiguration.SimulcastRecvLayers; IPeerConnection.NegotiatedReceiveSimulcastRids reads back the negotiated set.
📦 Also in this release
- Media-flow / silence monitoring on
ICall(MediaFlowChanged,MediaSilenceNotifyAfter) — media silence no longer tears the call down. - RTP header-extension coverage — RFC 8285 two-byte header extensions and the AV1 Dependency Descriptor (
EncodedFrame.KeyFrameSource/SpatialId/TemporalId). - SIP hardening — RFC 3261 §19.1.4 URI comparison corrected across all ten worked examples; served-user gating (
ServedUserAors, §8.2.2.1). - Static-IP trunks —
SipAccount.Register,LineState.Ready,Usernameno longer required,VoipConfiguration.LocalSipPort/LocalSipTlsPort. - Video — opaque (E2EE) frame format (
WebRtcConfiguration.OpaqueVideoFrames), per-track key-frame requests.
🔧 Compatibility
- Backward-compatible. 0 public API removals, 0 signature changes, 46 additions (verified). Interface additions (
IPeerConnection,ICall) are additive per ADR-006 §2. - Targets
net8.0,net9.0,net10.0.
dotnet add package CalloraVoipSdk --version 4.11.0⚠️ Honest open edges
The TCP/TLS stream relay is unit-proven; its browser / real-server data-path validation is tracked in the interop matrix (#228), and the working path is the controlling (offerer) agent. TLS certificate validation currently uses the platform default (custom-CA config is a follow-up).
🔗 Links
📄 RELEASE_NOTES_4.11.0.md · CHANGELOG.md · ADR-073 — stream relay transport
CalloraVoipSdk 4.10.0
CalloraVoipSdk 4.10.0
Per-call outgoing-audio mute on ICall. The SDK already had a device-wide mute
(IVoipClient.SetAudioInputMuted), but it silences the capture device for every call at once — so it cannot
mute one call while another stays live. That is exactly the case a contact-center agent or a multi-line
softphone hits: put one caller on private mute while continuing to speak on a second, concurrent call.
4.10.0 adds a mute that lives on the call itself and gates only that call's outgoing audio.
The mute is local — it stops the SDK from sending this call's captured audio to the peer. Unlike hold it is
not signalled: there is no re-INVITE and no media-direction renegotiation, so the peer simply stops
receiving audio for this call. And unlike the device-wide mute it is per call, so on a client running
several concurrent calls each one mutes independently.
New in 4.10.0
Two additive members on ICall (obtained from line.DialAsync(...), IncomingCallEventArgs.Call, or the
OnIncomingCall hook):
MuteAsync(bool muted, CancellationToken ct = default)— mutes (true) or resumes (false) this call's
outgoing audio. Local only, so it is valid in any live state and does not throw for state; a no-op when the
call is already in the requested state.IsMuted— reads the current outgoing-mute state (falseby default).
// Contact center: two concurrent calls, mute one while the other stays live.
ICall a = await line.DialAsync("sip:1001@pbx.example.com");
ICall b = await line.DialAsync("sip:1002@pbx.example.com");
await a.MuteAsync(true); // 1001 no longer hears the agent…
bool muted = a.IsMuted; // true
// …the agent keeps talking to 1002 (b is untouched), then resumes a:
await a.MuteAsync(false);Behaviour and limits
- No public API change beyond the two additions.
PublicApi.approved.txtgains exactly the twoICall
members; there is nothing to migrate and no on-wire behaviour change for a consumer that does not call them. - Outgoing / microphone mute, not speaker. While muted no RTP is sent for this call's outgoing direction;
inbound audio is unaffected — the agent still hears the caller. - Covers every outbound path. The mute gates the single outbound audio choke point, so both the default
microphone (via the media sender) and custom audio are muted. - Not hold, not device-wide mute. Hold (
HoldAsync) is signalled to the peer (a re-INVITE); this mute is
a silent local send-gate. The device-wideIVoipClient.SetAudioInputMutedmutes the capture device for
every call; this affects only the one call. - Scope: SIP calls (
ICall). The WebRTCIPeerConnectionhas its own track model and is not part of this
change.
See CHANGELOG.md for the concise entry.
CalloraVoipSdk 4.9.0
CalloraVoipSdk 4.9.0
Inbound caller and dialed identity on ICall. An inbound call already carried the identity that a PBX,
contact center or SFU front end needs to route it — the number it was dialed on (the To/DID), the caller's
number, and the caller's display name — but the SDK surfaced none of it. The dialed DID drove line selection
internally and was then dropped; the caller's display name was parsed off the From header and discarded.
4.9.0 exposes all of it as four additive, read-only properties.
This is the identity a screen-pop and trunk DID routing are built from: on an incoming call an agent
application can now show the caller's number and name and branch on which DID (which product line, which
campaign) the call arrived — including an SFU or contact-center front end that fans one trunk out to many
queues. The values are parsed by the SDK through a single SIP-URI parser (SipProtocol), so every consumer
shares one implementation instead of re-parsing From/To headers by hand.
The properties follow the existing RemoteAssertedIdentity pass-through and are purely additive —
PublicApi.approved.txt gains four ICall members and nothing else, so there is nothing to migrate.
New in 4.9.0
Four read-only properties on ICall (obtained from IncomingCallEventArgs.Call or the OnIncomingCall hook):
CalledNumber— the dialed number (DID) an inbound call was addressed to: the user part of the
To/Request URI, i.e. the number that selected the receiving line for a SIP trunk. Route by it without
re-parsing headers.LocalParty— the local party's SIP URI in this dialog, parallel toRemoteParty: on an inbound call
the called (DID) URI; on an outbound call the local account address.RemoteNumber— the remote party's number, the user part ofRemoteParty(the caller's number on an
inbound call).RemoteDisplayName— the caller's display name from the inboundFromheader (RFC 3261 §8.1.1.3),
which the SDK previously parsed and threw away. ComplementsRemoteParty(the URI without the display name).
Every property is null on outbound legs and when the corresponding data is absent (for example when the URI
has no user part, or the From header carried no display name).
using var subscription = client.OnIncomingCall(async call =>
{
// Screen-pop / trunk-DID routing on the inbound identity:
var did = call.CalledNumber; // dialed DID (the trunk line the call arrived on)
var caller = call.RemoteNumber; // caller's number
var name = call.RemoteDisplayName; // caller's display name, or null
ScreenPop(did, caller, name);
await call.AcceptAsync();
await client.AttachDefaultAudioAsync(call);
});Behaviour and limits
- No public API change beyond the four additions.
PublicApi.approved.txtgains exactly the fourICall
members; there is nothing to migrate and no on-wire behaviour changes. All four arenullfor outbound
calls and absent data. - Read-only, as-received. The properties surface parsed values from the inbound INVITE; they are
informational identity, not a trust decision. For a trust-gated caller identity use
RemoteAssertedIdentity(P-Asserted-Identity, RFC 3325).
See CHANGELOG.md for the concise entry.
CalloraVoipSdk 4.8.0
CalloraVoipSdk 4.8.0
A stack-wide hardening release, plus mutual TLS per SIP line and a public PCM transcoding surface.
4.8.0 closes a long series of protocol-layer review findings across every wire boundary — DTLS-SRTP,
STUN, TURN, RTP/RTCP, SDP, SIP and the WebRTC media plane — and adds two additive public capabilities.
The security work is defensive throughout: fail-closed decoding, constant-time comparisons, anti-amplification
and anti-slowloris deadlines, source binding on plaintext legs, and bounded wire-DoS caps at every parser and
listener. The public API only grew — PublicApi.approved.txt gained entries and lost none — so an existing
consumer that does not opt into the new configuration surfaces stays behaviour-identical. Every cap sits far
above any real signalling or media, so legitimate traffic is unaffected.
Headline features
- Mutual TLS per SIP line, with a certificate from memory (#183). Outbound SIP over TLS/WSS now presents a
client certificate when one is configured — sent only if the registrar asks for it (RFC 8446 §4.4.2), so
behaviour against registrars that do not request one is byte-identical. Two lines to the same registrar can
present different identities: the identity is bound to the line, keyed into the connection pool
((transport, addr:port, identity)), and stamped on every request the line originates (REGISTER, INVITE and
in-dialog requests, MESSAGE, PUBLISH). Configure it in memory viaTlsConfiguration.ClientCertificate(an
X509Certificate2you own and dispose — the SDK never disposes it), or override per connect boundary with
ConnectOptions.LineTls. Per-line domain identity and trust stay fail-closed (RFC 5922). See ADR-064. - Public PCM transcoding surface (#205).
IAudioPayloadCodec+AudioPayloadCodecFactory(in
CalloraVoipSdk.Audio.Abstractions) let a server-side consumer transcode Opus / G.711 (A-law/µ-law) / G.722
↔ PCM16 without taking a direct dependency on Concentus or NAudio — neither appears in any public signature.
The prototypical use case is an SFU that decodes N−1 legs to mix a phone participant into a WebRTC conference.
I/O is PCM16 little-endianbyte[]; one instance per stream direction. Shipped transitively via the
CalloraVoipSdkmeta-package — no newPackageReference. See ADR-065.
Security hardening
- DTLS-SRTP: constant-time fingerprint comparison (RFC 8122), zeroed private-identity staging bytes, a
stateless HelloVerifyRequest cookie before the amplified certificate flight (RFC 6347 §4.2.1), a handshake
deadline (DtlsHandshakeOptions.HandshakeTimeout, default 20 s), an ordered single-writer egress with real
error propagation, and — new in this release — post-handshake association servicing: a control-receive
loop that notices a peerclose_notify/alert, ends the association deterministically, and stops media from
flowing under a keying channel the peer considers closed (RFC 8827 §6.5). See ADR-066. - STUN/TURN: auth-response integrity binding, a stateless HMAC nonce (amplification cap), TCP/TLS slowloris
deadlines on both servers, fail-closedALTERNATE-SERVER/decode handling, and immediate teardown of a surplus
TURN relay allocation (RFC 8656 §7/§3.9). - RTP/RTCP wire-DoS caps: RTCP compound budgets, a transport-cc feedback expansion cap, depacketiser frame
caps (with discard telemetry), and a BUNDLE reception-state cap — an authenticated peer can no longer exhaust
process memory through compound floods, header-field expansion, unterminated frames or SSRC spray. - SIP / app-domain: fail-closed ICE (no session without a validated candidate pair), an atomic per-line
concurrent-call cap, bounded inbound connection/session/transaction admission with slowloris deadlines, and a
redactedSipCredentials.ToString()(the cleartext password no longer reaches logs). - STUN wire fail-closed:
StunMessageCodec.Decodenow rejects a whole malformed message rather than
accepting a partial one, closing an auth-bypass primitive.
Correctness fixes
- Inbound audio carries its real RTP timestamp (RFC 3550 §5.1) — an SFU no longer stamps forwarded audio at
0. - SDP BUNDLE is grouped semantically (RFC 5888/8843/9143), a remote answer is validated against the local
offer (RFC 3264 §6 / RFC 8829), and the offerer sends the codec the answer accepted (RFC 3264 §6.1). - DTLS handshake timeouts are classified by wall-clock, so a stalled handshake reports a timeout rather than
a generic protocol error.
Upgrading
- No breaking change. All new APIs are additive; a fixed configuration that does not use them is unaffected.
- Deprecation:
TlsConfiguration.AcceptUntrustedCertificatesis now an[Obsolete]alias for
TrustMode = SipTlsTrustMode.DangerousAcceptAnyChain. It still compiles and behaves as before; migrate to
TrustModeat your convenience. - Notable behaviour changes (correct, but observable): a non-conforming STUN server that omits
MESSAGE-INTEGRITY on a credentialed Binding success now triggers a safe host-only fallback; plaintext legs
discard foreign RTP/RTCP; a 416 no longer downgradessips:tosip:; a structurally mismatched SDP answer
is rejected; andSipCredentials.ToString()prints***for the password. SeeCHANGELOG.mdfor the full
list.
Status notes
- Full ICE and the browser-facing WebRTC peer remain opt-in and not yet browser-interop-certified — this
release hardens them but does not change that posture. Validate for your trunk/peer before enabling. - The DTLS close-block work (#190/#191) is wired for the WebRTC/bundle media plane; the SIP media-session owner
reaction to a peer close is a documented follow-up (a SIP leg ends via BYE, not a mid-call DTLSclose_notify).
See CHANGELOG.md for the concise per-change entry, and
docs/adr/ADR-064-per-line-sip-mutual-tls.md,
docs/adr/ADR-065-public-pcm-transcoding-surface.md and
docs/adr/ADR-066-dtls-post-handshake-association-servicing.md
for the architecture decisions.
CalloraVoipSdk 4.7.2
CalloraVoipSdk 4.7.2
ICE connection-setup latency patch for the 4.7 line. 4.7.2 reworks the internal ICE connectivity-check
scheduler so a call reaches a working candidate pair faster — especially when a higher-priority candidate
(a host or server-reflexive address) is unreachable and used to stall the whole checklist behind its timeout.
The ICE latency rework is transparent — a peer that connected in 4.7.1 runs the same checks, only sooner — and
continues the 4.7.1 ICE fix ("a lower-priority reachable candidate is checked before an unreachable
higher-priority one consumes another retry round") from a single tweak into a full RFC 8445 checklist. It ships
together with a round of review-finding fixes (below); PublicApi.approved.txt is unchanged (no API break),
though a few of those fixes adjust on-wire details for correctness.
Fixed in 4.7.2
- Serial checklist → globally paced, overlapping checks. Connectivity checks were run one pair at a time,
each fully awaited before the next started, with a fixed delay between rounds. An unreachable high-priority
pair therefore blocked every other pair behind its full timeout. Checks now start at most one per pacing
interval (RFC 8445 §14Ta) but run concurrently — a dead pair no longer delays the reachable ones. - Loss recovery moved into the STUN transaction. A lost check used to wait out the full 2 s check timeout
before the pair was retried. Each check now retransmits its request with the same transaction id on an
RFC 8489 §6.1 schedule, so ordinary packet loss recovers in hundreds of milliseconds, not seconds. - Both ICE roles check actively. Previously only the controlling agent probed pairs and the controlled
agent waited passively to adopt the peer's nomination (RFC 8445 §7.2). Both roles now run ordinary checks;
only the controlling role nominates. - Peer-reflexive triggered checks are prioritised and no longer gate on start-up. A check learned from an
inbound request (RFC 8445 §7.3.1.4) now preempts ordinary work and dispatches reactively, even before the
local checklist's own start — closing a window where a peer-reflexive path was probed late. - Role-conflict handling. An inbound role conflict (RFC 8445 §7.3.1.1) now re-computes pair priorities and
redirects nomination to match the resolved role instead of keeping stale ordering.
Review findings addressed
A pre-release review of the branch surfaced five issues, all fixed here:
- ICE — superseded nomination. A trickled candidate that outranks the pair already being nominated now
cancels that nomination (via its generation) instead of losing the race to the lower validated pair, so the
driver still selects the highest-priority validated pair (RFC 8445 §8.1.1). - ICE — priority-capped checklist. At the DoS pair cap the checklist now evicts its lowest-priority
evictable pair when a higher-priority candidate arrives, instead of dropping the newcomer — a late
top-priority candidate is no longer excluded by earlier low-priority ones (matches SIPSorcery). - ICE — type-scoped candidate foundations (RFC 8445 §5.1.1.3). Host foundations are now
h1,h2, …,
distinct from the fixed srflx (s1) and relay (r1) foundations. Exposed by the new multi-homed host
gathering, where a second host candidate previously collided with srflx and could wrongly freeze a peer's
NAT/relay fallback. - WebRTC — stable append-only track MIDs (RFC 8829). Runtime-added tracks now always take numeric MIDs in
call order, independent of kind. The grouped legacy layout could hand a video added before an audio the
audio's MID, soVideoTrack.SendFrameAsyncaddressed the wrong m-line; that layout is removed. A fixed 1+1
peer's SDP is unchanged. No reference SDK (libwebrtc, Firefox, Pion) grouped m-lines by type. See ADR-063. - WebRTC — recv-track DoS caps. Recv-side simulcast RID lanes and the learned SSRC→MID/RID tables are now
bounded (RFC 8853 / ENGINEERING_RULES §132-133), so an authenticated peer stamping a fresh RID/SSRC on every
packet cannot exhaust process memory.
Also fixed
- Build under net8.0 / net9.0. A nullable-reference warning in
SrtpHardeningTests(AuthKey) was
promoted to an error under-warnaserroron the older target frameworks; the test now asserts the key's
presence explicitly. Test-only, no runtime change.
Behaviour and limits
- No public API change.
PublicApi.approved.txtis unchanged; there is nothing to migrate. A fixed 1+1
peer's SDP is byte-identical. Peers that add runtime tracks in mixed audio/video order, or that read ICE
candidate foundations, see the corrected (append-only MID / type-scoped foundation) wire values. - Nomination stays regular, not aggressive (RFC 8445 §8.1.1). The highest-priority validated pair is
nominated only once no higher-priority pair is still in flight, so an unresolved high-priority pair still
adds its (now bounded, ~2 s worst-case) transaction budget before a lower pair is selected. This trades a
bounded delay for always selecting the best working pair. See ADR-062. - Full ICE remains opt-in and not yet browser-interop-proven. This patch improves setup latency; it does
not change the production-readiness posture of the ICE agent. Validate ICE for your trunk before enabling it.
See CHANGELOG.md for the concise entry,
docs/adr/ADR-062-ice-checklist-pacing.md for the ICE checklist
rationale, and docs/adr/ADR-063-jsep-append-only-track-mids.md
for the track-MID decision.