WebRTCとWeb Audio APIを組み合わせてブラウザで音声処理を視覚的に行う方法

UI開発者 泉口

前回「Web Audio APIで波形表示とオーディオエフェクトを実装する」においてWeb Audio APIに触れましたが、今回は応用編としてWebRTCを取り入れた音声入力情報のリアルタイム表示と多重録音を行ってみたいと思います。

WebRTCとは

動画・音声などのメディア、データの通信を可能にする「リアルタイムコミュニケーション」のためのAPIで、2011年より各ブラウザに実装が進んでいます。音声の録音についてはWebRTCを使った方法が一般的です、しかしWebRTC単体では入力情報のビジュアル要素を補うことはできません。「録音した内容はどのような状態か」「現在の音量はクリップして小さな歪みが発生していないか」などを音声だけで判断するのは難しいため、音声編集においても最低限のビジュアルが存在しないと録音の品質を下げる要因になってしまう可能性があります。

「入力状態の可視化により録音状態を把握しつつ、録音された音声を波形にして表示する」この録音において必要な工程が、特に編集用の機材やソフトウェアを購入する必要もなく、Webブラウザがあれば可能です。今回はただ録音するだけではなく、通常の録音に加え、エフェクトを加えた状態の音声も多重録音します。

今回の目的

  1. マイク入力から音声を録音すること
  2. リアルタイムで入力波形を表示すること
  3. 録音したデータにエフェクト処理をかけること
  4. 録音したデータと、エフェクト処理済のデータの波形を表示すること

条件

  • WebRTCの使用にはhttps環境が必要になります
  • Google Chrome (stable channel) バージョン57.0.2987.133以下では、decodeAudioDataによる「Uncaught (in promise) DOMException: Unable to decode audio data」エラーが出るため、Google Chrome (Chrome Canary)にてテストを行います
  • マイクが無い場合でも、イヤホンをマイク入力に挿すことで入力テストを行うことができますが、イヤホンの本来の用途とは異なるため推奨しません
  • オーディオインターフェイスが無い場合、入出力にレイテンシ(遅延)が発生します
  • あくまでも「ブラウザ上の処理」のためオーディオ品質においては限度があります

HTML/CSS

HTML/CSSコードは録音ボタン(オプションのミュートボタン)に加え、リアルタイム入力波形、録音波形(編集なし)、録音波形(編集あり)の3種類をスタックしただけの単純な構造になっています。各項目にはダウンロード、波形のクリア、音声再生をするボタンと共に、波形を表示するcanvas要素を配置します。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"><style>*{box-sizing:border-box;margin:0;padding:0;}html,body{font-family:Meiryo;height:100%;}input,button{font-family:Meiryo;}#app{display:flex;overflow-y:hidden;flex-direction:column;min-width:320px;height:100%;}#control{color:#fff;background:#424242;display:flex;align-items:center;height:50px;max-height:50px;}#content{background:#212121;height:calc(100% - 50px);}#content .col{height:calc(100% / 3);}#content .col canvas{width:100%;height:calc(100% - 40px);}#content .head{background:#455a64;display:flex;align-items:center;justify-content:space-between;height:40px;max-height:40px;padding:0 10px;}#content .head h2{font-size:18px;font-weight:normal;color:#fff;flex:1 0 0%;margin:0;}#btn-tgl-rec,#btn-tgl-mute,.btn-play,.btn-clear,.btn-download{font-size:14px;color:#333;background:#e0e0e0;display:flex;align-items:center;justify-content:center;height:30px;margin:0 0 0 10px;padding:0 10px;cursor:pointer;border:0;text-decoration:none;}.btn-download{color:#ccc;}.btn-download[href]{color:#333;}#btn-tgl-rec::before,#btn-tgl-mute::before,.btn-play::before,.btn-clear::before,.btn-download::before{font-family:"Material Icons";font-size:24px;display:inline-block;margin:0 5px 0 0;}#btn-tgl-rec::before{color:#b71c1c;content:"\E061";}#btn-tgl-rec.is-recording::before{color:#212121;content:"\E034";}#btn-tgl-mute::before{content:"\E029";}#btn-tgl-mute.is-muted::before{content:"\E02B";}.btn-play::before{content:"\E037";}.btn-play.is-played::before{content:"\E034";}.btn-clear::before{content:"\E92b";}.btn-download::before{content:"\E2C4";}</style>
</head>
<body>
<div id="app">
<div id="control">
<audio id="audio"></audio>
<button id="btn-tgl-rec"><span>REC</span></button>
<button id="btn-tgl-mute"><span>MUTE</span></button>
<!-- /#control --></div>
<div id="content">
<div class="col">
<div class="head">
<h2>リアルタイム入力波形</h2>
</div>
<canvas id="input-waveform"></canvas>
<!-- /.col --></div>

