まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

勉強中の HTML5 Web Audio API の備忘録を兼ねたブログエントリ記事です。前回はローカルシステム上のオーディオファイルを File API で読み取った上でオーディオバッファに変換して Audio API で再生する、というオーディオ出力処理に挑戦しました。今回は逆にオーディオ入力処理を扱ってみました。具体的にはマイクから入力した音声データをオーディオバッファに変換する処理を調べてみました(コードではオーディオバッファに変換した上で前回同様にそのまま再生させています)。

とりあえず完成形の HTML ファイルは以下のとおりです。「とりあえず」と書いたのは現時点では環境によって動いたり動かなかったりする挙動が見られて、まだ不安定版といった感じな所が理由です。手元の Windows 10 の FireFox および Chrome では動作しましたが Ubuntu 16.04 の FireFox だとエラーになりました:
<html>
<head>
<title>Voice Replay</title>
<script src="https://code.jquery.com/jquery-2.0.3.min.js"></script>
<script>
window.AudioContext = window.AudioContext || window.webkitAudioContext;
var context = new AudioContext();

var processor = null;
var num = 0;
var duration = 0.0;
var length = 0;
var sampleRate = 0;
var floatData = null;
function handleSuccess( stream ){
  var source = context.createBufferSource();
  var input = context.createMediaStreamSource( stream );
  processor = context.createScriptProcessor( 1024, 1, 1 );
  
  input.connect( processor );
  processor.onaudioprocess = function( e ){
    //. 音声データ
    var inputdata = e.inputBuffer.getChannelData(0);
    //console.log( inputdata );

    if( !num ){
      num = e.inputBuffer.numberOfChannels;
      floatData = new Array(num);
      for( var i = 0; i < num; i ++ ){
        floatData[i] = [];
      }
      sampleRate = e.inputBuffer.sampleRate;
    }
    
    var float32Array = e.inputBuffer.getChannelData( 0 );
    if( availableData( float32Array ) ){
      duration += e.inputBuffer.duration;
      length += e.inputBuffer.length;
      for( var i = 0; i < num ; i ++ ){
        float32Array = e.inputBuffer.getChannelData( i );
        Array.prototype.push.apply( floatData[i], float32Array );
      }
    }
  };
  processor.connect( context.destination );
}

function startRec(){
  $('#recBtn').css( 'display', 'none' );
  $('#stopBtn').css( 'display', 'block' );

  navigator.mediaDevices.getUserMedia( { audio: true } ).then( handleSuccess );
}

function stopRec(){
  $('#recBtn').css( 'display', 'block' );
  $('#stopBtn').css( 'display', 'none' );

  if( processor ){
    processor.disconnect();
    processor.onaudioprocess = null;
    processor = null;
    
    var audioBuffer = context.createBuffer( num, length, sampleRate );
    for( var i = 0; i < num; i ++ ){
      audioBuffer.getChannelData( i ).set( floatData[i] );
    }
    
    console.log( audioBuffer ); //. これを再生する
    
    var source = context.createBufferSource();

    source.buffer = audioBuffer;           //. オーディオデータの実体(AudioBuffer インスタンス)
    source.loop = false;                   //. ループ再生するか?
    source.loopStart = 0;                  //. オーディオ開始位置(秒単位)
    source.loopEnd = audioBuffer.duration; //. オーディオ終了位置(秒単位)
    source.playbackRate.value = 1.0;       //. 再生速度&ピッチ

    source.connect( context.destination );

    //. for lagacy browsers
    source.start( 0 );
    source.onended = function( event ){
      //. イベントハンドラ削除
      source.onended = null;
      document.onkeydown = null;
      num = 0;
      duration = 0.0;
      length = 0;

      //. オーディオ終了
      source.stop( 0 );

      console.log( 'audio stopped.' );
    };
  }
}

