UNPKG

@twilio/voice-sdk

Version:
1,012 lines (876 loc) 28.3 kB
import { EventEmitter } from 'events'; import Call from '../call'; import Device, { IExtendedDeviceOptions } from '../device'; import { GeneralErrors, NotSupportedError, SignalingErrors, TwilioError, } from '../errors'; import Log from '../log'; import { RTCSampleTotals } from '../rtc/sample'; import RTCSample from '../rtc/sample'; import { getRTCIceCandidateStatsReport } from '../rtc/stats'; import RTCWarning from '../rtc/warning'; import StatsMonitor from '../statsMonitor'; import { NetworkTiming, TimeMeasurement } from './timing'; import { COWBELL_AUDIO_URL, ECHO_TEST_DURATION } from '../constants'; /** * Placeholder until we convert peerconnection.js to TypeScript. * Represents the audio output object coming from Client SDK's PeerConnection object. */ export interface AudioOutput { /** * The audio element used to play out the sound. */ audio: HTMLAudioElement; } /** * @mergeModuleWith PreflightTest */ export declare interface PreflightTest { /** * Raised when [[PreflightTest.status]] has transitioned to [[PreflightTest.Status.Completed]]. * During this time, [[PreflightTest.report]] is available and ready to be inspected. * In some cases, this will not trigger if the test encounters a fatal error prior connecting to Twilio. * See [[PreflightTest.failedEvent]]. * @param report * @example `preflight.on('completed', report => console.log(report))` * @event */ completedEvent(report: PreflightTest.Report): void; /** * Raised when [[PreflightTest.status]] has transitioned to [[PreflightTest.Status.Connected]]. * @example `preflight.on('connected', () => console.log('Test connected'))` * @event */ connectedEvent(): void; /** * Raised when [[PreflightTest.status]] has transitioned to [[PreflightTest.Status.Failed]]. * This happens when establishing a connection to Twilio has failed or when a test call has encountered a fatal error. * This is also raised if [[PreflightTest.stop]] is called while the test is in progress. * @param error * @example `preflight.on('failed', error => console.log(error))` * @event */ failedEvent(error: TwilioError | DOMException): void; /** * Raised when the [[Call]] gets a webrtc sample object. This event is published every second. * @param sample * @example `preflight.on('sample', sample => console.log(sample))` * @event */ sampleEvent(sample: RTCSample): void; /** * Raised whenever the [[Call]] encounters a warning. * @param name - The name of the warning. * @example `preflight.on('warning', (name, data) => console.log({ name, data }))` * @event */ warningEvent(name: string, data: PreflightTest.Warning): void; } /** * Runs some tests to identify issues, if any, prohibiting successful calling. */ export class PreflightTest extends EventEmitter { /** * The {@link Call} for this test call */ private _call: Call; /** * Callsid generated for this test call */ private _callSid: string | undefined; /** * The {@link Device} for this test call */ private _device: Device; /** * The timer when doing an echo test * The echo test is used when fakeMicInput is set to true */ private _echoTimer: NodeJS.Timeout; /** * The edge that the `Twilio.Device` connected to. */ private _edge: string | undefined; /** * End of test timestamp */ private _endTime: number | undefined; /** * Whether this test has already logged an insights-connection-warning. */ private _hasInsightsErrored: boolean = false; /** * Latest WebRTC sample collected for this test */ private _latestSample: RTCSample | undefined; /** * An instance of Logger to use. */ private _log: Log = new Log('PreflightTest'); /** * Network related timing measurements for this test */ private _networkTiming: NetworkTiming = {}; /** * The options passed to {@link PreflightTest} constructor */ private _options: PreflightTest.ExtendedOptions = { codecPreferences: [Call.Codec.PCMU, Call.Codec.Opus], edge: 'roaming', fakeMicInput: false, logLevel: 'error', signalingTimeoutMs: 10000, }; /** * The report for this test. */ private _report: PreflightTest.Report | undefined; /** * The WebRTC ICE candidates stats information collected during the test */ private _rtcIceCandidateStatsReport: PreflightTest.RTCIceCandidateStatsReport; /** * WebRTC samples collected during this test */ private _samples: RTCSample[]; /** * Timer for setting up signaling connection */ private _signalingTimeoutTimer: NodeJS.Timeout; /** * Start of test timestamp */ private _startTime: number; /** * Current status of this test */ private _status: PreflightTest.Status = PreflightTest.Status.Connecting; /** * List of warning names and warning data detected during this test */ private _warnings: PreflightTest.Warning[]; /** * Construct a {@link PreflightTest} instance. * @param token - A Twilio JWT token string. * @param options */ constructor(token: string, options: PreflightTest.ExtendedOptions) { super(); Object.assign(this._options, options); this._samples = []; this._warnings = []; this._startTime = Date.now(); this._initDevice(token, { ...this._options, fileInputStream: this._options.fakeMicInput ? this._getStreamFromFile() : undefined, }); // Device sets the loglevel so start logging after initializing the device. // Then selectively log options that users can modify. const userOptions = [ 'codecPreferences', 'edge', 'fakeMicInput', 'logLevel', 'signalingTimeoutMs', ]; const userOptionOverrides = [ 'audioContext', 'deviceFactory', 'fileInputStream', 'getRTCIceCandidateStatsReport', 'iceServers', 'rtcConfiguration', ]; if (typeof options === 'object') { const toLog: any = { ...options }; Object.keys(toLog).forEach((key: string) => { if (!userOptions.includes(key) && !userOptionOverrides.includes(key)) { delete toLog[key]; } if (userOptionOverrides.includes(key)) { toLog[key] = true; } }); this._log.debug('.constructor', JSON.stringify(toLog)); } } /** * Stops the current test and raises a failed event. */ stop(): void { this._log.debug('.stop'); const error = new GeneralErrors.CallCancelledError(); if (this._device) { this._device.once(Device.EventName.Unregistered, () => this._onFailed(error)); this._device.destroy(); } else { this._onFailed(error); } } /** * Emit a {PreflightTest.Warning} */ private _emitWarning(name: string, description: string, rtcWarning?: RTCWarning): void { const warning: PreflightTest.Warning = { name, description }; if (rtcWarning) { warning.rtcWarning = rtcWarning; } this._warnings.push(warning); this._log.debug(`#${PreflightTest.Events.Warning}`, JSON.stringify(warning)); this.emit(PreflightTest.Events.Warning, warning); } /** * Returns call quality base on the RTC Stats */ private _getCallQuality(mos: number): PreflightTest.CallQuality { if (mos > 4.2) { return PreflightTest.CallQuality.Excellent; } else if (mos >= 4.1 && mos <= 4.2) { return PreflightTest.CallQuality.Great; } else if (mos >= 3.7 && mos <= 4) { return PreflightTest.CallQuality.Good; } else if (mos >= 3.1 && mos <= 3.6) { return PreflightTest.CallQuality.Fair; } else { return PreflightTest.CallQuality.Degraded; } } /** * Returns the report for this test. */ private _getReport(): PreflightTest.Report { const stats = this._getRTCStats(); const testTiming: TimeMeasurement = { start: this._startTime }; if (this._endTime) { testTiming.end = this._endTime; testTiming.duration = this._endTime - this._startTime; } const report: PreflightTest.Report = { callSid: this._callSid, edge: this._edge, iceCandidateStats: this._rtcIceCandidateStatsReport?.iceCandidateStats ?? [], networkTiming: this._networkTiming, samples: this._samples, selectedEdge: this._options.edge, stats, testTiming, totals: this._getRTCSampleTotals(), warnings: this._warnings, }; const selectedIceCandidatePairStats = this._rtcIceCandidateStatsReport?.selectedIceCandidatePairStats; if (selectedIceCandidatePairStats) { report.selectedIceCandidatePairStats = selectedIceCandidatePairStats; report.isTurnRequired = selectedIceCandidatePairStats.localCandidate.candidateType === 'relay' || selectedIceCandidatePairStats.remoteCandidate.candidateType === 'relay'; } if (stats) { report.callQuality = this._getCallQuality(stats.mos.average); } return report; } /** * Returns RTC stats totals for this test */ private _getRTCSampleTotals(): RTCSampleTotals | undefined { if (!this._latestSample) { return; } return { ...this._latestSample.totals }; } /** * Returns RTC related stats captured during the test call */ private _getRTCStats(): PreflightTest.RTCStats | undefined { const firstMosSampleIdx = this._samples.findIndex( sample => typeof sample.mos === 'number' && sample.mos > 0, ); const samples = firstMosSampleIdx >= 0 ? this._samples.slice(firstMosSampleIdx) : []; if (!samples || !samples.length) { return; } return ['jitter', 'mos', 'rtt'].reduce((statObj, stat) => { const values = samples.map(s => s[stat]); return { ...statObj, [stat]: { average: Number((values.reduce((total, value) => total + value) / values.length).toPrecision(5)), max: Math.max(...values), min: Math.min(...values), }, }; }, {} as any); } /** * Returns a MediaStream from a media file */ private _getStreamFromFile(): MediaStream { const audioContext = this._options.audioContext; if (!audioContext) { throw new NotSupportedError('Cannot fake input audio stream: AudioContext is not supported by this browser.'); } const audioEl: any = new Audio(COWBELL_AUDIO_URL); audioEl.addEventListener('canplaythrough', () => audioEl.play()); if (typeof audioEl.setAttribute === 'function') { audioEl.setAttribute('crossorigin', 'anonymous'); } const src = audioContext.createMediaElementSource(audioEl); const dest = audioContext.createMediaStreamDestination(); src.connect(dest); return dest.stream; } /** * Initialize the device */ private _initDevice(token: string, options: PreflightTest.ExtendedOptions): void { try { this._device = new (options.deviceFactory || Device)(token, { chunderw: options.chunderw, codecPreferences: options.codecPreferences, edge: options.edge, eventgw: options.eventgw, fileInputStream: options.fileInputStream, logLevel: options.logLevel, preflight: true, } as IExtendedDeviceOptions); this._device.once(Device.EventName.Registered, () => { this._onDeviceRegistered(); }); this._device.once(Device.EventName.Error, (error: TwilioError) => { this._onDeviceError(error); }); this._device.register(); } catch (error) { // We want to return before failing so the consumer can capture the event setTimeout(() => { this._onFailed(error); }); return; } this._signalingTimeoutTimer = setTimeout(() => { this._onDeviceError(new SignalingErrors.ConnectionError('WebSocket Connection Timeout')); }, options.signalingTimeoutMs); } /** * Called on {@link Device} error event * @param error */ private _onDeviceError(error: TwilioError): void { this._device.destroy(); this._onFailed(error); } /** * Called on {@link Device} ready event */ private async _onDeviceRegistered(): Promise<void> { clearTimeout(this._echoTimer); clearTimeout(this._signalingTimeoutTimer); this._call = await this._device.connect({ rtcConfiguration: this._options.rtcConfiguration, }); this._networkTiming.signaling = { start: Date.now() }; this._setupCallHandlers(this._call); this._edge = this._device.edge || undefined; if (this._options.fakeMicInput) { this._echoTimer = setTimeout(() => this._device.disconnectAll(), ECHO_TEST_DURATION); const audio = this._device.audio as any; if (audio) { audio.disconnect(false); audio.outgoing(false); } } this._call.once('disconnect', () => { this._device.once(Device.EventName.Unregistered, () => this._onUnregistered()); this._device.destroy(); }); const publisher = this._call['_publisher'] as any; publisher.on('error', () => { if (!this._hasInsightsErrored) { this._emitWarning('insights-connection-error', 'Received an error when attempting to connect to Insights gateway'); } this._hasInsightsErrored = true; }); } /** * Called when there is a fatal error * @param error */ private _onFailed(error: TwilioError | DOMException): void { clearTimeout(this._echoTimer); clearTimeout(this._signalingTimeoutTimer); this._releaseHandlers(); this._endTime = Date.now(); this._status = PreflightTest.Status.Failed; this._log.debug(`#${PreflightTest.Events.Failed}`, error); this.emit(PreflightTest.Events.Failed, error); } /** * Called when the device goes offline. * This indicates that the test has been completed, but we won't know if it failed or not. * The onError event will be the indicator whether the test failed. */ private _onUnregistered(): void { // We need to make sure we always execute preflight.on('completed') last // as client SDK sometimes emits 'offline' event before emitting fatal errors. setTimeout(() => { if (this._status === PreflightTest.Status.Failed) { return; } clearTimeout(this._echoTimer); clearTimeout(this._signalingTimeoutTimer); this._releaseHandlers(); this._endTime = Date.now(); this._status = PreflightTest.Status.Completed; this._report = this._getReport(); this._log.debug(`#${PreflightTest.Events.Completed}`, JSON.stringify(this._report)); this.emit(PreflightTest.Events.Completed, this._report); }, 10); } /** * Clean up all handlers for device and call */ private _releaseHandlers(): void { [this._device, this._call].forEach((emitter: EventEmitter) => { if (emitter) { emitter.eventNames().forEach((name: string) => emitter.removeAllListeners(name)); } }); } /** * Setup the event handlers for the {@link Call} of the test call * @param call */ private _setupCallHandlers(call: Call): void { if (this._options.fakeMicInput) { // When volume events start emitting, it means all audio outputs have been created. // Let's mute them if we're using fake mic input. call.once('volume', () => { call['_mediaHandler'].outputs .forEach((output: AudioOutput) => output.audio.muted = true); }); } call.on('warning', (name: string, data: RTCWarning) => { this._emitWarning(name, 'Received an RTCWarning. See .rtcWarning for the RTCWarning', data); }); call.once('accept', () => { this._callSid = call['_mediaHandler'].callSid; this._status = PreflightTest.Status.Connected; this._log.debug(`#${PreflightTest.Events.Connected}`); this.emit(PreflightTest.Events.Connected); }); call.on('sample', async (sample) => { // RTC Stats are ready. We only need to get ICE candidate stats report once. if (!this._latestSample) { this._rtcIceCandidateStatsReport = await ( this._options.getRTCIceCandidateStatsReport || getRTCIceCandidateStatsReport )(call['_mediaHandler'].version.pc); } this._latestSample = sample; this._samples.push(sample); this._log.debug(`#${PreflightTest.Events.Sample}`, JSON.stringify(sample)); this.emit(PreflightTest.Events.Sample, sample); }); // TODO: Update the following once the SDK supports emitting these events // Let's shim for now [{ reportLabel: 'peerConnection', type: 'pcconnection', }, { reportLabel: 'ice', type: 'iceconnection', }, { reportLabel: 'dtls', type: 'dtlstransport', }, { reportLabel: 'signaling', type: 'signaling', }].forEach(({type, reportLabel}) => { const handlerName = `on${type}statechange`; const originalHandler = call['_mediaHandler'][handlerName]; call['_mediaHandler'][handlerName] = (state: string) => { const timing = (this._networkTiming as any)[reportLabel] = (this._networkTiming as any)[reportLabel] || { start: 0 }; if (state === 'connecting' || state === 'checking') { timing.start = Date.now(); } else if ((state === 'connected' || state === 'stable') && !timing.duration) { timing.end = Date.now(); timing.duration = timing.end - timing.start; } originalHandler(state); }; }); } /** * The callsid generated for the test call. */ get callSid(): string | undefined { return this._callSid; } /** * A timestamp in milliseconds of when the test ended. */ get endTime(): number | undefined { return this._endTime; } /** * The latest WebRTC sample collected. */ get latestSample(): RTCSample | undefined { return this._latestSample; } /** * The report for this test. */ get report(): PreflightTest.Report | undefined { return this._report; } /** * A timestamp in milliseconds of when the test started. */ get startTime(): number { return this._startTime; } /** * The status of the test. */ get status(): PreflightTest.Status { return this._status; } } /** * @mergeModuleWith PreflightTest */ export namespace PreflightTest { /** * The quality of the call determined by different mos ranges. * Mos is calculated base on the WebRTC stats - rtt, jitter, and packet lost. */ export enum CallQuality { /** * If the average mos is over 4.2. */ Excellent = 'excellent', /** * If the average mos is between 4.1 and 4.2 both inclusive. */ Great = 'great', /** * If the average mos is between 3.7 and 4.0 both inclusive. */ Good = 'good', /** * If the average mos is between 3.1 and 3.6 both inclusive. */ Fair = 'fair', /** * If the average mos is 3.0 or below. */ Degraded = 'degraded', } /** * Possible events that a [[PreflightTest]] might emit. */ export enum Events { /** * See [[PreflightTest.completedEvent]] */ Completed = 'completed', /** * See [[PreflightTest.connectedEvent]] */ Connected = 'connected', /** * See [[PreflightTest.failedEvent]] */ Failed = 'failed', /** * See [[PreflightTest.sampleEvent]] */ Sample = 'sample', /** * See [[PreflightTest.warningEvent]] */ Warning = 'warning', } /** * Possible status of the test. */ export enum Status { /** * Call to Twilio has initiated. */ Connecting = 'connecting', /** * Call to Twilio has been established. */ Connected = 'connected', /** * The connection to Twilio has been disconnected and the test call has completed. */ Completed = 'completed', /** * The test has stopped and failed. */ Failed = 'failed', } /** * The WebRTC API's [RTCIceCandidateStats](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidateStats) * dictionary which provides information related to an ICE candidate. */ export type RTCIceCandidateStats = any; /** * Options that may be passed to {@link PreflightTest} constructor for internal testing. * @internal */ export interface ExtendedOptions extends Options { /** * The AudioContext instance to use */ audioContext?: AudioContext; /** * A string or array of strings representing the URI of the signaling * gateway to connect to. */ chunderw?: string | string[]; /** * Device class to use. */ deviceFactory?: typeof Device; /** * A string representing the URI of the insights gateway to connect to. */ eventgw?: string; /** * File input stream to use instead of reading from mic */ fileInputStream?: MediaStream; /** * The getRTCIceCandidateStatsReport to use for testing. */ getRTCIceCandidateStatsReport?: Function; /** * An RTCConfiguration to pass to the RTCPeerConnection constructor. */ rtcConfiguration?: RTCConfiguration; } /** * A WebRTC stats report containing relevant information about selected and gathered ICE candidates */ export interface RTCIceCandidateStatsReport { /** * An array of WebRTC stats for the ICE candidates gathered when connecting to media. */ iceCandidateStats: RTCIceCandidateStats[]; /** * A WebRTC stats for the ICE candidate pair used to connect to media, if candidates were selected. */ selectedIceCandidatePairStats?: RTCSelectedIceCandidatePairStats; } /** * Options passed to {@link PreflightTest} constructor. */ export interface Options { /** * An ordered array of codec names that will be used during the test call, * from most to least preferred. * @default ['pcmu','opus'] */ codecPreferences?: Call.Codec[]; /** * Specifies which Twilio Data Center to use when initiating the test call. * Please see this * [page](https://www.twilio.com/docs/voice/client/edges) * for the list of available edges. * @default roaming */ edge?: string; /** * If set to `true`, the test call will ignore microphone input and will use a default audio file. * If set to `false`, the test call will capture the audio from the microphone. * Setting this to `true` is only supported on Chrome and will throw a fatal error on other browsers * @default false */ fakeMicInput?: boolean; /** * An array of custom ICE servers to use to connect media. If you provide both STUN and TURN server configurations, * the test will detect whether a TURN server is required to establish a connection. * * The following example demonstrates how to use [Twilio's Network Traversal Service](https://www.twilio.com/stun-turn) * to generate STUN/TURN credentials and how to specify a specific [edge location](https://www.twilio.com/docs/global-infrastructure/edge-locations). * * ```ts * import Client from 'twilio'; * import { Device } from '@twilio/voice-sdk'; * * // Generate the STUN and TURN server credentials with a ttl of 120 seconds * const client = Client(twilioAccountSid, authToken); * const token = await client.tokens.create({ ttl: 120 }); * * let iceServers = token.iceServers; * * // By default, global will be used as the default edge location. * // You can replace global with a specific edge name for each of the iceServer configuration. * iceServers = iceServers.map(config => { * let { url, urls, ...rest } = config; * url = url.replace('global', 'ashburn'); * urls = urls.replace('global', 'ashburn'); * * return { url, urls, ...rest }; * }); * * // Use the TURN credentials using the iceServers parameter * const preflightTest = Device.runPreflight(token, { iceServers }); * * // Read from the report object to determine whether TURN is required to connect to media * preflightTest.on('completed', (report) => { * console.log(report.isTurnRequired); * }); * ``` * * @default null */ iceServers?: RTCIceServer[]; /** * Log level to use in the Device. * @default 'error' */ logLevel?: string; /** * Amount of time to wait for setting up signaling connection. * @default 10000 */ signalingTimeoutMs?: number; } /** * Represents the WebRTC stats for the ICE candidate pair used to connect to media, if candidates were selected. */ export interface RTCSelectedIceCandidatePairStats { /** * An [RTCIceCandidateStats](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidateStats) * object which provides information related to the selected local ICE candidate. */ localCandidate: RTCIceCandidateStats; /** * An [RTCIceCandidateStats](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidateStats) * object which provides information related to the selected remote ICE candidate. */ remoteCandidate: RTCIceCandidateStats; } /** * Represents RTC related stats that are extracted from RTC samples. */ export interface RTCStats { /** * Packets delay variation. */ jitter: Stats; /** * Mean opinion score, 1.0 through roughly 4.5. */ mos: Stats; /** * Round trip time, to the server back to the client. */ rtt: Stats; } /** * Represents general stats for a specific metric. */ export interface Stats { /** * The average value for this metric. */ average: number; /** * The maximum value for this metric. */ max: number; /** * The minimum value for this metric. */ min: number; } /** * Represents the report generated from a {@link PreflightTest}. */ export interface Report { /** * The quality of the call determined by different mos ranges. */ callQuality?: CallQuality; /** * CallSid generaged during the test. */ callSid: string | undefined; /** * The edge that the test call was connected to. */ edge?: string; /** * An array of WebRTC stats for the ICE candidates gathered when connecting to media. */ iceCandidateStats: RTCIceCandidateStats[]; /** * Whether a TURN server is required to connect to media. * This is dependent on the selected ICE candidates, and will be true if either is of type "relay", * false if both are of another type, or undefined if there are no selected ICE candidates. * See `PreflightTest.Options.iceServers` for more details. */ isTurnRequired?: boolean; /** * Network related time measurements. */ networkTiming: NetworkTiming; /** * WebRTC samples collected during the test. */ samples: RTCSample[]; /** * The edge passed to `Device.runPreflight`. */ selectedEdge?: string; /** * A WebRTC stats for the ICE candidate pair used to connect to media, if candidates were selected. */ selectedIceCandidatePairStats?: RTCSelectedIceCandidatePairStats; /** * RTC related stats captured during the test. */ stats?: RTCStats; /** * Time measurements of test run time. */ testTiming: TimeMeasurement; /** * Calculated totals in RTC statistics samples. */ totals?: RTCSampleTotals; /** * List of warning names and warning data detected during this test. */ warnings: PreflightTest.Warning[]; } /** * A warning that can be raised by Preflight, and returned in the Report.warnings field. */ export interface Warning { /** * Description of the Warning */ description: string; /** * Name of the Warning */ name: string; /** * If applicable, the RTCWarning that triggered this warning. */ rtcWarning?: RTCWarning; } }