diff --git a/README.md b/README.md index 27164511..24d0d4e2 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,8 @@ The `OTNetworkTest()` constructor includes the following parameters: (`true`) or not (`false`, the default). Disabling scalable video was added in OpenTok.js version 2.24.7. + * `fullHd` (Boolean) -- (Optional) Allows publishing with a resolution of 1920x1080. If the camera does not support 1920x1080 resolution, OTNetworkTest.testConnectivity() method is rejected with `UNSUPPORTED_RESOLUTION_ERROR` error. + The `options` parameter is optional. The constructor throws an Error object with a `message` property and a `name` property. The @@ -421,6 +423,9 @@ following properties: * `reason` (String) -- A string describing the reason for an unsupported video recommendation. For example, `'No camera was found.'` + + * `qualityLimitationReason` (String) -- Indicates the reason behind + the highest resolution tested failing. It can have values: `'cpu'` for CPU overload, `'bandwidth'` for insufficient network bandwidth, or value is `'null'` if there is no limitation. * `bitrate` (Number) -- The average number of video bits per second during the last five seconds of the test. If the the test ran in audio-only mode (for example, because @@ -567,6 +572,14 @@ method has a `name` property set to one of the following: | `SUBSCRIBE_TO_SESSION_ERROR` | The test encountered an unknown error while attempting to subscribe to a test stream. | | `SUBSCRIBER_GET_STATS_ERROR` | The test failed to get audio and video statistics for the test stream. | +#### Errors thrown by the OTNetworkTest.checkCameraSupport() method + +| Error.name property set
to this property of
ErrorNames ... | Description | +| ------------------------------------------------------------------ | ----------------- | +| `PERMISSION_DENIED_ERROR` | The user denied access to the camera. | +| `UNSUPPORTED_RESOLUTION_ERROR` | The camera does not support the requested resolution. | + + ## MOS estimates The `testQuality()` results include MOS estimates for video (if supported) and audio (if supported). diff --git a/package-lock.json b/package-lock.json index 1f2498bb..b159e46d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opentok-network-test-js", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opentok-network-test-js", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "axios": "^0.21.1", diff --git a/package.json b/package.json index 2dd1c4c3..b50a7c39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opentok-network-test-js", - "version": "3.0.0", + "version": "3.1.0", "description": "Precall network test for applications using the OpenTok platform.", "main": "dist/NetworkTest/index.js", "types": "dist/NetworkTest/index.d.ts", diff --git a/sample/index.html b/sample/index.html index 5e68a8d3..a47152c9 100644 --- a/sample/index.html +++ b/sample/index.html @@ -11,7 +11,9 @@

OpenTok Network test

Options: - + + + Maximum duration: @@ -27,6 +29,7 @@

OpenTok Network test

+

Testing connectivity

@@ -73,6 +76,9 @@

Video

Quality:

+

Video limitation: + +

Bitrate:

diff --git a/sample/package-lock.json b/sample/package-lock.json index 96342a67..b6431ef8 100644 --- a/sample/package-lock.json +++ b/sample/package-lock.json @@ -10,14 +10,14 @@ "license": "MIT", "dependencies": { "highcharts": "^9.0.0", - "opentok-network-test-js": "file://.." + "opentok-network-test-js": "../" }, "devDependencies": { "webpack": "^4.39.1" } }, "..": { - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "axios": "^0.21.1", diff --git a/sample/package.json b/sample/package.json index 4566bda9..6709d736 100644 --- a/sample/package.json +++ b/sample/package.json @@ -17,6 +17,6 @@ }, "dependencies": { "highcharts": "^9.0.0", - "opentok-network-test-js": "file://.." + "opentok-network-test-js": "../" } } diff --git a/sample/src/.DS_Store b/sample/src/.DS_Store new file mode 100644 index 00000000..dee0e3ef Binary files /dev/null and b/sample/src/.DS_Store differ diff --git a/sample/src/js/connectivity-ui.js b/sample/src/js/connectivity-ui.js index 497f84ef..f2688381 100644 --- a/sample/src/js/connectivity-ui.js +++ b/sample/src/js/connectivity-ui.js @@ -119,6 +119,8 @@ export function displayTestQualityResults(error, results) { + ' (' + rateMosScore(videoMos) + ')'; resultsEl.querySelector('#video-bitrate').textContent = results.video.bitrate ? (results.video.bitrate / 1000).toFixed(2) + ' kbps' : '--'; + resultsEl.querySelector("#video-qualityLimitationReason").textContent = + results.video.qualityLimitationReason ? results.video.qualityLimitationReason : "none"; resultsEl.querySelector('#video-plr').textContent = results.video.packetLossRatio ? (results.video.packetLossRatio * 100).toFixed(2) + '%' : '0.00%'; resultsEl.querySelector('#video-recommendedResolution').textContent = diff --git a/sample/src/js/index.js b/sample/src/js/index.js index 5c54bd7a..d2b38a67 100644 --- a/sample/src/js/index.js +++ b/sample/src/js/index.js @@ -11,16 +11,23 @@ precallDiv.querySelector('#precall button').addEventListener('click', function ( document.getElementById('connectivity_status_container').style.display = 'block'; precallDiv.style.display = 'none'; startTest(); -}) +}); function startTest() { - audioOnly = precallDiv.querySelector('#precall input').checked; - var timeoutSelect = precallDiv.querySelector('select'); - var timeout = timeoutSelect.options[timeoutSelect.selectedIndex].text * 1000; - var options = { + const audioOnly = precallDiv.querySelector('#audioOnlyCheckbox').checked; + const scalableVideo = precallDiv.querySelector('#scalableCheckbox').checked; + const fullHd = precallDiv.querySelector('#fullHdCheckbox').checked; + + const timeoutSelect = precallDiv.querySelector('select'); + const timeout = timeoutSelect.options[timeoutSelect.selectedIndex].text * 1000; + + const options = { audioOnly: audioOnly, + scalableVideo: scalableVideo, + fullHd: fullHd, timeout: timeout }; + otNetworkTest = new NetworkTest(OT, sessionInfo, options); otNetworkTest.testConnectivity() .then(results => ConnectivityUI.displayTestConnectivityResults(results)) diff --git a/src/NetworkTest/errors/index.ts b/src/NetworkTest/errors/index.ts index 2624129b..824f8f94 100644 --- a/src/NetworkTest/errors/index.ts +++ b/src/NetworkTest/errors/index.ts @@ -43,3 +43,16 @@ export class InvalidOnUpdateCallback extends NetworkTestError { ErrorNames.INVALID_ON_UPDATE_CALLBACK); } } +export class PermissionDeniedError extends NetworkTestError { + constructor() { + super('Precall failed to acquire camera due to a permissions error.', + ErrorNames.PERMISSION_DENIED_ERROR); + } +} + +export class UnsupportedResolutionError extends NetworkTestError { + constructor() { + super('The camera does not support the given resolution.', + ErrorNames.UNSUPPORTED_RESOLUTION_ERROR); + } +} diff --git a/src/NetworkTest/errors/types.ts b/src/NetworkTest/errors/types.ts index 1b379049..f9a1b53c 100644 --- a/src/NetworkTest/errors/types.ts +++ b/src/NetworkTest/errors/types.ts @@ -38,6 +38,8 @@ export enum ErrorNames { UNSUPPORTED_BROWSER = 'UnsupportedBrowser', SUBSCRIBER_GET_STATS_ERROR = 'SubscriberGetStatsError', MISSING_SUBSCRIBER_ERROR = 'MissingSubscriberError', + PERMISSION_DENIED_ERROR = 'PermissionDeniedError', + UNSUPPORTED_RESOLUTION_ERROR = 'UnsupportedResolutionError', } export enum OTErrorType { diff --git a/src/NetworkTest/index.ts b/src/NetworkTest/index.ts index 9b80a7aa..31b4e9fa 100644 --- a/src/NetworkTest/index.ts +++ b/src/NetworkTest/index.ts @@ -36,6 +36,7 @@ export interface NetworkTestOptions { initSessionOptions?: OT.InitSessionOptions; proxyServerUrl?: string; scalableVideo?: boolean; + fullHd?: boolean; } export default class NetworkTest { diff --git a/src/NetworkTest/testQuality/helpers/calculateThroughput.ts b/src/NetworkTest/testQuality/helpers/calculateThroughput.ts index 24116d55..a406cfa2 100644 --- a/src/NetworkTest/testQuality/helpers/calculateThroughput.ts +++ b/src/NetworkTest/testQuality/helpers/calculateThroughput.ts @@ -35,6 +35,11 @@ function getAverageBitrateAndPlr(type: AV, publisherStats => publisherStats.simulcastEnabled, ); + const lastPublisherStats = publisherStatsList[publisherStatsList.length - 1]; + + const qualityLimitationReason = lastPublisherStats.videoStats.find( + videoStats => videoStats.qualityLimitationReason !== null)?.qualityLimitationReason || null; + const averageStats: AverageStatsBase = { availableOutgoingBitrate: publisherStatsList[publisherStatsList.length - 1].availableOutgoingBitrate, simulcast: isSimulcastEnabled, @@ -52,7 +57,7 @@ function getAverageBitrateAndPlr(type: AV, recommendedFrameRate, frameRate: sumFrameRate / subscriberStatsList.length, } : {}; - return { ...averageStats, supported, reason, ...videoStats }; + return { ...averageStats, supported, reason, qualityLimitationReason, ...videoStats }; } return { ...averageStats }; } diff --git a/src/NetworkTest/testQuality/helpers/getPublisherRtcStatsReport.ts b/src/NetworkTest/testQuality/helpers/getPublisherRtcStatsReport.ts index 06c033b4..17e13c8a 100644 --- a/src/NetworkTest/testQuality/helpers/getPublisherRtcStatsReport.ts +++ b/src/NetworkTest/testQuality/helpers/getPublisherRtcStatsReport.ts @@ -76,7 +76,7 @@ const extractOutboundRtpStats = ( const baseStats = { kbs, ssrc, byteSent, currentTimestamp }; videoStats.push({ ...baseStats, - qualityLimitationReason: stats.qualityLimitationReason || 'N/A', + qualityLimitationReason: stats.qualityLimitationReason, resolution: `${stats.frameWidth || 0}x${stats.frameHeight || 0}`, framerate: stats.framesPerSecond || 0, active: stats.active || false, @@ -131,13 +131,10 @@ const extractPublisherStats = ( const timestamp = localCandidate?.timestamp || 0; /** - console.trace("videoStats: ", videoStats); - console.trace("audioStats: ", audioStats); - console.trace("availableOutgoingBitrate: ", availableOutgoingBitrate); - console.trace("currentRoundTripTime: ", currentRoundTripTime); - console.trace("videoSentKbs: ", videoSentKbs); - console.trace("simulcastEnabled: ", simulcastEnabled); - console.trace("transportProtocol: ", transportProtocol); + console.info("availableOutgoingBitrate: ", availableOutgoingBitrate); + console.info("currentRoundTripTime: ", currentRoundTripTime); + console.info("simulcastEnabled: ", simulcastEnabled); + console.info("transportProtocol: ", transportProtocol); console.info("availableOutgoingBitrate: ", availableOutgoingBitrate); console.info("videoByteSent: ", videoByteSent); **/ diff --git a/src/NetworkTest/testQuality/helpers/getUpdateCallbackStats.ts b/src/NetworkTest/testQuality/helpers/getUpdateCallbackStats.ts index 50467aba..ef09575b 100644 --- a/src/NetworkTest/testQuality/helpers/getUpdateCallbackStats.ts +++ b/src/NetworkTest/testQuality/helpers/getUpdateCallbackStats.ts @@ -4,7 +4,7 @@ import { UpdateCallbackStats, CallbackTrackStats } from '../../types/callbacks'; const getUpdateCallbackStats = ( subscriberStats: OT.SubscriberStats, publisherStats: OT.PublisherStats, - phase: string + phase: string, ): UpdateCallbackStats => { const { audio: audioTrackStats, video: videoTrackStats } = subscriberStats; diff --git a/src/NetworkTest/testQuality/index.ts b/src/NetworkTest/testQuality/index.ts index f33a359a..db120742 100644 --- a/src/NetworkTest/testQuality/index.ts +++ b/src/NetworkTest/testQuality/index.ts @@ -27,6 +27,12 @@ import MOSState from './helpers/MOSState'; import config from './helpers/config'; import isSupportedBrowser from './helpers/isSupportedBrowser'; import getUpdateCallbackStats from './helpers/getUpdateCallbackStats'; +import { PermissionDeniedError, UnsupportedResolutionError } from '../errors'; + +const FULL_HD_WIDTH = 1920; +const FULL_HD_HEIGHT = 1080; +const FULL_HD_RESOLUTION = '1920x1080'; +const HD_RESOUTION = '1280x720'; interface QualityTestResultsBuilder { state: MOSState; @@ -49,7 +55,6 @@ let stopTest: Function | undefined; let stopTestTimeoutId: number; let stopTestTimeoutCompleted = false; let stopTestCalled = false; - /** * If not already connected, connect to the OpenTok Session */ @@ -75,31 +80,67 @@ function connectToSession(session: OT.Session, token: string): Promise { + return new Promise((resolve, reject) => { + navigator.mediaDevices.getUserMedia({ + video: { + width: { exact: width }, + height: { exact: height }, + }, + audio: false, + }).then((mediaStream) => { + if (mediaStream) { + resolve(); + } + }).catch((error) => { + switch (error.name) { + case 'OverconstrainedError': + reject(new UnsupportedResolutionError()); + break; + case 'NotAllowedError': + reject(new PermissionDeniedError()); + break; + default: + reject(error); + } + }); + }); +} /** * Ensure that audio and video devices are available */ -function validateDevices(OT: OT.Client): Promise { +function validateDevices(OT: OT.Client, options?: NetworkTestOptions): Promise { return new Promise((resolve, reject) => { OT.getDevices((error?: OT.OTError, devices: OT.Device[] = []) => { - if (error) { reject(new e.FailedToObtainMediaDevices()); - } else { - - const availableDevices: AvailableDevices = devices.reduce( - (acc: AvailableDevices, device: OT.Device) => { - const type: AV = device.kind === 'audioInput' ? 'audio' : 'video'; - return { ...acc, [type]: { ...acc[type], [device.deviceId]: device } }; - }, - { audio: {}, video: {} }, - ); + return; + } - if (!Object.keys(availableDevices.audio).length) { - reject(new e.NoAudioCaptureDevicesError()); - } else { - resolve(availableDevices); - } + const availableDevices: AvailableDevices = devices.reduce( + (acc: AvailableDevices, device: OT.Device) => { + const type: AV = device.kind === 'audioInput' ? 'audio' : 'video'; + return { ...acc, [type]: { ...acc[type], [device.deviceId]: device } }; + }, + { audio: {}, video: {} }, + ); + + if (!Object.keys(availableDevices.audio).length) { + reject(new e.NoAudioCaptureDevicesError()); + return; + } + if (options?.fullHd) { + checkCameraSupport(FULL_HD_WIDTH, FULL_HD_HEIGHT) + .then(() => resolve(availableDevices)) + .catch(reject); + } else { + resolve(availableDevices); } }); }); @@ -120,13 +161,14 @@ function publishAndSubscribe(OT: OT.Client, options?: NetworkTestOptions) { containerDiv.style.opacity = '0'; document.body.appendChild(containerDiv); - validateDevices(OT) + validateDevices(OT, options) .then((availableDevices: AvailableDevices) => { if (!Object.keys(availableDevices.video).length) { audioOnly = true; } const publisherOptions: OT.PublisherProperties = { - resolution: '1280x720', + resolution: options.fullHd ? FULL_HD_RESOLUTION : HD_RESOUTION, + scalableVideo: options.scalableVideo, width: '100%', height: '100%', insertMode: 'append', @@ -202,7 +244,7 @@ function buildResults(builder: QualityTestResultsBuilder): QualityTestResults { } return { audio: pick(baseProps, builder.state.stats.audio), - video: pick(baseProps.concat(['frameRate', 'recommendedResolution', 'recommendedFrameRate']), + video: pick(baseProps.concat(['frameRate', 'qualityLimitationReason', 'recommendedResolution', 'recommendedFrameRate']), builder.state.stats.video), }; } diff --git a/src/NetworkTest/testQuality/types/stats.ts b/src/NetworkTest/testQuality/types/stats.ts index b4f7277d..9b6e8437 100644 --- a/src/NetworkTest/testQuality/types/stats.ts +++ b/src/NetworkTest/testQuality/types/stats.ts @@ -27,6 +27,7 @@ export interface AverageStats { packetLossRatio?: number; supported?: boolean; reason?: string; + qualityLimitationReason? : string; frameRate?: number; recommendedFrameRate?: number; recommendedResolution?: string; diff --git a/src/NetworkTest/types/opentok/publisher.ts b/src/NetworkTest/types/opentok/publisher.ts index b120cf7e..7c7d3f3c 100644 --- a/src/NetworkTest/types/opentok/publisher.ts +++ b/src/NetworkTest/types/opentok/publisher.ts @@ -60,6 +60,7 @@ export interface GetUserMediaProperties { frameRate?: 30 | 15 | 7 | 1; maxResolution?: Dimensions; resolution?: ( + '1920x1080' | '1280x960' | '1280x720' | '640x480' | @@ -82,6 +83,7 @@ export interface PublisherProperties extends WidgetProperties, GetUserMediaPrope publishAudio?: boolean; publishVideo?: boolean; resolution?: ( + '1920x1080' | '1280x960' | '1280x720' | '640x480' |