diff --git a/.changeset/tough-doors-do.md b/.changeset/tough-doors-do.md new file mode 100644 index 000000000..1bb098759 --- /dev/null +++ b/.changeset/tough-doors-do.md @@ -0,0 +1,29 @@ +--- +'@signalwire/webrtc': minor +'@signalwire/core': minor +'@signalwire/js': minor +--- + +Call Fabric and Video SDK: Introduce update media APIs with renegotiation + +```text + +await updateMedia({ + audio: { + direction: 'sendonly' | 'sendrecv', + constraints?: MediaTrackConstraints + }, + video: { + direction: 'recvonly' | 'inactive' + constraints?: MediaTrackConstraints + } +}) + +Either "audio" or "video" is required with "direction" property. +The "constraints" can only be passed if the "direction" is either "sendrecv" or "sendonly". + +await setVideoDirection('sendonly' | 'sendrecv' | 'recvonly' | 'inactive') + +await setAudioDirection('sendonly' | 'sendrecv' | 'recvonly' | 'inactive') + +``` diff --git a/.github/workflows/browser-js-production.yml b/.github/workflows/browser-js-production.yml index 78705e804..627caf077 100644 --- a/.github/workflows/browser-js-production.yml +++ b/.github/workflows/browser-js-production.yml @@ -28,6 +28,7 @@ jobs: audience, reattach, callfabric, + renegotiation, videoElement, v2WebRTC, ] diff --git a/.github/workflows/browser-js-staging.yml b/.github/workflows/browser-js-staging.yml index 31a5b500b..1143c58c6 100644 --- a/.github/workflows/browser-js-staging.yml +++ b/.github/workflows/browser-js-staging.yml @@ -27,6 +27,7 @@ jobs: # audience, # reattach, callfabric, + renegotiation, videoElement, v2WebRTC, ] diff --git a/internal/e2e-js/playwright.config.ts b/internal/e2e-js/playwright.config.ts index f5c556646..c3a1d67d4 100644 --- a/internal/e2e-js/playwright.config.ts +++ b/internal/e2e-js/playwright.config.ts @@ -46,9 +46,14 @@ const callfabricTests = [ 'videoRoom.spec.ts', 'videoRoomLayout.spec.ts', ] +const renegotiationTests = [ + 'roomSessionUpdateMedia.spec.ts', + 'renegotiateAudio.spec.ts', + 'renegotiateVideo.spec.ts', +] const videoElementTests = [ - 'buildVideoWithVideoSdk.spec.ts', - 'buildVideoWithFabricSdk.spec.ts', + 'buildVideoWithVideoSDK.spec.ts', + 'buildVideoWithFabricSDK.spec.ts', ] const v2WebRTC = ['v2WebrtcFromRest.spec.ts', 'webrtcCalling.spec.ts'] @@ -90,6 +95,7 @@ const config: PlaywrightTestConfig = { ...audienceTests, ...reattachTests, ...callfabricTests, + ...renegotiationTests, ...videoElementTests, ...v2WebRTC, ], @@ -129,6 +135,11 @@ const config: PlaywrightTestConfig = { use: useDesktopChrome, testMatch: callfabricTests, }, + { + name: 'renegotiation', + use: useDesktopChrome, + testMatch: renegotiationTests, + }, { name: 'videoElement', use: useDesktopChrome, diff --git a/internal/e2e-js/tests/buildVideoWithFabricSdk.spec.ts b/internal/e2e-js/tests/buildVideoWithFabricSDK.spec.ts similarity index 100% rename from internal/e2e-js/tests/buildVideoWithFabricSdk.spec.ts rename to internal/e2e-js/tests/buildVideoWithFabricSDK.spec.ts diff --git a/internal/e2e-js/tests/buildVideoWithVideoSdk.spec.ts b/internal/e2e-js/tests/buildVideoWithVideoSDK.spec.ts similarity index 100% rename from internal/e2e-js/tests/buildVideoWithVideoSdk.spec.ts rename to internal/e2e-js/tests/buildVideoWithVideoSDK.spec.ts diff --git a/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts b/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts new file mode 100644 index 000000000..3e1c5a950 --- /dev/null +++ b/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts @@ -0,0 +1,249 @@ +import { uuid } from '@signalwire/core' +import { FabricRoomSession } from '@signalwire/js' +import { test, expect } from '../../fixtures' +import { + SERVER_URL, + createCFClient, + dialAddress, + expectMCUVisible, + expectStatWithPolling, + getStats, + waitForStabilizedStats, +} from '../../utils' + +test.describe('CallFabric Audio Renegotiation', () => { + test('it should enable audio with "sendrecv" and then disable with "inactive"', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-room-audio-reneg-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page) + + // Dial an address with video only channel + await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + dialOptions: { + audio: false, + negotiateAudio: false, + }, + }) + + // Wait for the MCU to be visible + await expectMCUVisible(page) + + const stats1 = await getStats(page) + expect(stats1.outboundRTP.video?.packetsSent).toBeGreaterThan(0) + expect(stats1.inboundRTP.video?.packetsReceived).toBeGreaterThan(0) + expect(stats1.outboundRTP.audio?.packetsSent).toBe(0) + expect(stats1.inboundRTP.audio?.packetsReceived).toBe(0) + + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.setAudioDirection('sendrecv') + }) + + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + + await test.step('it should disable the audio with "inactive"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.updateMedia({ + audio: { direction: 'inactive' }, + }) + }) + + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await waitForStabilizedStats(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + }) + await waitForStabilizedStats(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + }) + }) + }) + + test('it should enable audio with "sendonly" and then disable with "recvonly"', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-room-audio-reneg-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page) + + // Dial an address with video only channel + await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + dialOptions: { + audio: false, + negotiateAudio: false, + }, + }) + + // Wait for the MCU to be visible + await expectMCUVisible(page) + + const stats1 = await getStats(page) + expect(stats1.outboundRTP.video?.packetsSent).toBeGreaterThan(0) + expect(stats1.inboundRTP.video?.packetsReceived).toBeGreaterThan(0) + expect(stats1.outboundRTP.audio?.packetsSent).toBe(0) + expect(stats1.inboundRTP.audio?.packetsReceived).toBe(0) + + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.setAudioDirection('sendonly') + }) + + const stats2 = await getStats(page) + expect(stats2.outboundRTP).toHaveProperty('video') + expect(stats2.inboundRTP).toHaveProperty('video') + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + expect(stats2.inboundRTP.audio?.packetsReceived).toBe(0) + + await test.step('it should disable the audio with "recvonly"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.updateMedia({ + audio: { direction: 'recvonly' }, + }) + }) + + const stats3 = await getStats(page) + expect(stats3.outboundRTP).toHaveProperty('video') + expect(stats3.inboundRTP).toHaveProperty('video') + await waitForStabilizedStats(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + }) + }) + + test('it should enable audio with "recvonly" and then disable with "inactive"', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-room-audio-reneg-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page) + + // Dial an address with video only channel + await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + dialOptions: { + audio: false, + negotiateAudio: false, + }, + }) + + // Wait for the MCU to be visible + await expectMCUVisible(page) + + const stats1 = await getStats(page) + expect(stats1.outboundRTP.video?.packetsSent).toBeGreaterThan(0) + expect(stats1.inboundRTP.video?.packetsReceived).toBeGreaterThan(0) + expect(stats1.outboundRTP.audio?.packetsSent).toBe(0) + expect(stats1.inboundRTP.audio?.packetsReceived).toBe(0) + + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.setAudioDirection('recvonly') + }) + + const stats2 = await getStats(page) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + expect(stats2.outboundRTP.audio?.packetsSent).toBe(0) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + + await test.step('it should disable the audio with "inactive"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.updateMedia({ + audio: { direction: 'inactive' }, + }) + }) + + const stats3 = await getStats(page) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + expect(stats3.outboundRTP.audio?.packetsSent).toBe(0) + await waitForStabilizedStats(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + }) + }) + }) +}) diff --git a/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts b/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts new file mode 100644 index 000000000..efc9c5818 --- /dev/null +++ b/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts @@ -0,0 +1,208 @@ +import { uuid } from '@signalwire/core' +import { FabricRoomSession } from '@signalwire/js' +import { test, expect } from '../../fixtures' +import { + SERVER_URL, + createCFClient, + dialAddress, + expectMCUNotVisible, + expectMCUVisible, + expectMCUVisibleForAudience, + expectStatWithPolling, + getStats, +} from '../../utils' + +test.describe('CallFabric Video Renegotiation', () => { + test('it should enable video with "sendrecv" and then disable with "inactive"', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-room-video-reneg-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page) + + // Dial an address with audio only channel + await dialAddress(page, { + address: `/public/${roomName}?channel=audio`, + }) + + const stats1 = await getStats(page) + expect(stats1.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats1.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats1.outboundRTP.video?.packetsSent).toBe(0) + expect(stats1.inboundRTP.video?.packetsReceived).toBe(0) + + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.setVideoDirection('sendrecv') + }) + + // Wait for the MCU to be visible + await expectMCUVisible(page) + + const stats2 = await getStats(page) + expect(stats2.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats2.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats2.outboundRTP.video?.packetsSent).toBeGreaterThan(0) + expect(stats2.inboundRTP.video?.packetsReceived).toBeGreaterThan(0) + + await test.step('it should disable the video with "inactive"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.updateMedia({ + video: { direction: 'inactive' }, + }) + }) + + const stats3 = await getStats(page) + expect(stats3.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats3.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBe', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBe', + expected: 0, + }) + }) + }) + + test('it should enable video with "sendonly" and then disable with "recvonly"', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-room-video-reneg-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page) + + // Dial an address with audio only channel + await dialAddress(page, { + address: `/public/${roomName}?channel=audio`, + }) + + const stats1 = await getStats(page) + expect(stats1.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats1.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats1.outboundRTP.video?.packetsSent).toBe(0) + expect(stats1.inboundRTP.video?.packetsReceived).toBe(0) + + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.setVideoDirection('sendonly') + }) + + // Verify the MCU is not visible + await expectMCUNotVisible(page) + + const stats2 = await getStats(page) + expect(stats2.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats2.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBe', + expected: 0, + }) + + await test.step('it should disable the video with "recvonly"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.updateMedia({ + video: { direction: 'recvonly' }, + }) + }) + + const stats3 = await getStats(page) + expect(stats3.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats3.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBe', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + }) + }) + + test('it should enable video with "recvonly" and then disable with "inactive"', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-room-video-reneg-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page) + + // Dial an address with audio only channel + await dialAddress(page, { + address: `/public/${roomName}?channel=audio`, + }) + + const stats1 = await getStats(page) + expect(stats1.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats1.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats1.outboundRTP.video?.packetsSent).toBe(0) + expect(stats1.inboundRTP.video?.packetsReceived).toBe(0) + + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.setVideoDirection('recvonly') + }) + + // Expect incoming video stream is visible + await expectMCUVisibleForAudience(page) + + const stats2 = await getStats(page) + expect(stats2.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats2.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats2.outboundRTP.video?.packetsSent).toBe(0) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + + await test.step('it should disable the video with "inactive"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const cfRoomSession: FabricRoomSession = window._roomObj + await cfRoomSession.updateMedia({ + video: { direction: 'inactive' }, + }) + }) + + const stats3 = await getStats(page) + expect(stats3.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats3.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats3.outboundRTP.video?.packetsSent).toBe(0) + expect(stats3.inboundRTP.video?.packetsReceived).toBe(0) + }) + }) +}) diff --git a/internal/e2e-js/tests/callfabric/videoRoom.spec.ts b/internal/e2e-js/tests/callfabric/videoRoom.spec.ts index 29e76349a..2a7fd50ef 100644 --- a/internal/e2e-js/tests/callfabric/videoRoom.spec.ts +++ b/internal/e2e-js/tests/callfabric/videoRoom.spec.ts @@ -424,11 +424,11 @@ test.describe('CallFabric VideoRoom', () => { // There should be no inbound/outbound video const stats = await getStats(page) - expect(stats.outboundRTP).not.toHaveProperty('video') - expect(stats.inboundRTP).not.toHaveProperty('video') + expect(stats.outboundRTP.video?.packetsSent).toBe(0) + expect(stats.inboundRTP.video?.packetsReceived).toBe(0) // There should be audio packets - expect(stats.inboundRTP.audio.packetsReceived).toBeGreaterThan(0) + expect(stats.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) // There should be no MCU either const videoElement = await page.$('div[id^="sw-sdk-"] > video') diff --git a/internal/e2e-js/tests/roomSessionUpdateMedia.spec.ts b/internal/e2e-js/tests/roomSessionUpdateMedia.spec.ts new file mode 100644 index 000000000..5caefb817 --- /dev/null +++ b/internal/e2e-js/tests/roomSessionUpdateMedia.spec.ts @@ -0,0 +1,247 @@ +import { Video } from '@signalwire/js' +import { Page, test, expect } from '../fixtures' +import { + createTestRoomSession, + expectMCUVisible, + expectRoomJoined, + expectStatWithPolling, + getStats, + randomizeRoomName, + SERVER_URL, + waitForStabilizedStats, +} from '../utils' + +test.describe('RoomSession Update Media', () => { + const setupAndJoinRoom = async (page: Page) => { + const roomName = randomizeRoomName('e2e-room-update-media') + const memberSettings = { + vrt: { + room_name: roomName, + user_name: 'e2e_participant_meta', + auto_create_room: true, + }, + } + + await createTestRoomSession(page, memberSettings) + + // --------------- Joining the room --------------- + const joinParams = await expectRoomJoined(page) + expect(joinParams.room).toBeDefined() + expect(joinParams.room_session).toBeDefined() + + // Wait for the video to be visible + await expectMCUVisible(page) + } + + test('should join a room be able to update "video" multiple times', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[update-media]' }) + await page.goto(SERVER_URL) + + // Setup and join the room and expect the MCU is visible + await test.step('it should join a room and expect the MCU is visible', async () => { + await setupAndJoinRoom(page) + }) + + await test.step('it should have stats with media packets flowing in both directions', async () => { + const stats = await getStats(page) + expect(stats.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats.outboundRTP.video?.packetsSent).toBeGreaterThan(0) + expect(stats.inboundRTP.video?.packetsReceived).toBeGreaterThan(0) + }) + + let lastAudioPacketsSent = 0, + lastAudioPacketsReceived = 0 + + await test.step('it should update media with audio "inactive" and video "sendonly"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const roomSession: Video.RoomSession = window._roomObj + await roomSession.updateMedia({ + audio: { direction: 'inactive' }, + video: { direction: 'sendonly' }, + }) + }) + + lastAudioPacketsSent = await waitForStabilizedStats(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + }) + lastAudioPacketsReceived = await waitForStabilizedStats(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + }) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBe', + expected: 0, + }) + }) + + await test.step('it should update media with video direction "sendrecv"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const roomSession: Video.RoomSession = window._roomObj + await roomSession.setVideoDirection('sendrecv') + }) + + const stats = await getStats(page) + expect(stats.outboundRTP.audio?.packetsSent).toBe(lastAudioPacketsSent) + expect(stats.inboundRTP.audio?.packetsReceived).toBe( + lastAudioPacketsReceived + ) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + }) + + await test.step('it should update media with audio "sendrecv" and video "recvonly"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const roomSession: Video.RoomSession = window._roomObj + await roomSession.updateMedia({ + audio: { direction: 'sendrecv' }, + video: { direction: 'recvonly' }, + }) + }) + + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + matcher: 'toBeGreaterThan', + expected: lastAudioPacketsSent, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + matcher: 'toBeGreaterThan', + expected: lastAudioPacketsReceived, + }) + await waitForStabilizedStats(page, { + propertyPath: 'outboundRTP.video.packetsSent', + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + }) + }) + + test('should join a room be able to update "audio" multiple times', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[update-media]' }) + await page.goto(SERVER_URL) + + // Setup and join the room and expect the MCU is visible + await test.step('it should join a room and expect the MCU is visible', async () => { + await setupAndJoinRoom(page) + }) + + await test.step('it should have stats with media packets flowing in both directions', async () => { + const stats = await getStats(page) + expect(stats.outboundRTP.audio?.packetsSent).toBeGreaterThan(0) + expect(stats.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect(stats.outboundRTP.video?.packetsSent).toBeGreaterThan(0) + expect(stats.inboundRTP.video?.packetsReceived).toBeGreaterThan(0) + }) + + let lastVideoPacketsSent = 0, + lastVideoPacketsReceived = 0 + + await test.step('it should update media with audio "sendonly" and video "inactive"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const roomSession: Video.RoomSession = window._roomObj + await roomSession.updateMedia({ + audio: { direction: 'sendonly' }, + video: { direction: 'inactive' }, + }) + }) + + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + /** + * Ideally, the audio packet received should become 0. + * However, it does not happen but the packets become stabilized. + */ + await waitForStabilizedStats(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + }) + lastVideoPacketsSent = await waitForStabilizedStats(page, { + propertyPath: 'outboundRTP.video.packetsSent', + }) + lastVideoPacketsReceived = await waitForStabilizedStats(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + }) + }) + + await test.step('it should update media with audio direction "sendrecv"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const roomSession: Video.RoomSession = window._roomObj + await roomSession.setAudioDirection('sendrecv') + }) + + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + const stats = await getStats(page) + expect(stats.outboundRTP.video?.packetsSent).toBe(lastVideoPacketsSent) + expect(stats.inboundRTP.video?.packetsReceived).toBe( + lastVideoPacketsReceived + ) + }) + + await test.step('it should update media with audio "recvonly" and video "sendrecv"', async () => { + await page.evaluate(async () => { + // @ts-expect-error + const roomSession: Video.RoomSession = window._roomObj + await roomSession.updateMedia({ + audio: { direction: 'recvonly' }, + video: { direction: 'sendrecv' }, + }) + }) + + await waitForStabilizedStats(page, { + propertyPath: 'outboundRTP.audio.packetsSent', + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.audio.packetsReceived', + matcher: 'toBeGreaterThan', + expected: 0, + }) + await expectStatWithPolling(page, { + propertyPath: 'outboundRTP.video.packetsSent', + matcher: 'toBeGreaterThan', + expected: lastVideoPacketsSent, + }) + await expectStatWithPolling(page, { + propertyPath: 'inboundRTP.video.packetsReceived', + matcher: 'toBeGreaterThan', + expected: lastVideoPacketsReceived, + }) + }) + }) +}) diff --git a/internal/e2e-js/utils.ts b/internal/e2e-js/utils.ts index a3af94827..01e6e9068 100644 --- a/internal/e2e-js/utils.ts +++ b/internal/e2e-js/utils.ts @@ -620,68 +620,154 @@ export const expectMCUVisibleForAudience = async (page: Page) => { // #region Utilities for RTP Media stats and SDP -export const getStats = async (page: Page) => { - const stats = await page.evaluate(async () => { +interface RTPInboundMediaStats { + packetsReceived: number + packetsLost: number + packetsDiscarded?: number +} + +interface RTPOutboundMediaStats { + active: boolean + packetsSent: number + targetBitrate: number + totalPacketSendDelay: number +} + +interface GetStatsResult { + inboundRTP: { + audio: RTPInboundMediaStats + video: RTPInboundMediaStats + } + outboundRTP: { + audio: RTPOutboundMediaStats + video: RTPOutboundMediaStats + } +} + +export const getStats = async (page: Page): Promise => { + return await page.evaluate(async () => { // @ts-expect-error const roomObj: Video.RoomSession = window._roomObj // @ts-expect-error const rtcPeer = roomObj.peer - const stats = await rtcPeer.instance.getStats(null) - const result: { - inboundRTP: Record - outboundRTP: Record - } = { inboundRTP: {}, outboundRTP: {} } + + // Get the currently active inbound and outbound tracks. + const inboundAudioTrackId = rtcPeer._getReceiverByKind('audio')?.track.id + const inboundVideoTrackId = rtcPeer._getReceiverByKind('video')?.track.id + const outboundAudioTrackId = rtcPeer._getSenderByKind('audio')?.track.id + const outboundVideoTrackId = rtcPeer._getSenderByKind('video')?.track.id + + // Default return value + const result: GetStatsResult = { + inboundRTP: { + audio: { + packetsReceived: 0, + packetsLost: 0, + packetsDiscarded: 0, + }, + video: { + packetsReceived: 0, + packetsLost: 0, + packetsDiscarded: 0, + }, + }, + outboundRTP: { + audio: { + active: false, + packetsSent: 0, + targetBitrate: 0, + totalPacketSendDelay: 0, + }, + video: { + active: false, + packetsSent: 0, + targetBitrate: 0, + totalPacketSendDelay: 0, + }, + }, + } const inboundRTPFilters = { - audio: ['packetsReceived', 'packetsLost', 'packetsDiscarded'], - video: ['packetsReceived', 'packetsLost', 'packetsDiscarded'], - } as const - - const inboundRTPHandler = (report: any) => { - const media = report.mediaType as 'video' | 'audio' - const trackId = rtcPeer._getReceiverByKind(media)!.track.id - console.log(`getStats trackId "${trackId}" for media ${media}`) - if (report.trackIdentifier !== trackId) { + audio: ['packetsReceived', 'packetsLost', 'packetsDiscarded'] as const, + video: ['packetsReceived', 'packetsLost', 'packetsDiscarded'] as const, + } + + const outboundRTPFilters = { + audio: [ + 'active', + 'packetsSent', + 'targetBitrate', + 'totalPacketSendDelay', + ] as const, + video: [ + 'active', + 'packetsSent', + 'targetBitrate', + 'totalPacketSendDelay', + ] as const, + } + + const handleInboundRTP = (report: any) => { + const media = report.mediaType as 'audio' | 'video' + if (!media) return + + // Check if trackIdentifier matches the currently active inbound track + const expectedTrackId = + media === 'audio' ? inboundAudioTrackId : inboundVideoTrackId + + if ( + report.trackIdentifier && + report.trackIdentifier !== expectedTrackId + ) { console.log( - `trackIdentifier "${report.trackIdentifier}" and trackId "${trackId}" are different` + `inbound-rtp trackIdentifier "${report.trackIdentifier}" and trackId "${expectedTrackId}" are different for "${media}"` ) return } - result.inboundRTP[media] = result.inboundRTP[media] || {} + inboundRTPFilters[media].forEach((key) => { result.inboundRTP[media][key] = report[key] }) } - const outboundRTPFilters = { - audio: ['active', 'packetsSent', 'targetBitrate', 'totalPacketSendDelay'], - video: ['active', 'packetsSent', 'targetBitrate', 'totalPacketSendDelay'], - } as const + const handleOutboundRTP = (report: any) => { + const media = report.mediaType as 'audio' | 'video' + if (!media) return + + // Check if trackIdentifier matches the currently active outbound track + const expectedTrackId = + media === 'audio' ? outboundAudioTrackId : outboundVideoTrackId + if ( + report.trackIdentifier && + report.trackIdentifier !== expectedTrackId + ) { + console.log( + `outbound-rtp trackIdentifier "${report.trackIdentifier}" and trackId "${expectedTrackId}" are different for "${media}"` + ) + return + } - const outboundRTPHandler = (report: any) => { - const media = report.mediaType as 'video' | 'audio' - result.outboundRTP[media] = result.outboundRTP[media] || {} outboundRTPFilters[media].forEach((key) => { - result.outboundRTP[media][key] = report[key] + ;(result.outboundRTP[media] as any)[key] = report[key] }) } - stats.forEach((report: any) => { + // Iterate over all RTCStats entries + const pc: RTCPeerConnection = rtcPeer.instance + const stats = await pc.getStats() + stats.forEach((report) => { switch (report.type) { case 'inbound-rtp': - inboundRTPHandler(report) + handleInboundRTP(report) break case 'outbound-rtp': - outboundRTPHandler(report) + handleOutboundRTP(report) break } }) return result }) - console.log('RTC Stats', stats) - - return stats } export const expectPageReceiveMedia = async (page: Page, delay = 5_000) => { @@ -799,6 +885,106 @@ export const getRemoteMediaIP = async (page: Page) => { return remoteIP } +interface WaitForStabilizedStatsParams { + propertyPath: string + maxAttempts?: number + stabilityCount?: number + intervalMs?: number +} +/** + * Waits for a given RTP stats property to stabilize. + * A stat is considered stable if the last `stabilityCount` readings are constant. + * Returns the stabled value. + */ +export const waitForStabilizedStats = async ( + page: Page, + params: WaitForStabilizedStatsParams +) => { + const { + propertyPath, + maxAttempts = 50, + stabilityCount = 10, + intervalMs = 1000, + } = params + + const recentValues: number[] = [] + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const stats = await getStats(page) + const currentValue = getValueFromPath(stats, propertyPath) as number + + recentValues.push(currentValue) + + if (recentValues.length >= stabilityCount) { + const lastNValues = recentValues.slice(-stabilityCount) + const allEqual = lastNValues.every((val) => val === lastNValues[0]) + if (allEqual) { + // The stat is stable now + return lastNValues[0] + } + } + + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + + // If we get here, the value never stabilized. + throw new Error( + `The value at "${propertyPath}" did not stabilize after ${maxAttempts} attempts.` + ) +} + +/** + * Retrieves a value from an object at a given path. + * + * @example + * const obj = { a: { b: { c: 42 } } }; + * const result = getValueFromPath(obj, "a.b.c"); // 42 + */ +export const getValueFromPath = (obj: T, path: string) => { + let current: unknown = obj + for (const part of path.split('.')) { + if (current == null || typeof current !== 'object') { + return undefined + } + current = (current as Record)[part] + } + return current +} + +interface ExpectStatWithPollingParams { + propertyPath: string + matcher: + | 'toBe' + | 'toBeGreaterThan' + | 'toBeLessThan' + | 'toBeGreaterThanOrEqual' + | 'toBeLessThanOrEqual' + expected: number + message?: string + timeout?: number +} + +export async function expectStatWithPolling( + page: Page, + params: ExpectStatWithPollingParams +) { + const { propertyPath, matcher, expected, message, timeout = 10000 } = params + + const defaultMessage = `Expected \`${propertyPath}\` ${matcher} ${expected}` + await expect + .poll( + async () => { + const stats = await getStats(page) + const value = getValueFromPath(stats, propertyPath) as number + return value + }, + { message: message ?? defaultMessage, timeout } + ) + [matcher](expected) +} + // #endregion // #region Utilities for v2 WebRTC testing diff --git a/packages/core/src/RPCMessages/VertoMessages.ts b/packages/core/src/RPCMessages/VertoMessages.ts index 07928d4db..6c3696cc4 100644 --- a/packages/core/src/RPCMessages/VertoMessages.ts +++ b/packages/core/src/RPCMessages/VertoMessages.ts @@ -62,3 +62,11 @@ export const VertoResult = (id: string, method: VertoMethod) => { }, }) } + +export interface VertoModifyResponse { + action: string + callID: string + holdState: 'hold' | 'active' + node_id: string + sdp: string +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 1d80dd901..bd2f6a788 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,6 +1,11 @@ import type { EventEmitter } from '../utils/EventEmitter' import type { VideoAPIEvent, InternalVideoEventNames } from './video' -import type { SessionEvents, JSONRPCRequest } from '../utils/interfaces' +import type { + SessionEvents, + JSONRPCRequest, + UpdateMediaParams, + UpdateMediaDirection, +} from '../utils/interfaces' import type { VideoManagerEvent } from './cantina' import type { ChatEvent } from './chat' import type { TaskEvent } from './task' @@ -154,6 +159,36 @@ export interface BaseConnectionContract< */ sendDigits(dtmf: string): Promise + /** + * Upgrade or downgrade the media in the WebRTC connection. + * It perform RTC Peer renegotiation. + * + * @param params - {@link UpdateMediaParams} + * + * @returns A Promise that resolves once the requested media is negotiated or failed. + */ + updateMedia(params: UpdateMediaParams): Promise + + /** + * Add or update the audio with requested direction. + * It perform RTC Peer renegotiation. + * + * @param params - {@link UpdateMediaDirection} + * + * @returns A Promise that resolves once the requested audio is negotiated or failed. + */ + setAudioDirection(direction: UpdateMediaDirection): Promise + + /** + * Add or update the video with requested direction. + * It perform RTC Peer renegotiation. + * + * @param params - {@link UpdateMediaDirection} + * + * @returns A Promise that resolves once the requested video is negotiated or failed. + */ + setVideoDirection(direction: UpdateMediaDirection): Promise + /** @internal */ stopOutboundAudio(): void /** @internal */ diff --git a/packages/core/src/utils/interfaces.ts b/packages/core/src/utils/interfaces.ts index 5652f6906..50d6087f7 100644 --- a/packages/core/src/utils/interfaces.ts +++ b/packages/core/src/utils/interfaces.ts @@ -504,3 +504,25 @@ export interface WsTrafficOptions { export interface InternalSDKLogger extends SDKLogger { wsTraffic: (options: WsTrafficOptions) => void } + +export type UpdateMediaDirection = + | 'sendonly' + | 'recvonly' + | 'sendrecv' + | 'inactive' + +type EnabledUpdateMedia = { + direction: Extract + constraints?: MediaTrackConstraints +} + +type DisabledUpdateMedia = { + direction: Extract + constraints?: never +} + +type MediaControl = EnabledUpdateMedia | DisabledUpdateMedia + +export type UpdateMediaParams = + | { audio: MediaControl; video?: MediaControl } + | { audio?: MediaControl; video: MediaControl } diff --git a/packages/webrtc/src/BaseConnection.ts b/packages/webrtc/src/BaseConnection.ts index a431c6817..ed22aa5c5 100644 --- a/packages/webrtc/src/BaseConnection.ts +++ b/packages/webrtc/src/BaseConnection.ts @@ -18,16 +18,23 @@ import { WebRTCMethod, // VertoAttach, VertoAnswer, + UpdateMediaParams, + UpdateMediaDirection, } from '@signalwire/core' -import type { ReduxComponent } from '@signalwire/core' +import type { ReduxComponent, VertoModifyResponse } from '@signalwire/core' import RTCPeer from './RTCPeer' import { ConnectionOptions, + EmitDeviceUpdatedEventsParams, + UpdateMediaOptionsParams, BaseConnectionEvents, OnVertoByeParams, } from './utils/interfaces' import { stopTrack, getUserMedia, streamIsValid } from './utils' -import { sdpRemoveLocalCandidates } from './utils/sdpHelpers' +import { + hasMatchingSdpDirection, + sdpRemoveLocalCandidates, +} from './utils/sdpHelpers' import * as workers from './workers' import { AUDIO_CONSTRAINTS, @@ -59,9 +66,6 @@ export class BaseConnection< /** @internal */ public gotEarly = false - /** @internal */ - public doReinvite = false - private state: BaseConnectionState = 'new' private prevState: BaseConnectionState = 'new' private activeRTCPeerId: string @@ -330,13 +334,13 @@ export class BaseConnection< * Verto messages have to be wrapped into an execute * request and sent using the proper RPC WebRTCMethod. */ - private vertoExecute(params: { + private vertoExecute(params: { message: JSONRPCRequest callID?: string node_id?: string subscribe?: EventEmitter.EventNames[] }) { - return this.execute({ + return this.execute({ method: this._getRPCMethod(), params, }) @@ -365,7 +369,7 @@ export class BaseConnection< } updateCamera(constraints: MediaTrackConstraints) { - return this.updateConstraints({ + return this.applyConstraintsAndRefreshStream({ video: { aspectRatio: 16 / 9, ...constraints, @@ -374,12 +378,50 @@ export class BaseConnection< } updateMicrophone(constraints: MediaTrackConstraints) { - return this.updateConstraints({ + return this.applyConstraintsAndRefreshStream({ audio: constraints, }) } - /** @internal */ + /** + * Determines the appropriate {@link RTCRtpTransceiverDirection} based on current audio/video + * and negotiation options. The returned direction tells the peer connection + * whether to send, receive, both, or remain inactive for the given media kind. + */ + private _getTransceiverDirection(kind: 'video' | 'audio' | string) { + let direction: RTCRtpTransceiverDirection = 'inactive' + + if (kind === 'audio') { + if (this.options.audio && this.options.negotiateAudio) { + direction = 'sendrecv' + } else if (this.options.audio && !this.options.negotiateAudio) { + direction = 'sendonly' + } else if (!this.options.audio && this.options.negotiateAudio) { + direction = 'recvonly' + } else { + direction = 'inactive' + } + } + if (kind === 'video') { + if (this.options.video && this.options.negotiateVideo) { + direction = 'sendrecv' + } else if (this.options.video && !this.options.negotiateVideo) { + direction = 'sendonly' + } else if (!this.options.video && this.options.negotiateVideo) { + direction = 'recvonly' + } else { + direction = 'inactive' + } + } + + return direction + } + + /** + * Adjusts senders based on the given audio/video constraints. If a constraint is set to false, + * it stops the corresponding outbound track. Returns true if at least one sender is active, + * otherwise false. + */ private manageSendersWithConstraints(constraints: MediaStreamConstraints) { if (constraints.audio === false) { this.logger.info('Switching off the microphone') @@ -395,12 +437,15 @@ export class BaseConnection< } /** - * @internal + * Attempts to obtain a new media stream that matches the given constraints, using a recursive + * strategy. If the new constraints fail, it tries to restore the old constraints. + * Returns a Promise that resolves to the new MediaStream or resolves without a stream if + * constraints were fully disabled. Rejects on unrecoverable errors. */ private updateConstraints( constraints: MediaStreamConstraints, { attempt = 0 } = {} - ): Promise { + ): Promise { if (attempt > 1) { return Promise.reject(new Error('Failed to update constraints')) } @@ -482,116 +527,209 @@ export class BaseConnection< return reject(error) } - await this.updateStream(newStream) this.logger.debug('updateConstraints done') - resolve() + resolve(newStream) } catch (error) { this.logger.error('updateConstraints', error) reject(error) - } finally { - this.peer?._attachAudioTrackListener() - this.peer?._attachVideoTrackListener() } }) } + /** + * Updates local tracks and transceivers from the given stream. + * For each new track, it adds/updates a transceiver and emits updated device events. + */ private async updateStream(stream: MediaStream) { + try { + if (!this.peer) { + throw new Error('Invalid RTCPeerConnection.') + } + + // Store the previous tracks for device.updated events + const prevVideoTrack = this.localVideoTrack + const prevAudioTrack = this.localAudioTrack + + this.logger.debug('updateStream got stream', stream) + if (!this.localStream) { + this.localStream = new MediaStream() + } + + const tracks = stream.getTracks() + this.logger.debug(`updateStream got ${tracks.length} tracks`) + + for (const newTrack of tracks) { + this.logger.debug('updateStream apply track: ', newTrack) + + // Add or update the transceiver (may trigger renegotiation) + await this.handleTransceiverForTrack(newTrack) + + // Emit the device.updated events + this.emitDeviceUpdatedEvents({ + newTrack, + prevAudioTrack, + prevVideoTrack, + }) + } + + this.logger.debug('updateStream done') + } catch (error) { + this.logger.error('updateStream error', error) + throw error + } + } + + /** + * Finds or creates a transceiver for the new track. If an existing transceiver is found, + * replaces its track and updates its direction, if needed. If no transceiver is found, + * adds a new one. The method can trigger renegotiation. + */ + private async handleTransceiverForTrack(newTrack: MediaStreamTrack) { if (!this.peer) { - throw new Error('Invalid RTCPeerConnection.') + return this.logger.error('Invalid RTCPeerConnection') } - // Store the previous tracks for device.updated event - const prevVideoTrack = this.localVideoTrack - const prevAudioTrack = this.localAudioTrack + const transceiver = this.peer.instance + .getTransceivers() + .find(({ mid, sender, receiver }) => { + if (sender.track && sender.track.kind === newTrack.kind) { + this.logger.debug('Found transceiver by sender') + return true + } + if (receiver.track && receiver.track.kind === newTrack.kind) { + this.logger.debug('Found transceiver by receiver') + return true + } + if (mid === null) { + this.logger.debug('Found disassociated transceiver') + return true + } + return false + }) - // Detach listeners because updateStream will trigger the track ended event - this.peer._detachAudioTrackListener() - this.peer._detachVideoTrackListener() + if (transceiver) { + this.logger.debug( + 'handleTransceiverForTrack got transceiver', + transceiver.currentDirection, + transceiver.mid + ) - this.logger.debug('updateStream got stream', stream) - if (!this.localStream) { - this.localStream = new MediaStream() - } - const { instance } = this.peer - const tracks = stream.getTracks() - this.logger.debug(`updateStream got ${tracks.length} tracks`) - for (let i = 0; i < tracks.length; i++) { - const newTrack = tracks[i] - this.logger.debug('updateStream apply track: ', newTrack) - const transceiver = instance - .getTransceivers() - .find(({ mid, sender, receiver }) => { - if (sender.track && sender.track.kind === newTrack.kind) { - this.logger.debug('Found transceiver by sender') - return true - } - if (receiver.track && receiver.track.kind === newTrack.kind) { - this.logger.debug('Found transceiver by receiver') - return true - } - if (mid === null) { - this.logger.debug('Found disassociated transceiver') - return true - } - return false - }) - if (transceiver && transceiver.sender) { + // Existing transceiver found, replace track if it's different + if (transceiver.sender.track?.id !== newTrack.id) { + await transceiver.sender.replaceTrack(newTrack) this.logger.debug( - 'updateStream got transceiver', - transceiver.currentDirection, - transceiver.mid + `handleTransceiverForTrack replaceTrack for ${newTrack.kind}` ) - await transceiver.sender.replaceTrack(newTrack) - this.logger.debug('updateStream replaceTrack') - transceiver.direction = 'sendrecv' - this.logger.debug('updateStream set to sendrecv') - this.localStream.getTracks().forEach((track) => { - if (track.kind === newTrack.kind && track.id !== newTrack.id) { - this.logger.debug( - 'updateStream stop old track and apply new one - ' - ) - stopTrack(track) - this.localStream?.removeTrack(track) - } - }) - this.localStream.addTrack(newTrack) - } else { + } + + // Update direction if needed + const newDirection = this._getTransceiverDirection(newTrack.kind) + if (transceiver.direction !== newDirection) { + transceiver.direction = newDirection this.logger.debug( - 'updateStream no transceiver found. addTrack and start dancing!' + `handleTransceiverForTrack set direction to ${newDirection}` ) - this.peer.type = 'offer' - this.doReinvite = true - this.localStream.addTrack(newTrack) - instance.addTrack(newTrack, this.localStream) } - this.logger.debug('updateStream simply update mic/cam') - if (newTrack.kind === 'audio') { - this.emit('microphone.updated', { - previous: { - deviceId: prevAudioTrack?.id, - label: prevAudioTrack?.label, - }, - current: { - deviceId: newTrack?.id, - label: newTrack?.label, - }, - }) - this.options.micId = newTrack.getSettings().deviceId - } else if (newTrack.kind === 'video') { - this.emit('camera.updated', { - previous: { - deviceId: prevVideoTrack?.id, - label: prevVideoTrack?.label, - }, - current: { - deviceId: newTrack?.id, - label: newTrack?.label, - }, - }) - this.options.camId = newTrack.getSettings().deviceId + + // Stop old track and add a new one + this.replaceOldTrack(newTrack) + } else { + this.logger.debug( + 'handleTransceiverForTrack no transceiver found; addTransceiver and start dancing!' + ) + + // No suitable transceiver found, add a new one + const direction = this._getTransceiverDirection(newTrack.kind) + this.peer.type = 'offer' + this.localStream?.addTrack(newTrack) + this.peer.instance.addTransceiver(newTrack, { + direction: direction, + streams: [this.localStream!], + }) + } + } + + /** + * Replaces old tracks of the same kind in the local stream with the new track. + * Stops and removes the old track, then adds the new one. Also updates related + * device options and reattaches track listeners. + */ + private replaceOldTrack(newTrack: MediaStreamTrack) { + if (!this.peer || !this.localStream) { + return this.logger.error('Invalid RTCPeerConnection') + } + + // Detach listeners because stopTrack will trigger the track ended event + this.peer._detachAudioTrackListener() + this.peer._detachVideoTrackListener() + + this.localStream.getTracks().forEach((oldTrack) => { + if (oldTrack.kind === newTrack.kind && oldTrack.id !== newTrack.id) { + this.logger.debug('replaceOldTrack stop old track and apply new one') + stopTrack(oldTrack) + this.localStream?.removeTrack(oldTrack) } + }) + this.localStream.addTrack(newTrack) + + if (newTrack.kind === 'audio') { + this.options.micId = newTrack.getSettings().deviceId + } + + if (newTrack.kind === 'video') { + this.options.camId = newTrack.getSettings().deviceId + } + + // Attach listeners again + this.peer._attachAudioTrackListener() + this.peer._attachVideoTrackListener() + } + + /** + * Emits device updated events for audio or video. Uses previously stored + * track references to indicate what changed between old and new devices. + */ + private emitDeviceUpdatedEvents({ + newTrack, + prevAudioTrack, + prevVideoTrack, + }: EmitDeviceUpdatedEventsParams) { + if (newTrack.kind === 'audio') { + this.emit('microphone.updated', { + previous: { + deviceId: prevAudioTrack?.id, + label: prevAudioTrack?.label, + }, + current: { + deviceId: newTrack.id, + label: newTrack.label, + }, + }) + } else if (newTrack.kind === 'video') { + this.emit('camera.updated', { + previous: { + deviceId: prevVideoTrack?.id, + label: prevVideoTrack?.label, + }, + current: { + deviceId: newTrack.id, + label: newTrack.label, + }, + }) + } + } + + /** + * Applies the given constraints by retrieving a new stream and then uses + * {@link updateStream} to synchronize local tracks with that new stream. + */ + private async applyConstraintsAndRefreshStream( + constraints: MediaStreamConstraints + ): Promise { + const newStream = await this.updateConstraints(constraints) + if (newStream) { + await this.updateStream(newStream) } - this.logger.debug('updateStream done') } runRTCPeerWorkers(rtcPeerId: string) { @@ -796,7 +934,11 @@ export class BaseConnection< } } - /** @internal */ + /** + * Send the `verto.modify` when it's an offer and remote is already present + * It helps in renegotiation. + * @internal + */ async executeUpdateMedia(sdp: string, rtcPeerId: string) { try { const message = VertoModify({ @@ -804,7 +946,7 @@ export class BaseConnection< sdp, action: 'updateMedia', }) - const response: any = await this.vertoExecute({ + const response = await this.vertoExecute({ message, callID: rtcPeerId, node_id: this.nodeId, @@ -819,8 +961,11 @@ export class BaseConnection< } await this.peer.onRemoteSdp(response.sdp) } catch (error) { + // Should we hangup when renegotiation fails? this.logger.error('UpdateMedia error', error) - // this.setState('hangup') + + // Reject the pending negotiation promise + this.peer?._pendingNegotiationPromise?.reject(error) throw error } } @@ -950,12 +1095,7 @@ export class BaseConnection< } /** @internal */ - updateMediaOptions(options: { - audio?: boolean | MediaTrackConstraints - video?: boolean | MediaTrackConstraints - negotiateAudio?: boolean - negotiateVideo?: boolean - }) { + updateMediaOptions(options: UpdateMediaOptionsParams) { this.logger.debug('updateMediaOptions', { ...options }) this.options = { ...this.options, @@ -1049,4 +1189,197 @@ export class BaseConnection< }) this.rtcPeerMap.clear() } + + /** + * Add or update the transceiver based on the media type and direction + */ + private _upsertTransceiverByKind( + direction: RTCRtpTransceiverDirection, + kind: 'audio' | 'video' + ) { + if (!this.peer) { + return this.logger.error('Invalid RTCPeerConnection') + } + + let transceiver = this.peer.instance + .getTransceivers() + .find( + (tr) => + tr.sender.track?.kind === kind || tr.receiver.track?.kind === kind + ) + + if (transceiver) { + if (transceiver.direction === direction) { + this.logger.info( + `Transceiver ${kind} has the same direction "${direction}".` + ) + return + } + + transceiver.direction = direction + this.logger.info(`Updated ${kind} transceiver to "${direction}" mode.`) + } else { + // No transceiver exists; add one if the direction is not "inactive" + if (direction !== 'inactive') { + // Ensure we act as the offerer when adding the transceiver during renegotiation + this.peer.type = 'offer' + transceiver = this.peer.instance.addTransceiver(kind, { direction }) + this.logger.info(`Added ${kind} transceiver in "${direction}" mode.`) + } + } + + if (direction === 'stopped' || direction === 'inactive') { + this.peer.stopTrackReceiver(kind) + this.peer.stopTrackSender(kind) + } else if (direction === 'sendonly') { + this.peer.stopTrackReceiver(kind) + } else if (direction === 'recvonly') { + this.peer.stopTrackSender(kind) + } + } + + /** + * Allow user to upgrade/downgrade media in a call. + * This performs RTC Peer renegotiation. + * + * @param params: {@link UpdateMediaParams} + */ + public async updateMedia(params: UpdateMediaParams) { + try { + const { audio, video } = params + + if (!this.peer) { + throw new Error('Invalid RTCPeerConnection') + } + + // Check if the peer is already negotiating + if (this.peer?.isNegotiating) { + throw new Error('The peer is already negotiating the media!') + } + + /** + * Create a new renegotiation promise that would be resolved by the {@link executeUpdateMedia} + */ + const peer = this.peer + const negotiationPromise = new Promise((resolve, reject) => { + peer._pendingNegotiationPromise = { + resolve, + reject, + } + }) + + const shouldEnableAudio = ['sendonly', 'sendrecv'].includes( + audio?.direction || '' + ) + const shouldEnableVideo = ['sendonly', 'sendrecv'].includes( + video?.direction || '' + ) + + const shouldNegotiateAudio = ['sendrecv', 'receive'].includes( + audio?.direction || '' + ) + const shouldNegotiateVideo = ['sendrecv', 'receive'].includes( + video?.direction || '' + ) + + this.updateMediaOptions({ + ...(audio && { audio: audio?.constraints ?? shouldEnableAudio }), + ...(video && { video: video?.constraints ?? shouldEnableVideo }), + ...(audio && { negotiateAudio: shouldNegotiateAudio }), + ...(video && { negotiateVideo: shouldNegotiateVideo }), + }) + + /** + * The {@link applyConstraintsAndRefreshStream} updates the constraints, + * gets the new user stream and updates the transceiver. + * However, it only handles the media which is being enabled. + * If the media is being disabled, we need to handle that below. + */ + await this.applyConstraintsAndRefreshStream({ + ...(audio && { audio: audio?.constraints ?? shouldEnableAudio }), + ...(video && { video: video?.constraints ?? shouldEnableVideo }), + }) + + // When disabling audio + if (audio && !shouldEnableAudio) { + this._upsertTransceiverByKind(audio.direction, 'audio') + } + + // When disabling video + if (video && !shouldEnableVideo) { + this._upsertTransceiverByKind(video.direction, 'video') + } + + /** + * Trigger the negotiation with the new settings. + * If the negotiation is already ongoing it would not have a side effect. + */ + await this.peer.startNegotiation() + + // Wait for the Renegotiation to complete + await negotiationPromise + + // Throw error if the remote SDP does not include the expected audio direction + if ( + !hasMatchingSdpDirection({ + localSdp: this.peer.localSdp!, + remoteSdp: this.peer.remoteSdp!, + media: 'audio', + }) + ) { + throw new Error('The server did not set the audio direction correctly') + } + + // Throw error if the remote SDP does not include the expected video direction + if ( + !hasMatchingSdpDirection({ + localSdp: this.peer.localSdp!, + remoteSdp: this.peer.remoteSdp!, + media: 'video', + }) + ) { + throw new Error('The server did not set the video direction correctly') + } + } catch (error) { + // Reject the negotiation promise if an error occurs + this.peer?._pendingNegotiationPromise?.reject(error) + throw error + } + } + + /** + * Allow user to set the audio direction on the RTC Peer. + * This performs RTC Peer renegotiation. + * + * @param direction {@link UpdateMediaDirection} + */ + public async setAudioDirection(direction: UpdateMediaDirection) { + if (!['sendonly', 'sendrecv', 'recvonly', 'inactive'].includes(direction)) { + throw new Error('Invalid direction specified') + } + + return this.updateMedia({ + audio: { + direction, + }, + }) + } + + /** + * Allow user to set the video direction on the RTC Peer. + * This performs RTC Peer renegotiation. + * + * @param direction {@link UpdateMediaDirection} + */ + public async setVideoDirection(direction: UpdateMediaDirection) { + if (!['sendonly', 'sendrecv', 'recvonly', 'inactive'].includes(direction)) { + throw new Error('Invalid direction specified') + } + + return this.updateMedia({ + video: { + direction, + }, + }) + } } diff --git a/packages/webrtc/src/RTCPeer.ts b/packages/webrtc/src/RTCPeer.ts index e79075d05..c2faab702 100644 --- a/packages/webrtc/src/RTCPeer.ts +++ b/packages/webrtc/src/RTCPeer.ts @@ -42,6 +42,16 @@ export default class RTCPeer { private _resolveStartMethod: (value?: unknown) => void private _rejectStartMethod: (error: unknown) => void + /** + * The promise that resolves or rejects when the negotiation succeed or fail. + * The consumer needs to declare the promise and assign it to this in order to + * wait for the negotiation to complete. + */ + public _pendingNegotiationPromise?: { + resolve: (value?: unknown) => void + reject: (error: unknown) => void + } + private _localStream?: MediaStream private _remoteStream?: MediaStream private rtcConfigPolyfill: RTCConfiguration @@ -84,6 +94,10 @@ export default class RTCPeer { return this.options.watchMediaPacketsTimeout ?? 2_000 } + get isNegotiating() { + return this._negotiating + } + get localStream() { return this._localStream } @@ -194,6 +208,21 @@ export default class RTCPeer { } } + stopTrackReceiver(kind: string) { + try { + const receiver = this._getReceiverByKind(kind) + if (!receiver) { + return this.logger.info(`There is not a '${kind}' receiver to stop.`) + } + if (receiver.track) { + stopTrack(receiver.track) + this._remoteStream?.removeTrack(receiver.track) + } + } catch (error) { + this.logger.error('RTCPeer stopTrackReceiver error', kind, error) + } + } + async restoreTrackSender(kind: string) { try { const sender = this._getSenderByKind(kind) @@ -291,7 +320,6 @@ export default class RTCPeer { this.logger.debug('Restart ICE') // Type must be Offer to send reinvite. this.type = 'offer' - // @ts-ignore this.instance.restartIce() } @@ -451,15 +479,15 @@ export default class RTCPeer { } } catch (error) { this.logger.error(`Error creating ${this.type}:`, error) + this._pendingNegotiationPromise?.reject(error) } } onRemoteBye({ code, message }: { code: string; message: string }) { // It could be a negotiation/signaling error so reject the "startMethod" - this._rejectStartMethod?.({ - code, - message, - }) + const error = { code, message } + this._rejectStartMethod?.(error) + this._pendingNegotiationPromise?.reject(error) this.stop() } @@ -484,6 +512,7 @@ export default class RTCPeer { */ if (this.isOffer) { this._resolveStartMethod() + this._pendingNegotiationPromise?.resolve() } this.resetNeedResume() @@ -494,6 +523,7 @@ export default class RTCPeer { ) this.call.hangup() this._rejectStartMethod(error) + this._pendingNegotiationPromise?.reject(error) } } @@ -513,6 +543,7 @@ export default class RTCPeer { this._localStream = await this._retrieveLocalStream() } catch (error) { this._rejectStartMethod(error) + this._pendingNegotiationPromise?.reject(error) return this.call.setState('hangup') } @@ -536,10 +567,7 @@ export default class RTCPeer { hasLocalTracks = Boolean(audioTracks.length || videoTracks.length) // TODO: use transceivers way only for offer - when answer gotta match mid from the ones from SRD - if ( - this.isOffer && - typeof this.instance.addTransceiver === 'function' - ) { + if (this.isOffer && this._supportsAddTransceiver()) { const audioTransceiverParams: RTCRtpTransceiverInit = { direction: this.options.negotiateAudio ? 'sendrecv' : 'sendonly', streams: [this._localStream], @@ -682,9 +710,11 @@ export default class RTCPeer { if (this.isAnswer) { this._resolveStartMethod() + this._pendingNegotiationPromise?.resolve() } } catch (error) { this._rejectStartMethod(error) + this._pendingNegotiationPromise?.reject(error) } } @@ -711,10 +741,12 @@ export default class RTCPeer { const config = this.getConfiguration() if (config.iceTransportPolicy === 'relay') { this.logger.info('RTCPeer already with "iceTransportPolicy: relay"') - this._rejectStartMethod({ + const error = { code: 'ICE_GATHERING_FAILED', message: 'Ice gathering timeout', - }) + } + this._rejectStartMethod(error) + this._pendingNegotiationPromise?.reject(error) this.call.setState('destroy') return } diff --git a/packages/webrtc/src/utils/helpers.ts b/packages/webrtc/src/utils/helpers.ts index 8b47fe4dc..cbf0b38cc 100644 --- a/packages/webrtc/src/utils/helpers.ts +++ b/packages/webrtc/src/utils/helpers.ts @@ -2,7 +2,7 @@ import { getLogger } from '@signalwire/core' import { getUserMedia as _getUserMedia } from './getUserMedia' import { assureDeviceId } from './deviceHelpers' import { ConnectionOptions } from './interfaces' -import { hasMediaSection } from './sdpHelpers' +import { sdpHasAudio, sdpHasVideo } from './sdpHelpers' // FIXME: Remove and use getUserMedia directly export const getUserMedia = (constraints: MediaStreamConstraints) => { @@ -18,35 +18,33 @@ export const getUserMedia = (constraints: MediaStreamConstraints) => { const _shouldNegotiateVideo = (options: ConnectionOptions) => { return ( (options.negotiateVideo ?? true) && - (!options.remoteSdp || - hasMediaSection(options.remoteSdp, 'video')) + (!options.remoteSdp || sdpHasVideo(options.remoteSdp)) ) } const _shouldNegotiateAudio = (options: ConnectionOptions) => { return ( (options.negotiateAudio ?? true) && - (!options.remoteSdp || - hasMediaSection(options.remoteSdp, 'audio')) + (!options.remoteSdp || sdpHasAudio(options.remoteSdp)) ) } const _getVideoConstraints = (options: ConnectionOptions) => { - return _shouldNegotiateVideo(options) ? options.video ?? !!options.camId + return _shouldNegotiateVideo(options) + ? options.video ?? !!options.camId : false } const _getAudioConstraints = (options: ConnectionOptions) => { - return _shouldNegotiateAudio(options) ? options.audio ?? true - : false + return _shouldNegotiateAudio(options) ? options.audio ?? true : false } export const getMediaConstraints = async ( options: ConnectionOptions ): Promise => { - let audio = _getAudioConstraints(options) + let audio = _getAudioConstraints(options) const { micLabel = '', micId } = options - + if (micId && audio) { const newMicId = await assureDeviceId(micId, micLabel, 'microphone').catch( (_error) => null @@ -61,7 +59,7 @@ export const getMediaConstraints = async ( let video = _getVideoConstraints(options) const { camLabel = '', camId } = options - + if (camId && video) { const newCamId = await assureDeviceId(camId, camLabel, 'camera').catch( (_error) => null diff --git a/packages/webrtc/src/utils/interfaces.ts b/packages/webrtc/src/utils/interfaces.ts index ca29ef9e1..bda64f22d 100644 --- a/packages/webrtc/src/utils/interfaces.ts +++ b/packages/webrtc/src/utils/interfaces.ts @@ -102,6 +102,17 @@ export interface ConnectionOptions { positions?: VideoPositions } +export interface EmitDeviceUpdatedEventsParams { + newTrack: MediaStreamTrack + prevAudioTrack?: MediaStreamTrack | null + prevVideoTrack?: MediaStreamTrack | null +} + +export type UpdateMediaOptionsParams = Pick< + ConnectionOptions, + 'video' | 'audio' | 'negotiateVideo' | 'negotiateAudio' +> + export interface OnVertoByeParams { byeCause: string byeCauseCode: string diff --git a/packages/webrtc/src/utils/sdpHelpers.test.ts b/packages/webrtc/src/utils/sdpHelpers.test.ts index 96c6a7250..9f243bdc8 100644 --- a/packages/webrtc/src/utils/sdpHelpers.test.ts +++ b/packages/webrtc/src/utils/sdpHelpers.test.ts @@ -3,10 +3,20 @@ import { sdpMediaOrderHack, sdpBitrateHack, sdpRemoveLocalCandidates, - hasMediaSection, + sdpHasMediaSection, + getSdpDirection, + getOppositeSdpDirection, + sdpHasAudio, + sdpHasVideo, } from './sdpHelpers' -describe('Helpers browser functions', () => { +describe('SDP utility functions', () => { + const AUDIO_SDP = `v=0\r\no=FreeSWITCH 1707233696 1707233697 IN IP4 190.102.98.211\r\ns=FreeSWITCH\r\nc=IN IP4 190.102.98.211\r\nt=0 0\r\nm=audio 19828 RTP/SAVPF 0 8 102\r\na=sendrecv\r\n` + + const VIDEO_SDP = `v=0\r\no=FreeSWITCH 1707233696 1707233697 IN IP4 190.102.98.211\r\ns=FreeSWITCH\r\nc=IN IP4 190.102.98.211\r\nt=0 0\r\nm=video 19828 RTP/SAVPF 0 8 102\r\na=sendonly\r\n` + + const AUDIO_VIDEO_SDP = `v=0\r\no=- 8094323291162995063 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=audio 51609 UDP/TLS/RTP/SAVPF 111\r\na=sendrecv\r\nm=video 52560 UDP/TLS/RTP/SAVPF 96\r\na=recvonly\r\n` + describe('sdpStereoHack', () => { const SDP_OPUS_STEREO = 'v=0\r\no=- 135160591336882782 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE audio video\r\na=msid-semantic: WMS 381b9efc-7cf5-45bb-8f39-c06558b288de\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:Y5Zy\r\na=ice-pwd:yQLVrXgG+irP0tgLLr4ZjQb5\r\na=ice-options:trickle\r\na=fingerprint:sha-256 45:ED:86:FB:EB:FE:21:20:62:C4:07:81:AA:B8:BC:87:60:CC:2B:54:CE:D5:F0:16:93:C4:61:23:28:59:DF:8B\r\na=setup:actpass\r\na=mid:audio\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=sendrecv\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=rtcp-fb:111 transport-cc\r\na=fmtp:111 minptime=10;useinbandfec=1; stereo=1; sprop-stereo=1\r\na=rtpmap:103 ISAC/16000\r\na=rtpmap:104 ISAC/32000\r\na=rtpmap:9 G722/8000\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:106 CN/32000\r\na=rtpmap:105 CN/16000\r\na=rtpmap:13 CN/8000\r\na=rtpmap:110 telephone-event/48000\r\na=rtpmap:112 telephone-event/32000\r\na=rtpmap:113 telephone-event/16000\r\na=rtpmap:126 telephone-event/8000\r\na=ssrc:3652058873 cname:kufSZ8JnlRUuQVc2\r\na=ssrc:3652058873 msid:381b9efc-7cf5-45bb-8f39-c06558b288de 8841cbb1-90ba-4655-8784-60a185846706\r\na=ssrc:3652058873 mslabel:381b9efc-7cf5-45bb-8f39-c06558b288de\r\na=ssrc:3652058873 label:8841cbb1-90ba-4655-8784-60a185846706\r\nm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 114\r\nc=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:Y5Zy\r\na=ice-pwd:yQLVrXgG+irP0tgLLr4ZjQb5\r\na=ice-options:trickle\r\na=fingerprint:sha-256 45:ED:86:FB:EB:FE:21:20:62:C4:07:81:AA:B8:BC:87:60:CC:2B:54:CE:D5:F0:16:93:C4:61:23:28:59:DF:8B\r\na=setup:actpass\r\na=mid:video\r\na=extmap:2 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:4 urn:3gpp:video-orientation\r\na=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\na=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\na=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\na=extmap:10 http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07\r\na=sendrecv\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=rtpmap:97 rtx/90000\r\na=fmtp:97 apt=96\r\na=rtpmap:98 VP9/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 profile-id=0\r\na=rtpmap:99 rtx/90000\r\na=fmtp:99 apt=98\r\na=rtpmap:100 H264/90000\r\na=rtcp-fb:100 goog-remb\r\na=rtcp-fb:100 transport-cc\r\na=rtcp-fb:100 ccm fir\r\na=rtcp-fb:100 nack\r\na=rtcp-fb:100 nack pli\r\na=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\na=rtpmap:101 rtx/90000\r\na=fmtp:101 apt=100\r\na=rtpmap:102 H264/90000\r\na=rtcp-fb:102 goog-remb\r\na=rtcp-fb:102 transport-cc\r\na=rtcp-fb:102 ccm fir\r\na=rtcp-fb:102 nack\r\na=rtcp-fb:102 nack pli\r\na=fmtp:102 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r\na=rtpmap:122 rtx/90000\r\na=fmtp:122 apt=102\r\na=rtpmap:127 H264/90000\r\na=rtcp-fb:127 goog-remb\r\na=rtcp-fb:127 transport-cc\r\na=rtcp-fb:127 ccm fir\r\na=rtcp-fb:127 nack\r\na=rtcp-fb:127 nack pli\r\na=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:121 rtx/90000\r\na=fmtp:121 apt=127\r\na=rtpmap:125 H264/90000\r\na=rtcp-fb:125 goog-remb\r\na=rtcp-fb:125 transport-cc\r\na=rtcp-fb:125 ccm fir\r\na=rtcp-fb:125 nack\r\na=rtcp-fb:125 nack pli\r\na=fmtp:125 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\r\na=rtpmap:107 rtx/90000\r\na=fmtp:107 apt=125\r\na=rtpmap:108 H264/90000\r\na=rtcp-fb:108 goog-remb\r\na=rtcp-fb:108 transport-cc\r\na=rtcp-fb:108 ccm fir\r\na=rtcp-fb:108 nack\r\na=rtcp-fb:108 nack pli\r\na=fmtp:108 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d0032\r\na=rtpmap:109 rtx/90000\r\na=fmtp:109 apt=108\r\na=rtpmap:124 H264/90000\r\na=rtcp-fb:124 goog-remb\r\na=rtcp-fb:124 transport-cc\r\na=rtcp-fb:124 ccm fir\r\na=rtcp-fb:124 nack\r\na=rtcp-fb:124 nack pli\r\na=fmtp:124 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032\r\na=rtpmap:120 rtx/90000\r\na=fmtp:120 apt=124\r\na=rtpmap:123 red/90000\r\na=rtpmap:119 rtx/90000\r\na=fmtp:119 apt=123\r\na=rtpmap:114 ulpfec/90000\r\na=ssrc-group:FID 1714381393 967654061\r\na=ssrc:1714381393 cname:kufSZ8JnlRUuQVc2\r\na=ssrc:1714381393 msid:381b9efc-7cf5-45bb-8f39-c06558b288de 99d2faa8-950d-40f7-ad80-16789c9b4faa\r\na=ssrc:1714381393 mslabel:381b9efc-7cf5-45bb-8f39-c06558b288de\r\na=ssrc:1714381393 label:99d2faa8-950d-40f7-ad80-16789c9b4faa\r\na=ssrc:967654061 cname:kufSZ8JnlRUuQVc2\r\na=ssrc:967654061 msid:381b9efc-7cf5-45bb-8f39-c06558b288de 99d2faa8-950d-40f7-ad80-16789c9b4faa\r\na=ssrc:967654061 mslabel:381b9efc-7cf5-45bb-8f39-c06558b288de\r\na=ssrc:967654061 label:99d2faa8-950d-40f7-ad80-16789c9b4faa\r\n' @@ -77,24 +87,73 @@ describe('Helpers browser functions', () => { }) }) - describe('hasSection', () => { - const AUDIO_SDP = - 'v=0\r\no=FreeSWITCH 1707233696 1707233697 IN IP4 190.102.98.211\r\ns=FreeSWITCH\r\nc=IN IP4 190.102.98.211\r\nt=0 0\r\na=msid-semantic: WMS xXtAEH0vyxeST9BACBkvRkF55amZ0EYo\r\nm=audio 19828 RTP/SAVPF 0 8 102\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:102 opus/48000/2\r\na=fmtp:102 useinbandfec=1; maxaveragebitrate=30000; maxplaybackrate=48000; ptime=20; minptime=10; maxptime=40\r\na=fingerprint:sha-256 0F:F7:47:2D:19:38:46:88:E7:42:2A:4B:53:53:F5:19:1B:DC:EF:8E:14:F7:44:79:ED:94:A7:1B:97:92:7F:C5\r\na=setup:actpass\r\na=rtcp-mux\r\na=rtcp:19828 IN IP4 190.102.98.211\r\na=ssrc:4043346828 cname:rhPWOFid3mVMmndP\r\na=ssrc:4043346828 msid:xXtAEH0vyxeST9BACBkvRkF55amZ0EYo a0\r\na=ssrc:4043346828 mslabel:xXtAEH0vyxeST9BACBkvRkF55amZ0EYo\r\na=ssrc:4043346828 label:xXtAEH0vyxeST9BACBkvRkF55amZ0EYoa0\r\na=ice-ufrag:OnbwxGrtGEix86Mq\r\na=ice-pwd:drdSXmVQzHtLVwrAKsW8Yerv\r\na=candidate:1409144412 1 udp 2130706431 190.102.98.211 19828 typ srflx raddr 172.17.0.2 rport 19828 generation 0\r\na=candidate:7363643456 1 udp 2130706431 172.17.0.2 19828 typ host generation 0\r\na=candidate:1409144412 2 udp 2130706430 190.102.98.211 19828 typ srflx raddr 172.17.0.2 rport 19828 generation 0\r\na=candidate:7363643456 2 udp 2130706430 172.17.0.2 19828 typ host generation 0\r\na=silenceSupp:off - - - -\r\na=ptime:20\r\na=sendrecv\r\n' - const VIDEO_SDP = - 'v=0\r\no=FreeSWITCH 1707233696 1707233697 IN IP4 190.102.98.211\r\ns=FreeSWITCH\r\nc=IN IP4 190.102.98.211\r\nt=0 0\r\na=msid-semantic: WMS xXtAEH0vyxeST9BACBkvRkF55amZ0EYo\r\nm=video 19828 RTP/SAVPF 0 8 102\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:102 opus/48000/2\r\na=fmtp:102 useinbandfec=1; maxaveragebitrate=30000; maxplaybackrate=48000; ptime=20; minptime=10; maxptime=40\r\na=fingerprint:sha-256 0F:F7:47:2D:19:38:46:88:E7:42:2A:4B:53:53:F5:19:1B:DC:EF:8E:14:F7:44:79:ED:94:A7:1B:97:92:7F:C5\r\na=setup:actpass\r\na=rtcp-mux\r\na=rtcp:19828 IN IP4 190.102.98.211\r\na=ssrc:4043346828 cname:rhPWOFid3mVMmndP\r\na=ssrc:4043346828 msid:xXtAEH0vyxeST9BACBkvRkF55amZ0EYo a0\r\na=ssrc:4043346828 mslabel:xXtAEH0vyxeST9BACBkvRkF55amZ0EYo\r\na=ssrc:4043346828 label:xXtAEH0vyxeST9BACBkvRkF55amZ0EYoa0\r\na=ice-ufrag:OnbwxGrtGEix86Mq\r\na=ice-pwd:drdSXmVQzHtLVwrAKsW8Yerv\r\na=candidate:1409144412 1 udp 2130706431 190.102.98.211 19828 typ srflx raddr 172.17.0.2 rport 19828 generation 0\r\na=candidate:7363643456 1 udp 2130706431 172.17.0.2 19828 typ host generation 0\r\na=candidate:1409144412 2 udp 2130706430 190.102.98.211 19828 typ srflx raddr 172.17.0.2 rport 19828 generation 0\r\na=candidate:7363643456 2 udp 2130706430 172.17.0.2 19828 typ host generation 0\r\na=silenceSupp:off - - - -\r\na=ptime:20\r\na=sendrecv\r\n' - const AUDIO_VIDEO_SDP = - 'v=0\r\no=- 8094323291162995063 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0 1\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS 45Xh7kvyxccAi1fP6gpacCd2XY5IPfmp9zkU\r\nm=audio 51609 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8 110 112 113 126\r\nc=IN IP4 172.17.0.5\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=candidate:528442011 1 udp 2122260223 192.168.1.12 52783 typ host generation 0 network-id 1 network-cost 10\r\na=candidate:3788879375 1 tcp 1518280447 192.168.1.12 9 typ host tcptype active generation 0 network-id 1 network-cost 10\r\na=candidate:511643837 1 udp 1686052607 37.118.148.114 52783 typ srflx raddr 192.168.1.12 rport 52783 generation 0 network-id 1 network-cost 10\r\na=candidate:427329035 1 udp 25108479 172.17.0.5 51609 typ relay raddr 172.17.0.6 rport 49152 generation 0 network-id 1 network-cost 10\r\na=ice-ufrag:Yoii\r\na=ice-pwd:uMmennPss4DGhOvNYiKxQT7w\r\na=ice-options:trickle\r\na=fingerprint:sha-256 C4:62:01:34:2C:20:32:37:00:BE:DD:40:E7:03:DA:0E:57:A0:EB:30:DD:BD:98:20:11:3B:1C:00:FD:A6:3D:37\r\na=setup:actpass\r\na=mid:0\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=sendrecv\r\na=msid:45Xh7kvyxccAi1fP6gpacCd2XY5IPfmp9zkU 29e5d7e5-de01-4058-b202-929b7e454469\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\na=rtcp-fb:111 transport-cc\r\na=fmtp:111 minptime=10;useinbandfec=1\r\na=rtpmap:63 red/48000/2\r\na=fmtp:63 111/111\r\na=rtpmap:103 ISAC/16000\r\na=rtpmap:104 ISAC/32000\r\na=rtpmap:9 G722/8000\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:110 telephone-event/48000\r\na=rtpmap:112 telephone-event/32000\r\na=rtpmap:113 telephone-event/16000\r\na=rtpmap:126 telephone-event/8000\r\na=ssrc:335962309 cname:7wjKGH97nM78eMmS\r\na=ssrc:335962309 msid:45Xh7kvyxccAi1fP6gpacCd2XY5IPfmp9zkU 29e5d7e5-de01-4058-b202-929b7e454469\r\nm=video 52560 UDP/TLS/RTP/SAVPF 96 97 102 122 127 121 125 107 108 109 124 120 39 40 45 46 98 99 100 101 123 119 114 115 116\r\nc=IN IP4 172.17.0.5\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=candidate:528442011 1 udp 2122260223 192.168.1.12 52673 typ host generation 0 network-id 1 network-cost 10\r\na=candidate:3788879375 1 tcp 1518280447 192.168.1.12 9 typ host tcptype active generation 0 network-id 1 network-cost 10\r\na=candidate:511643837 1 udp 1686052607 37.118.148.114 52673 typ srflx raddr 192.168.1.12 rport 52673 generation 0 network-id 1 network-cost 10\r\na=candidate:427329035 1 udp 25108479 172.17.0.5 52560 typ relay raddr 172.17.0.6 rport 49154 generation 0 network-id 1 network-cost 10\r\na=ice-ufrag:Yoii\r\na=ice-pwd:uMmennPss4DGhOvNYiKxQT7w\r\na=ice-options:trickle\r\na=fingerprint:sha-256 C4:62:01:34:2C:20:32:37:00:BE:DD:40:E7:03:DA:0E:57:A0:EB:30:DD:BD:98:20:11:3B:1C:00:FD:A6:3D:37\r\na=setup:actpass\r\na=mid:1\r\na=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r\na=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:13 urn:3gpp:video-orientation\r\na=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\na=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\na=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\na=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\na=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\na=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\na=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\na=sendrecv\r\na=msid:45Xh7kvyxccAi1fP6gpacCd2XY5IPfmp9zkU 95f5bd7e-f301-4349-aa8d-c493812cd7b0\r\na=rtcp-mux\r\na=rtcp-rsize\r\na=rtpmap:96 VP8/90000\r\na=rtcp-fb:96 goog-remb\r\na=rtcp-fb:96 transport-cc\r\na=rtcp-fb:96 ccm fir\r\na=rtcp-fb:96 nack\r\na=rtcp-fb:96 nack pli\r\na=rtpmap:97 rtx/90000\r\na=fmtp:97 apt=96\r\na=rtpmap:102 H264/90000\r\na=rtcp-fb:102 goog-remb\r\na=rtcp-fb:102 transport-cc\r\na=rtcp-fb:102 ccm fir\r\na=rtcp-fb:102 nack\r\na=rtcp-fb:102 nack pli\r\na=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\na=rtpmap:122 rtx/90000\r\na=fmtp:122 apt=102\r\na=rtpmap:127 H264/90000\r\na=rtcp-fb:127 goog-remb\r\na=rtcp-fb:127 transport-cc\r\na=rtcp-fb:127 ccm fir\r\na=rtcp-fb:127 nack\r\na=rtcp-fb:127 nack pli\r\na=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r\na=rtpmap:121 rtx/90000\r\na=fmtp:121 apt=127\r\na=rtpmap:125 H264/90000\r\na=rtcp-fb:125 goog-remb\r\na=rtcp-fb:125 transport-cc\r\na=rtcp-fb:125 ccm fir\r\na=rtcp-fb:125 nack\r\na=rtcp-fb:125 nack pli\r\na=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\na=rtpmap:107 rtx/90000\r\na=fmtp:107 apt=125\r\na=rtpmap:108 H264/90000\r\na=rtcp-fb:108 goog-remb\r\na=rtcp-fb:108 transport-cc\r\na=rtcp-fb:108 ccm fir\r\na=rtcp-fb:108 nack\r\na=rtcp-fb:108 nack pli\r\na=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\r\na=rtpmap:109 rtx/90000\r\na=fmtp:109 apt=108\r\na=rtpmap:124 H264/90000\r\na=rtcp-fb:124 goog-remb\r\na=rtcp-fb:124 transport-cc\r\na=rtcp-fb:124 ccm fir\r\na=rtcp-fb:124 nack\r\na=rtcp-fb:124 nack pli\r\na=fmtp:124 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f\r\na=rtpmap:120 rtx/90000\r\na=fmtp:120 apt=124\r\na=rtpmap:39 H264/90000\r\na=rtcp-fb:39 goog-remb\r\na=rtcp-fb:39 transport-cc\r\na=rtcp-fb:39 ccm fir\r\na=rtcp-fb:39 nack\r\na=rtcp-fb:39 nack pli\r\na=fmtp:39 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f\r\na=rtpmap:40 rtx/90000\r\na=fmtp:40 apt=39\r\na=rtpmap:45 AV1/90000\r\na=rtcp-fb:45 goog-remb\r\na=rtcp-fb:45 transport-cc\r\na=rtcp-fb:45 ccm fir\r\na=rtcp-fb:45 nack\r\na=rtcp-fb:45 nack pli\r\na=rtpmap:46 rtx/90000\r\na=fmtp:46 apt=45\r\na=rtpmap:98 VP9/90000\r\na=rtcp-fb:98 goog-remb\r\na=rtcp-fb:98 transport-cc\r\na=rtcp-fb:98 ccm fir\r\na=rtcp-fb:98 nack\r\na=rtcp-fb:98 nack pli\r\na=fmtp:98 profile-id=0\r\na=rtpmap:99 rtx/90000\r\na=fmtp:99 apt=98\r\na=rtpmap:100 VP9/90000\r\na=rtcp-fb:100 goog-remb\r\na=rtcp-fb:100 transport-cc\r\na=rtcp-fb:100 ccm fir\r\na=rtcp-fb:100 nack\r\na=rtcp-fb:100 nack pli\r\na=fmtp:100 profile-id=2\r\na=rtpmap:101 rtx/90000\r\na=fmtp:101 apt=100\r\na=rtpmap:123 H264/90000\r\na=rtcp-fb:123 goog-remb\r\na=rtcp-fb:123 transport-cc\r\na=rtcp-fb:123 ccm fir\r\na=rtcp-fb:123 nack\r\na=rtcp-fb:123 nack pli\r\na=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f\r\na=rtpmap:119 rtx/90000\r\na=fmtp:119 apt=123\r\na=rtpmap:114 red/90000\r\na=rtpmap:115 rtx/90000\r\na=fmtp:115 apt=114\r\na=rtpmap:116 ulpfec/90000\r\na=ssrc-group:FID 3973975883 3471156669\r\na=ssrc:3973975883 cname:7wjKGH97nM78eMmS\r\na=ssrc:3973975883 msid:45Xh7kvyxccAi1fP6gpacCd2XY5IPfmp9zkU 95f5bd7e-f301-4349-aa8d-c493812cd7b0\r\na=ssrc:3471156669 cname:7wjKGH97nM78eMmS\r\na=ssrc:3471156669 msid:45Xh7kvyxccAi1fP6gpacCd2XY5IPfmp9zkU 95f5bd7e-f301-4349-aa8d-c493812cd7b0\r\n' + describe('sdpHasMediaSection', () => { + it('should return true when the specified media section is present', () => { + expect(sdpHasMediaSection(AUDIO_SDP, 'audio')).toBe(true) + expect(sdpHasMediaSection(AUDIO_VIDEO_SDP, 'audio')).toBe(true) + expect(sdpHasMediaSection(VIDEO_SDP, 'video')).toBe(true) + expect(sdpHasMediaSection(AUDIO_VIDEO_SDP, 'video')).toBe(true) + }) - it('should return true in all cases', () => { - expect(hasMediaSection(AUDIO_SDP, 'audio')).toBeTruthy() - expect(hasMediaSection(AUDIO_VIDEO_SDP, 'audio')).toBeTruthy() - expect(hasMediaSection(VIDEO_SDP, 'video')).toBeTruthy() - expect(hasMediaSection(AUDIO_VIDEO_SDP, 'video')).toBeTruthy() + it('should return false when the specified media section is absent', () => { + expect(sdpHasMediaSection(AUDIO_SDP, 'video')).toBe(false) + expect(sdpHasMediaSection(VIDEO_SDP, 'audio')).toBe(false) }) - it('should return false in all cases', () => { - expect(hasMediaSection(AUDIO_SDP, 'video')).toBeFalsy() - expect(hasMediaSection(VIDEO_SDP, 'audio')).toBeFalsy() + describe('sdpHasAudio', () => { + it('should return true if SDP includes an audio section', () => { + expect(sdpHasAudio(AUDIO_SDP)).toBe(true) + expect(sdpHasAudio(AUDIO_VIDEO_SDP)).toBe(true) + }) + + it('should return false if SDP does not include an audio section', () => { + expect(sdpHasAudio(VIDEO_SDP)).toBe(false) + }) + }) + + describe('sdpHasVideo', () => { + it('should return true if SDP includes a video section', () => { + expect(sdpHasVideo(VIDEO_SDP)).toBe(true) + expect(sdpHasVideo(AUDIO_VIDEO_SDP)).toBe(true) + }) + + it('should return false if SDP does not include a video section', () => { + expect(sdpHasVideo(AUDIO_SDP)).toBe(false) + }) + }) + }) + + describe('getSdpDirection', () => { + it('should return the correct direction for audio', () => { + expect(getSdpDirection(AUDIO_SDP, 'audio')).toBe('sendrecv') + }) + + it('should return the correct direction for video', () => { + expect(getSdpDirection(VIDEO_SDP, 'video')).toBe('sendonly') + }) + + it('should return "stopped" as default when no direction is specified for video', () => { + const sdpWithoutDirection = `v=0 + o=FreeSWITCH 1707233696 1707233697 IN IP4 190.102.98.211 + s=FreeSWITCH + c=IN IP4 190.102.98.211 + t=0 0 + m=video 19828 RTP/SAVPF 0 8 102` + + expect(getSdpDirection(sdpWithoutDirection, 'video')).toBe('stopped') + }) + + it('should return the specified direction for video in combined audio-video SDP', () => { + expect(getSdpDirection(AUDIO_VIDEO_SDP, 'video')).toBe('recvonly') + }) + }) + + describe('getOppositeSdpDirection', () => { + it('should return the correct opposite directions', () => { + expect(getOppositeSdpDirection('sendrecv')).toBe('sendrecv') + expect(getOppositeSdpDirection('sendonly')).toBe('recvonly') + expect(getOppositeSdpDirection('recvonly')).toBe('sendonly') + expect(getOppositeSdpDirection('inactive')).toBe('inactive') }) }) }) diff --git a/packages/webrtc/src/utils/sdpHelpers.ts b/packages/webrtc/src/utils/sdpHelpers.ts index 6cd9afc00..35baacac1 100644 --- a/packages/webrtc/src/utils/sdpHelpers.ts +++ b/packages/webrtc/src/utils/sdpHelpers.ts @@ -8,12 +8,36 @@ const _getCodecPayloadType = (line: string) => { const result = line.match(pattern) return result && result.length == 2 ? result[1] : null } +const _normalizeSDPLines = (sdp: string) => { + // Make the line break consistent + return sdp.replace(/\r\n/g, '\n').replace(/\r/g, '\n') +} /** - * test if sdp has a video section + * Check if SDP has a media section (audio or video) */ -export const hasMediaSection = (sdp: string, media: 'audio' | 'video') => { - return sdp.includes(`\r\nm=${media}`) +export const sdpHasMediaSection = (sdp: string, media: 'audio' | 'video') => { + const lines = _normalizeSDPLines(sdp).split('\n') + for (let line of lines) { + if (line.startsWith(`m=${media}`)) { + return true + } + } + return false +} + +/** + * Check if SDP includes video + */ +export const sdpHasVideo = (sdp: string) => { + return sdpHasMediaSection(sdp, 'video') +} + +/** + * Check if SDP includes audio + */ +export const sdpHasAudio = (sdp: string) => { + return sdpHasMediaSection(sdp, 'audio') } /** @@ -177,3 +201,77 @@ export const sdpRemoveLocalCandidates = (sdp: string) => { .filter((line) => !pattern.test(line)) .join(endOfLine) } + +/** + * Get the SDP direction for the specified media type. + * Returns 'sendrecv' if no direction attribute is found, as per SDP standards. + */ +export const getSdpDirection = ( + sdp: string, + media: 'audio' | 'video' +): RTCRtpTransceiverDirection => { + const lines = _normalizeSDPLines(sdp).split('\n') + let inMediaSection = false + + const directions = ['inactive', 'recvonly', 'sendonly', 'sendrecv', 'stopped'] + + for (let line of lines) { + if (line.startsWith('m=')) { + // Check if this is the media section we're interested in + inMediaSection = line.startsWith(`m=${media}`) + } else if (inMediaSection && line.startsWith('a=')) { + // Check for direction attribute within this media section + const attr = line.substring(2) + if (directions.includes(attr)) { + // Return the found direction attribute + return attr as RTCRtpTransceiverDirection + } + } + } + + // If no media section is found, return `stopped` + if (!inMediaSection) { + return 'stopped' + } + + // If no direction attribute is found, return 'sendrecv' as per SDP standard + return 'sendrecv' +} + +/** + * Returns the opposite SDP direction based on the provided direction. + */ +export const getOppositeSdpDirection = ( + direction: RTCRtpTransceiverDirection +): RTCRtpTransceiverDirection => { + switch (direction) { + case 'sendrecv': + return 'sendrecv' + case 'sendonly': + return 'recvonly' + case 'recvonly': + return 'sendonly' + case 'inactive': + return 'inactive' + default: + return 'inactive' + } +} + +/** + * Returns boolean indicating remote and local SDPs has the expected opposite direction + */ +export const hasMatchingSdpDirection = ({ + localSdp, + remoteSdp, + media, +}: { + localSdp: string + remoteSdp: string + media: 'audio' | 'video' +}) => { + const localDirection = getSdpDirection(localSdp, media) + const expectedRemoteDirection = getOppositeSdpDirection(localDirection) + const remoteDirection = getSdpDirection(remoteSdp, media) + return remoteDirection === expectedRemoteDirection +}