-
-
Notifications
You must be signed in to change notification settings - Fork 55
Connection MediaSoup
An SFU. Every peer sends its media once to a mediasoup server, which forwards it to everyone else — so a call does not get more expensive for a client as peers are added.
var connection = connectionFactory.SelectConnection(ConnectionType.MediaSoup);Nothing above the connection layer changes. The IConnection surface, joining and PeerResponse
are on the Connection page; this page is about the client, the server, and the things
that will bite you.
Read this first, because it is the single most misunderstood thing here:
WebRTCme ships a client. The server it talks to is versatica's mediasoup-demo, which you deploy yourself.
That is a sharper statement than it sounds, because of what the client actually speaks. mediasoup
itself has no wire protocol — it is a library that your own signalling server calls. The join,
produce and newConsumer messages this client sends are the demo application's own signalling
API, invented by that demo.
So "point it at a mediasoup server" is not enough. It has to be that server, and its API does change between versions: between 3.7.17 and 3.26 the response envelopes moved, ids were renamed, and SCTP negotiation disappeared — each of which broke this client.
The version is therefore pinned. WebRTCme.Connection/MediaSoup/server/Dockerfile pins the
exact mediasoup-demo commit this client was verified against. Bump it deliberately and re-test; do
not float it.
Verified against mediasoup 3.26.0, mediasoup-demo commit
558aadd82985b98e293564288ee95c69ccd05e98.
The server announces its version in a mediasoupVersion notification on connect, which is the
quickest way to confirm what you are actually talking to.
WebRTCme.Connection.MediaSoup |
A C# port of mediasoup-client v3 — Device, Transport, Handler, Ortc, the SDP layer — plus the protoo client and the server contracts |
MediaSoupConnection |
Drives the port, and turns server events into the PeerResponse stream the middleware consumes |
MediaSoup/server/ |
A recipe for running the server. Not a server |
The port is the largest body of borrowed logic in the repository and the one most likely to drift
from its JavaScript original, which is why Ortc, H264 and ScalabilityModes are the most
heavily unit-tested things here — see Testing.
sequenceDiagram
autonumber
participant C as client
participant S as mediasoup-demo<br/>protoo over wss
participant P as other peers
C->>S: WebSocket connect — ?roomId=&peerId=
S-->>C: notification: mediasoupVersion
C->>S: getRouterRtpCapabilities
S-->>C: router capabilities
Note over C: Device.Load — works out what this<br/>client and the router can agree on
C->>S: createWebRtcTransport (send)
S-->>C: transport parameters — ICE, DTLS
C->>S: createWebRtcTransport (recv)
S-->>C: transport parameters
C->>S: join(displayName, rtpCapabilities)
S-->>C: peers already in the room
C->>S: connectWebRtcTransport (DTLS)
C->>S: produce(audio)
C->>S: produce(video)
Note over S,P: server forwards to every other peer
S-->>C: newConsumer(peerId, producerId, ...)
Note over C: consumer becomes a PeerResponse.PeerJoined
S-->>C: notification: speakingPeers
S-->>C: notification: peerClosed
Device.Load is where the capability negotiation happens — the part Ortc implements — and it is
what makes a C# client able to talk to a server that has only ever spoken to browsers.
mediasoup's worker is a native binary supported on Linux and macOS only; there is no Windows build. On a Windows machine, run it in Docker or on a Linux/macOS host.
cd WebRTCme.Connection/MediaSoup/server
docker build -t mediasoup-demo .The build clones the pinned commit and compiles mediasoup's worker if no prebuilt binary exists for the platform, which takes a few minutes the first time.
Run it, announcing the address your clients will reach it on — not localhost, unless every
client is on the same machine:
docker run -d --name mediasoup \
-p 4443:4443/tcp \
-p 44444:44444/udp -p 44444:44444/tcp \
-e HTTP_LISTEN_PORT=4443 \
-e NUM_WORKERS=1 \
-e MEDIASOUP_LISTEN_IP=0.0.0.0 \
-e MEDIASOUP_ANNOUNCED_ADDRESS=192.168.1.48 \
-e DOMAIN=192.168.1.48 \
-e DEBUG='mediasoup-demo-server:*' \
mediasoup-demoNUM_WORKERS is not a performance knob here — it decides how many ports you need. The server
creates one worker per CPU by default and gives each its own RTC port, starting at 44444 and
counting up. Thirty-two cores means ports 44444–44475. Publish every port you create, or media
silently never flows while signalling looks perfectly healthy. One worker is plenty for testing.
MEDIASOUP_ANNOUNCED_ADDRESS is the address mediasoup puts in its ICE candidates, so it must be
reachable from every client.
DOMAIN must be the address clients dial, and it defaults to localhost. The demo server
rejects a WebSocket upgrade whose Origin does not match it, and it does so before any application
logging - so a client on another machine fails like this, with an empty server log:
Connect: TcpSocketConnected
Connect: SecureSocketStreamConnected
Connect: SendingHandshakeToWebsocketServer
Cannot connect to the mediasoup server at wss://192.168.1.48:4443/?roomId=...:
Websocket connection aborted unexpectedly. Check connection and socket security version/TLS version
Everything about that message points at TLS, and TLS is fine - it connected. docker logs says
nothing at all, which is the tell: the rejection happened above the application. This cost an
evening on 2026-09-17 with an iPhone that was otherwise working perfectly.
Check it is up. The log should end with Server started. Over HTTP the API is access controlled,
so a 403 is the healthy answer — it means TLS terminated and the server replied:
curl -sk -o /dev/null -w '%{http_code}' https://192.168.1.48:4443/rooms/test # 403docker logs -f mediasoup shows every protoo request per peer, which is the fastest way to see how
far a client got.
MediaSoupServer:BaseUrl in the app's appsettings.json:
"MediaSoupServer": {
"BaseUrl": "wss://192.168.1.48:4443",
"Produce": true,
"Consume": true,
"UseDataChannel": true,
"UseSimulcast": false,
"ForceTcp": false,
"ForceH264": false,
"ForceVP9": false,
"AudioOnly": false
}Then join a room by name. Any number of clients using the same room name land in the same call.
DOMAIN must match the host in the clients' BaseUrl, and there is only one of it. The server
rejects a WebSocket whose HTTP Origin does not match its own, comparing scheme and host only — and
a missing Origin counts as a mismatch and is refused with 403. A browser sets the header itself,
from wherever the app is served; the mobile clients derive it from the server address. So a browser
served from localhost and a phone reaching 192.168.1.48 cannot both be accepted at once.
Serve the app on the same host the clients connect to, and set DOMAIN to that host.
Certificates. server/certs holds a self-signed pair. Browsers need it accepted once — visit
https://<host>:4443/ and click through. Debug builds of the mobile clients fall back to a
websocket that ignores certificate errors; release builds always validate, so a real deployment
needs a real certificate.
A client killed abruptly does not disconnect. Swiping an app away sends no close frame, so the server keeps the peer until its own keepalive notices, which takes minutes. To test peers leaving, leave the call page instead.
The demo server runs a bot peer that opens a data consumer with no peer id attached. Client code assuming every consumer belongs to a peer will trip over it.
Simulcast — sending several resolutions at once and letting the server pick — is implemented and
supported by the library. The demo apps set UseSimulcast: false deliberately, and the reason
is worth knowing before you turn it on.
With simulcast on, this SFU's congestion control parks a native consumer on spatial layer 0 and flaps. Measured over one LAN: 64–159 kbit/s at 289x240 with simulcast, against 1781–1828 kbit/s at 578x480 with a single layer. The estimate only collapses when simulcast is in play, and probation stops with it.
That is mediasoup's congestion control rather than this client, which is why it is not fixed here.
doc/KnownGaps.md has the measurements under "The SFU's bandwidth estimate collapses under
simulcast".
Separately, Android cannot encode simulcast with this libwebrtc build at all.
LogServerStats: true asks the server for its own view of each consumer and of the receive
transport, logged raw. Two extra requests per sample, so turn it on only while chasing something —
it is what showed availableOutgoingBitrate collapsing to 47 kbit/s.
The server runs an audio level observer and tells this client who is audible several times a
second. It arrives as PeerResponseType.PeerMedia with MediaContext.Speaking.
The notification to use is speakingPeers, not activeSpeaker, and the names suggest the
opposite of the truth:
-
speakingPeerscarries{ peerVolumes: [ { peerId, volume } ] }, continuously, per peer. Membership of the list is itself the signal — the observer only reports producers above its threshold. -
activeSpeakernever fired once in testing: not for tones, not for continuous synthesised speech, not with a single audio producer left in the room.
Only differences between snapshots are reported, since these arrive several times a second.
| Blazor WebAssembly | works — verified |
| Android | works — verified |
| iOS | works — verified |
| Mac Catalyst | compile-verified only, never run |
| Windows | not verified |
Blazor, Android and iOS have been verified together in one three-way call. See What works where for the whole picture.
| Connection |
IConnection, joining, PeerResponse
|
| Connection: Signaling | The mesh alternative — simpler, and all five platforms |
| Demo apps | This path, working |
| mediasoup-demo | The server, upstream |
Start here
The stack
- WebRTCnative
- Bindings
- The unified API
- Middleware
- Connection
- · Signaling (mesh)
- · MediaSoup (SFU)
- Demo apps
Reference
When it breaks