1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
   21
   22
   23
   24
   25
   26
   27
   28
   29
   30
   31
   32
   33
   34
   35
   36
   37
   38
   39
   40
   41
   42
   43
   44
   45
   46
   47
   48
   49
   50
   51
   52
   53
   54
   55
   56
   57
   58
   59
   60
   61
   62
   63
   64
   65
   66
   67
   68
   69
   70
   71
   72
   73
   74
   75
   76
   77
   78
   79
   80
   81
   82
   83
   84
   85
   86
   87
   88
   89
   90
   91
   92
   93
   94
   95
   96
   97
   98
   99
  100
  101
  102
  103
  104
  105
  106
  107
  108
  109
  110
  111
  112
  113
  114
  115
  116
  117
  118
  119
  120
  121
  122
  123
  124
  125
  126
  127
  128
  129
  130
  131
  132
  133
  134
  135
  136
  137
  138
  139
  140
  141
  142
  143
  144
  145
  146
  147
  148
  149
  150
  151
  152
  153
  154
  155
  156
  157
  158
  159
  160
  161
  162
  163
  164
  165
  166
  167
  168
  169
  170
  171
  172
  173
  174
  175
  176
  177
  178
  179
  180
  181
  182
  183
  184
  185
  186
  187
  188
  189
  190
  191
  192
  193
  194
  195
  196
  197
  198
  199
  200
  201
  202
  203
  204
  205
  206
  207
  208
  209
  210
  211

content / test / data / media / peerconnection-multiple-streams.js [blame]

/*
 * Copyright 2017 The Chromium Authors
 * Use of this source code is governed by a BSD-style license that can be
 * found in the LICENSE file.
 */
/*jshint esversion: 6 */

/**
 * A loopback peer connection with one or more streams.
 */
class PeerConnection {
  /**
   * Creates a loopback peer connection. One stream per supplied resolution is
   * created.
   * @param {!Element} videoElement the video element to render the feed on.
   * @param {!Array<!{x: number, y: number}>} resolutions. A width of -1 will
   *     result in disabled video for that stream.
   */
  constructor(videoElement, resolutions) {
    this.localConnection = null;
    this.remoteConnection = null;
    this.remoteView = videoElement;
    this.streams = [];
    // Ensure sorted in descending order to conveniently request the highest
    // resolution first through GUM later.
    this.resolutions = resolutions.slice().sort((x, y) => y.w - x.w);
    this.activeStreamIndex = resolutions.length - 1;
    this.badResolutionsSeen = 0;
    this.localAudioTransceiver = null;
    this.localVideoTransceiver = null;
  }

  /**
   * Starts the connections. Triggers GetUserMedia and starts
   * to render the video on {@code this.videoElement}.
   * @return {!Promise} a Promise that resolves when everything is initalized.
   */
  async start() {
    // getUserMedia fails if we first request a low resolution and
    // later a higher one. Hence, sort resolutions above and
    // start with the highest resolution here.
    const promises = this.resolutions.map((resolution) => {
      const constraints = createMediaConstraints(resolution);
      return navigator.mediaDevices
        .getUserMedia(constraints)
        .then((stream) => this.streams.push(stream));
    });
    await Promise.all(promises);
    // Start with the smallest video to not overload the machine instantly.
    await this.createPeerConnections_(this.streams[this.activeStreamIndex]);
  };

  /**
   * Verifies that the state of the streams are good. The state is good if all
   * streams are active and their video elements report the resolution the
   * stream is in. Video elements are allowed to report bad resolutions
   * numSequentialBadResolutionsForFailure times before failure is reported
   * since video elements occasionally report bad resolutions during the tests
   * when we manipulate the streams frequently.
   * @param {number=} numSequentialBadResolutionsForFailure number of bad
   *     resolution observations in a row before failure is reported.
   * @param {number=} allowedDelta allowed difference between expected and
   *     actual resolution. We have seen videos assigned a resolution one pixel
   *     off from the requested.
   */
  verifyState(numSequentialBadResolutionsForFailure=10, allowedDelta=1) {
    this.verifyAllStreamsActive_();
    const expectedResolution = this.resolutions[this.activeStreamIndex];
    if (expectedResolution.w < 0 || expectedResolution.h < 0) {
      // Video is disabled.
      return;
    }
    if (!isWithin(
            this.remoteView.videoWidth, expectedResolution.w, allowedDelta) ||
        !isWithin(
            this.remoteView.videoHeight, expectedResolution.h, allowedDelta)) {
      this.badResolutionsSeen++;
    } else if (
        this.badResolutionsSeen < numSequentialBadResolutionsForFailure) {
      // Reset the count, but only if we have not yet reached the limit. If the
      // limit is reached, let keep the error state.
      this.badResolutionsSeen = 0;
    }
    if (this.badResolutionsSeen >= numSequentialBadResolutionsForFailure) {
      throw new Error(
          'Expected video resolution ' +
          resStr(expectedResolution.w, expectedResolution.h) +
          ' but got another resolution ' + this.badResolutionsSeen +
          ' consecutive times. Last resolution was: ' +
          resStr(this.remoteView.videoWidth, this.remoteView.videoHeight));
    }
  }

