Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,18 +324,21 @@ Each item:
- **Acceptance:** `bufferedAmount` tracks queued bytes (decrement on SCTP ack),
the threshold + low event fire, `binaryType` is honoured.

### RtpSender / RtpReceiver / RtpTransceiver surface is minimal

- **Found:** 2026-05-29, RFC/W3C divergence audit. **Unverified.**
- **Detail:** W3C §5. Sender has only `replaceTrack` (missing `getParameters`,
`setParameters`, `getStats`, `transport`); receiver lacks
`getContributingSources` / `getSynchronizationSources` / `getStats`; there is
no public `RtpTransceiver` (only a private `_MediaTransceiver`) with `mid`,
`direction`, `currentDirection`, `setDirection`, `stop`.
### RtpSender / RtpReceiver surface is minimal

- **Found:** 2026-05-29, RFC/W3C divergence audit. RtpTransceiver shipped 2026-06.
- **Detail:** W3C §5. The public `RtpTransceiver` (with `mid`, `direction`,
`currentDirection`, `setDirection`, `stop`, `sender`, `receiver`) and the
`RtpTransceiverDirection` enum now exist, and `PeerConnection` exposes
`getTransceivers()` / `getReceivers()` (`addTransceiver` returns the
transceiver). Still missing: sender `getParameters` / `setParameters` /
`getStats`; receiver `getContributingSources` /
`getSynchronizationSources` / `getStats`.
[dart/lib/peer_connection/events.dart](dart/lib/peer_connection/events.dart).
- **Why deferred:** Surface-area work behind the negotiation core.
- **Acceptance:** At minimum `getParameters` / `setParameters` on the sender and
a public `RtpTransceiver`; `RtpTransceiverDirection` enum introduced.
- **Why deferred:** Surface-area work behind the negotiation core; `setParameters`
in particular pulls in encoding-parameter / simulcast plumbing.
- **Acceptance:** `getParameters` / `setParameters` on the sender (at least
active + bitrate), and CSRC tracking behind the receiver source methods.

### MediaStreamTrack: constraints surface

Expand Down
104 changes: 104 additions & 0 deletions dart/lib/peer_connection/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,51 @@ enum DtlsTransportState {
failed,
}

/// W3C `RTCRtpTransceiverDirection`.
enum RtpTransceiverDirection {
sendrecv,
sendonly,
recvonly,
inactive,
stopped;

/// SDP direction token (`a=sendrecv` etc.). A stopped transceiver maps to
/// `inactive` on the wire.
String get sdpToken => switch (this) {
RtpTransceiverDirection.sendrecv => 'sendrecv',
RtpTransceiverDirection.sendonly => 'sendonly',
RtpTransceiverDirection.recvonly => 'recvonly',
RtpTransceiverDirection.inactive => 'inactive',
RtpTransceiverDirection.stopped => 'inactive',
};

/// Parse an SDP direction token; unknown tokens fall back to `sendrecv`.
static RtpTransceiverDirection fromToken(String token) => switch (token) {
'sendonly' => RtpTransceiverDirection.sendonly,
'recvonly' => RtpTransceiverDirection.recvonly,
'inactive' => RtpTransceiverDirection.inactive,
_ => RtpTransceiverDirection.sendrecv,
};

/// The negotiated direction (W3C `currentDirection`) from our preferred
/// [local] direction and the [remote] direction on its SDP m-line — the
/// intersection: we send only if we want to and the peer will receive,
/// and vice-versa.
static RtpTransceiverDirection negotiated(
RtpTransceiverDirection local, RtpTransceiverDirection remote) {
bool sends(RtpTransceiverDirection d) =>
d == sendrecv || d == sendonly;
bool receives(RtpTransceiverDirection d) =>
d == sendrecv || d == recvonly;
final send = sends(local) && receives(remote);
final recv = receives(local) && sends(remote);
if (send && recv) return sendrecv;
if (send) return sendonly;
if (recv) return recvonly;
return inactive;
}
}

/// RTP sender — sends media RTP packets via SRTP.
///
/// Obtained via [PeerConnection.addTrack] or from a transceiver.
Expand Down Expand Up @@ -231,3 +276,62 @@ final class RtpSender {
_sendCallback?.call(rtp.build());
}
}

