openvidu/openvidu-test-e2e/src/test/resources/participant-passthrough-syn...

157 lines
5.7 KiB
JavaScript

// Synthetic camera and microphone for OpenViduTestAppE2eEgressProTest (Participant Passthrough Egress).
//
// Replaces navigator.mediaDevices.getUserMedia and getDisplayMedia so that every
// track the testapp captures (at connect, when unmuting, when adding a track,
// when sharing the screen) comes from:
// - a 640x480 canvas (the camera) or an 800x600 canvas (the screen) that is
// dark with a moving bar, and white during the first 100 ms of every second
// of the page clock. The bar crosses the canvas once every BAR_PERIOD_MS of
// that clock, whatever its width, so the left edge of the bar in any frame
// tells the page time it was drawn at, within 2 s and to a few ms (whole
// seconds at 0 and half the width);
// - a WebAudio 1 kHz tone that sounds during the first 100 ms of every second
// of the same clock, and is silent otherwise.
// A recording keeps audio and video in sync when each white flash starts with
// a beep, whenever each track was published.
//
// A camera track that livekit-client stops (it does on every mute) is kept live
// and handed out again by the next getUserMedia. Chrome times every new canvas
// capture track afresh, so a camera reopened as a new track sent RTP timestamps
// 30-90 ms off its frames, which a real camera does not do (with Chrome's fake
// capture device they stay within 1 ms across reopens): the recordings would
// show an A/V offset that no SFU or egress introduced.
(function () {
if (window.__ovSyncMedia) {
return;
}
var WIDTH = 640;
var HEIGHT = 480;
var SCREEN_WIDTH = 800;
var SCREEN_HEIGHT = 600;
var MARKER_MS = 100;
var BAR_PERIOD_MS = 2000; // a multiple of 1000: the bar also tells whole seconds
var origin = performance.now();
function clock() {
return performance.now() - origin;
}
function createCanvas(width, height) {
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
var camera = createCanvas(WIDTH, HEIGHT);
var screen = createCanvas(SCREEN_WIDTH, SCREEN_HEIGHT);
function drawOn(canvas, t) {
var ctx = canvas.getContext('2d');
var w = canvas.width;
var h = canvas.height;
if (t % 1000 < MARKER_MS) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
return;
}
ctx.fillStyle = '#202020';
ctx.fillRect(0, 0, w, h);
// something always moves, so that encoders keep producing frames
ctx.fillStyle = '#3070c0';
ctx.fillRect(((t % BAR_PERIOD_MS) / BAR_PERIOD_MS) * w, h / 3, 80, h / 3);
ctx.fillStyle = '#c0c0c0';
ctx.font = '40px sans-serif';
ctx.fillText((t / 1000).toFixed(1), 20, 60);
}
function draw() {
var t = clock();
drawOn(camera, t);
drawOn(screen, t);
}
// timers keep running (throttled) in background tabs, where requestAnimationFrame stops
setInterval(draw, 1000 / 60);
draw();
var video = camera.captureStream(30).getVideoTracks()[0];
var screenVideo = screen.captureStream(30).getVideoTracks()[0];
var audioContext = new AudioContext({ sampleRate: 48000 });
var destination = audioContext.createMediaStreamDestination();
var oscillator = audioContext.createOscillator();
oscillator.frequency.value = 1000;
var gain = audioContext.createGain();
gain.gain.value = 0;
oscillator.connect(gain).connect(destination);
oscillator.start();
var audio = destination.stream.getAudioTracks()[0];
// Beeps are scheduled on the audio clock, a few hundred ms ahead, mapping each
// whole second of the page clock to audio time at the moment of scheduling.
var nextBeep = Math.ceil(clock() / 1000) * 1000;
setInterval(function () {
var now = clock();
var audioNow = audioContext.currentTime;
while (nextBeep < now + 300) {
if (nextBeep >= now) {
var at = audioNow + (nextBeep - now) / 1000;
gain.gain.setValueAtTime(0.5, at);
gain.gain.setValueAtTime(0, at + MARKER_MS / 1000);
}
nextBeep += 1000;
}
}, 50);
// Autoplay policies keep the context suspended until a user gesture: the
// WebDriver clicks on the testapp buttons count as one.
function resume() {
if (audioContext.state !== 'running') {
audioContext.resume();
}
}
document.addEventListener('click', resume, true);
document.addEventListener('keydown', resume, true);
// camera tracks stopped by the page, still live, for the next getUserMedia
var stoppedCameras = [];
function cameraTrack() {
var track = stoppedCameras.pop();
if (!track) {
track = video.clone();
track.stop = function () {
if (stoppedCameras.indexOf(track) < 0) {
stoppedCameras.push(track);
}
};
}
track.enabled = true;
return track;
}
var originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = function (constraints) {
resume();
var tracks = [];
if (constraints && constraints.audio) {
tracks.push(audio.clone());
}
if (constraints && constraints.video) {
tracks.push(cameraTrack());
}
if (tracks.length === 0) {
return originalGetUserMedia(constraints);
}
return Promise.resolve(new MediaStream(tracks));
};
// the screen flashes with the camera: it must be in sync with the microphone too
// with audio when asked for it, as Chrome does when the user shares a tab's
// audio: the same beeps as the microphone
navigator.mediaDevices.getDisplayMedia = function (constraints) {
resume();
var tracks = [screenVideo.clone()];
if (constraints && constraints.audio) {
tracks.push(audio.clone());
}
return Promise.resolve(new MediaStream(tracks));
};
window.__ovSyncMedia = true;
})();