翻译:如何用媒体流 API 录音

863 阅读4分钟

译者:以后再也不翻这么长的文章了,好累……

原文:How to Record Audio Using the MediaStream API

媒体捕获和流 API(又名流媒体 API)允许你从用户的麦克风录音,然后获取记录的音频或媒体元素作为音轨。你可以在录制后直接播放音轨,或者上传媒体到你的服务器。

在这个教程中,我们将创建一个网站,使用流媒体 API 让用户录制一些东西,然后上传录制音频到服务器保存。用户将可以看到和播放上传的所有录制。

你能在 这个 GitHub 仓库 找到这个教程的完整代码。

设置服务器

我们以创建 Node.jsExpress 服务端为开始。所以首先确认 下载和安装 Node.js 如果你的机器上还没有这些。

创建目录

创建一个保存此项目的新目录,并进入该目录:

mkdir recording-tutorial
cd recording-tutorial

初始化项目

然后,用 npm 初始化项目:

npm init -y

选项 -y 表示用默认值创建 package.json

安装依赖

接下来,我们将为我们创建的服务端安装 Express ,并安装 nodemon 以便在任何修改时重启服务端。

npm i express nodemon

创建 Express 服务端

我们现在可以开始创建一个简单的服务端。在项目根目录下创建 index.js,写上如下内容:

const path = require('path');
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.use(express.static('public/assets'));

app.listen(port, () => {
  console.log(`App listening at http://localhost:${port}`);
});

这就创建了一个运行在 3000 端口的服务端,除非端口在环境中被设置过,它暴露 public/assets 目录——我们即将创建——存放 JavaScript、CSS 文件和图片。

添加脚本

最后,在 package.jsonscripts 下添加一个 start 脚本:

"scripts": {
  "start": "nodemon index.js"
},

启动 web 服务端

让我们测试一下服务端。运行下面的命令启动服务端:

npm start

服务端应该在 3000 端口启动。你可以在 localhost:3000 访问它,但是你会看到 "Cannot GET /" 的消息因为我们目前没有定义任何路由。

创建录音页面

接下来,我们将创建网站的主页面。用户将用这个页面录音、查看和播放。

创建 public 目录,在里面创建带如下内容的 index.html 文件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Record</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  <link href="/css/index.css" rel="stylesheet" />
</head>
<body class="pt-5">
  <div class="container">
    <h1 class="text-center">Record Your Voice</h1>
    <div class="record-button-container text-center mt-5">
      <button class="bg-transparent border btn record-button rounded-circle shadow-sm text-center" id="recordButton">
        <img src="/images/microphone.png" alt="Record" class="img-fluid" />
      </button>
    </div>
  </div>
</body>
</html>

这个页面用 Bootstrap 5 写样式。现在,页面仅仅展示一个按钮,用户可用它录音。

注意我们用一张图片表示麦克风。你可以在 Iconscout 下载该图标,或者用 Github 仓库 的修改版。

下载图标并放在 public/assets/images 里,命名为 microphone.png

添加样式

我们还要连接样式表 index.css,所以创建 public/assets/css/index.css 文件写上如下内容:

.record-button {
  height: 8em;
  width: 8em;
  border-color: #f3f3f3 !important;
}

.record-button:hover {
  box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
}

创建路由

最后,我们只需要在 index.js 添加新的路由。在 app.listen 之前添加如下内容:

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public/index.html'));
});

如果服务端没有运行,用 npm start 启动服务端。然后在浏览器里访问 localhost:3000。你将看到一个录音按钮。

image.png

该按钮现在什么也不做。我们需要绑定一个触发录音的点击事件。

创建一个带有如下内容的 public/assets/js/record.js 文件:

// 初始化我们将用的元素
const recordButton = document.getElementById('recordButton');
const recordButtonImage = recordButton.firstElementChild;

let chunks = []; // 将后面被用于录音
let mediaRecorder = null; // 将在后面被用于录音
let audioBlob = null; // 将保存录音的 blob

我们初始化这些我们后面要用到的变量。然后创建一个 record 函数,它是 recordButton 的 click 事件的事件监听器。

function record() {
  //TODO 开始录音
}

recordButton.addEventListener('click', record);

我们还要把这个函数作为事件监听器绑定到录音按钮上。

媒体录制

