Web端使用Webrtc中音视频设备获取及流处理

前言

注意本文和之前Electron获取设备的文章有重合,但是也不是一样的,因为在Electron中我们不但能用HTML的API,也能使用Electron的API,但是WEB中就有局限了,在WEB中就实现不了直接分享主屏幕,必须用户选择。

获取设备

所有设备

1
2
3
4
5
6
7
8
async function getDevices() {
let devices = await navigator
.mediaDevices
.enumerateDevices()
console.info(devices);
}

getDevices();

获取到数组的对象格式如下

1
2
3
4
5
6
{
deviceId: "default",
groupId: "052c3cdc7b40ad499d3378c99f07e928988364dde49df6264ec9833620c63c92",
kind: "audioinput",
label: "Default - 麦克风 (HECATE G30 GAMING HEADSET) (2d99:0026)"
}

其中kind有以下几种类型

  • videoinput 视频输入 (摄像头)
  • audioinput 音频输入 (麦克风)
  • audiooutput 音频输出 (扬声器)

其中deviceId是设备的id,有以下几种值

  • default 默认的设备(只有一个)
  • communications 通讯中的设备(只有一个)
  • id 设备的id 会和前面的默认设备重复

其中groupId代表同一个设备

比如我的耳机既能听声音又有麦克风,那么获取到的音频输入和音频输出设备的groupId就会是一样的。

其中label是设备的名称

注意的是默认设备和通讯设备会在名称前拼接了Default或者Communications并用-分隔

获取名称的方式

1
2
3
4
5
6
7
8
9
10
11
let devices = await navigator.mediaDevices
.enumerateDevices()
for (let device of devices) {
if (
device.deviceId !== "default"
&& device.deviceId !== "communications"
) {
let label = device.label;
console.info(label);
}
}

摄像头

1
2
3
4
5
6
7
8
async function getDevices() {
let devices = await navigator.mediaDevices
.enumerateDevices();
let videoinput = devices.filter((d) => d.kind === "videoinput");
console.info(videoinput);
}

getDevices();

麦克风

1
2
3
4
5
6
7
8
async function getDevices() {
let devices = await navigator.mediaDevices
.enumerateDevices();
let videoinput = devices.filter((d) => d.kind === "audioinput");
console.info(videoinput);
}

getDevices();

获取流

基本语法

1
2
3
4
5
6
7
8
9
10
11
navigator
.mediaDevices
.getUserMedia(constraints)
.then(
function(stream) {
/* 使用这个stream stream */
})
.catch(
function(err) {
/* 处理error */
});

constraints 参数是一个包含了videoaudio两个成员的MediaStreamConstraints 对象,用于说明请求的媒体类型。必须至少一个类型或者两个同时可以被指定。如果浏览器无法找到指定的媒体类型或者无法满足相对应的参数要求,那么返回的Promise对象就会处于rejected[失败]状态,NotFoundError作为rejected[失败]回调的参数。

其中约束条件constraints可以设置以下的值

同时请求不带任何参数的音频和视频:

1
2
3
4
{ 
audio: true,
video: true
}

当由于隐私保护的原因,无法访问用户的摄像头和麦克风信息时,应用可以使用额外的constraints参数请求它所需要或者想要的摄像头和麦克风能力。下面演示了应用想要使用1280x720的摄像头分辨率:

1
2
3
4
{
audio: true,
video: { width: 1280, height: 720 }
}

匹配最佳摄像头或理想值:当请求包含一个ideal(应用最理想的)值时,这个值有着更高的权重,意味着浏览器会先尝试找到最接近指定的理想值的设定或者摄像头(如果设备拥有不止一个摄像头)。

1
2
3
4
5
6
7
{
audio: true,
video: {
width: { min: 1024, ideal: 1280, max: 1920 },
height: { min: 776, ideal: 720, max: 1080 }
}
}

并不是所有的constraints 都是数字。例如, 在移动设备上面,如下的例子表示优先使用前置摄像头(如果有的话):

1
2
3
4
{ 
audio: true,
video: { facingMode: "user" }
}

强制使用后置摄像头,请用:

1
2
3
4
5
6
{ 
audio: true,
video: {
facingMode: { exact: "environment" }
}
}

在某些情况下,比如WebRTC上使用受限带宽传输时,低帧率可能更适宜。