function availableData( arr ){
  var b = false;
  for( var i = 0; i < arr.length && !b; i ++ ){
    b = ( arr[i] != 0 );
  }
  
  return b;
}
</script>
</head>
<body>
  <div id="page">
    <div>
      <h2>音声再生</h2>
      <input type="button" id="recBtn" value="Rec" onClick="startRec();" style="display:block;"/>
      <input type="button" id="stopBtn" value="Stop" onClick="stopRec();" style="display:none;"/>
    </div>
  </div>
</body>
</html>

動かし方としてはこの HTML をファイルに保存し、ウェブブラウザで開きます。サーバー上になくても構いません(ローカルファイルとして開く場合は Ctrl+O でファイルを選択します)。開くと "Rec" と書かれたボタンが1つ表示されるシンプルな画面が表示されます:
2018093001


この "Rec" ボタンが「録音」ボタンです。このボタンをクリックすると JavaScript の Audio API にマイクの利用を許可するかどうか聞かれるので、「許可」を選択してください:
2018093002


この時に実行されるコードが以下です。ボタン表示を切り替えると同時に HTML5 Audio API の getUserMedia() を実行してマイクにアクセスします。Web Audio API でマイクを使う時のスタート地点がここになります:
      :
function startRec(){
  $('#recBtn').css( 'display', 'none' );
  $('#stopBtn').css( 'display', 'block' );

  navigator.mediaDevices.getUserMedia( { audio: true } ).then( handleSuccess );
}
      :

マイクへのアクセスが成功するとボタンの表示が "Rec" から "Stop" に切り替わり、同時に録音モードになります。コンピュータの標準マイクを使って、周りの音の録音が開始されます。またマイクへのアクセス成功時に handleSuccess 関数がコールバック実行され、マイクに入力されたストリーミングデータが引数として渡されます。ここでは onaudioprocess イベントをハンドリングしてチャンネル毎のサンプリングデータを floatData 配列に格納しています。この例ではサンプリングデータを 1024 個ずつ送られてくるようにしていて「その中身が全て 0 ではない(無音データではない)」という確認をした上で配列の最後に追加して、後でまとめて再生できるようにしています:
      :
window.AudioContext = window.AudioContext || window.webkitAudioContext;
var context = new AudioContext();

var processor = null;
var num = 0;
var duration = 0.0;
var length = 0;
var sampleRate = 0;
var floatData = null;
function handleSuccess( stream ){
  var source = context.createBufferSource();
  var input = context.createMediaStreamSource( stream );
  processor = context.createScriptProcessor( 1024, 1, 1 );
  
  input.connect( processor );
  processor.onaudioprocess = function( e ){
    //. 音声データ
    var inputdata = e.inputBuffer.getChannelData(0);
    //console.log( inputdata );

    if( !num ){
      num = e.inputBuffer.numberOfChannels;
      floatData = new Array(num);
      for( var i = 0; i < num; i ++ ){
        floatData[i] = [];
      }
      sampleRate = e.inputBuffer.sampleRate;
    }
    
    var float32Array = e.inputBuffer.getChannelData( 0 );
    if( availableData( float32Array ) ){
      duration += e.inputBuffer.duration;
      length += e.inputBuffer.length;
      for( var i = 0; i < num ; i ++ ){
        float32Array = e.inputBuffer.getChannelData( i );
        Array.prototype.push.apply( floatData[i], float32Array );
      }
    }
  };
  processor.connect( context.destination );
}
      :

このタイミングでもう一度 "Stop" をクリックすると録音を終了します:
2018093003


