mirror of https://github.com/OpenVidu/openvidu.git
157 lines
5.7 KiB
JavaScript
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;
|
|
})();
|