UNPKG

@twilio/rtc-diagnostics

Version:

Various diagnostics functions to help analyze connections to Twilio

499 lines 23.1 kB
"use strict"; var __extends = (this && this.__extends) || (function () { var extendStatics = function (d, b) { extendStatics = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; return extendStatics(d, b); }; return function (d, b) { extendStatics(d, b); function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; })(); var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (_) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; Object.defineProperty(exports, "__esModule", { value: true }); var events_1 = require("events"); var constants_1 = require("./constants"); var errors_1 = require("./errors"); var polyfills_1 = require("./polyfills"); var audio_1 = require("./recorder/audio"); var optionValidation_1 = require("./utils/optionValidation"); /** * [[AudioInputTest]] class that parses options and starts an audio input device * test. * * Please see [[testAudioInputDevice]] for details and recommended practices. */ var AudioInputTest = /** @class */ (function (_super) { __extends(AudioInputTest, _super); /** * Initializes the `startTime` and `options`. * @param options Optional settings to pass to the test. */ function AudioInputTest(options) { var _this = _super.call(this) || this; /** * Active warnings to keep track of. */ _this.activeWarnings = new Set(); /** * An `AudioContext` to use for generating volume levels. */ _this._audioContext = null; /** * An AudioRecorder object used to capture audio input during the test */ _this._audioRecorder = null; /** * A function that will be assigned in `_startTest` that when run will clean * up the audio nodes created in the same function. */ _this._cleanupAudio = null; /** * The default media devices when starting the test. */ _this._defaultDevices = {}; /** * A timestamp that is set when the test ends. */ _this._endTime = null; /** * An array of any errors that occur during the run time of the test. */ _this._errors = []; /** * A `MediaStream` that is created from the input device. */ _this._mediaStream = null; /** * Volume levels generated from the audio source during the run time of the * test. */ _this._volumeStats = { timestamps: [], values: [], }; /** * The timeout that causes the volume event to loop; created by `setTimeout`. */ _this._volumeTimeout = null; _this._options = __assign(__assign({}, AudioInputTest.defaultOptions), options); // We need to use a `setTimeout` here to prevent a race condition. // This allows event listeners to bind before the test starts. setTimeout(function () { return _this._startTest(); }); return _this; } /** * Stop the currently running [[AudioInputTest]]. */ AudioInputTest.prototype.stop = function () { var _this = this; if (typeof this._endTime === 'number') { this._onWarning(new errors_1.AlreadyStoppedError()); return; } this._endTime = Date.now(); var report = { deviceId: this._options.deviceId || (this._defaultDevices.audioinput && this._defaultDevices.audioinput.deviceId), errors: this._errors, testName: AudioInputTest.testName, values: this._volumeStats.values, }; if (this._startTime) { report.testTiming = { duration: this._endTime - this._startTime, end: this._endTime, start: this._startTime, }; } var onEnd = function () { _this._cleanup(); _this.emit(AudioInputTest.Events.End, report); }; if (this._options.enableRecording && this._audioRecorder) { this._audioRecorder.stop().then(function () { report.recordingUrl = _this._audioRecorder.url; }).catch(function (ex) { _this._onError(ex); }).finally(onEnd); } else { onEnd(); } }; /** * Clean up any instantiated objects (i.e. `AudioContext`, `MediaStreams`, * etc.). * Called by `.stop`. */ AudioInputTest.prototype._cleanup = function () { if (this._volumeTimeout) { clearTimeout(this._volumeTimeout); } if (this._cleanupAudio) { this._cleanupAudio(); } if (this._mediaStream) { this._mediaStream.getTracks().forEach(function (track) { return track.stop(); }); } if (this._audioContext) { this._audioContext.close(); } }; /** * Helper function that should be called when an error occurs, recoverable * or not. * @param error */ AudioInputTest.prototype._onError = function (error) { this._errors.push(error); this.emit(AudioInputTest.Events.Error, error); }; /** * Called every `AudioInputTest._options.volumeEventIntervalMs` amount of * milliseconds, emits the volume passed to it as a `Events.Volume` event. * @param value the volume */ AudioInputTest.prototype._onVolume = function (value) { var now = Date.now(); if (!this._volumeStats.max || value > this._volumeStats.max) { this._volumeStats.max = value; } this._volumeStats.values.push(value); this._volumeStats.timestamps.push(now); this.emit(AudioInputTest.Events.Volume, value); // Find the last 3 seconds worth of volume values. var startIndex = this._volumeStats.timestamps.findIndex(function (timestamp) { return now - timestamp <= 3000; }); // We want to do nothing at 1 and not 0 here because this guarantees that // there is at least one timestamp before the sample set. This means that // there are at least three seconds of samples. if (startIndex < 1) { return; } var samples = this._volumeStats.values.slice(startIndex > 0 ? startIndex : 0); // Calculate the standard deviation of the sample set. var sampleAverage = samples.reduce(function (sample, partialSum) { return sample + partialSum; }, 0) / samples.length; var diffSquared = samples.map(function (sample) { return Math.pow(sample - sampleAverage, 2); }); var stdDev = Math.sqrt(diffSquared.reduce(function (sample, partialSum) { return sample + partialSum; }, 0) / samples.length); // 255 is max volume value; 2.55 is 1% of max var isConstantAudio = stdDev <= 2.55; if (isConstantAudio && sampleAverage <= 2.55) { if (!this.activeWarnings.has(constants_1.WarningName.LowAudioLevel)) { this.activeWarnings.add(constants_1.WarningName.LowAudioLevel); this.emit(AudioInputTest.Events.Warning, constants_1.WarningName.LowAudioLevel); } } else if (this.activeWarnings.has(constants_1.WarningName.LowAudioLevel)) { this.activeWarnings.delete(constants_1.WarningName.LowAudioLevel); this.emit(AudioInputTest.Events.WarningCleared, constants_1.WarningName.LowAudioLevel); } }; /** * Warning event handler. * @param warning */ AudioInputTest.prototype._onWarning = function (error) { if (this._options.debug) { // tslint:disable-next-line no-console console.warn(error); } }; /** * Entry point into the audio input device test. Uses the `MediaStream` that the * object was set up with, and performs a fourier transform on the audio data * using an `AnalyserNode`. The output of the fourier transform are the * relative amplitudes of the frequencies of the audio data. The average of * this data can then be used as an estimate as the average volume of the * entire volume source. * * @event Events.Volume */ AudioInputTest.prototype._startTest = function () { return __awaiter(this, void 0, void 0, function () { var invalidReasons, _a, _b, _c, analyser_1, microphone_1, frequencyDataBytes_1, volumeEvent_1, error_1; var _this = this; return __generator(this, function (_d) { switch (_d.label) { case 0: _d.trys.push([0, 4, , 5]); return [4 /*yield*/, optionValidation_1.validateOptions(this._options, { deviceId: optionValidation_1.validateDeviceId, duration: optionValidation_1.validateTime, volumeEventIntervalMs: optionValidation_1.validateTime, })]; case 1: invalidReasons = _d.sent(); if (invalidReasons) { throw new errors_1.InvalidOptionsError(invalidReasons); } if (!this._options.getUserMedia) { throw polyfills_1.GetUserMediaUnsupportedError; } _a = this; return [4 /*yield*/, this._options.getUserMedia({ audio: { deviceId: this._options.deviceId }, })]; case 2: _a._mediaStream = _d.sent(); if (!this._options.audioContextFactory) { throw polyfills_1.AudioContextUnsupportedError; } // We need to initialize AudioContext and MediaRecorder right after calling gUM // and before enumerateDevices. Certain browsers and headsets (Safari, AirPods) // loses the "user action" after enumerating devices. this._audioContext = new this._options.audioContextFactory(); if (this._options.enableRecording) { this._audioRecorder = new this._options.audioRecorderFactory({ audioContext: this._audioContext, stream: this._mediaStream, }); } if (!this._options.enumerateDevices) { throw polyfills_1.EnumerateDevicesUnsupportedError; } _b = this; _c = polyfills_1.getDefaultDevices; return [4 /*yield*/, this._options.enumerateDevices()]; case 3: _b._defaultDevices = _c.apply(void 0, [_d.sent()]); // Only starts the timer after successfully getting devices this._startTime = Date.now(); analyser_1 = this._audioContext.createAnalyser(); analyser_1.smoothingTimeConstant = 0.4; analyser_1.fftSize = 64; microphone_1 = this._audioContext.createMediaStreamSource(this._mediaStream); microphone_1.connect(analyser_1); this._cleanupAudio = function () { analyser_1.disconnect(); microphone_1.disconnect(); }; frequencyDataBytes_1 = new Uint8Array(analyser_1.frequencyBinCount); volumeEvent_1 = function () { if (_this._endTime) { return; } analyser_1.getByteFrequencyData(frequencyDataBytes_1); var volume = frequencyDataBytes_1.reduce(function (sum, val) { return sum + val; }, 0) / frequencyDataBytes_1.length; _this._onVolume(volume); if (Date.now() - _this._startTime > _this._options.duration) { _this.stop(); } else { _this._volumeTimeout = setTimeout(volumeEvent_1, _this._options.volumeEventIntervalMs); } }; this._volumeTimeout = setTimeout(volumeEvent_1, this._options.volumeEventIntervalMs); return [3 /*break*/, 5]; case 4: error_1 = _d.sent(); if (error_1 instanceof errors_1.DiagnosticError) { // There is some other fatal error. this._onError(error_1); } else if (typeof DOMException !== 'undefined' && error_1 instanceof DOMException) { this._onError(new errors_1.DiagnosticError(error_1, 'A `DOMException` has occurred.')); } else if (typeof DOMError !== 'undefined' && error_1 instanceof DOMError) { this._onError(new errors_1.DiagnosticError(error_1, 'A `DOMError` has occurred.')); } else if (typeof Error !== 'undefined' && error_1 instanceof Error) { this._onError(new errors_1.DiagnosticError(error_1, 'An error has occurred.')); } else { this._onError(new errors_1.DiagnosticError(undefined, 'Unknown error occurred.')); this._onWarning(error_1); } this.stop(); return [3 /*break*/, 5]; case 5: return [2 /*return*/]; } }); }); }; /** * Name of the test. */ AudioInputTest.testName = 'audio-input-test'; /** * Default options for the [[AudioInputTest]]. */ AudioInputTest.defaultOptions = { audioContextFactory: polyfills_1.AudioContext, audioRecorderFactory: audio_1.AudioRecorder, debug: false, duration: Infinity, enableRecording: false, enumerateDevices: polyfills_1.enumerateDevices, getUserMedia: polyfills_1.getUserMedia, volumeEventIntervalMs: 100, }; return AudioInputTest; }(events_1.EventEmitter)); exports.AudioInputTest = AudioInputTest; (function (AudioInputTest) { /** * Possible events that an [[AudioInputTest]] might emit. See [[AudioInputTest.on]]. */ var Events; (function (Events) { Events["End"] = "end"; Events["Error"] = "error"; Events["Volume"] = "volume"; Events["Warning"] = "warning"; Events["WarningCleared"] = "warning-cleared"; })(Events = AudioInputTest.Events || (AudioInputTest.Events = {})); })(AudioInputTest = exports.AudioInputTest || (exports.AudioInputTest = {})); exports.AudioInputTest = AudioInputTest; /** * [[AudioInputTest]] tests audio input capabilities. It serves to help diagnose * potential audio device issues that would prevent audio from being recognized * in a WebRTC call. * * --- * * The [[AudioInputTest]] class is an `EventEmitter` (please see [[AudioInputTest.on]] for * events and their details) and helps to diagnose issues by capturing user * audio and emitting the volume levels detected in that media. * ```ts * import { AudioInputTest, testAudioInputDevice } from '@twilio/rtc-diagnostics'; * const options: AudioInputTest.Options = { ... }; * // `options` may be left `undefined` to use default option values * const audioInputTest: AudioInputTest = testAudioInputDevice(options); * ``` * Applications can use the volume events emitted by the test to update their UI * to show to the user whether or not their media was captured successfully. * ```ts * audioInputTest.on(AudioInputTest.Events.Volume, (volume: number) => { * ui.updateVolume(volume); // Update your UI with the volume value here. * }); * ``` * The test can be normally stopped two ways: allowing the test to time out and * stopping the test manually. * * To end the test manually, the application can ask the end-user to confirm * that the volume levels it emits are what the end-user expects. If so, the * application can call the [[AudioInputTest.stop]] method with `true`. Otherwise, * if the audio values are not expected, the application can call * [[AudioInputTest.stop]] with `false`. * ```ts * // The UI should indicate that if the volume values are what the user * // expects, they can click this button to pass and stop the test... * const volumeCorrectButton = ...; * volumeCorrectButton.addEventListener('click', () => { * audioInputTest.stop(true); * }); * * // ...otherwise, if the volume levels are not what they expect, they can * // click this. * const volumeIncorrectButton = ...; * volumeIncorrectButton.addEventListener('click', () => { * audioInputTest.stop(false); * }); * ``` * Calling [[AudioInputTest.stop]] will immediately end the test. * * --- * * The [[AudioInputTest]] object will always emit a [[AudioInputTest.Report]] with the * [[AudioInputTest.Events.End]] event, regardless of the occurrence of errors during * the runtime of the test. * * Fatal errors will immediately end the test and emit a report such that the * value of [[AudioInputTest.Report.errors]] will contain the fatal error. * * Non-fatal errors will not end the test, but will be included in the value of * [[AudioInputTest.Report.errors]] upon completion of the test. * * --- * * Note: In Firefox, `deviceId` will be ignored, and instead the user will get a * browser pop-up where they can select the device they want to use. This is * unavoidable as it is Firefox's implementation of `getUserMedia()`. * * In most browsers, such as Chrome and Safari, when `getUserMedia()` is called, * a prompt will ask the user for broad microphone-access permissions. Then, the * parameters passed to `getUserMedia()` will determine the device that is * captured. * * Firefox differs in that the prompt will ask for a specific input device. * Regardless of the parameters passed to `getUserMedia()`, the device * selected in that prompt will be captured. If the user opts to have the * browser "Remember this selection" within the prompt, the device that was * selected will be captured by every future `getUserMedia()` call as well. * This selection will persist even through changes in the system OS, i.e. when * default devices are changed. In order to change the device, the user has to * revoke the webpage's microphone-access permissions for the prompt to show * again. * * Please see this link for more information on microphone access in Firefox: * https://support.mozilla.org/en-US/kb/how-manage-your-camera-and-microphone-permissions * * --- * * The function [[testAudioInputDevice]] serves as a factory function that accepts * [[AudioInputTest.Options]] as its only parameter and will instantiate an * [[AudioInputTest]] object with those options. * ```ts * import { AudioInputTest, testAudioInputDevice } from '@twilio/rtc-diagnostics'; * const options: AudioInputTest.Options = { ... }; * const audioInputTest: AudioInputTest = testAudioInputDevice(options); * ``` * * @param options Options to pass to the [[AudioInputTest]] constructor. */ function testAudioInputDevice(options) { return new AudioInputTest(options); } exports.testAudioInputDevice = testAudioInputDevice; //# sourceMappingURL=AudioInputTest.js.map