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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Refactored DefaultUserAgentParser to utilize userAgentData when possible, e.g. to get Chrome minor version. Refactored all previous use of UAParser to use DefaultUserAgentParser. This does not change existing dimensions on event reporting.
- Updated to Typescript 5.x and node >= 20
- Refactored DefaultUserAgentParser to utilize userAgentData when possible, e.g. to get Chrome minor version. Refactored all previous use of UAParser to use DefaultUserAgentParser. This does not change existing dimensions on event reporting.
- Updated to Typescript 5.x and node >= 20. This may cause compilation failures on old typescript versions, as `RTCRtpCodecCapability` was removed and replaced by `RTCRtpCodec`. If issues arise please enable `skipLibCheck` in your `tsconfig.json` file.
- Refactored Encoded Transform management into it's own component for better support of non-redundant audio transforms.
- Add scalability mode fallback when SVC is enabled. Limit SVC for content share to AV1 temporal scalability only.
- Completed migration to mocha tests.
Expand Down
28 changes: 26 additions & 2 deletions src/meetingsessiontiming/MeetingSessionTimingManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default class MeetingSessionTimingManager {
private expectingRemoteVideo: boolean = false;
private remoteVideoTiming: Map<number, MeetingSessionRemoteVideoTiming> = new Map();
private boundRemoteVideoGroupIds: Set<number> = new Set();
private emittedRemoteVideoGroupIds: Set<number> = new Set();

constructor(logger: Logger) {
this.logger = logger;
Expand Down Expand Up @@ -431,6 +432,9 @@ export default class MeetingSessionTimingManager {
* @param groupId The group ID of the remote video subscription
*/
onRemoteVideoAdded(groupId: number): void {
if (this.emittedRemoteVideoGroupIds.has(groupId)) {
return;
}
this.remoteVideoTiming.set(groupId, {
addedMs: this.getCurrentTimestamp(),
});
Expand All @@ -457,6 +461,7 @@ export default class MeetingSessionTimingManager {
*/
onRemoteVideoUnbound(groupId: number): void {
this.boundRemoteVideoGroupIds.delete(groupId);
this.emittedRemoteVideoGroupIds.delete(groupId);
this.maybeEmitBatch();
}

Expand Down Expand Up @@ -516,6 +521,7 @@ export default class MeetingSessionTimingManager {
const state = this.remoteVideoTiming.get(groupId);
if (state) {
state.removed = true;
this.emittedRemoteVideoGroupIds.delete(groupId);
this.logger.debug(`Remote video timing marked as removed for group_id=${groupId}`);
this.maybeEmitBatch();
}
Expand All @@ -537,6 +543,7 @@ export default class MeetingSessionTimingManager {
this.expectingRemoteVideo = false;
this.remoteVideoTiming.clear();
this.boundRemoteVideoGroupIds.clear();
this.emittedRemoteVideoGroupIds.clear();

this.logger.info('MeetingSessionTimingManager: reset');
}
Expand Down Expand Up @@ -732,8 +739,25 @@ export default class MeetingSessionTimingManager {
this.localAudioTiming = {};
this.localVideoTiming = {};
this.expectingRemoteVideo = false;
this.remoteVideoTiming.clear();
this.boundRemoteVideoGroupIds.clear();

// Preserve remote video entries that were not included in the emission.
// An entry is emitted only if it is bound. Unemitted entries carry over
// to the next batch so they can complete and be emitted later.
const pendingVideos = new Map<number, MeetingSessionRemoteVideoTiming>();
const pendingBound = new Set<number>();
for (const [groupId, state] of this.remoteVideoTiming) {
if (!this.boundRemoteVideoGroupIds.has(groupId)) {
pendingVideos.set(groupId, state);
} else {
this.emittedRemoteVideoGroupIds.add(groupId);
}
}
this.remoteVideoTiming = pendingVideos;
this.boundRemoteVideoGroupIds = pendingBound;

if (pendingVideos.size > 0) {
this.startBatchIfNeeded();
}
}

/**
Expand Down
156 changes: 156 additions & 0 deletions test/meetingsessiontiming/MeetingSessionTimingManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,5 +776,161 @@ describe('MeetingSessionTimingManager', () => {
manager.onRemoteAudioFirstPacketReceived();
expect(observerSpy.called).to.be.false;
});

it('ignores duplicate onRemoteAudioFirstPacketReceived', () => {
manager.onRemoteAudioAdded();
manager.onRemoteAudioFirstPacketReceived();
manager.onRemoteAudioFirstPacketReceived();
});

it('ignores duplicate onLocalAudioFirstPacketSent', () => {
manager.onLocalAudioAdded();
manager.onLocalAudioFirstPacketSent();
manager.onLocalAudioFirstPacketSent();
});

it('ignores duplicate onLocalVideoFirstFrameSent', () => {
manager.onLocalVideoAdded();
manager.onLocalVideoFirstFrameSent();
manager.onLocalVideoFirstFrameSent();
});

it('carries over unemitted remote video entries across batch emissions', () => {
// Start signaling batch
manager.onStart();
// Add remote video (unbound)
manager.onRemoteVideoAdded(1);
// Complete signaling+audio
manager.onJoinSent();
manager.onJoinAckReceived();
manager.onTransportConnected();
manager.onCreateOfferCalled();
manager.onSetLocalDescription();
manager.onIceGatheringStarted();
manager.onIceGatheringComplete();
manager.onSubscribeSent();
manager.onSubscribeAckReceived();
manager.onSetRemoteDescription();
manager.onIceConnected();
expect(observerSpy.calledOnce).to.be.true;
const first: MeetingSessionTiming = observerSpy.firstCall.args[0];
expect(first.remoteVideos).to.have.lengthOf(0); // unbound, not emitted

// Now bind and complete the carried-over entry
manager.onRemoteVideoBound(1);
manager.onRemoteVideoFirstFrameRendered(1);
expect(observerSpy.calledTwice).to.be.true;
const second: MeetingSessionTiming = observerSpy.secondCall.args[0];
expect(second.remoteVideos).to.have.lengthOf(1);
expect(second.remoteVideos[0].groupId).to.equal(1);
});

it('carries over bound-but-incomplete remote video entries', () => {
manager.onStart();
manager.onRemoteVideoAdded(1);
manager.onRemoteVideoBound(1);
// Complete signaling+audio — remote video is bound but incomplete (no firstFrameRendered)
manager.onJoinSent();
manager.onJoinAckReceived();
manager.onTransportConnected();
manager.onCreateOfferCalled();
manager.onSetLocalDescription();
manager.onIceGatheringStarted();
manager.onIceGatheringComplete();
manager.onSubscribeSent();
manager.onSubscribeAckReceived();
manager.onSetRemoteDescription();
manager.onIceConnected();
manager.onRemoteAudioAdded();
manager.onRemoteAudioFirstPacketReceived();
manager.onLocalAudioAdded();
manager.onLocalAudioFirstPacketSent();
// Batch doesn't emit yet — bound incomplete video blocks it
expect(observerSpy.called).to.be.false;
// Timeout fires
clock.tick(15000);
expect(observerSpy.calledOnce).to.be.true;
const first: MeetingSessionTiming = observerSpy.firstCall.args[0];
expect(first.remoteVideos).to.have.lengthOf(1);
expect(first.remoteVideos[0].timedOut).to.be.true;
});

it('does not re-add remote video after it was emitted', () => {
manager.onRemoteVideoAdded(1);
manager.onRemoteVideoBound(1);
manager.onRemoteVideoFirstFrameRendered(1);
expect(observerSpy.calledOnce).to.be.true;
// After emission, re-adding the same groupId should be ignored
manager.onRemoteVideoAdded(1);
// No new batch started — the re-add was silently dropped
clock.tick(15000);
expect(observerSpy.calledOnce).to.be.true;
});

it('allows re-add after remote video is unbound', () => {
manager.onRemoteVideoAdded(1);
manager.onRemoteVideoBound(1);
manager.onRemoteVideoFirstFrameRendered(1);
expect(observerSpy.calledOnce).to.be.true;
manager.onRemoteVideoUnbound(1);
// Verify the emitted set was cleared
// @ts-ignore: access private for test
expect(manager['emittedRemoteVideoGroupIds'].has(1)).to.be.false;
manager.onRemoteVideoAdded(1);
// @ts-ignore
expect(manager['remoteVideoTiming'].has(1)).to.be.true;
});

it('carries over bound-but-incomplete entry and preserves bound status', () => {
// Batch with signaling only; remote video is bound but incomplete
manager.onStart();
manager.onRemoteVideoAdded(1);
manager.onRemoteVideoBound(1);
manager.onRemoteVideoFirstPacketReceived(1);
// No firstFrameRendered yet — entry is bound but incomplete
// Signaling completes but bound incomplete video blocks batch
manager.onJoinSent();
manager.onJoinAckReceived();
manager.onTransportConnected();
manager.onCreateOfferCalled();
manager.onSetLocalDescription();
manager.onIceGatheringStarted();
manager.onIceGatheringComplete();
manager.onSubscribeSent();
manager.onSubscribeAckReceived();
manager.onSetRemoteDescription();
manager.onIceConnected();
expect(observerSpy.called).to.be.false; // blocked by incomplete bound video
// Complete it
manager.onRemoteVideoFirstFrameRendered(1);
expect(observerSpy.calledOnce).to.be.true;
const timing: MeetingSessionTiming = observerSpy.firstCall.args[0];
expect(timing.remoteVideos).to.have.lengthOf(1);
expect(timing.remoteVideos[0].firstPacketReceivedMs).to.not.be.undefined;
});

it('starts new batch for carried-over entries', () => {
manager.onStart();
manager.onRemoteVideoAdded(1);
// Complete signaling only (no audio/video added)
manager.onJoinSent();
manager.onJoinAckReceived();
manager.onTransportConnected();
manager.onCreateOfferCalled();
manager.onSetLocalDescription();
manager.onIceGatheringStarted();
manager.onIceGatheringComplete();
manager.onSubscribeSent();
manager.onSubscribeAckReceived();
manager.onSetRemoteDescription();
manager.onIceConnected();
expect(observerSpy.calledOnce).to.be.true;
// Carried-over entry should start a new batch that times out
clock.tick(15000);
expect(observerSpy.calledTwice).to.be.true;
const second: MeetingSessionTiming = observerSpy.secondCall.args[0];
// Unbound entry is omitted from emission
expect(second.remoteVideos).to.have.lengthOf(0);
});
});
});