<div class="col">
<div class="head">
<h2>録音した音声の波形(編集なし)</h2>
<a id="btn-rec-download" class="btn-download"><span>DOWNLOAD</span></a>
<button id="btn-rec-clear" class="btn-clear"><span>CLEAR</span></button>
<button id="btn-rec-play" class="btn-play"><span>PLAY</span></button>
<audio id="rec-audio"></audio>
</div>
<canvas id="rec-waveform"></canvas>
<!-- /.col --></div>

<div class="col">
<div class="head">
<h2>録音した音声の波形(ゲイン+50)</h2>
<a id="btn-effect-download" class="btn-download"><span>DOWNLOAD</span></a>
<button id="btn-effect-clear" class="btn-clear"><span>CLEAR</span></button>
<button id="btn-effect-play" class="btn-play"><span>PLAY</span></button>
<audio id="effect-audio"></audio>
</div>
<canvas id="effect-waveform"></canvas>
<!-- /.col --></div>
<!-- /#content --></div>
<!-- /#app --></div>
<script src="run.js"></script>
</body>
</html>

JavaScript

HTMLに関連付けたボタンの処理とcanvas要素へのレンダリング、音声処理を記載します。

!function(win,doc,AudioContext,MediaRecorder){"use strict"
function resizeCanvas(){$waveformInput.width="100%",$waveformInput.style.width="100%",offsetWidth=$waveformInput.offsetWidth,offsetHeight=$waveformInput.offsetHeight,function(arr){for(var i=0;i<arr.length;i++)arr[i].style.width=offsetWidth+"px",arr[i].style.height=offsetHeight+"px",arr[i].width=offsetWidth,arr[i].height=offsetHeight}([$waveformInput,$waveformRec,$waveformEffect])}function clearCanvas(ctx){ctx.clearRect(0,0,offsetWidth,offsetHeight),ctx.beginPath()}function createWaveform(audioBuffer,ctx,size){var bufferFl32=new Float32Array(audioBuffer.length),leng=bufferFl32.length
clearCanvas(ctx),bufferFl32.set(audioBuffer.getChannelData(0))
for(var idx=0;leng>idx;idx++)if(idx%size===0){var x=offsetWidth*(idx/leng),y=(1-bufferFl32[idx])/2*offsetHeight
0===idx?ctx.moveTo(x,y):ctx.lineTo(x,y)}var gradient=ctx.createLinearGradient(0,0,0,offsetHeight)
gradient.addColorStop("0","#f44336"),gradient.addColorStop("0.5","#4caf50"),gradient.addColorStop("1","#2196f3"),ctx.strokeStyle=gradient,ctx.stroke()}function clearSection(ctx,$btnDownload,$btnPlay,$audio){clearCanvas(ctx),$btnDownload.removeAttribute("href"),$btnPlay.classList.remove("is-played"),$audio.removeAttribute("src")}function playAudio($btnPlay,$audio){$btnPlay.classList.value.indexOf("is-played")<0?($btnPlay.classList.add("is-played"),$audio.play()):($btnPlay.classList.remove("is-played"),$audio.pause())}function fetchArrayBufferFromURL(blob){var url=URL.createObjectURL(blob)
return fetch(url).then(function(response){return response.arrayBuffer()})}function renderCanvas(data,$audio,$btnDownload,ctx){var blob=new Blob([data],{type:"audio/webm"})
$audio.src=win.URL.createObjectURL(blob),$btnDownload.href=$audio.src,$btnDownload.download="rec.webm",fetchArrayBufferFromURL(blob).then(function(arrayBuffer){actx.decodeAudioData(arrayBuffer).then(function(audioBuffer){createWaveform(audioBuffer,ctx,1)})})}var offsetWidth,offsetHeight,recorder1,recorder2,recorderStream,$btnRec=doc.getElementById("btn-tgl-rec"),$btnMute=doc.getElementById("btn-tgl-mute"),$waveformInput=doc.getElementById("input-waveform"),$waveformRec=doc.getElementById("rec-waveform"),$waveformEffect=doc.getElementById("effect-waveform"),$btnRecDownload=doc.getElementById("btn-rec-download"),$btnRecClear=doc.getElementById("btn-rec-clear"),$btnRecPlay=doc.getElementById("btn-rec-play"),$btnEffectDownload=doc.getElementById("btn-effect-download"),$btnEffectClear=doc.getElementById("btn-effect-clear"),$btnEffectPlay=doc.getElementById("btn-effect-play"),actx=new AudioContext,ictx=$waveformInput.getContext("2d"),rctx=$waveformRec.getContext("2d"),ectx=$waveformEffect.getContext("2d"),$raudio=doc.getElementById("rec-audio"),$eaudio=doc.getElementById("effect-audio"),fft=1024,gainNode=actx.createGain(),scriptProcessor=actx.createScriptProcessor(fft,1,1),streamDestination=actx.createMediaStreamDestination(),isMute=!1,isRecording=!1
resizeCanvas(),win.addEventListener("resize",resizeCanvas,!1),scriptProcessor.onaudioprocess=function(event){var outputBuffer=event.outputBuffer
outputBuffer.getChannelData(0).set(event.inputBuffer.getChannelData(0)),createWaveform(outputBuffer,ictx,1)},gainNode.gain.value=50,gainNode.connect(streamDestination),scriptProcessor.connect(actx.destination),navigator.mediaDevices.getUserMedia({video:!1,audio:!0}).then(function(stream){var input=actx.createMediaStreamSource(stream)
recorderStream=stream,$btnMute.addEventListener("click",function(){isMute?(isMute=!1,input.connect(scriptProcessor),input.connect(gainNode),$btnMute.classList.remove("is-muted")):(isMute=!0,input.disconnect(scriptProcessor),input.disconnect(gainNode),$btnMute.classList.add("is-muted"))},!1),input.connect(scriptProcessor),input.connect(gainNode)}),$btnRec.addEventListener("click",function(){isRecording?(isRecording=!1,$btnRec.classList.remove("is-recording"),recorder1.stop(),recorder2.stop()):(isRecording=!0,$btnRec.classList.add("is-recording"),$btnRecDownload.removeAttribute("href"),recorder1=new MediaRecorder(recorderStream),recorder2=new MediaRecorder(streamDestination.stream),recorder1.ondataavailable=function(event){renderCanvas(event.data,$raudio,$btnRecDownload,rctx)},recorder2.ondataavailable=function(event){renderCanvas(event.data,$eaudio,$btnEffectDownload,ectx)},recorder1.start(),recorder2.start())},!1),$btnRecClear.addEventListener("click",function(){clearSection(rctx,$btnRecDownload,$btnRecPlay,$raudio)},!1),$btnEffectClear.addEventListener("click",function(){clearSection(ectx,$btnEffectDownload,$btnEffectPlay,$eaudio)},!1),$btnRecPlay.addEventListener("click",function(){playAudio($btnRecPlay,$raudio)},!1),$btnEffectPlay.addEventListener("click",function(){playAudio($btnEffectPlay,$eaudio)},!1)}(window,document,window.AudioContext,window.MediaRecorder)
録音した波形を表示した例、エフェクト処理を行った波形は、編集なしの波形と比べてクリップしていることが解る

