Unityでマイクの音を任意の時間だけファイルに書き出しながら録音する

## やりたいこと
- Unityでマイクからの入力を録音したい。
- 好きなタイミングで録音を開始して、好きなタイミングで録音を停止したい。
- wavファイルにしたい。

Unity標準の録音機能だとあらかじめ録音時間を指定した固定長でしか録音できません。途中で録音を止めると残り時間に無音データを詰め込まれたものが出てきます。
普通の用途なら[このやり方](https://qiita.com/segur/items/6d7b140418ef9adf6e7c)で必要な長さの`AudioClip`を作って[UnityWav](https://github.com/deadlyfingers/UnityWav)[^fork]で変換をかければ十分だと思います。

自分の場合は以下の条件が追加されたので一捻りする必要がありました。

- 録音時間が不明、長時間も予想される   
Unity標準APIでの録音は60分が限界
- メモリがキツキツ   
音声データをメモリに保持しておけないので随時書き出す必要がある


## コードと使い方
###コード

[gist](https://gist.github.com/nekomimi-daimao/a14301d7008d0a1c7e55977d6d9e2cc1)

```c#:MicRecorder.cs
using System;
using System.Collections;
using System.IO;
using UnityEngine;

namespace NekomimiDaimao
{
    ///  https://gist.github.com/nekomimi-daimao/a14301d7008d0a1c7e55977d6d9e2cc1
    public class MicRecorder : MonoBehaviour
    {
        private const int Frequency = 44100;
        private const int MaxLengthSec = 600;

        private const int HeaderLength = 44;
        private const int RescaleFactor = 32767;

        private FileStream _fileStream;
        private AudioClip _audioClip;
        private string _micName = null;

        private Coroutine _recordingCoroutine;

        public bool IsRecording { get; private set; } = false;


        public void StartRecord()
        {
            if (IsRecording || _recordingCoroutine != null)
            {
                return;
            }

            IsRecording = true;
            _recordingCoroutine = StartCoroutine(StartRecordCoroutine());
        }

        /// <summary>
        /// yield return StartCoroutine(MicRecorder.StopRecord());
        /// </summary>
        public IEnumerator StopRecord()
        {
            IsRecording = false;
            yield return _recordingCoroutine;
            _recordingCoroutine = null;
        }


        private IEnumerator StartRecordCoroutine(string defaultPath = null)
        {
            try
            {
                var path = defaultPath ?? $"{Application.temporaryCachePath}/record/{DateTime.Now:MMddHHmmss}.wav";
                _fileStream = new FileStream(path, FileMode.Create);
                const byte emptyByte = new byte();
                for (var count = 0; count < HeaderLength; count++)
                {
                    _fileStream.WriteByte(emptyByte);
                }

                if (Microphone.devices.Length == 0)
                {
                    yield break;
                }

                _micName = Microphone.devices[0];
                _audioClip = Microphone.Start(_micName, true, MaxLengthSec, Frequency);
                var buffer = new float[MaxLengthSec * Frequency];

                var head = 0;
                int pos;
                do
                {
                    pos = Microphone.GetPosition(_micName);
                    if (pos >= 0 && pos != head)
                    {
                        _audioClip.GetData(buffer, 0);
                        var writeBuffer = CreateWriteBuffer(pos, head, buffer);
                        ConvertAndWrite(writeBuffer);
                        head = pos;
                    }

                    yield return null;
                } while (IsRecording);


                pos = Microphone.GetPosition(_micName);
                if (pos >= 0 && pos != head)
                {
                    _audioClip.GetData(buffer, 0);
                    var writeBuffer = CreateWriteBuffer(pos, head, buffer);
                    ConvertAndWrite(writeBuffer);
                }

                Microphone.End(_micName);

                WriteWavHeader(_fileStream, _audioClip.channels, Frequency);
            }
            finally
            {
                _fileStream?.Dispose();
                _fileStream = null;
                AudioClip.Destroy(_audioClip);
                _audioClip = null;
                _micName = null;
            }
        }

        private static float[] CreateWriteBuffer(int pos, int head, float[] buffer)
        {
            float[] writeBuffer;
            if (head < pos)
            {
                writeBuffer = new float[pos - head];
                Array.Copy(buffer, head, writeBuffer, 0, writeBuffer.Length);
            }
            else
            {
                writeBuffer = new float[(buffer.Length - head) + pos];
                Array.Copy(buffer, head, writeBuffer, 0, (buffer.Length - head));
                Array.Copy(buffer, 0, writeBuffer, (buffer.Length - head), pos);
            }

            return writeBuffer;
        }

        private void ConvertAndWrite(float[] dataSource)
        {
            Int16[] intData = new Int16[dataSource.Length];
            var bytesData = new byte[dataSource.Length * 2];
            for (int i = 0; i < dataSource.Length; i++)
            {
                intData[i] = (short) (dataSource[i] * RescaleFactor);
                var byteArr = new byte[2];
                byteArr = BitConverter.GetBytes(intData[i]);
                byteArr.CopyTo(bytesData, i * 2);
            }

            _fileStream.Write(bytesData, 0, bytesData.Length);
        }

        private void WriteWavHeader(FileStream fileStream, int channels, int samplingFrequency)
        {
            var samples = ((int) fileStream.Length - HeaderLength) / 2;

            fileStream.Flush();
            fileStream.Seek(0, SeekOrigin.Begin);

            Byte[] riff = System.Text.Encoding.UTF8.GetBytes("RIFF");
            fileStream.Write(riff, 0, 4);

            Byte[] chunkSize = BitConverter.GetBytes(fileStream.Length - 8);
            fileStream.Write(chunkSize, 0, 4);

            Byte[] wave = System.Text.Encoding.UTF8.GetBytes("WAVE");
            fileStream.Write(wave, 0, 4);

            Byte[] fmt = System.Text.Encoding.UTF8.GetBytes("fmt ");
            fileStream.Write(fmt, 0, 4);

            Byte[] subChunk1 = BitConverter.GetBytes(16);
            fileStream.Write(subChunk1, 0, 4);

            //UInt16 _two = 2;
            UInt16 _one = 1;

            Byte[] audioFormat = BitConverter.GetBytes(_one);
            fileStream.Write(audioFormat, 0, 2);

            Byte[] numChannels = BitConverter.GetBytes(channels);
            fileStream.Write(numChannels, 0, 2);

            Byte[] sampleRate = BitConverter.GetBytes(samplingFrequency);
            fileStream.Write(sampleRate, 0, 4);

            Byte[] byteRate = BitConverter.GetBytes(samplingFrequency * channels * 2);
            fileStream.Write(byteRate, 0, 4);

            UInt16 blockAlign = (ushort) (channels * 2);
            fileStream.Write(BitConverter.GetBytes(blockAlign), 0, 2);

            UInt16 bps = 16;
            Byte[] bitsPerSample = BitConverter.GetBytes(bps);
            fileStream.Write(bitsPerSample, 0, 2);

            Byte[] datastring = System.Text.Encoding.UTF8.GetBytes("data");
            fileStream.Write(datastring, 0, 4);

            Byte[] subChunk2 = BitConverter.GetBytes(samples * channels * 2);
            fileStream.Write(subChunk2, 0, 4);

            fileStream.Flush();
            fileStream.Close();
        }
    }
}
```

### 使い方
```c#
// 特に依存しているComponentはないです
[SerializeField] private MicRecorder _micRecorder;


// 録音開始。これは別にCoroutineではない
_micRecorder.StartRecord();

// 録音停止するときは処理をぜんぶ終える必要があるため、Coroutineの終了を待機してあげる
yield return StartCoroutine(_micRecorder.StopRecord());

// 録音中フラグだがMicRecorder.StopRecordを実行してから
// 完全に終了するまでの間もfalseなのであんま信用してはいけない
Debug.Log($"Are you listening? {_micRecorder.IsRecording}");
```

## 解説と注意事項
探すと理屈を解説した記事は見つかるのですが、何故かコピペしてそのまま使えるやつはなかったので、いろんなところからコピペして切り貼りしました。なんかめっちゃかけ算？　してるな？　ぐらいの理解度だ！
特に`CreateWriteBuffer`でバッファを使い切って最初からになったときの処理。
(バッファのヘッドから最後まで) + (バッファの最初から現在のポジションまで)
ですが*ちょっとくらい取りこぼしててもバレへんやろ*の精神です。`Enumerable.Range()`とかで配列作れば確認できますがめんどくせえのでしてません。
`Frequency`は`44100`でしか確認してないので他のサンプリングレートで動くかは未確認。
`MaxLengthSec`は`600`なので結局10分もメモリを確保してます。状況に合わせて削ってください。理屈の上では10もあれば動くはずです。

## セルフレビュー
- using()使ってない
微妙に使い方がまだよくわかってないやつ。
この手のリソースを自動で閉じておいてくれる記法としては`Java`の`try-with-resources`がいちばん優れてるような気がするんですが、どこも導入してくれなくてつらい。

- 成否がわからない
途中で録音に失敗してもなんらかのゴミファイルは作成されてしまう。なので成否のコールバックが必要だが……。
`StopRecord`を`Task`に置き換えて成否の`bool`を返すとか。

- `IsRecording`を外部に公開している
内部処理用のフラグと外部公開用のフラグは分割するべき。

- `MonoBehaviour`を継承している
コルーチンがなければ継承の必要がない。のでできれば`Task`にしたい。が、UnityのApiはメインスレッドからしか実行できないので`Task`にすると管理がめんどくせえ！

- メインスレッドでファイルを書き込みしてる
せめてバッファ変換・ファイル書き込みをワーカースレッドに逃したい。別にそんな処理重くないっぽいけど一応。



こんなコードが上がってきたらコードレビューで撥ねますが、まあ動いてるしいいかなって思いました。

おしまい。


## 参考
[【Unity】長時間のマイク録音を実現する方法](http://section31.jp/gamedevelopment/unity/microphone/)
[【Unity】AudioListerを録音してwavにする](http://akiiro.hatenablog.com/entry/2017/08/09/031237)
[UnityのMicrophoneで正確な録音時間を取得する方法](https://qiita.com/segur/items/6d7b140418ef9adf6e7c)



[^fork]: なんか適当なパスにファイル保存しちゃうのなんで？