ブラウザで録音してwavで保存

# はじめに
ブラウザ経由で音声を取得して、いろいろやりたくて調べた。最終的に、`getUserMedia`して`ScriptProcessorNode`で音声データをバッファにためて、wavヘッダをつけて`Blob`で保存した。

最初は、`MediaRecorder`とか、`AnalyserNode`とかで、録音したり解析したりしようとしたけど、なんだか思ったのと挙動が違った。

`MediaRecorder`で保存しようとすると、webmとかいうのんで保存された。wavじゃできなかった。ブラウザ依存なんかもしれん。ChromeとFirefoxで試したけど、wavは無理やった。

`AnalyserNode`は、データをFFTしたりできるけど、どうもすべてのバッファに対して処理するんじゃなくて、一定タイミングとか画面の更新タイミングで、そのときたまってるデータを解析してるっぽい。たぶん。

すべてのデータに対して処理するには、`ScriptProcessorNode`とか`AudioWorklet`っての使うらしい。最近は、`ScriptProcessorNode`じゃなくて`AudioWorklet`を使った方が良さそう。後継っぽい。`AudioWorklet`でもよかったんだけど、やりたいこと調べてたら、`ScriptProcessorNode`使って、似たようなことしてる人見つけたので、そっちを使うことにした。

調べてると、下記サイトが出てきた。これを真似したらできるはずなんやけど、Javascriptのこと、あまりわかってないので、試行錯誤した。中身ほぼコピペで恐縮やけど、僕みたいな初心者でも真似できるように、やったことを残しておく。

[getUserMediaで録音したデータをWAVファイルとして保存する - Qiita](https://qiita.com/HirokiTanaka/items/56f80844f9a32020ee3b)

# コード
こんな感じ。ひとつのコードに書いてるからわかりにくいけど、やってることはシンプルなはず。exportWAVでwavに変換してて、データを処理するだけなら、`bufferData`とか`audioData`とかを使ったらいい。


``` html:wav.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <title>Save audio data</title>
</head>

<body>
  <a id="download">Download</a>
  <button id="stop">Stop</button>
  <script>
    // for html
    const downloadLink = document.getElementById('download');
    const stopButton = document.getElementById('stop');

    // for audio
    let audio_sample_rate = null;
    let scriptProcessor = null;
    let audioContext = null;

    // audio data
    let audioData = [];
    let bufferSize = 1024;

    let saveAudio = function () {
      downloadLink.href = exportWAV(audioData);
      downloadLink.download = 'test.wav';
      downloadLink.click();

      audioContext.close().then(function () {
        stopButton.setAttribute('disabled', 'disabled');
      });
    }

    // export WAV from audio float data
    let exportWAV = function (audioData) {

      let encodeWAV = function (samples, sampleRate) {
        let buffer = new ArrayBuffer(44 + samples.length * 2);
        let view = new DataView(buffer);

        let writeString = function (view, offset, string) {
          for (let i = 0; i < string.length; i++) {
            view.setUint8(offset + i, string.charCodeAt(i));
          }
        };

        let floatTo16BitPCM = function (output, offset, input) {
          for (let i = 0; i < input.length; i++ , offset += 2) {
            let s = Math.max(-1, Math.min(1, input[i]));
            output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
          }
        };

        writeString(view, 0, 'RIFF');  // RIFFヘッダ
        view.setUint32(4, 32 + samples.length * 2, true); // これ以降のファイルサイズ
        writeString(view, 8, 'WAVE'); // WAVEヘッダ
        writeString(view, 12, 'fmt '); // fmtチャンク
        view.setUint32(16, 16, true); // fmtチャンクのバイト数
        view.setUint16(20, 1, true); // フォーマットID
        view.setUint16(22, 1, true); // チャンネル数
        view.setUint32(24, sampleRate, true); // サンプリングレート
        view.setUint32(28, sampleRate * 2, true); // データ速度
        view.setUint16(32, 2, true); // ブロックサイズ
        view.setUint16(34, 16, true); // サンプルあたりのビット数
        writeString(view, 36, 'data'); // dataチャンク
        view.setUint32(40, samples.length * 2, true); // 波形データのバイト数
        floatTo16BitPCM(view, 44, samples); // 波形データ

        return view;
      };

      let mergeBuffers = function (audioData) {
        let sampleLength = 0;
        for (let i = 0; i < audioData.length; i++) {
          sampleLength += audioData[i].length;
        }
        let samples = new Float32Array(sampleLength);
        let sampleIdx = 0;
        for (let i = 0; i < audioData.length; i++) {
          for (let j = 0; j < audioData[i].length; j++) {
            samples[sampleIdx] = audioData[i][j];
            sampleIdx++;
          }
        }
        return samples;
      };

      let dataview = encodeWAV(mergeBuffers(audioData), audio_sample_rate);
      let audioBlob = new Blob([dataview], { type: 'audio/wav' });
      console.log(dataview);

      let myURL = window.URL || window.webkitURL;
      let url = myURL.createObjectURL(audioBlob);
      return url;
    };

    // stop button
    stopButton.addEventListener('click', function () {
      saveAudio();
      console.log('saved wav');
    });

    // save audio data
    var onAudioProcess = function (e) {
      var input = e.inputBuffer.getChannelData(0);
      var bufferData = new Float32Array(bufferSize);
      for (var i = 0; i < bufferSize; i++) {
        bufferData[i] = input[i];
      }

      audioData.push(bufferData);
    };

    // getusermedia
    let handleSuccess = function (stream) {
      audioContext = new AudioContext();
      audio_sample_rate = audioContext.sampleRate;
      console.log(audio_sample_rate);
      scriptProcessor = audioContext.createScriptProcessor(bufferSize, 1, 1);
      var mediastreamsource = audioContext.createMediaStreamSource(stream);
      mediastreamsource.connect(scriptProcessor);
      scriptProcessor.onaudioprocess = onAudioProcess;
      scriptProcessor.connect(audioContext.destination);

      console.log('record start?');

      // when time passed without pushing the stop button
      setTimeout(function () {
        console.log("10 sec");
        if (stopButton.disabled == false) {
          saveAudio();
          console.log("saved audio");
        }
      }, 10000);
    };

    // getUserMedia
    navigator.mediaDevices.getUserMedia({ audio: true, video: false })
      .then(handleSuccess);

  </script>
</body>

</html>
```

# 動作
Chromeで実行。

はじめに、マイクの許可を聞かれる。許可したら録音開始される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184221/d7d71b02-aac7-abc4-3416-acde09eacb7e.png)

