前端 er 怎样玩转音视频流-WebRTC 技能介绍
最近做了一个 AI 问答的项目,需求获取用户的摄像头,录像录音,实时语音转文字等等功用,记载一下踩过的坑。以及现在的最佳完结。
WebRTC 技能介绍
WebRTC (Web Real-Time Communications) 是一项实时通讯技能,它答应网络运用树立阅读器之间点对点(Peer-to-Peer)的连接,完结视频流和(或)音频流或者其他任意数据的传输。
一句话总结便是:支撑阅读器实时的传输音频流和视频流。
详细的运用事例有:
- 单/多人的视频会议
- 视频直播
- 等等
前语
本文里边没有 视频直播,多人会议的 case,由于咱们这个项目需求快速落地,在最开端架构的时分,没有考虑选用流式的数据传输,仍是传统的 ajax 前后端别离的项目,只不过运用 WebRTC 中的一些才干完结了录音和录像,并处理了一些进程中遇到的坑。
不过腾讯有一个 TRTC 产品,一个很老练的计划,运用的是流式的数据传输,用来处理视频直播等等场景。
获取用户的设备(摄像头,麦克风)
API 介绍
主要是运用了 getUserMedia 这个 api。
兼容性如下:
这个 API 的根本运用如下:
const isSupportMediaDevicesMedia = () => {
return !!(navigator.getUserMedia || (navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
};
if (isSupportMediaDevicesMedia()) {
// 兼容性
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
// 装备
const mediaOption = { audio: true, video: true };
navigator.mediaDevices
.getUserMedia(mediaOption)
.then((stream) => {
console.log(`[Log] stream-->`, stream);
})
.catch((err) => {
console.error(`[Log] 获取摄像头和麦克风权限失败-->`, err);
});
} else {
if (navigator.userAgent.toLowerCase().match(/chrome/) && location.origin.indexOf('https://') < 0) {
console.log('chrome下获取阅读器录音功用,由于安全性问题,需求在localhost或127.0.0.1或https下才干获取权限');
} else {
console.log('无法获取阅读器录音功用,请晋级阅读器或运用chrome');
}
}
中心便是 getUserMedia 办法和 mediaOption 这个装备。
mediaOption 装备:
const mediaOption = {
audio: true, // true 标识需求获取音频流
// video: true, // true 标识需求获取视频流
// 指定视频的宽高和帧率
video: {
width: { min: 980, ideal: 980, max: 1920 }, // min,max 指定一个规模,ideal 表明优先运用的值
height: { min: 560, ideal: 560, max: 1080 },
frameRate: { ideal: 12, max: 15 }, // 指定帧率
deviceId: { exact: '设备id' }, // 多设备的时分,能够通过设备id获取指定的设备
facingMode: 'user', // user:前置摄像头,environment:后置摄像头
},
};
getUserMedia
回来了一个 Promise<MediaStream>
,MediaStream
便是咱们需求媒体流,那么拿到了流就能够干咱们想干的工作了。
简略 case
OK,先简略完结一个播映这个流。要播映流,其实逻辑很简略,video 标签有一个 srcObject 特点,直接设置就能够了。
import React, { useEffect, useRef, useState } from 'react';
import './index.scss';
function RecordInfo() {
const streamRef = useRef<MediaStream>(); // 流目标
const videoRef = useRef<HTMLVideoElement | null>();
useEffect(() => {
const constraints = {
video: {
width: { min: 980, ideal: 1920, max: 1920 },
height: { min: 560, ideal: 1080, max: 1080 },
frameRate: { ideal: 12, max: 15 },
},
audio: true,
};
navigator.mediaDevices
.getUserMedia(constraints)
.then((stream: MediaStream) => {
streamRef.current && streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = stream;
videoRef.current!.srcObject = streamRef.current!; // 用视频标签播映这个流
})
.catch((err) => {
console.error(`[Log] 用户回绝运用摄像头和麦克风`, err);
});
return () => {
streamRef.current && streamRef.current.getTracks().forEach((track) => track.stop()); // 中止这个流
};
}, []);
return (
<div className="record-info-wrapper">
<div className="record-info-video">
<video width="640" height="480" autoPlay={true} ref={(el) => (videoRef.current = el)}></video>
</div>
</div>
);
}
export default RecordInfo;
终究作用:
有几个留意点:
- video 标签的 autoPlay 特点要是 true,这样流才会播映
- 用完一定要记得中止,避免 cpu 占用过高
- 假如想要静音啥的,直接设置 video 标签相关的特点就好了
获取指定设备
一台电脑能够外接多个音视频设备, getUserMedia 默许是拿默许设备,怎样获取一切设备以及获取指定设备的流呢?
运用到的是 enumerateDevices 这个 API,回来的是 Promise<MediaDeviceInfo[]>
,
MediaDeviceInfo 有如下特点:
- kind:设备类型,是摄像头仍是麦克风,
- label: 设备名称
- deviceId: 设备 ID
有了设备 id 就能够用 getUserMedia 获取指定的设备。
录屏
getUserMedia 是通过用户的物理设备来获取流,假如想要获取用户的屏幕的话,需求运用getDisplayMedia 这个 api,运用办法都差不多,不过多说明晰。
设备检测
设备检测应该是项目里边一个必不可少的环节,主要有网络检测和硬件检测。
网络检测
网络检测大体分两种,一个是网速检测,一个是稳定性检测。
不过全体上计划都相同,细节不多说,说一下大体完结:
- 你需求有一张网络图片,然后结构一个数组,长度 5 到 10 都能够
-
https://assistant.ceping.com/Images/all_img.png?t=${Math.random()}
加上随机参数避免缓存
-
- 循环数据,new Image(),然后设置 src,然后监听 onload 工作,然后记载时刻,来完结网速检测
- 稳定性检测,便是看你一切的数据,成功了多少,失败了多少,来判别是否稳定
假如你想简略做,能够运用 ahooks 里边封装的 useNetwork,完结如下
硬件检测
其实便是直接调用 getUserMedia
,看看会不会失败。
留意点
- 系统默许的设备假如被占用了,获取流的时分可能会失败,比方你在会议里边共享桌面,然后在去获取流就可能会失败。
- 联想笔记本有一个物理开关,能总控摄像头的拜访权限,所以有时分一直是黑屏能够检查一下是不是这个原因。
录制视频
根本运用
录制视频是运用的 MediaRecorder 这个 API,运用办法也很简略
-
创立一个 MediaRecorder 目标,传入咱们获取到的 stream 流,能够运用 mimeType 指定编码类型,默许是
video/webm
-
监听 dataavailable 工作,类似于 change 工作,会吐出录像数据,数据其实便是二进制的 blob 目标,直接 push 到数组里边就好了
- 调用 start 办法开端录制,假如不传时刻参数,那 dataavailable 只会在 stop 的时分触发一次,传了时刻参数,就每间隔时刻触发 dataavailable 工作
- 调用 stop 办法完毕录制
回看的逻辑也很简略,咱们 videoBlobs 里边现已搜集到了许多的二进制录屏数据,直接 createObjectURL 传给 video 就能够了
编码类型
结构 MediaRecorder 能够传递一个 mimeType 类型,用来指定录制的数据的编码类型,可是我还没弄明白这些类型终究生成的数据有啥区别,主张直接运用 video/webm
,由于不管是啥编码类型,MediaRecorder 终究生成的文件只会是 webm 格局
贴一个 东西办法,获取当时阅读一切支撑的 mimeType 类型
function getSupportedMimeTypes(media: string) {
const videoTypes = ['webm', 'ogg', 'mp4', 'x-matroska'];
const audioTypes = ['webm', 'ogg', 'mp3', 'x-matroska'];
// prettier-ignore
const codecs = ['should-not-be-supported', 'vp9', 'vp9.0', 'vp8', 'vp8.0', 'avc1', 'av1', 'h265', 'h.265', 'h264', 'h.264', 'opus', 'pcm', 'aac', 'mpeg', 'mp4a'];
const types = media === 'video' ? videoTypes : audioTypes;
const isSupported = MediaRecorder.isTypeSupported;
const supported: string[] = [];
types.forEach((type) => {
const mimeType = `${media}/${type}`;
codecs.forEach((codec) =>
[
`${mimeType};codecs=${codec}`,
// `${mimeType};codecs=${codec.toUpperCase()}`,
].forEach((variation) => {
if (isSupported(variation)) supported.push(variation);
})
);
if (isSupported(mimeType)) supported.push(mimeType);
});
return supported;
}
// 运用
const videoMimeTypeList = getSupportedMimeTypes('video');
console.log(`[Log] videoMimeTypeList-->`, videoMimeTypeList);
const audioMimeTypeList = getSupportedMimeTypes('audio');
console.log(`[Log] audioMimeTypeList-->`, audioMimeTypeList);
编码类型能够了解成压缩数据的方法,有一些是无损压缩的,有一些是有损的,无损的压缩文件巨细就会非常大,这儿就不得不说到一些类型了,由于在项目中踩了许多的坑
在本文中,需求了解的视频格局
- webm: 由于 MediaRecorder 只能录制 webm 格局的数据
- mp4: web 中比较通用的视频格局
在本文中,需求了解的音频格局
- webm: 由于 MediaRecorder 只能录制 webm 格局的数据
- mp3: web 中比较通用的音频格局
- pcm、wav: wav 在 pcm 文件中加入了一些描绘信息,其他和 pcm 完全一致,pcm 是一种无损的音频格局,文件非常巨大,webm 能够不是很费事的转成 pcm 格局
这些文件格局的转化是非常复杂的,但并不是不能完结,需求将 blob 数据转成最原始的二进制数组,然后运用对应的编码计划,操作这个二进制数组。
视频截图
原理是画在 canvas 上,然后用 canvas 的 api 转成 blob 二进制数据
export function takeScreenshot(video: HTMLVideoElement) {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
resolve(blob!); // 图片制作完结
},
'image/jpeg',
1 // 图片压缩比率
);
});
}
可能遇到的坑
- 截图是黑色的:截图的时刻点 video 还没有缓存数据,导致截图是黑色的,需求等 video 缓存数据
- 上面的代码是直接在录屏的时分截图,假如是任意 video 标签截图,需求让 video.load(),而且 loadeddata 工作触发之后,video.readyState 确保加载了足够的帧再截图
- 截的图是重复的:
- 屡次截图的时分,video 标签是同一个,有许多原因会导致屡次截图的时分,拿到的 video 标签的状况是一致的,所以截图便是相同的了,主张是 await 一下 takeScreenshot 办法,截图完结之后,再去截下一张图
频谱-音频可视化
实践的需求是需求获取到说话声响的巨细,类似于微信发语音,有一个声响的动摇作用。
信任大家都看到过下面这种音乐频谱,能在音乐播映的时分跟从跳动。看上去很酷炫,可是实践底层的完结很简略。
我贴一个技能文章和一段代码完结,有兴趣的能够研讨研讨。
代码
// 剖析音频
const analysisAudio = useCallback((audio: HTMLMediaElement) => {
// 创立一个用来处理音频的工作环境(上下文),咱们能够通过它来进行音频读取、解码等,进行一些更底层的音频操作
const audioContext = new AudioContext();
// 设置音频数据源
const source = audioContext.createMediaElementSource(audio);
// 获取音频时刻和频率数据,以及完结数据可视化,connect 之前调用
const analyser = audioContext.createAnalyser();
// connect 连接器,把声响数据连接到剖析器,除了 createAnalyser,还有:BiquadFilterNode[进步音色]、ChannelSplitterNode[切割左右声道] 等对音频数据进行处理
source.connect(analyser);
// 通过 connect 把处理后的数据连接到扬声器进行播映,不需求播映能够不履行
analyser.connect(audioContext.destination);
// fftSize 用来设置剖析的精度,值需求是2的幂次方,值越大 剖析精度越高
analyser.fftSize = 256;
function analyzeAudio() {
// analyser.frequencyBinCount : 二进制音频频率数据的数量(个数)
const bufferLength = analyser.frequencyBinCount;
// Uint8Array : 生成一个长度为 analyser.frequencyBinCount 的,用于处理二进制数据的数组
const dataArray = new Uint8Array(bufferLength);
// 将当时频率数据复制到 freqArray 中
analyser.getByteFrequencyData(dataArray);
// 下面的逻辑便是自定义了 dataArray 中的数据便是频谱数组
const sum = dataArray.reduce((pre, cur) => pre + cur, 0);
const scale = Math.min(100, Math.floor((sum * 100) / bufferLength / 128));
console.log(`[Log] 声响巨细-->`, scale);
requestAnimationFrame(analyzeAudio);
}
audioContext.resume().then(() => {
analyzeAudio();
});
}, []);
上面的代码完全是模板,一切地方都能够 copy,不必了解是啥意思,照着抄就完事了,然后大致的讲一下一些要害点
-
const source = audioContext.createMediaElementSource(audio);
这儿是剖析了 audio dom,audioContext 还有一个createMediaStreamSource
办法,能够剖析 MediaStream -
analyser.connect(audioContext.destination);
这个是播映剖析的音频,假如只剖析,不需求播映出来,注释就好了 -
analyser.getByteFrequencyData(dataArray);
这行便是中心的逻辑,把剖析的数据 赋值给 dataArray,履行完之后,能够吧 dataArray log 出来,便是咱们需求的频谱信息
-
声响有高腔调有低腔调,dataArray 的数据便是从低往高腔调的一个描绘,dataArray[0] 表明的是最低的腔调,值的巨细表明最低腔调的饱满程度。
-
analyser.fftSize = 256;
是用来决定剖析的精度,dataArray 的长度是它的一半
坑点
- 留意 requestAnimationFrame 的性能,我这儿是获取到声响的巨细之后 setState 渲染组件了,假如不及时的中止的花,会形成页面越来越卡顿
实时语音转文字
实时语音转文字咱们用的是腾讯云的服务,他供给了 对应的 js sdk 示例
sdk 里边的中心功用便是三个:
- 创立一个声响采集目标,相关的代码在
webrecorder.js
里边,这个里边代码直接抄就完事了- 这儿边也挺有意思的,咱们光看函数的命名,就知道这儿做了一层 webm 格局的数据转 pcm 格局,细节感兴趣的小伙伴能够看看
- AudioWorkletNode MDN
- createScriptProcessor MDN
- 创立一个声响剖析目标,相关的代码在
speechrecognizer.js
里边- 第一步,树立链接
- 第二步,发送
webrecorder.js
获取到的声响数据 - 第三步,将服务端的数据吐给调用者,在 strat 里边树立链接后,监听 onmessage 工作,然后判别服务端回来的数据,做一系列的逻辑判别,最后用许多回调吧数据回来给用户
- 把这两个相关起来,相关的代码在
webaudiospeechrecognizer.js
里边,中心的逻辑便是实例化上面两个类,然后监听录音的回调,拿到声响数据调用 speechrecognizer 示例的 write 办法传输数据给服务端
踩过的坑-本文中心
其实至此,上面说到的代码,去问 GPT,他都现已能给你写个 80%出来了,不得不慨叹 GPT 的强壮
可是,不出意外肯定是出意外了。
坑点 1,录制的 视频/声响 没有时长
这个的表象是啥呢?
- 咱们的项目需求获取到声响的时长,模拟 GPT 说话的一种打字作用
- 咱们在播映录音数据的时分,获取 audio 标签的 duration 是一个 Infinity,并不是详细的时刻值
- 在播映视频文件的时分,不管是下载到本地,仍是直接用阅读器的 video 标签播映,播映器的进度条没有 总时长,播映器的控制按钮没有倍数才干
OK,去百度一圈,发现一个写法,能够精确的拿到文件的时长,便是录音文件播映的时分,在一开端将文件的 currentTime 设置成一个巨大的值,这样他一定会超越文件的时长,让他直接播映完结,在 ontimeupdate 工作中,就能够拿到精确的 duration 了,完事之后再把 currentTime 改成 0,对用户来说仍是从头开端播映
这样做有一个小小的缺陷,便是假如文件很大的话,一开端并不会悉数加载完,这个时分设置 currentTime 会导致进度条跳一下到最后,在跳回起点,这个仍是能够看到的,不过全体的体会也没那么糟糕,还算能够接受。
坑点 2,不能支撑倍数播映
我滴乖乖,上面的坑点也说了,用原生的 video 标签播映阅读器它自己录制的视频,竟然不给我悉数功用,还缺臂膀少腿,然后咱们的产品还非常重视这个倍速功用
有问题的长这样
正常的长这样,多一个倍速的才干
你说这可咋整,让我百度我都没有条理去百度啊!
OK,言归正传,在 chromium 的 bug 列表里边,仍是能搜出来几个与之相关的问题。
- Issue1: MediaRecorder output should have Cues element
- Issue2: Videos created with MediaRecorder API are not seekable / scrubbable
- Issue3: No duration or seeking cue for opus audio produced with mediarecoder
- Issue4: MediaRecorder: consider producing seekable WebM files
一个视频文件,大体能够分红两个部分,一个是 文件头信息,一个是视频的本体内容,文件头信息里边会记载许多视频的描绘数据,比方编码格局,时长,等等。
MediaRecorder 能够录制数据,甚至能够在 strat 办法里边设置一个时刻,分段录制数据,所以他并不知道咱们的视频是何时中止的,也就不可能往头信息里边写相关的数据,甚至,咱们录制的 webm,根本就没有文件头信息。
Chromium 官方现已将上面的 bug 标识为wont fix
不会修正,并推荐开发者自行找社区处理。
OK 通过不懈努力,咱们现在现已知道 bug 的缘由了,那就找计划处理呗。
文章最开端的时分有说到一嘴,咱们录制的 webm 格局的时分,我能够转化成其他格局的数据,只需求解码 webm 格局的数据,在依照相关格局的数据要求的编码方法进行编码就能够了。
能解码 webm 数据,也便是说咱们也能够剖析 webm 格局的数据,然后依照 WebM 格局的要求修正 文件头信息。
一句话说的很简略,让咱们手撸这个进程也太不实际了吧,所以就去百度了一圈,找到几个相关的库。
官方给的实例里边,咱们需求自己手动的去记载视频时长,然后在修正文件头信息,这儿的修正只是很简略的修正,并没有解析整个视频文件的内容
这个官方给的 事例很简略,甚至看不出来能修正视频时长,在 github issue 里边搜了一圈才搜到完整的用法
import { tools, Reader, Decoder } from 'ts-ebml';
import { Buffer } from 'buffer';
window.Buffer = Buffer; // ts-ebml 最新版依靠了这个库,需求咱们自己外部引进
export default async function fixWebmMetaInfo(blob: Blob): Promise<Blob> {
try {
const decoder = new Decoder();
const reader = new Reader();
reader.logging = false;
reader.drop_default_duration = false;
const bufSlice = await blob.arrayBuffer();
const ebmlElms = decoder.decode(bufSlice);
ebmlElms.forEach((element) => reader.read(element));
reader.stop();
const refinedMetadataBuf = tools.makeMetadataSeekable(reader.metadatas, reader.duration, reader.cues); // 修正出来的文件头二进制数据
const body = bufSlice.slice(reader.metadataSize); // 本来的数据切断一些,留出文件头的方位
const result = new Blob([refinedMetadataBuf, body], { type: blob.type }); // 把文件头拼接回去
return result;
} catch (error) {
console.log(`[Log] fixWebmMetaInfo error-->`, error);
return blob;
}
}
fix-webm-metainfo 的源码其实便是 copy 的 ts-ebml,那这个库存在的含义是处理了什么问题呢?
new ArrayBuffer()
读取 二进制数据的时分 ArrayBuffer 的长度最大只有 1GB,假如你的文件巨细超越了 1GB,那 ts-ebml 就不太够用了,fix-webm-metainfo 便是在 ts-ebml 基础上,在解析 webm 格局的数据时分,做了一个分段解析 webm 文件的才干。
坑点 3,文件格局
咱们项目里边还有一个功用,便是需求做语音识别功用,本来是想着把录音数据给到后端,后端去调第三方接口来完结这个功用,可是第三方的接口文档里边,不支撑 webm 格局的文件!!!
oh no! 这下不得不做文件格局转化了。
这个时分回想一下咱们上面的实时语音转文字功用,其实就现已帮咱们完结了这个功用了。
实时的语音转文字便是 webm 转 pcm 格局发送到服务端的,而且webrecorder.js
里边竟然就存了一切的 pcm 数据,而且在 完毕回调里边会传出来。
我滴妈,泪目了,腾讯 你干得好啊,直接把我要做的功用搞定了。
所以我兴致勃勃的去跟我的 leader 反应这个工作,可是 leader 直接给我泼了一盆凉水,pcm 格局的数据太大了,不适合网络传输,最好仍是转成 MP3 格局的文件。
555 尽管可是,我太难了。
期间遇到一个贼搞笑的工作,leader 去问他花钱买的 GPT4.0 怎样吧 webm 转 mp3,成果 GPT 一本正经的在胡说,说有个库直接装置就能够做到,成果这个库都搜不到,问它是不是胡说,成果它又胡说了一个库出来,哈哈哈。
ok,来讲一下怎样音频的 webm 转 mp3
现在有两个库
这儿只介绍 Recorder 这个库,由于这个库太强壮了,便是专门服务于音频范畴的,而且这个库底层的 webm 转 mp3 便是运用 lamejs 这个库完结的。这个库的 api 运用起来也非常的简略
import Recorder from 'recorder-core/recorder.mp3.min';
Recorder.CLog = function () {}; // 屏蔽日志
const audioRecorder = Recorder({ type: 'mp3', sampleRate: 16000, bitRate: 16 }); // 这儿有一个坑点,采样率和比特率一定要和后端对好,第三方的api对这个有要求
audioRecorder.open(); // 初始化 的时分需求 open 一下
audioRecorder.start(); // 开端录制mp3
audioRecorder.stop((blob: Blob) => {
audioBlob.current = blob;
}); // 录制完毕的时分会吐出 mp3 数据
audioRecorder.close(); // 用完需求 close 一下
后续
视频格局转化
在上面处理视频不能倍数的进程中查找到一个 WebAV 库,这个库是 B 站的大佬开发的,主要是做视频剪辑范畴的,一起也能够录视频,而且没有上面说到的问题,而且还能够直接输出 MP4 格局,仅有的问题便是,底层运用的是一个阅读器相当新的一个 API 完结的,兼容性非常的差,chrome 都需求 94 版本才干支撑。
RecordRTC
阅读器录制视频运用的是 MediaRecorder 这个 API,咱们现已原生手撸了录制的功用,而且处理了其中遇到的一些坑点。
项目完毕之后,回过头来总结的时分,在 github 上查找到了 RecordRTC这个库,是一个专门用来处理录屏计划的库。
哎,只能怪这个项目时刻实在是太赶了,否则肯定就不会手撸录视频的功用了,而是直接运用这个库。
也便是说,音频范畴有很老练的 Recorder 这个库,视频范畴有很老练的 RecordRTC 这个库。
那现在咱们稍微的翻翻这个 RecordRTC 库的完结吧 看看它是怎样处理咱们上面遇到的问题。
在这个文件中咱们能够看到,录视频的才干也是运用的 MediaRecorder 这个 API。
MediaRecorder 录制的视频没有 duration 也有相关评论的 bug
终究的处理计划,作者是说供给了一个 getSeekableBlob 办法,来修正录制出来的 Blob 文件
getSeekableBlob 办法的完结 也是用的 ts-ebml
这个库。
哎,本来咱们遇到的坑,前人早就遇到而且处理了。
不过现在还有一个坑点,便是 webm 格局的文件,怎样转化成 mp4? RecordRTC 这个库并没有一个很好的计划,相关的问题中,也只能运用 ffmpeg
在服务端去做这个工作。
前端有 ffmpeg.wasm,现在事务里边还没有遇到视频格局转化的场景,先留坑一下