1
2
3
4
5
{ 
video: {
frameRate: { ideal: 10, max: 15 }
}
}

固定的摄像头

1
2
3
4
5
6
{
video: {
deviceId: video.deviceId,
groupId: video.groupId,
}
}

固定麦克风

1
2
3
4
5
6
{
audio: {
deviceId: video.deviceId,
groupId: video.groupId,
}
}

关闭摄像头

1
2
3
4
5
if (window.mystream) {
window.mystream.getTracks().forEach(track => {
track.stop();
});
}

获取摄像头的流

1
2
3
4
5
6
7
8
9
10
11
let device_index = this.device_index;
let devices = await navigator.mediaDevices
.enumerateDevices()
.then((devices) => devices.filter((d) => d.kind === "videoinput"));
let video = devices[device_index];
navigator.mediaDevices
.getUserMedia({video})
.then(function (localStream) {
window.mystream = localStream;
that.$refs["camera_video"].srcObject = localStream;
})

切换摄像头

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
async change_camera_click() {
let that = this;
if (window.mystream) {
window.mystream.getTracks().forEach(track => {
track.stop();
});
}
let device_index = this.device_index;
let devices = await navigator.mediaDevices
.enumerateDevices()
.then((devices) => devices.filter((d) => d.kind === "videoinput"));

if (devices.length > 1) {
if (device_index === 0) {
device_index = devices.length - 1;
} else {
device_index = 0;
}
that.device_index = device_index;
} else if (devices.length === 1) {
that.device_index = 0;
device_index = 0;
}
this.devices = devices;
if (devices.length > 0) {
let video = devices[device_index];
navigator.mediaDevices
.getUserMedia(
{
video: {
deviceId: video.deviceId,
groupId: video.groupId,
}
})
.then(
function (localStream) {
window.mystream = localStream;
that.$refs["camera_video"].srcObject = localStream;
that.show_camera_div = true;
})
.catch(function (e) {
console.info(e);
});
} else {
that.show_camera_div = false;
}
},

选择桌面共享

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
<video autoplay playsinline id="video_player"></video>
<script type="text/javascript">
var videoPlayer = document.querySelector("video#video_player");
async function getDevices() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
console.log('getDisplayMedia is not supported!');
} else {
var constraints = {
video: {
frameRate: 24,
width: 1366,
height: 800
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
};
try {
let mediaStream = await navigator.mediaDevices.getDisplayMedia(constraints);
videoPlayer.srcObject = mediaStream;
} catch (e) {
console.error(e)
}
}
}
getDevices();
</script>

流处理

MediaStream

添加轨道的时候支持添加一个视频轨道和多个音频轨道。

方法:

  • MediaStream.getTracks()

    返回流中所有的MediaStreamTrack列表。

  • MediaStream.getVideoTracks()

    返回流中 kind 属性为”video”的MediaStreamTrack列表。顺序是不确定的,不同浏览器间会有不同,每次调用也有可能不同。

  • MediaStream.getAudioTracks()

    返回流中 kind 属性为”audio”的MediaStreamTrack列表。顺序是不确定的,不同浏览器间会有不同,每次调用也有可能不同。

  • MediaStream.addTrack()

    存储传入参数 MediaStreamTrack的一个副本。如果这个轨道已经被添加到了这个媒体流,什么也不会发生; 如果目标轨道为“完成”状态(也就是已经到尾部了),一个 INVALID_STATE_RAISE 异常会产生。

  • MediaStream.clone()

    返回这个MediaStream 对象的克隆版本。返回的版本会有一个新的 ID。

    返回给定 ID 的轨道。如果没有参数或者没有指定 ID 的轨道,将返回 null。如果有几个轨道有同一个 ID,将返回第一个。

  • MediaStream.getTrackById()

    返回给定 ID 的轨道。如果没有参数或者没有指定 ID 的轨道,将返回 null。如果有几个轨道有同一个 ID,将返回第一个。

  • MediaStream.removeTrack()

    移除作为参数传入的 MediaStreamTrack。 如果这个轨道不在MediaStream 对象中什么也不会发生; 如果目标轨道为“完成”状态,一个 INVALID_STATE_RAISE 异常会产生。

MediaStreamTrack

获取轨道