为了开始录制,我们需要使用 mediaDevices.getUserMedia() 方法。

这个方法允许我们获取流、录制用户的音频和/或视频,仅当用户提供让网站那样做的权限。getUserMedia 方法允许我们访问本地输入设备。

getUserMedia 接收 MediaStreamConstraints 对象作为参数,该对象包含了一组限制,该限制明确我们从 getUserMeida 获得的哪种媒体类型是想要的。

如果值为 false,意味着我们不会访问设备或者录制媒体。

getUserMedia 返回一个 Promise。如果用户允许网站录制,该 promise 的成功回调会接收到一个 MediaStream 对象,我们能用它捕获用户的视频或音频流。

媒体捕获和流

为了使用流媒体 API 对象捕获媒体,我们需要使用 MediaRecorder 接口。我们将要创建一个该接口的新对象,它的构造函数接收 MediaStream 对象、从而使我们能容易的通过它的方法控制录制。

record 函数中,添加如下代码:

// 检查浏览器是否支持 getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  alert('Your browser does not support recording!');
  return;
}

// 浏览器支持 getUserMedia
// 改变按钮中的图片
recordButtonImage.src = `/images/${mediaRecorder && mediaRecorder.state === 'recording' ? 'microphone' : 'stop'}.png`;
if (!mediaRecorder) {
  // 开始录制
  navigator.mediaDevices.getUserMedia({
    audio: true,
  })
    .then((stream) => {
      mediaRecorder = new MediaRecorder(stream);
      mediaRecorder.start();
      mediaRecorder.ondataavailable = mediaRecorderDataAvailable;
      mediaRecorder.onstop = mediaRecorderStop;
    })
    .catch((err) => {
      alert(`The following error occurred: ${err}`);
      // 改变按钮中的图片
      recordButtonImage.src = '/images/microphone.png';
    });
} else {
  // 停止录制
  mediaRecorder.stop();
}

浏览器支持

我们先检查 navigator.mediaDevicesnavigator.mediaDevices.getUserMedia 是否定义了,因为有些浏览器像 IE,Chrome 安卓版,或其他的 不支持

此外,使用 getUserMedia 要求 安全网站,即页面加载使用 HTTPS,file:// 或者来源于localhost。所以,如果页面加载不安全,mediaDevicesgetUserMedia 会是未定义的。

开始录制

如果条件是 false(换言之,mediaDevicesgetUserMedia 都支持),我们先将按钮的录制图片改成 stop.png,你能从 IconscoutGitHub 仓库 下载并放到 public/assets/images 里。

然后,我们检查 mediaRecord ——我们在文件开头定义的——是否是 null。

如果它是 null,它表示没有进行中的录制。所以,我们用 getUserMedia 获取一个 MediaStream 实例开始录制。

这里浏览器提示用户允许网站访问麦克风。如果用户允许,成功回调里的代码将被执行:

mediaRecorder = new MediaRecorder(stream);
mediaRecorder.start();
mediaRecorder.ondataavailable = mediaRecorderDataAvailable;
mediaRecorder.onstop = mediaRecorderStop;

这里我们创建新的 MediaRecorder,赋值给我们在文件开头定义的 mediaRecorder

我们给构造函数传从 getUserMedia 接收的流。然后,我们用 mediaRecorder.start() 开始录制。

最后,我们绑定事件句柄(我们稍候创建)到两个事件上,dataavailablestop

我们也要添加 catch 句柄万一用户不允许网站访问麦克风或者其他任何 可能抛出的异常

停止录制

这都发生在 mediaRecorder 不是 null 的情况下。如果它是 null,说明存在进行中的录制并且用户结束它。所以,我们使用 mediaRecorder.stop() 方法停止录制。

} else {
  //stop recording
  mediaRecorder.stop();
}

处理媒体录制事件

目前我们的代码在用户点击录制按钮时开始和停止录制。接下来,我们将给 dataavailablestop 添加事件句柄。

数据可用时

dataavailable 在全部录制完成时被触发,或者基于被传给 mediaRecorder.start() 的可选参数 timeslice、它表明这个事件被触发的毫秒数。传递 timeslice 允许分片录制以及以小块的形式获取。

