前端音频处理入门 (1)- 录音
写在前面
本专栏主要整理前端音视频处理相关的内容,会由浅到深的逐步解析应用,可能会设计音视频频解码、音视频频图像、转码、WASM 等相关内容,总之 DEMO 写到哪儿,内容整理到哪儿 。
录音是一个非常通用的功能,DEMO 见效快,作为入手最为容易, 就让我们先从 API 开始吧。
API
录音会使用到的 API 主要有三个,分别是 mediaDevices,MediaRecorder 与 AudioContext。
mediaDevices
mediaDevices 接口提供访问连接媒体输入的设备,如照相机和麦克风,及屏幕共享等,通过它可以获取硬件资源。也就是说,你想要录音,就需要从这个接口获取权限。
在调用如下代码时,浏览器会向用户申请麦克风权限。
window.navigator.mediaDevices.getUserMedia({
audio: 'audio',
}).then(audioStream => {
MediaRecorder
MediaRecorder 接口提供媒体录制的能力,通过建立数据流的方式提供音视频处理基础。不过该 API 在 iOS 上的支持并不太好,在 14.3 beta 版本以上才提供支持,详细情况可以阅读 WebKit 的 MediaRecorder API 文章,目前的进度标记为开发中。
使用上通过读取 audioStream 完成处理。
const mediaChunks = []
mediaRecorder = new MediaRecorder(mediaStream);
mediaRecorder.ondataavailable = ({ data }) => {
mediaChunks.push(data);
mediaRecorder.onstop = () => {
const blobProperty = {"audio/webm; codecs=opus" };
const blob = new Blob(mediaChunks, blobProperty);
const url = URL.createObjectURL(blob);
mediaRecorder.start();
这里有一个坑,就是 start 过后,并不一定会立即进入录音状态。如果你采用的是有线设备连接,那么麦克风的响应速度会非常快。但如果你使用的是蓝牙设备,那很可能会有相当长的设备响应时间。
那什么时候设备真的完成就绪了呢?实际上是在 onstart 接口之后,录音设备彻底完成就绪。
mediaRecorder.onstart = () => {
};
到此为止,录音功能就基本完成了。但这样录音是不是有点儿单调呢,通常我们见到的录音界面会绘制一个起伏不定的波形图,那如果想要画图,就得再通过 AudioContext API 解析读取的数据。
AudioContext
AudioContext API 仅仅是 Web Audio API 中的一项,用于创建音频处理的上下文环境。通过 createAnalyser API 创建 AnalyserNode 分析节点,可以获取音频节点上信息,大致就像下面这样,绘制一个声音的波形图像。
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(mediaStream);
source.connect(analyser);
const bufferLength = analyser.fftSize; // 默认为2048
const backgroundColor = 'white'
const strokeColor = 'black'
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
有了以上的 API 基础后,我们就可以编写一个录音 Demo 了。
Demo
在 React 17 的基础上,编写一个用于录音的 Hooks,把逻辑全部抽象封装进去。
import { useCallback, useRef, useState } from "react";
export const useMediaRecorder = (options) => {
const {
audio,
onStart = () => {},
onStop = () => {},
} = options
const mediaRecorder = useRef(null);
const mediaChunks = useRef([]);
const mediaStream = useRef(null);
const [status, setStatus] = useState("idle");
const [mediaBlobUrl, setMediaBlobUrl] = useState(null);
const [error, setError] = useState("NONE");
const getMediaStream = useCallback(async () => {
setStatus("acquiring_media");
try {
const audioStream = await window.navigator.mediaDevices.getUserMedia({
audio,
mediaStream.current = audioStream;
setStatus("idle");
} catch (error) {
setError(error.name);
setStatus("idle");
}, [audio]);
const startRecording = async () => {
setError("NONE");
if (!mediaStream.current) {
await getMediaStream();
if (mediaStream.current) {
const isStreamEnded = mediaStream.current
.getTracks()
.some((track) => track.readyState === "ended");
if (isStreamEnded) {
await getMediaStream();
mediaRecorder.current = new MediaRecorder(mediaStream.current);
mediaRecorder.current.ondataavailable = onRecordingActive;
mediaRecorder.current.onstart = () => {
onStart(mediaStream.current)
mediaRecorder.current.onstop = onRecordingStop;
mediaRecorder.current.onerror = () => {
setError("NO_RECORDER");
setStatus("idle");
mediaRecorder.current.start();
setStatus("recording");
const onRecordingActive = ({ data }) => {
mediaChunks.current.push(data);
const onRecordingStop = () => {
const [chunk] = mediaChunks.current;
const blobProperty = { type: chunk.type || "audio/webm; codecs=opus" };
const blob = new Blob(mediaChunks.current, blobProperty);
const url = URL.createObjectURL(blob);
setStatus("stopped");
setMediaBlobUrl(url);
onStop(url, blob);
const pauseRecording = () => {
if (mediaRecorder.current && mediaRecorder.current.state === "recording") {
mediaRecorder.current.pause();
const resumeRecording = () => {
if (mediaRecorder.current && mediaRecorder.current.state === "paused") {
mediaRecorder.current.resume();
const stopRecording = () => {
if (mediaRecorder.current) {
if (mediaRecorder.current.state !== "inactive") {
setStatus("stopping");
mediaRecorder.current.stop();
mediaStream.current &&
mediaStream.current.getTracks().forEach((track) => track.stop());
mediaChunks.current = [];
return {
error,
startRecording,
pauseRecording,
resumeRecording,
stopRecording,
mediaBlobUrl,
status,
stream: mediaStream.current,
在此基础上,再封装一个绘制波形图的函数。
const renderAudioGraph= (analyser, canvas) => {
const height = 200
const width = 200
const bufferLength = analyser.fftSize; // 默认为2048
const backgroundColor = 'white'
const strokeColor = 'black'
const dataArray = new Uint8Array(bufferLength);
analyser.getByteTimeDomainData(dataArray);
const canvasCtx = canvas.getContext("2d");
canvasCtx.clearRect(0, 0, width, height);
canvasCtx.fillStyle = backgroundColor;
canvasCtx.fillRect(0, 0, width, height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = strokeColor;
canvasCtx.beginPath();
const sliceWidth = Number(width) / bufferLength;
let x = 0;
canvasCtx.moveTo(x, height / 2);
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * height / 2;
canvasCtx["lineTo"](x, y);
x += sliceWidth;
canvasCtx.lineTo(width, height / 2);
canvasCtx.stroke();
最后就是在组件中直接使用,整体就大功告成了。
function App() {
const canvasRef = useRef()
const {
status,
startRecording,
stopRecording,
mediaBlobUrl,
stream,
} = useMediaRecorder({
audio: true,
useEffect(() => {
if (!stream) {
return;
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const renderCurve = () => {
renderAudioGraph(analyser, canvasRef.current)
if (status === 'recording') {
window.requestAnimationFrame(renderCurve);
} else {
console.log('no rendering')
renderCurve()
}, [status, stream])
return (
<div className="App">
<button onClick={status === 'recording' ? stopRecording : startRecording}>
{status === 'recording' ? "停止" : "录制" }
</button>
<canvas
ref={canvasRef}
height={200}
width={200}
style={{width: 200, height: 200}}