インターフェースはStopボタンがあるだけ。Stop押したら、録音止まる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184221/9c7d81d6-1730-477c-bee2-7c1b41d68509.png)

録音が終わったら、自動でデータ保存のポップアップがでる。そのあとは、Downloadがリンクになって、保存のポップアップでるようになる。Stopボタンはdisabledされる。あんまり、長いデータを貯めるのも微妙かなと思って、10秒間押されなかったら、勝手に保存するようにしてみた。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184221/e583d526-ad5d-1702-403c-b47d40fce791.png)

# おわりに
やりたいことから、紆余曲折して、最終的に、`ScriptProcessorNode`に落ち着くまでが、それなりに時間かかった。でも、JavaScriptのクラスっぽい動きとか、変数のスコープとか、コールバックとかをそこそこ勉強できたので、よかった。変数を関数間で受け渡しするときに、めんどくさくてグローバル変数的なことしちゃったけど、もっとうまいことやりたいなと思う。エラー処理も、もっとちゃんとやりたい。

# 参照
ここを大いに参照。
[getUserMediaで録音したデータをWAVファイルとして保存する - Qiita](https://qiita.com/HirokiTanaka/items/56f80844f9a32020ee3b)
[AudioContext.createScriptProcessor() - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/AudioContext/createScriptProcessor)
[JavaScriptのWeb Audio APIで録音してみる - saitodev.co](https://saitodev.co/article/JavaScript%E3%81%AEWeb_Audio_API%E3%81%A7%E9%8C%B2%E9%9F%B3%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B)
[Blobを使って（jQueryを使わずに）JavaScriptでデータをファイルに保存 - Qiita](https://qiita.com/iton/items/dc0d7831f9783c0808da)

# 参照しなかったところ
調べてたけど、使わなかった。

[ユーザーから音声データを取得する  |  Web  |  Google Developers](https://developers.google.com/web/fundamentals/media/recording-audio/?hl=ja)
[MediaRecorder.ondataavailable - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/MediaRecorder/ondataavailable)
[[WebAudio API] AudioWorklet の使い方 | g200kg Music & Software](https://www.g200kg.com/archives/2019/01/webaudio-api-au.html)
[Web Audio API – AudioWorklet で遊ぶ – – rilakkuma3xjapan's blog](https://weblike-curtaincall.ssl-lolipop.jp/blog/?p=2027)
[AnalyserNode.getByteTimeDomainData() - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/getByteTimeDomainData)