diff --git a/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts index 65fd47040f926..86fa92bb08709 100644 --- a/ee/packages/media-calls/src/constants.ts +++ b/ee/packages/media-calls/src/constants.ts @@ -1,4 +1,4 @@ import type { CallFeature } from '@rocket.chat/media-signaling'; export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio']; -export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold']; +export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share']; diff --git a/packages/media-signaling/jest.config.ts b/packages/media-signaling/jest.config.ts new file mode 100644 index 0000000000000..96c49597cc273 --- /dev/null +++ b/packages/media-signaling/jest.config.ts @@ -0,0 +1,6 @@ +import client from '@rocket.chat/jest-presets/client'; +import type { Config } from 'jest'; + +export default { + preset: client.preset, +} satisfies Config; diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 12f7a2389305d..6f16c8b73afb1 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -26,7 +26,9 @@ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "lint": "eslint .", "lint:fix": "eslint --fix .", - "test": "jest" + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@rocket.chat/emitter": "^0.33.0", diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index 7bd3dfcdd1939..8cb07a327822e 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -4,6 +4,7 @@ import type { IClientMediaCall } from '../../call'; import type { IMediaSignalLogger } from '../../logger'; import type { IMediaStreamManager } from '../../media/IMediaStreamManager'; import type { MediaStreamIdentification } from '../../media/MediaStreamIdentification'; +import type { ServerMediaSignalRemoteSDP } from '../../signals'; import type { IServiceProcessor, ServiceProcessorEvents } from '../IServiceProcessor'; export type WebRTCInternalStateMap = { @@ -48,7 +49,7 @@ export interface IWebRTCProcessor extends IServiceProcessor { - if (this.hidden || this.shouldIgnoreWebRTC()) { - return; - } - - this.config.logger?.debug('ClientMediaCall.processAnswerRequest', signal); - - this.requireWebRTC(); - - void this.negotiationManager.addNegotiation(signal.negotiationId, signal.sdp); - } - protected sendError(error: Partial): void { this.config.logger?.debug('ClientMediaCall.sendError', error); @@ -1080,30 +1068,22 @@ export class ClientMediaCall implements IClientMediaCall { } if (!this.isSignalTargetingThisSession(signal)) { - this.config.logger?.error('Received an offer request that is unsigned, or signed to a different session.'); + this.config.logger?.error('Received a remote sdp that is not signed to this session.'); return; } if (this.shouldIgnoreWebRTC()) { return; } + if (!['offer', 'answer'].includes(signal.sdp.type)) { + this.config.logger?.error('Unsupported remote sdp type.', signal.sdp.type); + return; + } this.requireWebRTC(); - if (signal.streams) { - this.webrtcProcessor.setRemoteIds(signal.streams); - } - switch (signal.sdp.type) { - case 'offer': - await this.processAnswerRequest(signal); - break; - case 'answer': - await this.negotiationManager.setRemoteDescription(signal.negotiationId, signal.sdp); - break; - default: - this.config.logger?.error('Unsupported sdp type.'); - return; - } + this.webrtcProcessor.setRemoteIds(signal); + await this.negotiationManager.setRemoteDescription(signal.negotiationId, signal.sdp); this.receivedRemoteSdp = true; this.updateClientState(); diff --git a/packages/media-signaling/src/lib/media/MediaStreamManager.ts b/packages/media-signaling/src/lib/media/MediaStreamManager.ts index 8925ff2b6929d..d29e8fea66938 100644 --- a/packages/media-signaling/src/lib/media/MediaStreamManager.ts +++ b/packages/media-signaling/src/lib/media/MediaStreamManager.ts @@ -101,9 +101,9 @@ export class MediaStreamManager implements IMediaStreamManager { return [this.mainRemote]; } - // A video track for an unidentified stream, let's ignore it - this.logger?.debug('unidentified stream, ignoring video track'); - return []; + // A video track for an unidentified stream - since the only video we support now is screen share, assume that's what this is + this.logger?.debug('unidentified stream, assuming screen-share'); + return [this.screenShareRemote]; } private createStream(remote: boolean, tag: string): MediaStreamWrapper { diff --git a/packages/media-signaling/src/lib/services/webrtc/Negotiation.ts b/packages/media-signaling/src/lib/services/webrtc/Negotiation.ts index 94bf864871fff..f6619660bb275 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Negotiation.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Negotiation.ts @@ -1,5 +1,6 @@ import { Emitter } from '@rocket.chat/emitter'; +import { SDP } from './sdp'; import type { IMediaSignalLogger, IWebRTCProcessor, NegotiationData, NegotiationEvents } from '../../../definition'; export class Negotiation { @@ -206,13 +207,39 @@ export class Negotiation { if (!sdp) { throw new Error('No local description'); } - return sdp; + return this.mutateLocalDescription(sdp); } catch (err) { this.logger?.error(err); this.fail('failed-to-get-local-description'); throw err; } } + + protected mutateLocalDescription(this: WebRTCNegotiation, description: RTCSessionDescriptionInit): RTCSessionDescriptionInit { + const { sdp, type } = description; + if (!sdp) { + return description; + } + + this.logger?.debug('MediaCallWebRTCProcessor.mutateLocalDescription', type); + + const mainStreamId = this.webrtcProcessor.streams.mainLocal.stream.id; + const screenShareStreamId = this.webrtcProcessor.streams.screenShareLocal.stream.id; + + const mutated = SDP.mutateSDPWithStreamContents(sdp, [ + { id: mainStreamId, content: 'main' }, + { id: screenShareStreamId, content: 'slides' }, + ]); + + if (sdp !== mutated) { + this.logger?.debug('SDP was mutated'); + } + + return { + type, + sdp: mutated, + }; + } } export abstract class WebRTCNegotiation extends Negotiation { diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index fda3466345952..fb1c13df9da59 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -1,8 +1,10 @@ import { Emitter } from '@rocket.chat/emitter'; +import { SDP } from './sdp'; import type { IWebRTCProcessor, WebRTCInternalStateMap, WebRTCProcessorConfig, WebRTCProcessorEvents } from '../../../definition'; import type { MediaStreamIdentification } from '../../../definition/media/MediaStreamIdentification'; import type { ServiceStateValue } from '../../../definition/services/IServiceProcessor'; +import type { ServerMediaSignalRemoteSDP } from '../../../definition/signals'; import { MediaStreamManager } from '../../media/MediaStreamManager'; import { getExternalWaiter, type PromiseWaiterData } from '../../utils/getExternalWaiter'; @@ -308,8 +310,52 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { await iceGatheringData.promise; } - public setRemoteIds(streams: MediaStreamIdentification[]): void { - this.streams.setRemoteIds(streams); + public setRemoteIds(signal: ServerMediaSignalRemoteSDP): void { + const { + streams, + sdp: { sdp }, + } = signal; + + const streamsFromSDP = sdp ? this.getRemoteIdsFromSDP(sdp) : []; + const allStreams = this.combineRemoteIds(streams || [], streamsFromSDP); + + if (allStreams.length) { + this.streams.setRemoteIds(allStreams); + } + } + + protected combineRemoteIds(streams1: MediaStreamIdentification[], streams2: MediaStreamIdentification[]): MediaStreamIdentification[] { + if (!streams2.length) { + return streams1; + } + if (!streams1.length) { + return streams2; + } + + const result = [...streams1]; + for (const stream of streams2) { + if (result.find(({ id }) => id === stream.id)) { + continue; + } + + result.push(stream); + } + + return result; + } + + protected getRemoteIdsFromSDP(sdp: string): MediaStreamIdentification[] { + const contentMap = SDP.getStreamContentMapFromSDP(sdp); + return Object.entries(contentMap) + .map(([id, content]) => { + const tag = SDP.getStreamTagByMediaContent(content); + if (!tag) { + return null; + } + + return { id, tag }; + }) + .filter((stream): stream is MediaStreamIdentification => Boolean(stream)); } public getLocalStreamIds(): MediaStreamIdentification[] { diff --git a/packages/media-signaling/src/lib/services/webrtc/index.ts b/packages/media-signaling/src/lib/services/webrtc/index.ts index c0b1f5e865bd7..162d8fb909014 100644 --- a/packages/media-signaling/src/lib/services/webrtc/index.ts +++ b/packages/media-signaling/src/lib/services/webrtc/index.ts @@ -1 +1,2 @@ export * from './Processor'; +export * from './sdp'; diff --git a/packages/media-signaling/src/lib/services/webrtc/sdp.spec.ts b/packages/media-signaling/src/lib/services/webrtc/sdp.spec.ts new file mode 100644 index 0000000000000..0250c19bf4cb6 --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/sdp.spec.ts @@ -0,0 +1,496 @@ +import { MediaDescription, SDP } from './sdp'; + +const CRLF = '\r\n'; + +const audioMediaLines = ['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'c=IN IP4 0.0.0.0', 'a=mid:0', 'a=msid:audio-stream audio-track', 'a=sendrecv']; + +const videoMediaLines = [ + 'm=video 9 UDP/TLS/RTP/SAVPF 96', + 'c=IN IP4 0.0.0.0', + 'a=mid:1', + 'a=msid:video-stream video-track', + 'a=content:slides', + 'a=sendrecv', +]; + +const headerLines = ['v=0', 'o=- 4611731400430051336 2 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=group:BUNDLE 0 1']; + +const buildSDP = (lines: string[], delimiter = CRLF, trailing = true): string => { + const body = lines.join(delimiter); + return trailing ? `${body}${delimiter}` : body; +}; + +const sampleSDP = buildSDP([...headerLines, ...audioMediaLines, ...videoMediaLines]); + +// A realistic screen-share negotiation with what is currently supported: an +// audio-only `main` stream and a `slides` screen-share video stream, both +// content tags already present. +const screenShareMediaLines = [ + ...headerLines, + 'm=audio 9 UDP/TLS/RTP/SAVPF 111', + 'a=mid:0', + 'a=msid:main-stream audio-track', + 'a=content:main', + 'a=sendrecv', + 'm=video 9 UDP/TLS/RTP/SAVPF 96', + 'a=mid:1', + 'a=msid:screen-stream screen-track', + 'a=content:slides', + 'a=sendrecv', +]; + +// Same negotiation but with a camera video track on the `main` stream. +const screenShareWithCameraMediaLines = [ + ...headerLines, + 'm=video 9 UDP/TLS/RTP/SAVPF 96', + 'a=mid:0', + 'a=msid:main-stream camera-track', + 'a=content:main', + 'a=sendrecv', + 'm=video 9 UDP/TLS/RTP/SAVPF 96', + 'a=mid:1', + 'a=msid:screen-stream screen-track', + 'a=content:slides', + 'a=sendrecv', +]; + +// A full browser-generated offer as it arrives before any tagging: an audio +// `main` stream and a video screen-share stream, neither carrying an +// `a=content:` line yet. This is the common input to the mutation function. +const untaggedOfferLines = [ + 'v=0', + 'o=- 4611731400430051336 2 IN IP4 127.0.0.1', + 's=-', + 't=0 0', + 'a=group:BUNDLE 0 1', + 'a=extmap-allow-mixed', + 'a=msid-semantic: WMS main-stream screen-stream', + 'm=audio 9 UDP/TLS/RTP/SAVPF 111 63', + 'c=IN IP4 0.0.0.0', + 'a=rtcp:9 IN IP4 0.0.0.0', + 'a=ice-ufrag:4ZcD', + 'a=ice-pwd:by2Xr5jL6i2S3NqZ0P0m0Xa8', + 'a=fingerprint:sha-256 8F:32:1A:0B:44:9C:2E:11:7D:6F:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45', + 'a=setup:actpass', + 'a=mid:0', + 'a=sendrecv', + 'a=msid:main-stream audio-track', + 'a=rtcp-mux', + 'a=rtpmap:111 opus/48000/2', + 'm=video 9 UDP/TLS/RTP/SAVPF 96 97', + 'c=IN IP4 0.0.0.0', + 'a=rtcp:9 IN IP4 0.0.0.0', + 'a=ice-ufrag:4ZcD', + 'a=ice-pwd:by2Xr5jL6i2S3NqZ0P0m0Xa8', + 'a=fingerprint:sha-256 8F:32:1A:0B:44:9C:2E:11:7D:6F:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45', + 'a=setup:actpass', + 'a=mid:1', + 'a=sendrecv', + 'a=msid:screen-stream screen-track', + 'a=rtcp-mux', + 'a=rtpmap:96 VP8/90000', +]; + +describe('MediaDescription', () => { + describe('media type parsing', () => { + it('should parse the media type from the m= line', () => { + expect(new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111']).type).toBe('audio'); + expect(new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96']).type).toBe('video'); + expect(new MediaDescription(['m=application 9 UDP/DTLS/SCTP webrtc-datachannel']).type).toBe('application'); + }); + + it('should return null when there is no m= line', () => { + expect(new MediaDescription(['a=mid:0', 'a=sendrecv']).type).toBeNull(); + }); + + it('should only use the first m= line to determine the type', () => { + const media = new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'm=video 9 UDP/TLS/RTP/SAVPF 96']); + expect(media.type).toBe('audio'); + }); + }); + + describe('stream id parsing', () => { + it('should parse a stream id from the a=msid line', () => { + const media = new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'a=msid:audio-stream audio-track']); + expect(media.streamIds).toEqual(['audio-stream']); + }); + + it('should parse a stream id even without a track id', () => { + const media = new MediaDescription(['a=msid:audio-stream']); + expect(media.streamIds).toEqual(['audio-stream']); + }); + + it('should ignore the "-" placeholder stream id', () => { + const media = new MediaDescription(['a=msid:- audio-track']); + expect(media.streamIds).toEqual([]); + }); + + it('should ignore an empty stream id', () => { + const media = new MediaDescription(['a=msid: audio-track']); + expect(media.streamIds).toEqual([]); + }); + + it('should deduplicate repeated stream ids', () => { + const media = new MediaDescription(['a=msid:stream track-a', 'a=msid:stream track-b']); + expect(media.streamIds).toEqual(['stream']); + }); + + it('should collect multiple distinct stream ids', () => { + const media = new MediaDescription(['a=msid:stream-a track-a', 'a=msid:stream-b track-b']); + expect(media.streamIds).toEqual(['stream-a', 'stream-b']); + }); + + it('should return an empty array when there is no msid line', () => { + const media = new MediaDescription(['m=audio 9 UDP/TLS/RTP/SAVPF 111']); + expect(media.streamIds).toEqual([]); + }); + }); + + describe('content parsing', () => { + it('should parse the content tag from the a=content line', () => { + const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides']); + expect(media.content).toBe('slides'); + }); + + it('should return null when there is no content line', () => { + const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96']); + expect(media.content).toBeNull(); + }); + + it('should return null for an empty content value', () => { + const media = new MediaDescription(['a=content:']); + expect(media.content).toBeNull(); + }); + }); + + describe('lines', () => { + it('should expose a copy of the given lines', () => { + const input = ['m=audio 9 UDP/TLS/RTP/SAVPF 111', 'a=sendrecv']; + const media = new MediaDescription(input); + + expect(media.lines).toEqual(input); + expect(media.lines).not.toBe(input); + }); + + it('should not be affected by mutations to the original array', () => { + const input = ['m=audio 9 UDP/TLS/RTP/SAVPF 111']; + const media = new MediaDescription(input); + input.push('a=sendrecv'); + + expect(media.lines).toEqual(['m=audio 9 UDP/TLS/RTP/SAVPF 111']); + }); + }); + + describe('setContent', () => { + it('should add a content line when none exists', () => { + const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv']); + media.setContent('slides'); + + expect(media.content).toBe('slides'); + expect(media.lines).toEqual(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv', 'a=content:slides']); + }); + + it('should replace an existing content line', () => { + const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides', 'a=sendrecv']); + media.setContent('main'); + + expect(media.content).toBe('main'); + expect(media.lines).toEqual(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv', 'a=content:main']); + expect(media.lines.filter((line) => line.startsWith('a=content:'))).toHaveLength(1); + }); + + it('should remove the content line when set to null', () => { + const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides', 'a=sendrecv']); + media.setContent(null); + + expect(media.content).toBeNull(); + expect(media.lines).toEqual(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=sendrecv']); + }); + + it('should be a no-op when the value does not change', () => { + const media = new MediaDescription(['m=video 9 UDP/TLS/RTP/SAVPF 96', 'a=content:slides', 'a=sendrecv']); + const linesBefore = media.lines; + media.setContent('slides'); + + expect(media.lines).toBe(linesBefore); + expect(media.content).toBe('slides'); + }); + }); +}); + +describe('SDP', () => { + describe('parsing', () => { + it('should split header lines from media descriptions', () => { + const sdp = new SDP(sampleSDP); + expect(sdp.medias).toHaveLength(2); + expect(sdp.medias[0].type).toBe('audio'); + expect(sdp.medias[1].type).toBe('video'); + }); + + it('should assign each media its own set of lines', () => { + const sdp = new SDP(sampleSDP); + expect(sdp.medias[0].lines[0]).toBe('m=audio 9 UDP/TLS/RTP/SAVPF 111'); + expect(sdp.medias[1].lines[0]).toBe('m=video 9 UDP/TLS/RTP/SAVPF 96'); + }); + + it('should parse stream ids and content per media', () => { + const sdp = new SDP(sampleSDP); + expect(sdp.medias[0].streamIds).toEqual(['audio-stream']); + expect(sdp.medias[0].content).toBeNull(); + expect(sdp.medias[1].streamIds).toEqual(['video-stream']); + expect(sdp.medias[1].content).toBe('slides'); + }); + + it('should handle SDPs delimited with \\n only', () => { + const sdp = new SDP(buildSDP([...headerLines, ...audioMediaLines, ...videoMediaLines], '\n')); + expect(sdp.medias).toHaveLength(2); + expect(sdp.medias[0].type).toBe('audio'); + }); + + it('should produce no media descriptions when there are no m= lines', () => { + const sdp = new SDP(buildSDP(headerLines)); + expect(sdp.medias).toHaveLength(0); + }); + + it('should handle an empty string', () => { + const sdp = new SDP(''); + expect(sdp.medias).toHaveLength(0); + }); + }); + + describe('joinLines', () => { + it('should round-trip an SDP delimited with CRLF', () => { + const sdp = new SDP(sampleSDP); + expect(sdp.joinLines()).toBe(sampleSDP); + }); + + it('should serialize using CRLF delimiters', () => { + const sdp = new SDP(buildSDP([...headerLines, ...audioMediaLines], '\n')); + const output = sdp.joinLines(); + expect(output).toContain(CRLF); + expect(output).toBe(buildSDP([...headerLines, ...audioMediaLines], CRLF)); + }); + + it('should not append a delimiter to empty lines', () => { + const sdp = new SDP(`${headerLines.join(CRLF)}${CRLF}`); + expect(sdp.joinLines()).not.toContain(`${CRLF}${CRLF}`); + }); + }); + + describe('setContentMediaByStreamId', () => { + it('should set the content on the media owning the stream id', () => { + const sdp = new SDP(sampleSDP); + sdp.setContentMediaByStreamId('audio-stream', 'main'); + + expect(sdp.medias[0].content).toBe('main'); + expect(sdp.medias[1].content).toBe('slides'); + }); + + it('should do nothing when no media owns the stream id', () => { + const sdp = new SDP(sampleSDP); + sdp.setContentMediaByStreamId('unknown-stream', 'main'); + + expect(sdp.medias[0].content).toBeNull(); + expect(sdp.medias[1].content).toBe('slides'); + }); + }); + + describe('mutateSDPWithStreamContents', () => { + it('should return the SDP unchanged when no streams are given', () => { + expect(SDP.mutateSDPWithStreamContents(sampleSDP, [])).toBe(sampleSDP); + }); + + it('should add content tags to the matching media', () => { + const output = SDP.mutateSDPWithStreamContents(sampleSDP, [{ id: 'audio-stream', content: 'main' }]); + expect(output).toContain(`a=content:main${CRLF}`); + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ + 'audio-stream': 'main', + 'video-stream': 'slides', + }); + }); + + it('should apply multiple stream contents at once', () => { + const output = SDP.mutateSDPWithStreamContents(sampleSDP, [ + { id: 'audio-stream', content: 'main' }, + { id: 'video-stream', content: 'speaker' }, + ]); + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ + 'audio-stream': 'main', + 'video-stream': 'speaker', + }); + }); + + it('should ignore stream ids that are not present', () => { + const output = SDP.mutateSDPWithStreamContents(sampleSDP, [{ id: 'ghost-stream', content: 'main' }]); + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ 'video-stream': 'slides' }); + }); + + describe('on a full offer with no content tags (common case)', () => { + it('should confirm the sample offer starts with no content tags', () => { + expect(buildSDP(untaggedOfferLines)).not.toContain('a=content:'); + expect(SDP.getStreamContentMapFromSDP(buildSDP(untaggedOfferLines))).toEqual({}); + }); + + it('should tag both streams and leave everything else intact', () => { + const input = buildSDP(untaggedOfferLines); + const output = SDP.mutateSDPWithStreamContents(input, [ + { id: 'main-stream', content: 'main' }, + { id: 'screen-stream', content: 'slides' }, + ]); + + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ + 'main-stream': 'main', + 'screen-stream': 'slides', + }); + // Exactly one tag added per media, none duplicated. + expect(output.match(/a=content:/g)).toHaveLength(2); + // Every original line survives the mutation, only content lines are new. + const addedLines = output.split(CRLF).filter((line) => line && !untaggedOfferLines.includes(line)); + expect(addedLines).toEqual(['a=content:main', 'a=content:slides']); + }); + + it('should append the content tag inside the media section that owns the stream', () => { + const output = SDP.mutateSDPWithStreamContents(buildSDP(untaggedOfferLines), [{ id: 'screen-stream', content: 'slides' }]); + const parsed = new SDP(output); + + expect(parsed.medias[0].type).toBe('audio'); + expect(parsed.medias[0].content).toBeNull(); + expect(parsed.medias[1].type).toBe('video'); + expect(parsed.medias[1].content).toBe('slides'); + // The tag lands in the video block, not the audio block or the header. + expect(parsed.medias[1].lines).toContain('a=content:slides'); + expect(parsed.medias[0].lines).not.toContain('a=content:slides'); + }); + + it('should only tag the requested stream and leave the other untagged', () => { + const output = SDP.mutateSDPWithStreamContents(buildSDP(untaggedOfferLines), [{ id: 'main-stream', content: 'main' }]); + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ 'main-stream': 'main' }); + expect(output.match(/a=content:/g)).toHaveLength(1); + }); + + it.each([ + ['CRLF', CRLF], + ['LF', '\n'], + ])('should tag a %s-delimited offer and serialize with CRLF', (_label, delimiter) => { + const output = SDP.mutateSDPWithStreamContents(buildSDP(untaggedOfferLines, delimiter), [ + { id: 'main-stream', content: 'main' }, + { id: 'screen-stream', content: 'slides' }, + ]); + + expect(output).toContain(`a=content:main${CRLF}`); + expect(output).toContain(`a=content:slides${CRLF}`); + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ + 'main-stream': 'main', + 'screen-stream': 'slides', + }); + }); + }); + }); + + describe('getStreamContentMapFromSDP', () => { + it('should map stream ids to their content tag', () => { + expect(SDP.getStreamContentMapFromSDP(sampleSDP)).toEqual({ 'video-stream': 'slides' }); + }); + + it('should skip media without a content tag', () => { + const sdp = buildSDP([...headerLines, ...audioMediaLines]); + expect(SDP.getStreamContentMapFromSDP(sdp)).toEqual({}); + }); + + it('should map every stream id of a media sharing the same content', () => { + const sdp = buildSDP([ + ...headerLines, + 'm=video 9 UDP/TLS/RTP/SAVPF 96', + 'a=msid:stream-a track-a', + 'a=msid:stream-b track-b', + 'a=content:slides', + ]); + expect(SDP.getStreamContentMapFromSDP(sdp)).toEqual({ + 'stream-a': 'slides', + 'stream-b': 'slides', + }); + }); + }); + + describe('getStreamTagByMediaContent', () => { + it('should map "slides" to "screen-share"', () => { + expect(SDP.getStreamTagByMediaContent('slides')).toBe('screen-share'); + }); + + it('should map "main" to "main"', () => { + expect(SDP.getStreamTagByMediaContent('main')).toBe('main'); + }); + + it.each(['speaker', 'sl', 'alt', 'unknown', ''])('should return null for %p', (content) => { + expect(SDP.getStreamTagByMediaContent(content)).toBeNull(); + }); + }); + + describe('main/slides screen-share SDP', () => { + it('should parse the audio-only main stream and the slides video stream', () => { + const sdp = new SDP(buildSDP(screenShareMediaLines)); + + expect(sdp.medias[0].type).toBe('audio'); + expect(sdp.medias[0].content).toBe('main'); + expect(sdp.medias[0].streamIds).toEqual(['main-stream']); + + expect(sdp.medias[1].type).toBe('video'); + expect(sdp.medias[1].content).toBe('slides'); + expect(sdp.medias[1].streamIds).toEqual(['screen-stream']); + }); + + it.each([ + ['CRLF', CRLF], + ['LF', '\n'], + ])('should read existing main and slides content tags from %s input', (_label, delimiter) => { + const sdp = buildSDP(screenShareMediaLines, delimiter); + expect(SDP.getStreamContentMapFromSDP(sdp)).toEqual({ + 'main-stream': 'main', + 'screen-stream': 'slides', + }); + }); + + it.each([ + ['CRLF', CRLF], + ['LF', '\n'], + ])('should preserve the exact content tags when round-tripping %s input', (_label, delimiter) => { + const sdp = new SDP(buildSDP(screenShareMediaLines, delimiter)); + const output = sdp.joinLines(); + expect(output).toContain(`a=content:main${CRLF}`); + expect(output).toContain(`a=content:slides${CRLF}`); + expect(output.match(/a=content:/g)).toHaveLength(2); + }); + + it('should replace an existing tag in place without leaving a stale one', () => { + const output = SDP.mutateSDPWithStreamContents(buildSDP(screenShareMediaLines), [{ id: 'main-stream', content: 'slides' }]); + + // The main section flips main -> slides; the screen-share section is untouched. + expect(SDP.getStreamContentMapFromSDP(output)).toEqual({ + 'main-stream': 'slides', + 'screen-stream': 'slides', + }); + // No stale `main` tag left behind after replacement. + expect(output).not.toContain('a=content:main'); + expect(output.match(/a=content:/g)).toHaveLength(2); + }); + + it('should place tags only on the media owning each stream id', () => { + const sdp = new SDP(buildSDP(screenShareMediaLines)); + sdp.setContentMediaByStreamId('screen-stream', 'speaker'); + + expect(sdp.medias[0].content).toBe('main'); + expect(sdp.medias[1].content).toBe('speaker'); + }); + + it('should handle a main stream carrying a camera video track', () => { + const sdp = new SDP(buildSDP(screenShareWithCameraMediaLines)); + + expect(sdp.medias[0].type).toBe('video'); + expect(sdp.medias[0].content).toBe('main'); + expect(SDP.getStreamContentMapFromSDP(buildSDP(screenShareWithCameraMediaLines))).toEqual({ + 'main-stream': 'main', + 'screen-stream': 'slides', + }); + }); + }); +}); diff --git a/packages/media-signaling/src/lib/services/webrtc/sdp.ts b/packages/media-signaling/src/lib/services/webrtc/sdp.ts new file mode 100644 index 0000000000000..c984fd17f2aa5 --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/sdp.ts @@ -0,0 +1,197 @@ +/* Content tags defined by RFC 4796 - external SDPs may still have other values */ +type MediaContent = 'slides' | 'speaker' | 'sl' | 'main' | 'alt'; +type StreamContent = { id: string; content: MediaContent }; + +const lineDelimiter = '\r\n'; + +export class MediaDescription { + private _lines: string[]; + + public readonly streamIds: string[]; + + private _type: string | null = null; + + private _content: string | null = null; + + public get type(): string | null { + return this._type; + } + + public get content(): string | null { + return this._content; + } + + public get lines(): string[] { + return this._lines; + } + + constructor(lines: string[]) { + this._lines = [...lines]; + this.streamIds = []; + this.parseLines(); + } + + private parseLines() { + for (const line of this.lines) { + this.parseMediaType(line); + this.parseStreamId(line); + this.parseContent(line); + } + } + + private parseMediaType(line: string) { + if (this._type || !line.startsWith('m=')) { + return; + } + + this._type = line.match(/^m=(\w+)/)?.[1] || null; + } + + private parseStreamId(line: string) { + if (!line.startsWith('a=msid:')) { + return; + } + + const streamId = line.slice('a=msid:'.length).split(' ')[0]; + if (!streamId || streamId === '-') { + return; + } + + if (this.streamIds.includes(streamId)) { + return; + } + + this.streamIds.push(streamId); + } + + private parseContent(line: string) { + if (!line.startsWith('a=content:')) { + return; + } + + this._content = line.replace('a=content:', '') || null; + } + + public setContent(value: MediaContent | null) { + if (this._content === value) { + return; + } + + this._content = value || null; + const lines = this.lines.filter((line) => !line.startsWith('a=content:')); + this._lines = [...lines, ...(value ? [`a=content:${value}`] : [])]; + } +} + +export class SDP { + private headerLines: string[]; + + public readonly medias: MediaDescription[]; + + constructor(sdp: string) { + this.headerLines = []; + this.medias = []; + + this.parseSDP(sdp); + } + + private addMediaDescription(lines?: string[]) { + if (!lines?.length) { + return; + } + + this.medias.push(new MediaDescription(lines)); + } + + private parseSDP(sdp: string) { + const allLines = sdp.split(/\r?\n/); + + let currentMediaLines: string[] | undefined; + + for (const line of allLines) { + if (line.startsWith('m=')) { + this.addMediaDescription(currentMediaLines); + + currentMediaLines = [line]; + continue; + } + + if (!currentMediaLines) { + this.headerLines.push(line); + continue; + } + + currentMediaLines.push(line); + } + + this.addMediaDescription(currentMediaLines); + } + + public joinLines(): string { + const lines = [...this.headerLines, ...this.medias.flatMap(({ lines }) => lines)]; + + const delimitedLines = lines.map((line) => { + if (!line) { + return line; + } + + return `${line}${lineDelimiter}`; + }); + + return delimitedLines.join(''); + } + + public setContentMediaByStreamId(streamId: string, content: MediaContent) { + for (const media of this.medias) { + if (media.streamIds.includes(streamId)) { + media.setContent(content); + } + } + } + + public static mutateSDPWithStreamContents(sdp: string, streams: StreamContent[]): string { + if (!streams.length) { + return sdp; + } + + const parsed = new SDP(sdp); + + for (const { id, content } of streams) { + parsed.setContentMediaByStreamId(id, content); + } + + return parsed.joinLines(); + } + + /* + * Returns an object where the key is a stream id and the object is a stream content tag + */ + public static getStreamContentMapFromSDP(sdp: string): Record { + const streams: Record = {}; + + const parsed = new SDP(sdp); + for (const media of parsed.medias) { + const { streamIds, content } = media; + if (!streamIds.length || !content) { + continue; + } + + for (const id of streamIds) { + streams[id] = content; + } + } + + return streams; + } + + public static getStreamTagByMediaContent(content: string): string | null { + switch (content) { + case 'slides': + return 'screen-share'; + case 'main': + return 'main'; + default: + return null; + } + } +}