  verifyAllStreamsActive_() {
    if (this.streams.some((x) => !x.active)) {
      throw new Error('At least one media stream is not active')
    }
  }

  /**
   * Switches to a random stream, i.e., use a random resolution of the
   * resolutions provided to the constructor.
   * @return {!Promise} A promise that resolved when everything is initialized.
   */
  async switchToRandomStream() {
    await this.stopSending_();
    const newStreamIndex = Math.floor(Math.random() * this.streams.length);
    await this.startSending_(this.streams[newStreamIndex]);
    this.activeStreamIndex = newStreamIndex;
  }

  async createPeerConnections_(stream) {
    this.localConnection = new RTCPeerConnection();
    this.localConnection.onicecandidate = (event) => {
      this.onIceCandidate_(this.remoteConnection, event);
    };
    this.remoteConnection = new RTCPeerConnection();
    this.remoteConnection.onicecandidate = (event) => {
      this.onIceCandidate_(this.localConnection, event);
    };
    this.remoteConnection.ontrack = (e) => {
      this.remoteView.srcObject = e.streams[0];
    };

    const [audioTrack] = stream.getAudioTracks();
    const [videoTrack] = stream.getVideoTracks();
    this.localAudioTransceiver =
        audioTrack ? this.localConnection.addTransceiver(audioTrack) : null;
    this.localVideoTransceiver =
        videoTrack ? this.localConnection.addTransceiver(videoTrack) : null;
    await this.renegotiate_();
  }

  async startSending_(stream) {
    const [audioTrack] = stream.getAudioTracks();
    const [videoTrack] = stream.getVideoTracks();
    if (audioTrack) {
      await this.localAudioTransceiver.sender.replaceTrack(audioTrack);
      this.localAudioTransceiver.direction = 'sendrecv';
    }
    if (videoTrack) {
      await this.localVideoTransceiver.sender.replaceTrack(videoTrack);
      this.localVideoTransceiver.direction = 'sendrecv';
    }
    await this.renegotiate_();
  }

  async stopSending_() {
    await this.localAudioTransceiver.sender.replaceTrack(null);
    this.localAudioTransceiver.direction = 'inactive';
    await this.localVideoTransceiver.sender.replaceTrack(null);
    this.localVideoTransceiver.direction = 'inactive';
    await this.renegotiate_();
  }

  async renegotiate_() {
    // Implicitly creates the offer.
    await this.localConnection.setLocalDescription();
    await this.remoteConnection.setRemoteDescription(
        this.localConnection.localDescription);
    // Implicitly creates the answer.
    await this.remoteConnection.setLocalDescription();
    await this.localConnection.setRemoteDescription(
        this.remoteConnection.localDescription);
  };

  onIceCandidate_(connection, event) {
    if (event.candidate) {
      connection.addIceCandidate(new RTCIceCandidate(event.candidate));
    }
  };
}

/**
 * Checks if a value is within an expected value plus/minus a delta.
 * @param {number} actual
 * @param {number} expected
 * @param {number} delta
 * @return {boolean}
 */
function isWithin(actual, expected, delta) {
  return actual <= expected + delta && actual >= actual - delta;
}

/**
 * Creates constraints for use with GetUserMedia.
 * @param {!{x: number, y: number}} widthAndHeight Video resolution.
 */
function createMediaConstraints(widthAndHeight) {
  let constraint;
  if (widthAndHeight.w < 0) {
    constraint = false;
  } else {
    constraint = {
      width: {exact: widthAndHeight.w},
      height: {exact: widthAndHeight.h}
    };
  }
  return {
    audio: true,
    video: constraint
  };
}

function resStr(width, height) {
  return `${width}x${height}`
}

function logError(err) {
  console.error(err);
}