"Stop" をクリックすると同時にこれまでに配列に集めてきたサンプリングデータをオーディオバッファ・インスタンスに変換します。オーディオバッファ側になれば前回紹介したのと同様の方法で再生できるので、マイクに入力した音声がそのままオウム返しのように再生されます:
function stopRec(){
  $('#recBtn').css( 'display', 'block' );
  $('#stopBtn').css( 'display', 'none' );

  if( processor ){
    processor.disconnect();
    processor.onaudioprocess = null;
    processor = null;
    
    var audioBuffer = context.createBuffer( num, length, sampleRate );
    for( var i = 0; i < num; i ++ ){
      audioBuffer.getChannelData( i ).set( floatData[i] );
    }
    
    console.log( audioBuffer ); //. これを再生する
    
    var source = context.createBufferSource();

    source.buffer = audioBuffer;           //. オーディオデータの実体(AudioBuffer インスタンス)
    source.loop = false;                   //. ループ再生するか?
    source.loopStart = 0;                  //. オーディオ開始位置(秒単位)
    source.loopEnd = audioBuffer.duration; //. オーディオ終了位置(秒単位)
    source.playbackRate.value = 1.0;       //. 再生速度&ピッチ

    source.connect( context.destination );

    //. for lagacy browsers
    source.start( 0 );
    source.onended = function( event ){
      //. イベントハンドラ削除
      source.onended = null;
      document.onkeydown = null;
      num = 0;
      duration = 0.0;
      length = 0;

      //. オーディオ終了
      source.stop( 0 );

      console.log( 'audio stopped.' );
    };
  }
}

いくつかの環境ではこれで録音→再生できることを確認していますが、エラーになる環境もあります。手元で試した限りでは Windows10 の Firefox & Chrome は大丈夫そうで、Ubuntu 16.04 の FireFox はダメそうでした。この辺りはまだよくわかっていないのですが、動く環境があるということは根本的な考え方が間違っていることはないと思っています。


前回のと合わせて、これで音声データをマイクから入力したり、スピーカーから出力したり、という 基本的な I/O 部分を取り扱うレベルのものは作れました。次はサンプリングデータ(波形データ)の中身をもう少し深掘りしてみたいと思ってます。




これまでの人生の中で楽器とか音楽全般にあまり縁がなかったこともあって、ソフトウェア開発の中でもオーディオ系のデータを扱う話になると途端にチンプンカンプンでした。HTML5 に Web Audio API が存在していることは知っていましたが、そんな理由で(使おうとしても前提知識がサッパリで付いていけず)ほぼ手付かずでしたが、「苦手科目をなくす」目的でちと勉強を始めてみた経緯をブログで記録していこうと思っています。分かる人からすれば超初心者向けの内容になっていると思いますがお許しを。

とりあえずの題材としてやってみようと思ったのは「オーディオファイルの再生」です。念のため書いておきますが、単にウェブページ内でオーディオファイルを再生することが目的であれば <audio> タグを使うのが(ブラウザやバージョンの違いなどを意識することもなく)簡単だということは理解しています:
<audio controls>
  <source type="audio/mp3" src="./xxxx.mp3"/>
</audio>

これと同じ、または近いことを <audio> タグを使わずに HTML5 の Web Audio API だけで実現することを最初の目標にしてみました。で、作ってみたのがこちらです:
<html>
<head>
<title>Audio File Play</title>
<script>
window.AudioContext = window.AudioContext || window.webkitAudioContext;
var context = new AudioContext();