展開後のコード量としては200行程度ですが、そのほとんどはcanvas要素に対するものや、再生ボタンなどのコントローラーに対する処理のため、音声処理に関連する処理は100行に達しません。内容としては、navigator.mediaDevices.getUserMediaで受け取った入力音声のストリームを原音用、エフェクト用の2系統にルーティングし、原音用からは入力音声を録音用ストリームとは別にscriptProcessorに介して、リアルタイム波形の表示と実際の音声を出力します。エフェクト用のストリームは特定のエフェクト(今回はgainNodeでボリュームを上げる)を介して、MediaStreamDestinationNodeに接続し、エフェクト処理を行ったストリームを取得します。この2つのストリームをMediaRecorderから録音処理を行うことで、録音後のデータを変換した波形を表示することができます。

要点としては、WebRTCで取得した音声データを、Web Audio APIのルーティングで多方向に出力し、その内容を変換して各canvas要素にレンダリングすると言った簡単な内容ですが、ルーティング周りや変換周りで難しく感じるかもしれません。しかし、実際の物理的な機材を用いた場合はもっと複雑な配線のルーティングが必要になると考えれば、一部コードの変更のみで目的のルーティングを実装できることは「柔軟性に優れている」と考えることもできます。

Web Audio APIからcanvas要素にレンダリングする際、周波数変換のコツさえ理解していれば音声のビジュアライザーを作ることも可能ですし、WebRTCは音声だけでなく様々なメディアにも対応しているので、今後この2つのAPIの組み合わせで実装されるオーディオサービスなども増えていくのではないかと期待しています。