创建 mediaRecorderDataAvailable 函数,它将处理 dataavailable 事件,仅以添加收到的 BlobEvent 参数上的 Blob 音频到 chunks 数组里,此数组在文件开头定义过:

function mediaRecorderDataAvailable(e) {
  chunks.push(e.data);
}

该小块将是用户录制的音频追踪数组。

停止时

在创建处理 stop 事件的 mediaRecorderStop 之前,我们先添加 HTML 元素容器以 保存丢弃 按钮处理录制过的音频。

添加如下内容到 public/index.html,只需在闭合标签 </body> 之前:

<div class="recorded-audio-container mt-5 d-none flex-column justify-content-center align-items-center"
  id="recordedAudioContainer">
  <div class="actions mt-3">
    <button class="btn btn-success rounded-pill" id="saveButton">Save</button>
    <button class="btn btn-danger rounded-pill" id="discardButton">Discard</button>
  </div>
</div>

然后,在 public/assets/js/record.js 开头,添加一个变量表示 #recordedAudioContainer 元素的节点实例:

const recordedAudioContainer = document.getElementById('recordedAudioContainer');

我们现在可以实现 mediaRecorderStop。这个函数先移除之前录制或未保存的 audio 元素,创建一个新的 audio 元素,把 src 设置为录制流的 Blob,并显示容器:

function mediaRecorderStop () {
  // 检测是否存在之前的录制并移除它们
  if (recordedAudioContainer.firstElementChild.tagName === 'AUDIO') {
    recordedAudioContainer.firstElementChild.remove();
  }
  // 创建一个新的保存录音的 audio 元素
  const audioElm = document.createElement('audio');
  audioElm.setAttribute('controls', ''); // 添加 controls
  // 从 chunk 创建 Blob
  audioBlob = new Blob(chunks, { type: 'audio/mp3' });
  const audioURL = window.URL.createObjectURL(audioBlob);
  audioElm.src = audioURL;
  // 展示 audio
  recordedAudioContainer.insertBefore(audioElm, recordedAudioContainer.firstElementChild);
  recordedAudioContainer.classList.add('d-flex');
  recordedAudioContainer.classList.remove('d-none');
  // 重置为默认
  mediaRecorder = null;
  chunks = [];
}

在最后,我们重置 mediaRecorderchunks 为初始值以便处理下次录制。用这个代码,我们的网址应该能录音,并当用户停止,能允许他们播放录制的音频。

最后我们要做的事是在 index.html 里连接 record.js。在 body 末尾添加 script

<script src="/js/record.js"></script>

测试录制

我们现在去看效果。浏览器里访问 localhost:3000 并点击 录制 按钮,你将被询问是否允许网站使用麦克风。

image.png

确认你是以 localhostHTTPS 加载网站即使你用的是支持的浏览器。媒体设备和 getUserMedia 在其他情况是不可用的。

点击 允许。麦克风图片将变成停止图片。同时,你应该能看到录音图标在浏览器的地址栏中。这表示麦克风正被网站访问。

image.png

尝试录制几秒钟。然后点击 停止 按钮。按钮的图片将变回麦克风图片,并且音频播放器和两个按钮—— 保存丢弃 会出现。

image.png

接下来,我们是想 保存丢弃 按钮点击事件。保存 按钮应该上次音频到服务端,丢弃 按钮应该移除它。

丢弃点击事件句柄

我们先实现 丢弃 按钮的事件句柄。点击这个按钮应该先向用户提示确认他们想丢弃录制。然后,如果用户确认了,它将移除音频播放器和隐藏按钮。

public/assets/js/record.js 开头添加引用 丢弃 按钮的变量:

const discardAudioButton = document.getElementById('discardButton');

然后,在文件末尾添加下面的内容:

function discardRecording () {
  // 提示用户确认他们想丢弃
  if (confirm('Are you sure you want to discard the recording?')) {
    // 丢弃刚刚录制的音频
    resetRecording();
  }
}

function resetRecording () {
  if (recordedAudioContainer.firstElementChild.tagName === 'AUDIO') {
    // 移除 audio
    recordedAudioContainer.firstElementChild.remove();
    // 因此 recordedAudioContainer
    recordedAudioContainer.classList.add('d-none');
    recordedAudioContainer.classList.remove('d-flex');
  }
  // 重置 audioBlob 以便下次录制
  audioBlob = null;
}