window.onload = function(){
  if( ( document.readyState == 'interactive' ) || ( document.readyState == 'complete' ) ){
    onDOMContentLoaded();
  }else{
    document.addEventListener( 'DOMContentLoaded', onDOMContentLoaded, true );
  }
  
  function onDOMContentLoaded(){
    function loadAudio( node ){
      var successCallback = function( audioBuffer ){
        console.log( audioBuffer );
        var source = context.createBufferSource();
        
        source.buffer = audioBuffer;           //. オーディオデータの実体(AudioBuffer インスタンス)
        source.loop = false;                   //. ループ再生するか?
        source.loopStart = 0;                  //. オーディオ開始位置(秒単位)
        source.loopEnd = audioBuffer.duration; //. オーディオ終了位置(秒単位)
        source.playbackRate.value = 1.0;       //. 再生速度&ピッチ
        
        source.connect( context.destination );
        
        //. for lagacy browsers
        source.start( 0 );
        source.onended = function( event ){
          //. イベントハンドラ削除
          source.onended = null;
          document.onkeydown = null;
          
          //. オーディオ終了
          source.stop( 0 );
          
          console.log( 'audio stopped.' );
        };
      };
      
      var errorCallback = function( error ){
        if( error instanceof Error ){
          window.alert( error.message );
        }else{
          window.alert( 'Error: "decodeAudioData"');
        }
      };
      
      //. オーディオバッファインスタンス作成
      context.decodeAudioData( node, successCallback, errorCallback );
    };
    
    document.querySelector( '[type="file"]' ).addEventListener( 'change', function( event ){
      var uploader = this;
      
      var file = event.target.files[0];
      if( !( file instanceof File ) ){
        window.alert( 'Error: Please upload file.' );
      }else if( file.type.indexOf( 'audio' ) == -1 ){
        window.alert( 'Error: Please upload audio file.' );
      }else{
        var reader = new FileReader();
        reader.onprogress = function( event ){
        };
        
        reader.onerror = function(){
          window.alert( 'Error: FileReader(' + reader.error.code + ')' );
          uploader.value = '';
        };
        
        reader.onload = function(){
          var arrayBuffer = reader.result;   //. ArrayBuffer(Web Audio API では Float32Array 型配列)
          
          loadAudio( arrayBuffer );
        };
        
        reader.readAsArrayBuffer( file );
      }
    }, false );
  }
}
</script>
</head>
<body>
  <div id="page">
    <div>
      <h2>オーディオファイルローダー</h2>
      <input type="file" accept="audio/*"/>
    </div>
  </div>
</body>
</html>

まず最初に、こちらを作っていて今回のテーマ内であっても <audio> タグでできることとできないことがあることに気づきました。<audio> タグでオーディオファイルを再生するには、そのオーディオファイルは(ファイル場所を src 属性で指定する必要があるため)この HTML ファイルが存在するサーバー上か、または HTTP(S) で取得できる場所に存在している必要があります。一方、オーディオファイルが手元の PC 内にある場合はあらかじめそのファイルをサーバー上にコピーしておかないと <audio> タグで指定できるようにはなりません。そのような場合であれば(上記で作成したように)HTML5 の File API を併用して手元のファイルを読み取り、オーディオバッファを取り出して Web Audio API に渡して再生することで実現できます。というわけで、実際にそのような挙動をする例を作ってみました。

<input type="file" accept="audio/*"/> のコントロールにオーディオファイル(MP3 ファイル)を指定するとイベントリスナーで定義された部分がハンドリングして、オーディオファイルであることを確認した上で以下の処理が実行されます:
      :

    document.querySelector( '[type="file"]' ).addEventListener( 'change', function( event ){
      var uploader = this;
      
      var file = event.target.files[0];
      if( !( file instanceof File ) ){
        window.alert( 'Error: Please upload file.' );
      }else if( file.type.indexOf( 'audio' ) == -1 ){
        window.alert( 'Error: Please upload audio file.' );
      }else{
        var reader = new FileReader();
        reader.onprogress = function( event ){
        };
        
        reader.onerror = function(){
          window.alert( 'Error: FileReader(' + reader.error.code + ')' );
          uploader.value = '';
        };
        
        reader.onload = function(){
          var arrayBuffer = reader.result;
          
          loadAudio( arrayBuffer );
        };
        
        reader.readAsArrayBuffer( file );
      }
    }, false );

      :

FileReader インスタンスを作成して readAsArrayBuffer() メソッドを実行し、アップロード指定したファイルを(アップロードせずにローカルで)読み取ります。読み取りが完了すると reader.onload イベントハンドラによって読み取り結果(reader.result)を取り出して、loadAudio() 関数を実行しています。