1
2
var mediaStreamTracks = mediaStream.getTracks()
console.info(mediaStreamTracks);

我们可以看到获取到的轨道

image-20220629100127656

属性:

  • enabled

    布尔值,为 true 时表示轨道有效,并且可以被渲染。为 false 时表示轨道失效,只能被渲染为静音或黑屏。如果该轨道连接中断,该值还是可以被改变但不会有任何效果了。

  • id

    返回一个由浏览器产生的DOMString类型的 GUID 值,作为这个轨道的唯一标识值。

  • kind

    返回一个DOMString类型的值。如果为“audio”表示轨道为音频轨道,为“video”则为视频轨道。如果该轨道从它的源上分离,这个值也不会改变。

  • label

    返回一个DOMString类型。内容为一个用户代理指定的标签,来标识该轨道的来源,比如“internal microphone”。该字符串可以为空,并且在没有源与这个轨道连接的情况下会一直为空。当该轨道从它的源上分离时,这个值也不会改变。

  • muted

    返回一个布尔类型的值,为 true 时表示轨道是静音,其它为 false。

  • readonly

    返回一个布尔类型的值,为 true 时表示该轨道是只读的,比如视频文件源或一个被设置为不能修改的摄像头源,或则为 false。

  • readyState

    返回枚举类型的值,表示轨道的当前状态。该枚举值为以下中的一个:”live”表示当前输入已经连接并且在尽力提供实时数据。在这种情况下,输出数据可以通过操作 MediaStreamTrack.enabled 属性进行开关。“ended”表示这个输出连接没有更多的数据了,而且也不会提供更多的数据了。

  • remote

    返回布尔值类型,当为 true 时表示数据是通过RTCPeerConnection提供的,否则为 false。

流录制

音频录制与播放

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
let mediaRecorder = new MediaRecorder(stream);
mediaRecorder.start();
mediaRecorder.ondataavailable = mediaRecorderDataAvailable;
mediaRecorder.onstop = mediaRecorderStop;

let chunks = [];
function mediaRecorderDataAvailable(e) {
chunks.push(e.data);
}

function mediaRecorderStop () {
//check if there are any previous recordings and remove them
if (recordedAudioContainer.firstElementChild.tagName === 'AUDIO') {
recordedAudioContainer.firstElementChild.remove();
}
//create a new audio element that will hold the recorded audio
const audioElm = document.createElement('audio');
audioElm.setAttribute('controls', ''); //add controls
//create the Blob from the chunks
audioBlob = new Blob(chunks, { type: 'audio/mp3' });
const audioURL = window.URL.createObjectURL(audioBlob);
audioElm.src = audioURL;
//show audio
recordedAudioContainer.insertBefore(audioElm, recordedAudioContainer.firstElementChild);
recordedAudioContainer.classList.add('d-flex');
recordedAudioContainer.classList.remove('d-none');
//reset to default
mediaRecorder = null ;
chunks = [];
}

音视频保存

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
function formatLength(str, length) {
str += '';
if (str.length < length)
return formatLength('0' + str, length)
else
return str
}

function getnowstr() {
var now = new Date();
var year = now.getFullYear(); //得到年份
var month = formatLength(now.getMonth(), 2);//得到月份
var date = formatLength(now.getDate(), 2);//得到日期
var hour = formatLength(now.getHours(), 2);//得到小时
var minu = formatLength(now.getMinutes(), 2);//得到分钟
var all_time = year + "-" + month + "-" + date + "_" + hour + "-" + minu;
return all_time;
}

// 保存视频
function saveRecord() {
let blob = new Blob(recordedChunks, {type: "video/x-matroska;codecs=avc1,opus"});
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
var all_time = getnowstr();
document.body.appendChild(a);
a.style = 'display: none';
a.href = url;
a.download = all_time + 'video.webm';
a.click()
setTimeout(function () {
document.body.removeChild(a);
window.URL.revokeObjectURL(url)
}, 100)
}

音视频播放

1
2
3
4
5
6
7
8
function playRecord() {
let video = document.querySelector('video')
video.controls = true;
video.muted = false;
let blob = new Blob(recordedChunks, {type: "video/x-matroska;codecs=avc1,opus"})
video.src = window.URL.createObjectURL(blob)
video.play();
}