/// W3C `RTCRtpTransceiver` — a paired [RtpSender] / [RtpReceiver] for one
/// media m-line. Created by [PeerConnection.addTransceiver] /
/// [PeerConnection.addTrack] and listed by [PeerConnection.getTransceivers].
final class RtpTransceiver {
/// Media kind: 'audio' or 'video'.
final String kind;

/// Codec names this transceiver prefers to offer, in order (internal).
final List<String>? preferredCodecs;

/// The sender for outgoing media, or null for a receive-only transceiver.
RtpSender? sender;

RtpReceiver? _receiver;
String? _mid;
RtpTransceiverDirection _direction;
RtpTransceiverDirection? _currentDirection;

RtpTransceiver._({
required this.kind,
RtpTransceiverDirection direction = RtpTransceiverDirection.sendrecv,
this.preferredCodecs,
}) : _direction = direction;

/// The receiver for incoming media, or null until media arrives on this
/// transceiver.
RtpReceiver? get receiver => _receiver;

/// The negotiated media-line identifier (BUNDLE `a=mid`), or null before
/// the first negotiation.
String? get mid => _mid;

/// Preferred direction applied at the next negotiation (W3C `direction`).
RtpTransceiverDirection get direction => _direction;

/// Direction negotiated at the last offer/answer, or null before the first
/// negotiation completes (W3C `currentDirection`).
RtpTransceiverDirection? get currentDirection => _currentDirection;

/// Whether [stop] has been called (W3C `stopped`).
bool get stopped => _direction == RtpTransceiverDirection.stopped;

/// W3C: set the preferred [direction] for the next negotiation.
void setDirection(RtpTransceiverDirection direction) {
if (stopped) throw StateError('RtpTransceiver has been stopped');
_direction = direction;
}

/// W3C: stop the transceiver. It stops sending/receiving and negotiates as
/// `inactive`; a subsequent [setDirection] throws.
void stop() {
if (stopped) return;
_direction = RtpTransceiverDirection.stopped;
_currentDirection = RtpTransceiverDirection.stopped;
sender?._track = null;
_receiver?._close();
}
}
85 changes: 50 additions & 35 deletions dart/lib/peer_connection/peer_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ final class PeerConnection {
int _nextDataChannelId = 0; // even for offerer, odd for answerer

// Media transceivers
final List<_MediaTransceiver> _transceivers = [];
final List<RtpTransceiver> _transceivers = [];
final Map<int, RtpReceiver> _receivers = {}; // SSRC → receiver

// RTP reception stats for RTCP RR
Expand Down Expand Up @@ -217,7 +217,7 @@ final class PeerConnection {
: mediaEngine.resolveVideoCodecs(t.preferredCodecs);
return MediaTrack(
type: t.kind,
direction: t.direction,
direction: t.direction.sdpToken,
senderSsrc: t.sender?.ssrc,
codecs: codecs,
);
Expand Down Expand Up @@ -278,7 +278,7 @@ final class PeerConnection {

// PT must come from the answer (the codec we narrowed down to), not the
// offer (which lists every PT the remote was willing to use).
_assignMidToSenders(sdp);
_assignMidToTransceivers(sdp);

return SessionDescription(type: SessionDescriptionType.answer, sdp: answerSdp);
}
Expand Down Expand Up @@ -331,7 +331,7 @@ final class PeerConnection {
// negotiated MID/PT to our senders so outgoing RTP uses the remote's
// expected payload type.
if (desc.type == SessionDescriptionType.answer) {
_assignMidToSenders(sdp);
_assignMidToTransceivers(sdp);
}

if (sdp.media.isEmpty) return;
Expand Down Expand Up @@ -499,14 +499,16 @@ final class PeerConnection {
///
/// [preferredCodecs] is an ordered list of codec names (e.g. `['H264', 'VP8']`)
/// to offer, in preference order. If null, a library default is used.
void addTransceiver(String kind, {
RtpTransceiver addTransceiver(String kind, {
String direction = 'sendrecv',
List<String>? preferredCodecs,
}) {
final t = _MediaTransceiver(
kind: kind, direction: direction, preferredCodecs: preferredCodecs);
final dir = RtpTransceiverDirection.fromToken(direction);
final t = RtpTransceiver._(
kind: kind, direction: dir, preferredCodecs: preferredCodecs);
// Create sender if direction includes sending
if (direction == 'sendrecv' || direction == 'sendonly') {
if (dir == RtpTransceiverDirection.sendrecv ||
dir == RtpTransceiverDirection.sendonly) {
final pt = kind == 'audio' ? 111 : 96;
final clockRate = kind == 'audio' ? 48000 : 90000;
t.sender = RtpSender._(
Expand All @@ -518,6 +520,7 @@ final class PeerConnection {
t.sender!._sendCallback = _sendSrtpRtp;
}
_transceivers.add(t);
return t;
}

/// W3C: Add a MediaStreamTrack to the connection.
Expand All @@ -531,8 +534,14 @@ final class PeerConnection {
}

/// Get all RTP senders (for sending media).
List<RtpSender> getSenders() =>
_transceivers.where((t) => t.sender != null).map((t) => t.sender!).toList();
List<RtpSender> getSenders() => List.unmodifiable(
_transceivers.where((t) => t.sender != null).map((t) => t.sender!));

/// W3C: all media transceivers, in creation order.
List<RtpTransceiver> getTransceivers() => List.unmodifiable(_transceivers);

/// W3C: all RTP receivers that have produced an incoming stream.
List<RtpReceiver> getReceivers() => List.unmodifiable(_receivers.values);

/// Codec names to advertise in the answer for [kind], filtered by the
/// matching transceiver's preferredCodecs (if any). m-lines without a
Expand All @@ -549,10 +558,11 @@ final class PeerConnection {
return [for (final c in resolved) c.name];
}

/// Align senders to the remote SDP: set MID, MID header-extension ID (if
/// negotiated), and the negotiated payload type. Runs for both offerer
/// (after receiving the answer) and answerer (when building the answer).
void _assignMidToSenders(SdpSessionDescription remoteSdp) {
/// Align transceivers to the remote SDP: record each transceiver's MID +
/// currentDirection, and set its sender's MID, MID header-extension ID (if
/// negotiated), and negotiated payload type. Runs for both offerer (after
/// receiving the answer) and answerer (when building the answer).
void _assignMidToTransceivers(SdpSessionDescription remoteSdp) {
int midExtId = 0;
for (final m in remoteSdp.media) {
for (final extmap in m.getAll('extmap')) {
Expand All @@ -566,23 +576,31 @@ final class PeerConnection {
// Pair each m-line with the first unassigned matching-kind sender.
// Walking transceivers in lockstep with m-lines breaks when the two
// lists diverge (e.g. offer m=audio+m=video, only video transceiver).
final assigned = <RtpSender>{};
final assigned = <RtpTransceiver>{};
for (var i = 0; i < remoteSdp.media.length; i++) {
final m = remoteSdp.media[i];
if (m.type == 'application' || m.port == 0) continue;
final mid = m.mid ?? '$i';
for (final t in _transceivers) {
if (t.kind != m.type) continue;
if (t.kind != m.type || assigned.contains(t) || t.stopped) continue;
// Record the negotiated mid + currentDirection on the transceiver
// (W3C): currentDirection is the intersection of our preferred
// direction and the remote m-line's direction.
t._mid = mid;
t._currentDirection = RtpTransceiverDirection.negotiated(
t._direction, RtpTransceiverDirection.fromToken(m.direction));
// Align the sender (if any) to the negotiated mid + payload type.
final sender = t.sender;
if (sender == null || assigned.contains(sender)) continue;
sender._mid = mid;
sender._midExtId = midExtId;
if (m.formats.isNotEmpty) {
final negotiatedPt = int.tryParse(m.formats.first);
if (negotiatedPt != null) sender.payloadType = negotiatedPt;
if (sender != null) {
sender._mid = mid;
sender._midExtId = midExtId;
if (m.formats.isNotEmpty) {
final negotiatedPt = int.tryParse(m.formats.first);
if (negotiatedPt != null) sender.payloadType = negotiatedPt;
}
if (_debug) _log('[pc] sender ${t.kind} mid=$mid extId=$midExtId pt=${sender.payloadType}');
}
assigned.add(sender);
if (_debug) _log('[pc] sender ${t.kind} mid=$mid extId=$midExtId pt=${sender.payloadType}');
assigned.add(t);
break;
}
}
Expand Down Expand Up @@ -1243,6 +1261,14 @@ final class PeerConnection {
final kind = _resolveTrackKind(rtp.payloadType);
final receiver = RtpReceiver._(kind: kind, ssrc: ssrc);
_receivers[ssrc] = receiver;
// Associate the receiver with a matching transceiver (W3C: each
// transceiver has one receiver) so getTransceivers() reflects it.
for (final t in _transceivers) {
if (t.kind == kind && t.receiver == null && !t.stopped) {
t._receiver = receiver;
break;
}
}
_trackController.add(TrackEvent(kind: kind, ssrc: ssrc, receiver: receiver));
receiver._deliver(rtp);
if (_debug) _log('[pc] onTrack fired: kind=$kind ssrc=$ssrc');
Expand Down Expand Up @@ -1643,14 +1669,3 @@ final class _TwccEntry {
const _TwccEntry(this.seq, this.arrivalUs);
}

final class _MediaTransceiver {
final String kind; // 'audio' or 'video'
final String direction; // 'sendrecv', 'recvonly', 'sendonly', 'inactive'
final List<String>? preferredCodecs;
RtpSender? sender;
_MediaTransceiver({
required this.kind,
this.direction = 'sendrecv',
this.preferredCodecs,
});
}
11 changes: 11 additions & 0 deletions dart/test/api/stats_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,17 @@ void main() {
expect(outboundA.bytesSent, packetCount * payload.length);
expect(outboundA.id, 'outbound-rtp-${senderA.ssrc}');

// Negotiation populated each transceiver's mid + currentDirection,
// and the receiver got linked to pcB's transceiver (W3C).
final txA = pcA.getTransceivers().single;
expect(txA.mid, isNotNull);
expect(txA.currentDirection, RtpTransceiverDirection.sendrecv);
final txB = pcB.getTransceivers().single;
expect(txB.mid, isNotNull);
expect(txB.receiver, isNotNull);
expect(txB.receiver!.ssrc, senderA.ssrc);
expect(pcB.getReceivers().map((r) => r.ssrc), contains(senderA.ssrc));

// Inbound on the receiver side: keyed by the same SSRC.
final inboundB = reportB
.ofType<InboundRtpStats>(RtcStatsType.inboundRtp)
Expand Down
33 changes: 33 additions & 0 deletions dart/test/e2e/media_receiver_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,35 @@ void main(List<String> args) async {
exit(exitCode);
}

/// Verify each transceiver got a negotiated `mid` + `currentDirection`
/// after Chrome's answer (PR #42). Returns true on success; on failure logs
/// and completes [done] with exit code 2.
bool _checkTransceivers(PeerConnection pc, Completer<int> done) {
final txs = pc.getTransceivers();
if (txs.isEmpty) {
stderr.writeln('[media-receiver] FAIL: no transceivers after answer');
if (!done.isCompleted) done.complete(2);
return false;
}
for (final t in txs) {
if (t.mid == null) {
stderr.writeln('[media-receiver] FAIL: ${t.kind} transceiver has no '
'mid after answer');
if (!done.isCompleted) done.complete(2);
return false;
}
if (t.currentDirection != RtpTransceiverDirection.recvonly) {
stderr.writeln('[media-receiver] FAIL: ${t.kind} currentDirection='
'${t.currentDirection} (expected recvonly)');
if (!done.isCompleted) done.complete(2);
return false;
}
}
stdout.writeln('[media-receiver] transceivers negotiated: ${txs.map((t) =>
'${t.kind} mid=${t.mid} ${t.currentDirection!.name}').join(', ')}');
return true;
}

Future<int> _run(int sigPort, String kind) async {
final ws = await _WsClient.connect(sigPort);
ws.sendJson({'type': 'register', 'role': 'offerer'});
Expand Down Expand Up @@ -235,6 +264,10 @@ Future<int> _run(int sigPort, String kind) async {
type: SessionDescriptionType.answer,
sdp: msg['sdp'] as String,
));
// Verify W3C RtpTransceiver negotiation against Chrome (PR #42):
// we offered recvonly, Chrome answers sendonly, so every
// transceiver must now carry a mid and currentDirection=recvonly.
if (!_checkTransceivers(pc, done)) return;
case 'candidate':
final cand = msg['candidate'];
if (cand != null && cand is Map<String, dynamic>) {
Expand Down
Loading
Loading