// 给按钮添加事件监听
discardAudioButton.addEventListener('click', discardRecording);

你现在能试试随便录制,然后点击 丢弃 按钮。音频播放器将被移除并且按钮被隐藏。

上传到服务端

保存点击事件句柄

现在,我们将实现 保存 按钮的点击句柄。这个句柄将在用户点击 保存 按钮时用 Fetch API 上传 audioBlob 到服务端。

如果你对 Fetch API 不熟悉,你可以在我们的“Fetch API 介绍”教程学习更多。

让我们开始在项目根目录创建 uploads

mkdir uploads

然后,在 record.js 开头,添加一个引用 保存 按钮元素的变量:

const saveAudioButton = document.getElementById('saveButton');

然后,在末尾,添加如下内容:

function saveRecording () {
  // 持有上传的 Blob 的表单数据
  const formData = new FormData();
  // 添加 Blob 到 formData
  formData.append('audio', audioBlob, 'recording.mp3');
  // 像终端发送请求
  fetch('/record', {
    method: 'POST',
    body: formData
  })
  .then((response) => response.json())
  .then(() => {
    alert("Your recording is saved");
    // 重置以便下次录制
    resetRecording();
    // TODO 获取录制
  })
  .catch((err) => {
    console.error(err);
    alert("An error occurred, please try again later");
    // 重置以便下次录制
    resetRecording();
  })
}

// 添加事件句柄到点击事件
saveAudioButton.addEventListener('click', saveRecording);

注意,一旦录音上传了,我们使用 resetRecording 重置音频以便下次录制。后面,我们将获取全部录音展示给用户。

创建 API 终端

我们现在需要实现 API 终端,该终端将上传音频到 uploads 目录。

为了在 Express 中方便的处理上传,我们将使用 Multer 库。Multer 通过一个中间件来处理文件上传。

运行下面的命令安装啊它:

npm i multer

然后,在 index.js,添加如下内容到文件开头:

const fs = require('fs');
const multer = require('multer');

const storage = multer.diskStorage({
  destination(req, file, cb) {
    cb(null, 'uploads/');
  },
  filename(req, file, cb) {
    const fileNameArr = file.originalname.split('.');
    cb(null, `${Date.now()}.${fileNameArr[fileNameArr.length - 1]}`);
  },
});
const upload = multer({ storage });

我们用 multer.diskStorage 声明 storage,我们用它配置保存文件到 uploads 里,我们基于当前时间戳和文件名后缀保存文件。

然后,我们声明 upload,它是上传文件的中间件。

接下来,我们希望 uploads 目录里的文件可对外访问。所以,在 app.listen 前添加如下内容:

app.use(express.static('uploads'));

最后,我们创建上传终端。该终端仅使用 upload 中间件上传音频并返回 JSON 响应:

app.post('/record', upload.single('audio'), (req, res) => res.json({ success: true }));

upload 中间件将处理文件上传。我们只需要把上传文件的字段名传给 upload.single

请注意,通常,你需要执行 文件验证 并确保正确的,预期的文件类型被上传。简单起见,我们在这个教材中忽略了。

测试上传

让我们测试一下。在浏览器再次访问 localhost:3000,录制一些事,并点击 保存 按钮。

此请求将被发送到终端,文件将被上传,并将向用户展示一个警示框通知他们录音已被保存。

你可以通过检查 项目根目录中的 uploads 目录确认音频事实上已被上传。

展示录音

创建一个 API 终端

我们要做的最后一件事是向用户展示全部录音以便用户能播放它们。

首先,我们将创建用于获取全部文件的终端。在 index.jsapp.listen 前添加如下内容:

app.get('/recordings', (req, res) => {
  let files = fs.readdirSync(path.join(__dirname, 'uploads'));
  files = files.filter((file) => {
    // 检查文件是音频文件
    const fileNameArr = file.split('.');
    return fileNameArr[fileNameArr.length - 1] === 'mp3';
  }).map((file) => `/${file}`);
  return res.json({ success: true, files });
});

我们仅读 uploads 目录的文件,过滤出 mp3 文件,并给每个文件名补上 /。最后,我们返回一个带文件的 JSON 对象。

添加录音容器

接下来,我们将添加作为展示录音的容器的 HTML 元素。在 body 结束处 record.js 脚本前添加如下内容:

<h2 class="mt-3">Saved Recordings</h2>
<div class="recordings row" id="recordings">

</div>

从 API 获取文件

再在 record.js 开头添加引用 #recordings 元素的变量:

const recordingsContainer = document.getElementById('recordings');

然后,我们添加一个调用我们之前创建的终端的 fetchRecordings 函数,那时,借助 createRecordingElement 函数,渲染音频播放器元素。

我们还为播放音频按钮的点击事件添加 playRecording 事件监听。

record.js 末尾添加如下内容:

function fetchRecordings () {
  fetch('/recordings')
  .then((response) => response.json())
  .then((response) => {
    if (response.success && response.files) {
      // 移除全部之前显示的录制
      recordingsContainer.innerHTML = '';
      response.files.forEach((file) => {
        // 创建录制元素
        const recordingElement = createRecordingElement(file);
        // 把它加进录制容器
        recordingsContainer.appendChild(recordingElement);
      })
    }
  })
  .catch((err) => console.error(err));
}

// 创建录制元素
function createRecordingElement (file) {
  // 容器元素
  const recordingElement = document.createElement('div');
  recordingElement.classList.add('col-lg-2', 'col', 'recording', 'mt-3');
  // audio 元素
  const audio = document.createElement('audio');
  audio.src = file;
  audio.onended = (e) => {
    // 当音频结束,再修改按钮图片为播放状态
    e.target.nextElementSibling.firstElementChild.src = 'images/play.png';
  };
  recordingElement.appendChild(audio);
  // 按钮元素
  const playButton = document.createElement('button');
  playButton.classList.add('play-button', 'btn', 'border', 'shadow-sm', 'text-center', 'd-block', 'mx-auto');
  // 按钮图片
  const playImage = document.createElement('img');
  playImage.src = '/images/play.png';
  playImage.classList.add('img-fluid');
  playButton.appendChild(playImage);
  // 给按钮添加事件监听以便播放录音
  playButton.addEventListener('click', playRecording);
  recordingElement.appendChild(playButton);
  // 返回容器元素
  return recordingElement;
}

function playRecording (e) {
  let button = e.target;
  if (button.tagName === 'IMG') {
    // 获取父元素按钮
    button = button.parentElement;
  }
  // 获取 audio 兄弟元素
  const audio = button.previousElementSibling;
  if (audio && audio.tagName === 'AUDIO') {
    if (audio.paused) {
      // 如果音频暂停,播放它
      audio.play();
      // 修改按钮图片为暂停状态
      button.firstElementChild.src = 'images/pause.png';
    } else {
      // 如果音频播放,暂停它
      //if audio is playing, pause it
      audio.pause();
      // 修改按钮图片为播放状态
      button.firstElementChild.src = 'images/play.png';
    }
  }
}

注意,在 playRecording 函数中,我们用 audio.paused 检查音频是否正在播放,它在当时音频未播放时返回 true。

我们还用了在每次录制中出现的 播放暂停 图标,你可以从 Iconscout 或 the GitHub 仓库 获取它们。

当页面加载和新录音上传后,我们使用了 fetchRecordings

所以,在 record.js 文件末尾、saveRecording 的成功回调的 TODO 注释位置调用此函数:

.then(() => {
  alert("Your recording is saved");
  //reset for next recording
  resetRecording();
  //fetch recordings
  fetchRecordings();
})

添加样式

最后一件我们需要做的事是添加一些样式到我们创建的元素上。添加如下内容到 public/assets/css/index.css

.play-button:hover {
  box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
}

.play-button {
  height: 8em;
  width: 8em;
  background-color: #5084d2;
}

测试全部

现在全部准备好了。在浏览器打开 localhost:3000 网站,如果你之前已上传过一些录音,你现在将能看到它们。你也可以尝试上传新的录音并看到列表更新。

用户现在可以录制他们的声音,保持或丢弃它们。用户还可以看到全部上传的录音并能播放它们。

image.png

总结

使用流媒体 API 允许我们未用户添加媒体功能,例如录音。流媒体 Web API 还允许录视频,截屏,等等。跟随这个教程里的信息,以及 MDNSitePoint 提供实用教程,你也能够添加全部其他媒体功能到你的网站上。