diff --git a/BACKLOG.md b/BACKLOG.md index ef540f1..c87c03a 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/dart/lib/peer_connection/events.dart b/dart/lib/peer_connection/events.dart index bf93e14..ba9a721 100644 --- a/dart/lib/peer_connection/events.dart +++ b/dart/lib/peer_connection/events.dart @@ -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. @@ -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? 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(); + } +} diff --git a/dart/lib/peer_connection/peer_connection.dart b/dart/lib/peer_connection/peer_connection.dart index 1e707d3..7a05f6e 100644 --- a/dart/lib/peer_connection/peer_connection.dart +++ b/dart/lib/peer_connection/peer_connection.dart @@ -105,7 +105,7 @@ final class PeerConnection { int _nextDataChannelId = 0; // even for offerer, odd for answerer // Media transceivers - final List<_MediaTransceiver> _transceivers = []; + final List _transceivers = []; final Map _receivers = {}; // SSRC → receiver // RTP reception stats for RTCP RR @@ -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, ); @@ -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); } @@ -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; @@ -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? 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._( @@ -518,6 +520,7 @@ final class PeerConnection { t.sender!._sendCallback = _sendSrtpRtp; } _transceivers.add(t); + return t; } /// W3C: Add a MediaStreamTrack to the connection. @@ -531,8 +534,14 @@ final class PeerConnection { } /// Get all RTP senders (for sending media). - List getSenders() => - _transceivers.where((t) => t.sender != null).map((t) => t.sender!).toList(); + List getSenders() => List.unmodifiable( + _transceivers.where((t) => t.sender != null).map((t) => t.sender!)); + + /// W3C: all media transceivers, in creation order. + List getTransceivers() => List.unmodifiable(_transceivers); + + /// W3C: all RTP receivers that have produced an incoming stream. + List 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 @@ -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')) { @@ -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 = {}; + final assigned = {}; 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; } } @@ -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'); @@ -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? preferredCodecs; - RtpSender? sender; - _MediaTransceiver({ - required this.kind, - this.direction = 'sendrecv', - this.preferredCodecs, - }); -} diff --git a/dart/test/api/stats_test.dart b/dart/test/api/stats_test.dart index 1bdfd81..9d8f435 100644 --- a/dart/test/api/stats_test.dart +++ b/dart/test/api/stats_test.dart @@ -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(RtcStatsType.inboundRtp) diff --git a/dart/test/e2e/media_receiver_helper.dart b/dart/test/e2e/media_receiver_helper.dart index aadfdd5..f19850c 100644 --- a/dart/test/e2e/media_receiver_helper.dart +++ b/dart/test/e2e/media_receiver_helper.dart @@ -169,6 +169,35 @@ void main(List 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 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 _run(int sigPort, String kind) async { final ws = await _WsClient.connect(sigPort); ws.sendJson({'type': 'register', 'role': 'offerer'}); @@ -235,6 +264,10 @@ Future _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) { diff --git a/dart/test/peer_connection/transceiver_test.dart b/dart/test/peer_connection/transceiver_test.dart new file mode 100644 index 0000000..015bfa0 --- /dev/null +++ b/dart/test/peer_connection/transceiver_test.dart @@ -0,0 +1,137 @@ +import 'package:test/test.dart'; +import 'package:webdartc/webdartc.dart'; + +/// Minimal track for addTrack — a real capture track is platform-specific. +final class _FakeTrack extends MediaStreamTrack { + @override + final String id; + @override + final String kind; + _FakeTrack(this.id, this.kind); + @override + String get label => 'fake'; + @override + bool enabled = true; + @override + MediaStreamTrackState get readyState => MediaStreamTrackState.live; + @override + MediaStreamTrack clone() => _FakeTrack(id, kind); + @override + void stop() {} + @override + Stream get onVideoFrame => throw UnsupportedError('audio'); + @override + Stream get onAudioData => const Stream.empty(); +} + +void main() { + group('RtpTransceiverDirection', () { + test('token round-trips', () { + for (final token in ['sendrecv', 'sendonly', 'recvonly', 'inactive']) { + expect(RtpTransceiverDirection.fromToken(token).sdpToken, token); + } + }); + + test('stopped maps to inactive on the wire', () { + expect(RtpTransceiverDirection.stopped.sdpToken, 'inactive'); + }); + + test('unknown token falls back to sendrecv', () { + expect(RtpTransceiverDirection.fromToken('bogus'), + RtpTransceiverDirection.sendrecv); + }); + + test('negotiated direction is the local/remote intersection', () { + const d = RtpTransceiverDirection.values; + RtpTransceiverDirection neg( + RtpTransceiverDirection l, RtpTransceiverDirection r) => + RtpTransceiverDirection.negotiated(l, r); + // symmetric + expect(neg(d[0], d[0]), RtpTransceiverDirection.sendrecv); // sendrecv×sendrecv + // asymmetric, both directions usable + expect(neg(RtpTransceiverDirection.sendonly, + RtpTransceiverDirection.recvonly), RtpTransceiverDirection.sendonly); + expect(neg(RtpTransceiverDirection.recvonly, + RtpTransceiverDirection.sendonly), RtpTransceiverDirection.recvonly); + // remote narrows us: we offer sendrecv, peer only receives → we send only + expect(neg(RtpTransceiverDirection.sendrecv, + RtpTransceiverDirection.recvonly), RtpTransceiverDirection.sendonly); + expect(neg(RtpTransceiverDirection.sendrecv, + RtpTransceiverDirection.sendonly), RtpTransceiverDirection.recvonly); + // no overlap → inactive + expect(neg(RtpTransceiverDirection.sendonly, + RtpTransceiverDirection.sendonly), RtpTransceiverDirection.inactive); + expect(neg(RtpTransceiverDirection.inactive, + RtpTransceiverDirection.sendrecv), RtpTransceiverDirection.inactive); + }); + }); + + group('PeerConnection transceivers', () { + late PeerConnection pc; + setUp(() { + pc = PeerConnection(configuration: const PeerConnectionConfiguration()); + }); + tearDown(() => pc.close()); + + test('addTransceiver returns a sendrecv transceiver with a sender', () { + final t = pc.addTransceiver('audio'); + expect(t.kind, 'audio'); + expect(t.direction, RtpTransceiverDirection.sendrecv); + expect(t.currentDirection, isNull); // not negotiated yet + expect(t.mid, isNull); + expect(t.stopped, isFalse); + expect(t.sender, isNotNull); + expect(t.receiver, isNull); + + expect(pc.getTransceivers(), [t]); + expect(pc.getSenders(), [t.sender]); + expect(pc.getReceivers(), isEmpty); + }); + + test('a recvonly transceiver has no sender', () { + final t = pc.addTransceiver('video', direction: 'recvonly'); + expect(t.direction, RtpTransceiverDirection.recvonly); + expect(t.sender, isNull); + expect(pc.getSenders(), isEmpty); + expect(pc.getTransceivers(), hasLength(1)); + }); + + test('addTrack creates a sendrecv transceiver carrying the track', () { + final track = _FakeTrack('t1', 'audio'); + final sender = pc.addTrack(track); + expect(sender.track, same(track)); + final t = pc.getTransceivers().single; + expect(t.kind, 'audio'); + expect(t.sender, same(sender)); + }); + + test('getTransceivers preserves creation order', () { + final a = pc.addTransceiver('audio'); + final v = pc.addTransceiver('video', direction: 'recvonly'); + expect(pc.getTransceivers(), [a, v]); + }); + + test('setDirection updates the preferred direction', () { + final t = pc.addTransceiver('audio'); + t.setDirection(RtpTransceiverDirection.inactive); + expect(t.direction, RtpTransceiverDirection.inactive); + }); + + test('stop marks the transceiver stopped and blocks setDirection', () { + final t = pc.addTransceiver('audio'); + t.stop(); + expect(t.stopped, isTrue); + expect(t.direction, RtpTransceiverDirection.stopped); + expect(t.currentDirection, RtpTransceiverDirection.stopped); + expect(() => t.setDirection(RtpTransceiverDirection.sendrecv), + throwsStateError); + expect(t.stop, returnsNormally); // idempotent + }); + + test('getTransceivers returns an unmodifiable view', () { + pc.addTransceiver('audio'); + expect(() => pc.getTransceivers().add(pc.getTransceivers().first), + throwsUnsupportedError); + }); + }); +}