@twilio/voice-sdk
Version:
Twilio's JavaScript Voice SDK
1,505 lines (1,281 loc) • 67.3 kB
text/typescript
import { EventEmitter } from 'events';
import Backoff from './backoff';
import Device from './device';
import DialtonePlayer from './dialtonePlayer';
import {
GeneralErrors,
getPreciseSignalingErrorByCode,
InvalidArgumentError,
InvalidStateError,
MediaErrors,
SignalingErrors,
TwilioError,
UserMediaErrors,
} from './errors';
import Log from './log';
import { PeerConnection } from './rtc';
import { IceCandidate, RTCIceCandidate } from './rtc/icecandidate';
import RTCSample from './rtc/sample';
import { getPreferredCodecInfo } from './rtc/sdp';
import RTCWarning from './rtc/warning';
import { generateVoiceEventSid } from './sid';
import StatsMonitor from './statsMonitor';
import { isChrome } from './util';
import { RELEASE_VERSION } from './constants';
// Placeholders until we convert the respective files to TypeScript.
export type IAudioHelper = any;
export type IPStream = any;
export type IPeerConnection = any;
export type IPublisher = any;
export type ISound = any;
const BACKOFF_CONFIG = {
factor: 1.1,
jitter: 0.5,
max: 30000,
min: 1,
};
const DTMF_INTER_TONE_GAP: number = 70;
const DTMF_PAUSE_DURATION: number = 500;
const DTMF_TONE_DURATION: number = 160;
const METRICS_BATCH_SIZE: number = 10;
const METRICS_DELAY: number = 5000;
const MEDIA_DISCONNECT_ERROR = {
disconnect: true,
info: {
code: 31003,
message: 'Connection with Twilio was interrupted.',
twilioError: new MediaErrors.ConnectionError(),
},
};
const MULTIPLE_THRESHOLD_WARNING_NAMES: Record<string, Record<string, string>> = {
// The stat `packetsLostFraction` is monitored by two separate thresholds,
// `maxAverage` and `max`. Each threshold emits a different warning name.
packetsLostFraction: {
max: 'packet-loss',
maxAverage: 'packets-lost-fraction',
},
};
const WARNING_NAMES: Record<string, string> = {
audioInputLevel: 'audio-input-level',
audioOutputLevel: 'audio-output-level',
bytesReceived: 'bytes-received',
bytesSent: 'bytes-sent',
jitter: 'jitter',
mos: 'mos',
rtt: 'rtt',
};
const WARNING_PREFIXES: Record<string, string> = {
max: 'high-',
maxAverage: 'high-',
maxDuration: 'constant-',
min: 'low-',
minStandardDeviation: 'constant-',
};
/**
* A {@link Call} represents a media and signaling connection to a TwiML application.
*/
class Call extends EventEmitter {
/**
* String representation of the {@link Call} class.
*/
static toString = () => '[Twilio.Call class]';
/**
* Returns caller verification information about the caller.
* If no caller verification information is available this will return null.
*/
readonly callerInfo: Call.CallerInfo | null;
/**
* The custom parameters sent to (outgoing) or received by (incoming) the TwiML app.
*/
readonly customParameters: Map<string, string>;
/**
* Whether this {@link Call} is incoming or outgoing.
*/
get direction(): Call.CallDirection {
return this._direction;
}
/**
* Audio codec used for this {@link Call}. Expecting {@link Call.Codec} but
* will copy whatever we get from RTC stats.
*/
get codec(): string {
return this._codec;
}
/**
* The connect token is available as soon as the call is established
* and connected to Twilio. Use this token to reconnect to a call via the {@link Device.connect}
* method.
*
* For incoming calls, it is available in the call object after the {@link Device.incomingEvent} is emitted.
* For outgoing calls, it is available after the {@link Call.acceptEvent} is emitted.
*/
get connectToken(): string | undefined {
const signalingReconnectToken = this._signalingReconnectToken;
const callSid = this.parameters && this.parameters.CallSid ? this.parameters.CallSid : undefined;
if (!signalingReconnectToken || !callSid) {
return;
}
const customParameters = this.customParameters && typeof this.customParameters.keys === 'function' ?
Array.from(this.customParameters.keys()).reduce((result: Record<string, string>, key: string) => {
result[key] = this.customParameters.get(key)!;
return result;
}, {}) : {};
const parameters = this.parameters || {};
return btoa(encodeURIComponent(JSON.stringify({
customParameters,
parameters,
signalingReconnectToken,
})));
}
/**
* The temporary CallSid for this call, if it's outbound.
*/
readonly outboundConnectionId?: string;
/**
* Call parameters received from Twilio for an incoming call.
*/
parameters: Record<string, string> = { };
/**
* Audio codec used for this {@link Call}. Expecting {@link Call.Codec} but
* will copy whatever we get from RTC stats.
*/
private _codec: string;
/**
* Whether this {@link Call} is incoming or outgoing.
*/
private readonly _direction: Call.CallDirection;
/**
* The number of times input volume has been the same consecutively.
*/
private _inputVolumeStreak: number = 0;
/**
* Whether the call has been answered.
*/
private _isAnswered: boolean = false;
/**
* Whether the call has been cancelled.
*/
private _isCancelled: boolean = false;
/**
* Whether the call has been rejected
*/
private _isRejected: boolean = false;
/**
* Whether or not the browser uses unified-plan SDP by default.
*/
private readonly _isUnifiedPlanDefault: boolean | undefined;
/**
* The most recent public input volume value. 0 -> 1 representing -100 to -30 dB.
*/
private _latestInputVolume: number = 0;
/**
* The most recent public output volume value. 0 -> 1 representing -100 to -30 dB.
*/
private _latestOutputVolume: number = 0;
/**
* An instance of Logger to use.
*/
private _log: Log = new Log('Call');
/**
* The MediaHandler (Twilio PeerConnection) this {@link Call} is using for
* media signaling.
*/
private _mediaHandler: IPeerConnection;
/**
* An instance of Backoff for media reconnection
*/
private _mediaReconnectBackoff: any;
/**
* Timestamp for the initial media reconnection
*/
private _mediaReconnectStartTime: number;
/**
* State of the {@link Call}'s media.
*/
private _mediaStatus: Call.State = Call.State.Pending;
/**
* A map of messages sent via sendMessage API using voiceEventSid as the key.
* The message will be deleted once an 'ack' or an error is received from the server.
*/
private _messages: Map<string, Call.Message> = new Map();
/**
* A batch of metrics samples to send to Insights. Gets cleared after
* each send and appended to on each new sample.
*/
private readonly _metricsSamples: Call.CallMetrics[] = [];
/**
* An instance of StatsMonitor.
*/
private readonly _monitor: StatsMonitor;
/**
* Method to be run after {@link Call.ignore} is called.
*/
private _onIgnore: () => void;
/**
* Options passed to this {@link Call}.
*/
private _options: Call.Options = {
MediaHandler: PeerConnection,
MediaStream: null,
enableImprovedSignalingErrorPrecision: false,
offerSdp: null,
shouldPlayDisconnect: () => true,
voiceEventSidGenerator: generateVoiceEventSid,
};
/**
* The number of times output volume has been the same consecutively.
*/
private _outputVolumeStreak: number = 0;
/**
* The PStream instance to use for Twilio call signaling.
*/
private readonly _pstream: IPStream;
/**
* An instance of EventPublisher.
*/
private readonly _publisher: IPublisher;
/**
* Whether the {@link Call} should send a hangup on disconnect.
*/
private _shouldSendHangup: boolean = true;
/**
* The signaling reconnection token used to re-establish a lost signaling connection.
*/
private _signalingReconnectToken: string | undefined;
/**
* State of the {@link Call}'s signaling.
*/
private _signalingStatus: Call.State = Call.State.Pending;
/**
* A Map of Sounds to play.
*/
private readonly _soundcache: Map<Device.SoundName, ISound> = new Map();
/**
* State of the {@link Call}.
*/
private _status: Call.State = Call.State.Pending;
/**
* Voice event SID generator, creates a unique voice event SID.
*/
private _voiceEventSidGenerator: () => string;
/**
* Whether the {@link Call} has been connected. Used to determine if we are reconnected.
*/
private _wasConnected: boolean = false;
/**
* @internal
* @param config - Mandatory configuration options
* @param options - Optional settings
*/
constructor(config: Call.Config, options?: Call.Options) {
super();
this._isUnifiedPlanDefault = config.isUnifiedPlanDefault;
this._soundcache = config.soundcache;
if (typeof config.onIgnore === 'function') {
this._onIgnore = config.onIgnore;
}
const message = options && options.twimlParams || { };
this.customParameters = new Map(
Object.entries(message).map(([key, val]: [string, any]): [string, string] => [key, String(val)]));
Object.assign(this._options, options);
if (this._options.callParameters) {
this.parameters = this._options.callParameters;
}
if (this._options.reconnectToken) {
this._signalingReconnectToken = this._options.reconnectToken;
}
this._voiceEventSidGenerator =
this._options.voiceEventSidGenerator || generateVoiceEventSid;
this._direction = this.parameters.CallSid && !this._options.reconnectCallSid ?
Call.CallDirection.Incoming : Call.CallDirection.Outgoing;
if (this.parameters) {
this.callerInfo = this.parameters.StirStatus
? { isVerified: this.parameters.StirStatus === 'TN-Validation-Passed-A' }
: null;
} else {
this.callerInfo = null;
}
this._mediaReconnectBackoff = new Backoff(BACKOFF_CONFIG);
this._mediaReconnectBackoff.on('ready', () => this._mediaHandler.iceRestart());
// temporary call sid to be used for outgoing calls
this.outboundConnectionId = generateTempCallSid();
const publisher = this._publisher = config.publisher;
if (this._direction === Call.CallDirection.Incoming) {
publisher.info('connection', 'incoming', null, this);
} else {
publisher.info('connection', 'outgoing', {
preflight: this._options.preflight,
reconnect: !!this._options.reconnectCallSid,
}, this);
}
const monitor = this._monitor = new (this._options.StatsMonitor || StatsMonitor)();
monitor.on('sample', this._onRTCSample);
// First 20 seconds or so are choppy, so let's not bother with these warnings.
monitor.disableWarnings();
setTimeout(() => monitor.enableWarnings(), METRICS_DELAY);
monitor.on('warning', (data: RTCWarning, wasCleared?: boolean) => {
if (data.name === 'bytesSent' || data.name === 'bytesReceived') {
this._onMediaFailure(Call.MediaFailure.LowBytes);
}
this._reemitWarning(data, wasCleared);
});
monitor.on('warning-cleared', (data: RTCWarning) => {
this._reemitWarningCleared(data);
});
this._mediaHandler = new (this._options.MediaHandler)
(config.audioHelper, config.pstream, {
MediaStream: this._options.MediaStream,
RTCPeerConnection: this._options.RTCPeerConnection,
codecPreferences: this._options.codecPreferences,
dscp: this._options.dscp,
forceAggressiveIceNomination: this._options.forceAggressiveIceNomination,
isUnifiedPlan: this._isUnifiedPlanDefault,
maxAverageBitrate: this._options.maxAverageBitrate,
});
this.on('volume', (inputVolume: number, outputVolume: number): void => {
this._inputVolumeStreak = this._checkVolume(
inputVolume, this._inputVolumeStreak, this._latestInputVolume, 'input');
this._outputVolumeStreak = this._checkVolume(
outputVolume, this._outputVolumeStreak, this._latestOutputVolume, 'output');
this._latestInputVolume = inputVolume;
this._latestOutputVolume = outputVolume;
});
this._mediaHandler.onaudio = (remoteAudio: typeof Audio) => {
this._log.debug('#audio');
this.emit('audio', remoteAudio);
};
this._mediaHandler.onvolume = (inputVolume: number, outputVolume: number,
internalInputVolume: number, internalOutputVolume: number) => {
// (rrowland) These values mock the 0 -> 32767 format used by legacy getStats. We should look into
// migrating to a newer standard, either 0.0 -> linear or -127 to 0 in dB, matching the range
// chosen below.
monitor.addVolumes((internalInputVolume / 255) * 32767, (internalOutputVolume / 255) * 32767);
// (rrowland) 0.0 -> 1.0 linear
this.emit('volume', inputVolume, outputVolume);
};
this._mediaHandler.ondtlstransportstatechange = (state: string): void => {
const level = state === 'failed' ? 'error' : 'debug';
this._publisher.post(level, 'dtls-transport-state', state, null, this);
};
this._mediaHandler.onpcconnectionstatechange = (state: string): void => {
let level = 'debug';
const dtlsTransport = this._mediaHandler.getRTCDtlsTransport();
if (state === 'failed') {
level = dtlsTransport && dtlsTransport.state === 'failed' ? 'error' : 'warning';
}
this._publisher.post(level, 'pc-connection-state', state, null, this);
};
this._mediaHandler.onicecandidate = (candidate: RTCIceCandidate): void => {
const payload = new IceCandidate(candidate).toPayload();
this._publisher.debug('ice-candidate', 'ice-candidate', payload, this);
};
this._mediaHandler.onselectedcandidatepairchange = (pair: RTCIceCandidatePair): void => {
const localCandidatePayload = new IceCandidate(pair.local).toPayload();
const remoteCandidatePayload = new IceCandidate(pair.remote, true).toPayload();
this._publisher.debug('ice-candidate', 'selected-ice-candidate-pair', {
local_candidate: localCandidatePayload,
remote_candidate: remoteCandidatePayload,
}, this);
};
this._mediaHandler.oniceconnectionstatechange = (state: string): void => {
const level = state === 'failed' ? 'error' : 'debug';
this._publisher.post(level, 'ice-connection-state', state, null, this);
};
this._mediaHandler.onicegatheringfailure = (type: Call.IceGatheringFailureReason): void => {
this._publisher.warn('ice-gathering-state', type, null, this);
this._onMediaFailure(Call.MediaFailure.IceGatheringFailed);
};
this._mediaHandler.onicegatheringstatechange = (state: string): void => {
this._publisher.debug('ice-gathering-state', state, null, this);
};
this._mediaHandler.onsignalingstatechange = (state: string): void => {
this._publisher.debug('signaling-state', state, null, this);
};
this._mediaHandler.ondisconnected = (msg: string): void => {
this._log.warn(msg);
this._publisher.warn('network-quality-warning-raised', 'ice-connectivity-lost', {
message: msg,
}, this);
this._log.debug('#warning', 'ice-connectivity-lost');
this.emit('warning', 'ice-connectivity-lost');
this._onMediaFailure(Call.MediaFailure.ConnectionDisconnected);
};
this._mediaHandler.onfailed = (msg: string): void => {
this._onMediaFailure(Call.MediaFailure.ConnectionFailed);
};
this._mediaHandler.onconnected = (): void => {
// First time _mediaHandler is connected, but ICE Gathering issued an ICE restart and succeeded.
if (this._status === Call.State.Reconnecting) {
this._onMediaReconnected();
}
};
this._mediaHandler.onreconnected = (msg: string): void => {
this._log.info(msg);
this._publisher.info('network-quality-warning-cleared', 'ice-connectivity-lost', {
message: msg,
}, this);
this._log.debug('#warning-cleared', 'ice-connectivity-lost');
this.emit('warning-cleared', 'ice-connectivity-lost');
this._onMediaReconnected();
};
this._mediaHandler.onerror = (e: any): void => {
if (e.disconnect === true) {
this._disconnect(e.info && e.info.message);
}
const error = e.info.twilioError || new GeneralErrors.UnknownError(e.info.message);
this._log.error('Received an error from MediaStream:', e);
this._log.debug('#error', error);
this.emit('error', error);
};
this._mediaHandler.onopen = () => {
// NOTE(mroberts): While this may have been happening in previous
// versions of Chrome, since Chrome 45 we have seen the
// PeerConnection's onsignalingstatechange handler invoked multiple
// times in the same signalingState 'stable'. When this happens, we
// invoke this onopen function. If we invoke it twice without checking
// for _status 'open', we'd accidentally close the PeerConnection.
//
// See <https://code.google.com/p/webrtc/issues/detail?id=4996>.
if (this._status === Call.State.Open || this._status === Call.State.Reconnecting) {
return;
} else if (this._status === Call.State.Ringing || this._status === Call.State.Connecting) {
this.mute(this._mediaHandler.isMuted);
this._mediaStatus = Call.State.Open;
this._maybeTransitionToOpen();
} else {
// call was probably canceled sometime before this
this._mediaHandler.close();
}
};
this._mediaHandler.onclose = () => {
this._status = Call.State.Closed;
if (this._options.shouldPlayDisconnect && this._options.shouldPlayDisconnect()
// Don't play disconnect sound if this was from a cancel event. i.e. the call
// was ignored or hung up even before it was answered.
// Similarly, don't play disconnect sound if the call was rejected.
&& !this._isCancelled && !this._isRejected) {
this._soundcache.get(Device.SoundName.Disconnect).play();
}
monitor.disable();
this._publishMetrics();
if (!this._isCancelled && !this._isRejected) {
// tslint:disable no-console
this._log.debug('#disconnect');
this.emit('disconnect', this);
}
};
this._pstream = config.pstream;
this._pstream.on('ack', this._onAck);
this._pstream.on('cancel', this._onCancel);
this._pstream.on('error', this._onSignalingError);
this._pstream.on('ringing', this._onRinging);
this._pstream.on('transportClose', this._onTransportClose);
this._pstream.on('connected', this._onConnected);
this._pstream.on('message', this._onMessageReceived);
this.on('error', error => {
this._publisher.error('connection', 'error', {
code: error.code, message: error.message,
}, this);
if (this._pstream && this._pstream.status === 'disconnected') {
this._cleanupEventListeners();
}
});
this.on('disconnect', () => {
this._cleanupEventListeners();
});
}
/**
* Set the audio input tracks from a given stream.
* @internal
* @param stream
*/
_setInputTracksFromStream(stream: MediaStream | null): Promise<void> {
return this._mediaHandler.setInputTracksFromStream(stream);
}
/**
* Set the audio output sink IDs.
* @internal
* @param sinkIds
*/
_setSinkIds(sinkIds: string[]): Promise<void> {
return this._mediaHandler._setSinkIds(sinkIds);
}
/**
* Accept the incoming {@link Call}.
* @param [options]
*/
accept(options?: Call.AcceptOptions): void {
this._log.debug('.accept', options);
if (this._status !== Call.State.Pending) {
this._log.debug(`.accept noop. status is '${this._status}'`);
return;
}
options = options || { };
const rtcConfiguration = options.rtcConfiguration || this._options.rtcConfiguration;
const rtcConstraints = options.rtcConstraints || this._options.rtcConstraints || { };
const audioConstraints = {
audio: typeof rtcConstraints.audio !== 'undefined' ? rtcConstraints.audio : true,
};
this._status = Call.State.Connecting;
const connect = () => {
if (this._status !== Call.State.Connecting) {
// call must have been canceled
this._cleanupEventListeners();
this._mediaHandler.close();
return;
}
const onAnswer = (pc: RTCPeerConnection) => {
// Report that the call was answered, and directionality
const eventName = this._direction === Call.CallDirection.Incoming
? 'accepted-by-local'
: 'accepted-by-remote';
this._publisher.info('connection', eventName, null, this);
// Report the preferred codec and params as they appear in the SDP
const { codecName, codecParams } = getPreferredCodecInfo(this._mediaHandler.version.getSDP());
this._publisher.info('settings', 'codec', {
codec_params: codecParams,
selected_codec: codecName,
}, this);
// Enable RTC monitoring
this._monitor.enable(pc);
};
const sinkIds = typeof this._options.getSinkIds === 'function' && this._options.getSinkIds();
if (Array.isArray(sinkIds)) {
this._mediaHandler._setSinkIds(sinkIds).catch(() => {
// (rrowland) We don't want this to throw to console since the customer
// can't control this. This will most commonly be rejected on browsers
// that don't support setting sink IDs.
});
}
this._pstream.addListener('hangup', this._onHangup);
if (this._direction === Call.CallDirection.Incoming) {
this._isAnswered = true;
this._pstream.on('answer', this._onAnswer);
this._mediaHandler.answerIncomingCall(this.parameters.CallSid,
this._options.offerSdp, rtcConfiguration, onAnswer);
} else {
const params = Array.from(this.customParameters.entries()).map(pair =>
`${encodeURIComponent(pair[0])}=${encodeURIComponent(pair[1])}`).join('&');
this._pstream.on('answer', this._onAnswer);
this._mediaHandler.makeOutgoingCall(params, this._signalingReconnectToken,
this._options.reconnectCallSid || this.outboundConnectionId, rtcConfiguration, onAnswer);
}
};
if (this._options.beforeAccept) {
this._options.beforeAccept(this);
}
const inputStream = typeof this._options.getInputStream === 'function' && this._options.getInputStream();
const promise = inputStream
? this._mediaHandler.setInputTracksFromStream(inputStream)
: this._mediaHandler.openDefaultDeviceWithConstraints(audioConstraints);
promise.then(() => {
this._publisher.info('get-user-media', 'succeeded', {
data: { audioConstraints },
}, this);
connect();
}, (error: Record<string, any>) => {
let twilioError;
if (error.code === 31208
|| ['PermissionDeniedError', 'NotAllowedError'].indexOf(error.name) !== -1) {
twilioError = new UserMediaErrors.PermissionDeniedError();
this._publisher.error('get-user-media', 'denied', {
data: {
audioConstraints,
error,
},
}, this);
} else {
twilioError = new UserMediaErrors.AcquisitionFailedError();
this._publisher.error('get-user-media', 'failed', {
data: {
audioConstraints,
error,
},
}, this);
}
this._disconnect();
this._log.debug('#error', error);
this.emit('error', twilioError);
});
}
/**
* Disconnect from the {@link Call}.
*/
disconnect(): void {
this._log.debug('.disconnect');
this._disconnect();
}
/**
* Get the local MediaStream, if set.
*/
getLocalStream(): MediaStream | undefined {
return this._mediaHandler && this._mediaHandler.stream;
}
/**
* Get the remote MediaStream, if set.
*/
getRemoteStream(): MediaStream | undefined {
return this._mediaHandler && this._mediaHandler._remoteStream;
}
/**
* Ignore the incoming {@link Call}.
*/
ignore(): void {
this._log.debug('.ignore');
if (this._status !== Call.State.Pending) {
this._log.debug(`.ignore noop. status is '${this._status}'`);
return;
}
this._status = Call.State.Closed;
this._mediaHandler.ignore(this.parameters.CallSid);
this._publisher.info('connection', 'ignored-by-local', null, this);
if (this._onIgnore) {
this._onIgnore();
}
}
/**
* Check whether call is muted
*/
isMuted(): boolean {
return this._mediaHandler.isMuted;
}
/**
* Mute incoming audio.
* @param shouldMute - Whether the incoming audio should be muted. Defaults to true.
*/
mute(shouldMute: boolean = true): void {
this._log.debug('.mute', shouldMute);
const wasMuted = this._mediaHandler.isMuted;
this._mediaHandler.mute(shouldMute);
const isMuted = this._mediaHandler.isMuted;
if (wasMuted !== isMuted) {
this._publisher.info('connection', isMuted ? 'muted' : 'unmuted', null, this);
this._log.debug('#mute', isMuted);
this.emit('mute', isMuted, this);
}
}
/**
* Post an event to Endpoint Analytics indicating that the end user
* has given call quality feedback. Called without a score, this
* will report that the customer declined to give feedback.
* @param score - The end-user's rating of the call; an
* integer 1 through 5. Or undefined if the user declined to give
* feedback.
* @param issue - The primary issue the end user
* experienced on the call. Can be: ['one-way-audio', 'choppy-audio',
* 'dropped-call', 'audio-latency', 'noisy-call', 'echo']
*/
postFeedback(score?: Call.FeedbackScore, issue?: Call.FeedbackIssue): Promise<void> {
if (typeof score === 'undefined' || score === null) {
return this._postFeedbackDeclined();
}
if (!Object.values(Call.FeedbackScore).includes(score)) {
throw new InvalidArgumentError(`Feedback score must be one of: ${Object.values(Call.FeedbackScore)}`);
}
if (typeof issue !== 'undefined' && issue !== null && !Object.values(Call.FeedbackIssue).includes(issue)) {
throw new InvalidArgumentError(`Feedback issue must be one of: ${Object.values(Call.FeedbackIssue)}`);
}
return this._publisher.info('feedback', 'received', {
issue_name: issue,
quality_score: score,
}, this, true);
}
/**
* Reject the incoming {@link Call}.
*/
reject(): void {
this._log.debug('.reject');
if (this._status !== Call.State.Pending) {
this._log.debug(`.reject noop. status is '${this._status}'`);
return;
}
this._isRejected = true;
this._pstream.reject(this.parameters.CallSid);
this._mediaHandler.reject(this.parameters.CallSid);
this._publisher.info('connection', 'rejected-by-local', null, this);
this._cleanupEventListeners();
this._mediaHandler.close();
this._status = Call.State.Closed;
this._log.debug('#reject');
this.emit('reject');
}
/**
* Send a string of digits.
* @param digits
*/
sendDigits(digits: string): void {
this._log.debug('.sendDigits', digits);
if (digits.match(/[^0-9*#w]/)) {
throw new InvalidArgumentError('Illegal character passed into sendDigits');
}
const customSounds = this._options.customSounds || {};
const sequence: string[] = [];
digits.split('').forEach((digit: string) => {
let dtmf = (digit !== 'w') ? `dtmf${digit}` : '';
if (dtmf === 'dtmf*') { dtmf = 'dtmfs'; }
if (dtmf === 'dtmf#') { dtmf = 'dtmfh'; }
sequence.push(dtmf);
});
const playNextDigit = () => {
const digit = sequence.shift() as Device.SoundName | undefined;
if (digit) {
if (this._options.dialtonePlayer && !customSounds[digit]) {
this._options.dialtonePlayer.play(digit);
} else {
this._soundcache.get(digit).play();
}
}
if (sequence.length) {
setTimeout(() => playNextDigit(), 200);
}
};
playNextDigit();
const dtmfSender = this._mediaHandler.getOrCreateDTMFSender();
function insertDTMF(dtmfs: string[]) {
if (!dtmfs.length) { return; }
const dtmf: string | undefined = dtmfs.shift();
if (dtmf && dtmf.length) {
dtmfSender.insertDTMF(dtmf, DTMF_TONE_DURATION, DTMF_INTER_TONE_GAP);
}
setTimeout(insertDTMF.bind(null, dtmfs), DTMF_PAUSE_DURATION);
}
if (dtmfSender) {
if (!('canInsertDTMF' in dtmfSender) || dtmfSender.canInsertDTMF) {
this._log.info('Sending digits using RTCDTMFSender');
// NOTE(mroberts): We can't just map 'w' to ',' since
// RTCDTMFSender's pause duration is 2 s and Twilio's is more
// like 500 ms. Instead, we will fudge it with setTimeout.
insertDTMF(digits.split('w'));
return;
}
this._log.info('RTCDTMFSender cannot insert DTMF');
}
// send pstream message to send DTMF
this._log.info('Sending digits over PStream');
if (this._pstream !== null && this._pstream.status !== 'disconnected') {
this._pstream.dtmf(this.parameters.CallSid, digits);
} else {
const error = new GeneralErrors.ConnectionError('Could not send DTMF: Signaling channel is disconnected');
this._log.debug('#error', error);
this.emit('error', error);
}
}
/**
* Send a message to Twilio. Your backend application can listen for these
* messages to allow communication between your frontend and backend applications.
* <br/><br/>This feature is currently in Beta.
* @param message - The message object to send.
* @returns A voice event sid that uniquely identifies the message that was sent.
*/
sendMessage(message: Call.Message): string {
this._log.debug('.sendMessage', JSON.stringify(message));
const { content, contentType, messageType } = message;
if (typeof content === 'undefined' || content === null) {
throw new InvalidArgumentError('`content` is empty');
}
if (typeof messageType !== 'string') {
throw new InvalidArgumentError(
'`messageType` must be a string.',
);
}
if (messageType.length === 0) {
throw new InvalidArgumentError(
'`messageType` must be a non-empty string.',
);
}
if (this._pstream === null) {
throw new InvalidStateError(
'Could not send CallMessage; Signaling channel is disconnected',
);
}
const callSid = this.parameters.CallSid;
if (typeof this.parameters.CallSid === 'undefined') {
throw new InvalidStateError(
'Could not send CallMessage; Call has no CallSid',
);
}
const voiceEventSid = this._voiceEventSidGenerator();
this._messages.set(voiceEventSid, { content, contentType, messageType, voiceEventSid });
this._pstream.sendMessage(callSid, content, contentType, messageType, voiceEventSid);
return voiceEventSid;
}
/**
* Get the current {@link Call} status.
*/
status(): Call.State {
return this._status;
}
/**
* String representation of {@link Call} instance.
* @internal
*/
toString = () => '[Twilio.Call instance]';
/**
* Check the volume passed, emitting a warning if one way audio is detected or cleared.
* @param currentVolume - The current volume for this direction
* @param streakFieldName - The name of the field on the {@link Call} object that tracks how many times the
* current value has been repeated consecutively.
* @param lastValueFieldName - The name of the field on the {@link Call} object that tracks the most recent
* volume for this direction
* @param direction - The directionality of this audio track, either 'input' or 'output'
* @returns The current streak; how many times in a row the same value has been polled.
*/
private _checkVolume(currentVolume: number, currentStreak: number,
lastValue: number, direction: 'input'|'output'): number {
const wasWarningRaised: boolean = currentStreak >= 10;
let newStreak: number = 0;
if (lastValue === currentVolume) {
newStreak = currentStreak;
}
if (newStreak >= 10) {
this._emitWarning('audio-level-', `constant-audio-${direction}-level`, 10, newStreak, false);
} else if (wasWarningRaised) {
this._emitWarning('audio-level-', `constant-audio-${direction}-level`, 10, newStreak, true);
}
return newStreak;
}
/**
* Clean up event listeners.
*/
private _cleanupEventListeners(): void {
const cleanup = () => {
if (!this._pstream) { return; }
this._pstream.removeListener('ack', this._onAck);
this._pstream.removeListener('answer', this._onAnswer);
this._pstream.removeListener('cancel', this._onCancel);
this._pstream.removeListener('error', this._onSignalingError);
this._pstream.removeListener('hangup', this._onHangup);
this._pstream.removeListener('ringing', this._onRinging);
this._pstream.removeListener('transportClose', this._onTransportClose);
this._pstream.removeListener('connected', this._onConnected);
this._pstream.removeListener('message', this._onMessageReceived);
};
// This is kind of a hack, but it lets us avoid rewriting more code.
// Basically, there's a sequencing problem with the way PeerConnection raises
// the
//
// Cannot establish call. SDK is disconnected
//
// error in Call#accept. It calls PeerConnection#onerror, which emits
// the error event on Call. An error handler on Call then calls
// cleanupEventListeners, but then control returns to Call#accept. It's
// at this point that we add a listener for the answer event that never gets
// removed. setTimeout will allow us to rerun cleanup again, _after_
// Call#accept returns.
cleanup();
setTimeout(cleanup, 0);
}
/**
* Create the payload wrapper for a batch of metrics to be sent to Insights.
*/
private _createMetricPayload(): Partial<Record<string, string|boolean>> {
const payload: Partial<Record<string, string|boolean>> = {
call_sid: this.parameters.CallSid,
dscp: !!this._options.dscp,
sdk_version: RELEASE_VERSION,
};
if (this._options.gateway) {
payload.gateway = this._options.gateway;
}
payload.direction = this._direction;
return payload;
}
/**
* Disconnect the {@link Call}.
* @param message - A message explaining why the {@link Call} is being disconnected.
* @param wasRemote - Whether the disconnect was triggered locally or remotely.
*/
private _disconnect(message?: string | null, wasRemote?: boolean): void {
message = typeof message === 'string' ? message : null;
if (this._status !== Call.State.Open
&& this._status !== Call.State.Connecting
&& this._status !== Call.State.Reconnecting
&& this._status !== Call.State.Ringing) {
return;
}
this._log.info('Disconnecting...');
// send pstream hangup message
if (this._pstream !== null && this._pstream.status !== 'disconnected' && this._shouldSendHangup) {
const callsid: string | undefined = this.parameters.CallSid || this.outboundConnectionId;
if (callsid) {
this._pstream.hangup(callsid, message);
}
}
this._cleanupEventListeners();
this._mediaHandler.close();
if (!wasRemote) {
this._publisher.info('connection', 'disconnected-by-local', null, this);
}
}
private _emitWarning = (groupPrefix: string, warningName: string, threshold: number,
value: number|number[], wasCleared?: boolean, warningData?: RTCWarning): void => {
const groupSuffix = wasCleared ? '-cleared' : '-raised';
const groupName = `${groupPrefix}warning${groupSuffix}`;
// Ignore constant input if the Call is muted (Expected)
if (warningName === 'constant-audio-input-level' && this.isMuted()) {
return;
}
let level = wasCleared ? 'info' : 'warning';
// Avoid throwing false positives as warnings until we refactor volume metrics
if (warningName === 'constant-audio-output-level') {
level = 'info';
}
const payloadData: Record<string, any> = { threshold };
if (value) {
if (value instanceof Array) {
payloadData.values = value.map((val: any) => {
if (typeof val === 'number') {
return Math.round(val * 100) / 100;
}
return value;
});
} else {
payloadData.value = value;
}
}
this._publisher.post(level, groupName, warningName, { data: payloadData }, this);
if (warningName !== 'constant-audio-output-level') {
const emitName = wasCleared ? 'warning-cleared' : 'warning';
this._log.debug(`#${emitName}`, warningName);
this.emit(emitName, warningName, warningData && !wasCleared ? warningData : null);
}
}
/**
* Transition to {@link CallStatus.Open} if criteria is met.
*/
private _maybeTransitionToOpen(): void {
const wasConnected = this._wasConnected;
if (this._isAnswered) {
this._onSignalingReconnected();
this._signalingStatus = Call.State.Open;
if (this._mediaHandler && this._mediaHandler.status === 'open') {
this._status = Call.State.Open;
if (!this._wasConnected) {
this._wasConnected = true;
this._log.debug('#accept');
this.emit('accept', this);
}
}
}
}
/**
* Called when the {@link Call} receives an ack from signaling
* @param payload
*/
private _onAck = (payload: Record<string, any>): void => {
const { acktype, callsid, voiceeventsid } = payload;
if (this.parameters.CallSid !== callsid) {
this._log.warn(`Received ack from a different callsid: ${callsid}`);
return;
}
if (acktype === 'message') {
this._onMessageSent(voiceeventsid);
}
}
/**
* Called when the {@link Call} is answered.
* @param payload
*/
private _onAnswer = (payload: Record<string, any>): void => {
if (typeof payload.reconnect === 'string') {
this._signalingReconnectToken = payload.reconnect;
}
// answerOnBridge=false will send a 183 which we need to catch in _onRinging when
// the enableRingingState flag is disabled. In that case, we will receive a 200 after
// the callee accepts the call firing a second `accept` event if we don't
// short circuit here.
if (this._isAnswered && this._status !== Call.State.Reconnecting) {
return;
}
this._setCallSid(payload);
this._isAnswered = true;
this._maybeTransitionToOpen();
}
/**
* Called when the {@link Call} is cancelled.
* @param payload
*/
private _onCancel = (payload: Record<string, any>): void => {
// (rrowland) Is this check necessary? Verify, and if so move to pstream / VSP module.
const callsid = payload.callsid;
if (this.parameters.CallSid === callsid) {
this._isCancelled = true;
this._publisher.info('connection', 'cancel', null, this);
this._cleanupEventListeners();
this._mediaHandler.close();
this._status = Call.State.Closed;
this._log.debug('#cancel');
this.emit('cancel');
this._pstream.removeListener('cancel', this._onCancel);
}
}
/**
* Called when we receive a connected event from pstream.
* Re-emits the event.
*/
private _onConnected = (): void => {
this._log.info('Received connected from pstream');
if (this._signalingReconnectToken && this._mediaHandler.version) {
this._pstream.reconnect(
this._mediaHandler.version.getSDP(),
this.parameters.CallSid,
this._signalingReconnectToken,
);
}
}
/**
* Called when the {@link Call} is hung up.
* @param payload
*/
private _onHangup = (payload: Record<string, any>): void => {
if (this.status() === Call.State.Closed) {
return;
}
/**
* see if callsid passed in message matches either callsid or outbound id
* call should always have either callsid or outbound id
* if no callsid passed hangup anyways
*/
if (payload.callsid && (this.parameters.CallSid || this.outboundConnectionId)) {
if (payload.callsid !== this.parameters.CallSid
&& payload.callsid !== this.outboundConnectionId) {
return;
}
} else if (payload.callsid) {
// hangup is for another call
return;
}
this._log.info('Received HANGUP from gateway');
if (payload.error) {
const code = payload.error.code;
const errorConstructor = getPreciseSignalingErrorByCode(
this._options.enableImprovedSignalingErrorPrecision,
code,
);
const error = typeof errorConstructor !== 'undefined'
? new errorConstructor(payload.error.message)
: new GeneralErrors.ConnectionError('Error sent from gateway in HANGUP', payload.error);
this._log.error('Received an error from the gateway:', error);
this._log.debug('#error', error);
this.emit('error', error);
}
this._shouldSendHangup = false;
this._publisher.info('connection', 'disconnected-by-remote', null, this);
this._disconnect(null, true);
this._cleanupEventListeners();
}
/**
* Called when there is a media failure.
* Manages all media-related states and takes action base on the states
* @param type - Type of media failure
*/
private _onMediaFailure = (type: Call.MediaFailure): void => {
const {
ConnectionDisconnected, ConnectionFailed, IceGatheringFailed, LowBytes,
} = Call.MediaFailure;
// These types signifies the end of a single ICE cycle
const isEndOfIceCycle = type === ConnectionFailed || type === IceGatheringFailed;
// All browsers except chrome doesn't update pc.iceConnectionState and pc.connectionState
// after issuing an ICE Restart, which we use to determine if ICE Restart is complete.
// Since we cannot detect if ICE Restart is complete, we will not retry.
if (!isChrome(window, window.navigator) && type === ConnectionFailed) {
return this._mediaHandler.onerror(MEDIA_DISCONNECT_ERROR);
}
// Ignore subsequent requests if ice restart is in progress
if (this._mediaStatus === Call.State.Reconnecting) {
// This is a retry. Previous ICE Restart failed
if (isEndOfIceCycle) {
// We already exceeded max retry time.
if (Date.now() - this._mediaReconnectStartTime > BACKOFF_CONFIG.max) {
this._log.warn('Exceeded max ICE retries');
return this._mediaHandler.onerror(MEDIA_DISCONNECT_ERROR);
}
// Issue ICE restart with backoff
try {
this._mediaReconnectBackoff.backoff();
} catch (error) {
// Catch and ignore 'Backoff in progress.' errors. If a backoff is
// ongoing and we try to start another one, there shouldn't be a
// problem.
if (!(error.message && error.message === 'Backoff in progress.')) {
throw error;
}
}
}
return;
}
const pc = this._mediaHandler.version.pc;
const isIceDisconnected = pc && pc.iceConnectionState === 'disconnected';
const hasLowBytesWarning = this._monitor.hasActiveWarning('bytesSent', 'min')
|| this._monitor.hasActiveWarning('bytesReceived', 'min');
// Only certain conditions can trigger media reconnection
if ((type === LowBytes && isIceDisconnected)
|| (type === ConnectionDisconnected && hasLowBytesWarning)
|| isEndOfIceCycle) {
const mediaReconnectionError = new MediaErrors.ConnectionError('Media connection failed.');
this._log.warn('ICE Connection disconnected.');
this._publisher.warn('connection', 'error', mediaReconnectionError, this);
this._publisher.info('connection', 'reconnecting', null, this);
this._mediaReconnectStartTime = Date.now();
this._status = Call.State.Reconnecting;
this._mediaStatus = Call.State.Reconnecting;
this._mediaReconnectBackoff.reset();
this._mediaReconnectBackoff.backoff();
this._log.debug('#reconnecting');
this.emit('reconnecting', mediaReconnectionError);
}
}
/**
* Called when media call is restored
*/
private _onMediaReconnected = (): void => {
// Only trigger once.
// This can trigger on pc.onIceConnectionChange and pc.onConnectionChange.
if (this._mediaStatus !== Call.State.Reconnecting) {
return;
}
this._log.info('ICE Connection reestablished.');
this._mediaStatus = Call.State.Open;
if (this._signalingStatus === Call.State.Open) {
this._publisher.info('connection', 'reconnected', null, this);
this._log.debug('#reconnected');
this.emit('reconnected');
this._status = Call.State.Open;
}
}
/**
* Raised when a Call receives a message from the backend.
* @param payload - A record representing the payload of the message from the
* Twilio backend.
*/
private _onMessageReceived = (payload: Record<string, any>): void => {
const { callsid, content, contenttype, messagetype, voiceeventsid } = payload;
if (this.parameters.CallSid !== callsid) {
this._log.warn(`Received a message from a different callsid: ${callsid}`);
return;
}
const data = {
content,
contentType: contenttype,
messageType: messagetype,
voiceEventSid: voiceeventsid,
};
this._publisher.info('call-message', messagetype, {
content_type: contenttype,
event_type: 'received',
voice_event_sid: voiceeventsid,
}, this);
this._log.debug('#messageReceived', JSON.stringify(data));
this.emit('messageReceived', data);
}
/**
* Raised when a Call receives an 'ack' with an 'acktype' of 'message.
* This means that the message sent via sendMessage API has been received by the signaling server.
* @param voiceEventSid
*/
private _onMessageSent = (voiceEventSid: string): void => {
if (!this._messages.has(voiceEventSid)) {
this._log.warn(`Received a messageSent with a voiceEventSid that doesn't exists: ${voiceEventSid}`);
return;
}
const message = this._messages.get(voiceEventSid);
this._messages.delete(voiceEventSid);
this._publisher.info('call-message', message?.messageType, {
content_type: message?.contentType,
event_type: 'sent',
voice_event_sid: voiceEventSid,
}, this);
this._log.debug('#messageSent', JSON.stringify(message));
this.emit('messageSent', message);
}
/**
* When we get a RINGING signal from PStream, update the {@link Call} status.
* @param payload
*/
private _onRinging = (payload: Record<string, any>): void => {
this._setCallSid(payload);
// If we're not in 'connecting' or 'ringing' state, this event was received out of order.
if (this._status !== Call.State.Connecting && this._status !== Call.State.Ringing) {
return;
}
const hasEarlyMedia = !!payload.sdp;
this._status = Call.State.Ringing;
this._publisher.info('connection', 'outgoing-ringing', { hasEarlyMedia }, this);
this._log.debug('#ringing');
this.emit('ringing', hasEarlyMedia);
}
/**
* Called each time StatsMonitor emits a sample.
* Emits stats event and batches the call stats metrics and sends them to Insights.
* @param sample
*/
private _onRTCSample = (sample: RTCSample): void => {
const callMetrics: Call.CallMetrics = {
...sample,
inputVolume: this._latestInputVolume,
outputVolume: this._latestOutputVolume,
};
this._codec = callMetrics.codecName;
this._metricsSamples.push(callMetrics);
if (this._metricsSamples.length >= METRICS_BATCH_SIZE) {
this._publishMetrics();
}
this.emit('sample', sample);
}
/**
* Called when an 'error' event is received from the signaling stream.
*/
private _onSignalingError = (payload: Record<string, any>): void => {
const { callsid, voiceeventsid, error } = payload;
if (this.parameters.CallSid !== callsid) {
this._log.warn(`Received an error from a different callsid: ${callsid}`);
return;
}
if (voiceeventsid && this._messages.has(voiceeventsid)) {
// Do not emit an error here. Device is handling all signaling related errors.
this._messages.delete(voiceeventsid);
this._log.warn(`Received an error while sending a message.`, payload);
this._publisher.error('call-message', 'error', {
code: error.code,
message: error.message,
voice_event_sid: voiceeventsid,
}, this);
let twilioError;
const errorConstructor = getPreciseSignalingErrorByCode(
!!this._options.enableImprovedSignalingErrorPrecision,
error.code,
);
if (typeof errorConstructor !== 'undefined') {
twilioError = new errorConstructor(error);
}
if (!twilioError) {
this._log.error('Unknown Call Message Error: ', error);
twilioError = new GeneralErrors.UnknownError(error.message, error);
}
this._log.debug('#error', error, twilioError);
this.emit('error', twilioError);
}
}
/**
* Called when signaling is restored
*/
private _onSignalingReconnected = (): void => {
if (this._signalingStatus !== Call.State.Reconnecting) {
return;
}
this._log.info('Signaling Connection reestablished.');
this._signalingStatus = Call.State.Open;
if (this._mediaStatus === Call.State.Open) {
this._publisher.info('connection', 'reconnected', null, this);
this._log.debug('#reconnected');
this.emit('reconnected');
this._status = Call.State.Open;
}
}
/**
* Called when we receive a transportClose event from pstream.
* Re-emits the event.
*/
private _onTransportClose = (): void => {
this._log.error('Received transportClose from pstream');
this._log.debug('#transp