loadAudio() 関数内では読み取った ArrayBuffer に対して AudioContext の decodeAudioData() 関数を実行してオーディオバッファのインスタンスを生成します。生成に成功したらオーディオコンテキストからバッファソースを作って(createBufferSource())、オーディオバッファの実体等の属性を指定して、出力先に接続(connect())して、再生を開始(start())します:
      :

    function loadAudio( node ){
      var successCallback = function( audioBuffer ){
        console.log( audioBuffer );
        var source = context.createBufferSource();
        
        source.buffer = audioBuffer;           //. オーディオデータの実体(AudioBuffer インスタンス)
        source.loop = false;                   //. ループ再生するか?
        source.loopStart = 0;                  //. オーディオ開始位置(秒単位)
        source.loopEnd = audioBuffer.duration; //. オーディオ終了位置(秒単位)
        source.playbackRate.value = 1.0;       //. 再生速度&ピッチ
        
        source.connect( context.destination );
        
        //. for lagacy browsers
        source.start( 0 );
        source.onended = function( event ){
          //. イベントハンドラ削除
          source.onended = null;
          document.onkeydown = null;
          
          //. オーディオ終了
          source.stop( 0 );
          
          console.log( 'audio stopped.' );
        };
      };
      
      var errorCallback = function( error ){
        if( error instanceof Error ){
          window.alert( error.message );
        }else{
          window.alert( 'Error: "decodeAudioData"');
        }
      };
      
      //. オーディオバッファインスタンス作成
      context.decodeAudioData( node, successCallback, errorCallback );
    };

      :

とりあえず現時点で理解できたのはここまでです。オーディオバッファの中身をもう少し理解して、波形データとか取り出したり、オーディオファイル以外からの再生もできるようになりたいなあ。。


以前に PouchDB を使った IBM Cloudant(Apache CouchDB) との同期について紹介しました:
IBM Cloudant と PouchDB で同期をとる

multiuser-couch-pouch


PouchDB は JavaScript 上で動作する CouchDB 互換の NoSQL データベースで、最大の特徴の1つが CouchDB との同期機能です(だと思ってます)。JavaScript で動作するということはブラウザ内のローカル DB で同期することもできて、PWA(Progressive Web Application) を作る際にも非常に有用な機能だと思っています。上記エントリではブラウザ内の PouchDB データベースと、サーバー上の CouchDB データベース間で双方向に全同期したり、双方向に部分同期する方法を紹介しました。

実際の PouchDB の使いみちを考えると、単方向にのみ同期させたいこともあると思います。変更作業はローカルのみで行って、その変更した内容のみをサーバー側に反映させたいとか、逆にサーバーで変更された内容をローカルに取り込みたいとかいったケースです。この実現方法を紹介します。

まずローカル側とサーバー側、両方でデータベースを指定します:
<script src="//cdn.jsdelivr.net/pouchdb/5.4.5/pouchdb.min.js"></script>

   :
   :

<script>
var local_db = new PouchDB( 'localdb' );
var remote_db = new PouchDB( 'https://remoteserver/remotedb' );


この2つのデータベース間で全文書を対象に双方向同期を行うのであれば前回紹介した方法を使って、以下のように実行することで実現できました:
local_db.sync( remote_db, {
  live: true,
  retry: true
});

ここを「ローカルでの変更をサーバー側にのみ反映させる(サーバー側の変更はローカルには反映させない)」場合は以下のように指定します:
local_db.replicate.to( remote_db, {
  live: true,
  retry: true
});

または

remote_db.replicate.from( local_db, {
  live: true,
  retry: true
});

逆に「サーバー側での変更をローカル側にのみ反映させる(ローカル側での変更はサーバーには反映させない)」場合は以下のように指定します:
local_db.replicate.from( remote_db, {
  live: true,
  retry: true
});

または

remote_db.replicate.to( local_db, {
  live: true,
  retry: true
});

全文書を対象とするのではなく、一部の文書を対象とする場合は、前回紹介した方法で doc_ids を指定することで同様に実現できます:
local_db.replicate.from( remote_db, {
  doc_ids: [ 001, 002, 003, ... ],
  live: true,
  retry: true
});

または

remote_db.replicate.to( local_db, {
  doc_ids: [ 001, 002, 003, ... ],
  live: true,
  retry: true
});

参考:
https://pouchdb.com/api.html#replication



 

このページのトップヘ