HoloLensで多言語ベースの音声入力システムを作る際のTips - その1：マイクからの音声取得編


# HoloLensは英語しかできないんですよ。
これずっとHoloLensがある意味試作機だから仕方がないのかなともいつつ。でも今出ている[Insider Preview 17074](https://blogs.windows.com/windowsexperience/2018/01/11/announcing-windows-10-insider-preview-build-17074-pc/#K6WXuXHHMDmpmcrI.97)ではMicrosoft ストアから言語パックのセットアップができる対応が入っているようなのでHoloLensにも導入できるようになってほしいところです。

## 音声認識について
音声認識で「確実に意図通り認識できるか」という問題が結構重要だったりするとおもいます。これちゃんとできないと何回も言う事になってシステムとして立ちいかない。。。
この点において日本語がかなり不利なんですよね。音声認識から命令に変換するところが日本語だとかなり精度低く感じます。それを逆手に取ったりすれば遊びの領域では楽しめますが（塩対応なエージェント等）。一方ビジネスではそうはいかないため、結果的に英語での音声入力の方が確実だと思います。

### 多言語ベースで音声を認識させるには
先の話からHoloLensで日本語を含む音声認識をするためにはOSレベルではどうしようもなくクラウド等のサービスを利用する必要があります。この際主なサービスは音声データ形式を以下のものに指定しているものが多いと思います。

* フォーマット：Wave形式
* 量子化ビット：16bit
* チャネル数：1channel(モノラル)
* サンプリングレート:16Khz

## HoloLensで利用できる音声認識系の機能
音声認識をするにあたりまずは何らかの方法でマイクから録音する必要があります。そのうえで、先ほどのフォーマットでデータを送れるように音声データを加工する必要もあります。色々手段があるのでHoloLensで利用できる方法を整理しました。

* DictationRecognizerの利用
* AudioSource＋Micorphoneクラスの利用
* MediaCaptureの利用
* AudioSource＋MRTK(MicStreamSelector)の利用

また、各対応方法で上記フォーマットに合わせるためのロジックのサンプルについても紹介したいと思います。この辺りの処理を利用することでAzureのCognitive Service等の音声系のAIサービスなどの活用も広がります。

### 環境
実装部分については以下の環境を利用しています。

* Windows 10 Pro Fall Creators Update
* Unity 2017.2.1f1
* Mixed Reality Toolkit - Unity 2017.2.1.0
* Mixed Reality Toolkit Examples - Unity 2017.2.1.0
* Visual Studio 2017 Community Edition

### DictationRecognizerの利用
Unityで提供される機能で英語に限定される方法としては以下のものがあります。これらの機能は厳密には音声の録音ではなく認識相当の機能までを提供します。
これらの機能については特にHoloLensだから何かをするというものはないので詳細は割愛します。

<dl>
<dt>DictationRecognizer</dt>
<dd>音声認識による音声→テキスト変換。話した内容をテキストに変換します。音声認識の開始、終了等のタイミングで発生するイベントを利用する。</dd>
<dt> KeywordRecognizer</dt>
<dd>キーワードによる音声→テキスト変換。予め登録したキーワードに合致した際にイベントを発生させることができる。</dd>
</dl>

### AudioSource＋Micorphoneクラスの利用

AudioSourceとMicrophoneクラスを駆使して録音する方法です。以下の手順で行えば音声データのサンプリングが可能です。

#### 手順

1. 空のGameObjectにAudioSourceコンポーネントを追加
2. Audio Mixerを追加してvolumeを-80dbに設定
3. 1で追加したAudioSourceのoutputプロパティにAudio MixerのMasterを設定(自分のマイクの音をスピーカから出さない設定) 
4. 空のスクリプトを作成（例：UnityMicrophone）
5. Audio Sourceを追加したGameObjectに4のスクリプトを追加
6. Microphoneに対して録音の開始、データ収集、録音の停止に対する実装

1～3については、マイクからの音をスピーカーに出力しないための設定です。これをしておかないとハウリング起こします。

#### 実装について

AudioSourceを用いたマイクの録音については以下の通りです。

##### 録音の開始
録音を行うためには、マイクの音声をAudioSourceで再生する形で実装します。マイクからの音声をAudioClipとして返すためにはMicrophoneクラスを利用します。このクラスにはマイクのデバイス名、録音時間、サンプリングレートを設定してマイクの準備を行います。このマイク用のAudioClipをAudioSourceに設定しAudioSourceを再生するとUnity内でマイクの音声が拾えるようになります。サンプルコードはAudioSourceを再生している間マイクが常に録音状態になるように録音時間をできるだけ大きくしたうえでAudioSourceでループをするように実装します。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php

private AudioSource _audioSource;
private string _microphone;
    
private void Start()
{
    _microphone = Microphone.devices[0];
    _audioSource = GetComponent<AudioSource>();
    _audioSource.clip = Microphone.Start(_microphone, false, 999, AudioSettings.outputSampleRate);
    _audioSource.loop = true;
    while (!(Microphone.GetPosition(_microphone) > 0)) { }
    
    _audioSource.Play();
    _isInitialize = true;
}
```

##### 音声データの取得
音声データはOnAudioFilterReadメソッドを利用します。このメソッドはAudioSourceで再生される生の音声データを取得することができます。AudioSourceコンポーネントを追加したオブジェクトではフレーム毎に呼び出され、その間の生の音声データをfloatの配列で取得することができます。なお、WebSocketを用いたリアルタイム処理をする場合は後述の量子化までここで処理することになります。サンプルコードは録音中はデータを蓄積する例です。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
private bool _isInitialize;
private List<float> _samplingData;

private void OnAudioFilterRead(float[] buffer, int numChannels)
{
    if (!_isInitialize) return;
    lock (this)
    {
       _samplingData.AddRange(f);
    }
}
```

##### 録音の終了
録音を終了する場合は、AudioSourceとマイクの停止を実装します。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
private void OnDestory()
{
    _isInitialize = false;
    _audioSource.Stop();
    Microphone.End(_microphone);
}
```

### MediaCaptureの利用

UWPの機能に「MediaCapture」というMedia（カメラ、マイク等）から情報を取得するための機能があります。これを使って録音が可能です。なお、MRTK-Unityには「MicrophoneHelper」クラスというのがあります。MediaCaptureクラスを利用した音声デバイス制御を支援するための機能なんですが、現時点ではマイクの状態（使えるかなど）を見るための機能しかありません。

#### 実装について

実装については以下の通りです。

##### 録音の開始とデータの取得
少し厄介なのはMediaCapture自体はUWPかつ非同期で動作するためasync/awaitが必要になることです。このため「#if ENABLE_WINMD_SUPPORT」を利用してUnity Editor上では無視されるように対応します。MediaCaptureで録音を開始するためには以下のように実装します。この際データ取得用のストリームを渡すことでそこから情報を取得します。MediaCapture.StartRecordToStreamAsyncは指定されたMediaタイプに応じたデバイスを開始しそのデータを引数のストリームに格納します。ストリームについてはメモリ上で処理するためにInMemoryRandomAccessStreamを利用しています。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
using System.Collections.Generic;
using HoloToolkit.Unity;
using HoloToolkit.Unity.InputModule;
using UnityEngine;
using UnityEngine.XR;
using UnityEngine.XR.WSA;
#if ENABLE_WINMD_SUPPORT
using System;
using System.Threading.Tasks;
using Windows.Storage.Streams;
using Windows.Media.Capture;
using Windows.Media.MediaProperties;
using System.Runtime.InteropServices.WindowsRuntime;
#endif

[RequireComponent(typeof(AudioSource))]
public class UWPMediaCapture : MonoBehaviour
{
#if ENABLE_WINMD_SUPPORT
    private MediaCapture _capture;
    private InMemoryRandomAccessStream _stream;
#endif

#if ENABLE_WINMD_SUPPORT
    private async Task Start()
#else
    private void Start()
#endif
    {
        if (!_isInitialize)
         {
        
            _isInitialize = true;
#if ENABLE_WINMD_SUPPORT
            MediaCaptureInitializationSettings settings = new MediaCaptureInitializationSettings();
            settings.StreamingCaptureMode = StreamingCaptureMode.Audio;
            settings.MediaCategory = MediaCategory.Speech;
            _capture = new MediaCapture();
            _stream = new InMemoryRandomAccessStream();
            await _capture.InitializeAsync(settings);
            await _capture.StartRecordToStreamAsync(MediaEncodingProfile.CreateWav(AudioEncodingQuality.Medium),_stream);
#endif
    }
}
```

##### 録音の終了
録音を終了する場合は、MediaCaptureのStopRecordAsyncを呼び出します。ストリームからバイト配列を取得するためにはInMemoryRandomAccessStream.ReadAsyncメソッドを利用します。MediaCaptureから取得できるデータは量子化された音声データ(16bit,ステレオ）です。ファイルに保存する場合はWaveのヘッダを付ける必要があります。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
#if ENABLE_WINMD_SUPPORT
private async Task OnDestory()
#else
private void OnDestory()
#endif
    {
        _isInitialize = false;
#if ENABLE_WINMD_SUPPORT
        await _capture.StopRecordAsync();
        
        byte[] bytes=new byte[(uint)_stream.Size];
        //バッファに読み込む
        await _stream.ReadAsync(bytes.AsBuffer(),(uint)_stream.Size,InputStreamOptions.None);
#endif
}
```

### AudioSource＋MRTK(MicStreamSelector)の利用
[Mixed Reality Toolkit](https://github.com/Microsoft/MixedRealityToolkit)(Unityがついてない方)のモジュールにはMicStreamSelectorというマイクでの録音に関する部品があります。こちらはHoloLens問わずに使えるC++のライブラリとなっています。

#### 使い方
C++のライブラリのためC#で使えるようにするためにはDLLインポートをする必要があるのですが、「Micstream」がExampleに含まれているのでこれをインポートすることで簡単に使うことができます（なんでMicStreamがMRTK-Unityにはいっていないんだろ）。
「Mixed Reality Toolkit Examples - Unity」のUnitypackageを用いて以下の２か所をインポートします。

* HoloToolkit-Examples/Input/Pulgin配下
* HoloToolkit-Examples/Input/Scripts/VoiceChat/MicStream.cs

![image](https://qiita-image-store.s3.amazonaws.com/0/152548/7c6b05c9-7dab-f041-82f4-eff01cb5440f.png)

後は、MicStreamを使って録音のために実装します。実装は「AudioSource＋Micorphoneクラスの利用」で説明した方法とほとんど変わりません。違うのはMicrophoneクラスの代わりにMicStreamを利用するという点だけです。

##### 録音の開始
MicStreamでの録音の開始は以下の通りです。リアルタイムで音声データを取得する場合、もしくはメモリ上で処理する場合はMicStartStreamメソッドを使用します。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php

public MicStream.StreamCategory StreamType = MicStream.StreamCategory.HIGH_QUALITY_VOICE;
public int InputGain = 1;
    
private void Start()
{
    MicStream.CheckForErrorOnCall(MicStream.MicInitializeCustomRate((int) StreamType, AudioSettings.outputSampleRate));
    MicStream.CheckForErrorOnCall(MicStream.MicStartStream(KeepAllData, false));
    MicStream.CheckForErrorOnCall(MicStream.MicSetGain(InputGain));
    _isInitialize = true;
}
```

##### 音声データの取得
音声データはAudioSourceコンポーネントを追加したスクリプトで利用できるOnAudioFilterReadメソッドを利用します。ただし、AudioClipに何も設定していないため引数のbufferは空です。MicStreamではMicGetFrameメソッドをフレーム毎に呼出しbufferにデータを詰める形で利用します。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
private bool _isInitialize;
private List<float> _samplingData;

private void OnAudioFilterRead(float[] buffer, int numChannels)
{
    if (!_isInitialize) return;
    lock (this)
    {
        MicStream.CheckForErrorOnCall(MicStream.MicGetFrame(buffer, buffer.Length, numChannels));
       _samplingData.AddRange(f);
    }
}
```

##### 録音の終了
録音を終了する場合は、MicStopStreamを呼び出します。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php
private void OnDestory()
{
    _isInitialize = false;
    MicStream.CheckForErrorOnCall(MicStream.MicStopStream());
}
```

### MicStreamについて

MicStreamについてはMicStreamSelectorからインポートした以下のメソッドが用意されています。各メソッドともに引数がintのものは処理結果が返ります。正常終了は0でそれ以外は何らかのエラーが発生しています。エラーの内容を把握するためのヘルパー的なものとしてCheckForErrorOnCallメソッドがありますので基本的にはこれとセットで呼出す方が扱いやすいです。

|メソッド名|説明|
|:-:|:--|
|int MicInitializeDefault(int category)|マイクを初期化処理を実行します。MicStreamSelectorモジュールの中で一番最初に呼び出す必要があるメソッドです。引数に音声品質を設定します（音声品質は後述のStreamCategory列挙体で指定します）|
|int MicInitializeCustomRate(int category, int samplerate)|マイクを初期化処理を実行します。こちらはオーバーロードでサンプリングレートを追加で指定します。|
|int MicStartStream(bool keepData, bool previewOnDevice)|マイクからの音声データ受信を開始します。これを呼び出すとMicStreamSelector内で音声データがバッファリングされます。バッファのデータはMicGetFrameで取得します。|
|int MicStopStream()|マイクとの接続を切断します。これによって、マイクからデータを受信できなくなります。|
|int MicStartRecording(string filename, bool previewOnDevice)| 指定されたファイルに音声データの記録を開始します。|
|void MicStopRecording(StringBuilder sb)|MicStartRecordingで対で使用するメソッドです。音声データを録音したファイルの書き込みを終了します。引数のStringBuilderには書込みに成功したファイルのフルパスが格納されています。|
|string MicStopRecording()| MicStartRecordingで録音したファイルの書き込みを終了します。戻り値は書き込みに成功したファイルのフルパス名が返ります。|
|int MicDestroy()|MicInitializeXXXメソッドと対になるメソッドで、マイク録音に関連する設定を廃棄します。後始末として最後に呼び出します。|
|int MicPause()|音声データのストリーミングを一時停止します。MicGetFrameメソッドやMicStartRecordingに対してデータを送らないようになります。|
| int MicResume()|MicPauseメソッドで一時停止したストリーミングを再開します。|
|int MicSetGain(float g)| 音声データのゲインを調整します。音声の大小に応じて調整を行う場合はメソッドを利用します。|
| bool CheckForErrorOnCall|このクラスの関数から返されたエラーコードに基づいて、エラー/警告メッセージを出力します。メッセージはUnityコンソールに出力されます。また、引数のboolは正常終了の時にtrueそれ以外はfalseが返ります。 |

### StreamCategory列挙体

音声データについて用途に合わせてデータのとり方を変更することができます。先ほど説明したMicInitializeXXXXメソッドを呼び出す際の引数にこの列挙体を渡す形で利用します。

|名前|内容|
|:-:|:--|
|LOW_QUALITY_VOICE|会話内容を分析するために最適化されたデータ|
|COMMUNICATIONS|より高い品質で会話に最適なデータ|
|ROOM_CAPTURE|会話に加えて部屋周囲の音声も含めたデータ|


### ErrorCodes列挙体

MicStreamSelectorはC++モジュールのため、C#側でDllインポートして利用します。この際それぞれの処理が正しく完了したかはエラーコード（int）で返ります。ただ、コードだと何が原因であるかの確認が難しいため、CheckForErrorOnCallメソッド内ではエラーコードに応じて分類やエラーメッセージを出力します。
その際、ErrorCodes列挙体を使います。それぞれのエラー内容は以下の通りです。

|名前|内容|
|:-:|:--|
|ALREADY_RECORDING|WARNING: 録音が開始されているにも関わらず、再度実施されたときに発生します。再実行するためには現在の録音を停止する必要があります。|
|ALREADY_RUNNING|WARNING:マイクを複数回初期化が実行された場合に発生します。|
|GRAPH_NOT_EXIST|ERROR: MicInitializeXXXXメソッドを呼ぶ前に録音の開始が実施された場合に発せします。|
|NO_AUDIO_DEVICE|ERROR: マイクの初期化を試みたが、、オーディオデバイスが見つからず失敗した場合に発生します。 OSのオーディオ設定を確認してください。|
|NO_INPUT_DEVICE|ERROR: マイクの初期化を試みたが、接続されていない可能性がある場合に発生します。|
|CHANNEL_COUNT_MISMATCH|ERROR: マイクのチャンネル数とデバイスの設定が不一致の場合に発生します。 OSのマイクの設定を確認し、モノラル/ステレオの確認をしてください。|
|FILE_CREATION_PERMISSION_ERROR|ERROR: 音声ファイルを作成する際に書き込み権限がなかった場合に発生します。出力場所及び権限の確認をしてください。|
|NOT_ENOUGH_DATA|これ自体はエラーではなく、マイク起動直後にバッファリングが十分行われていない間この値が返ります。|
|NEED_ENABLED_MIC_CAPABILITY|ERROR: Unityの設定でマイクに使用にチェックが入っていない場合に発生します。Capabilityの設定を見直してください。|

## 生の音声データの加工

UWPのMediaCaptureを除き、音声データは量子化されていません。このためWave形式などのデジタル値に変換するためには量子化を行います。また、サンプリングレートやチャネル数が欲しいフォーマットとあっていない場合はそれらの調整も必要になります。ここでは一般的なマイク（48Khz,ステレオ）の音声データを16Khz,モノラル,16Bitで量子化しWaveフォーマット形式のデータ列に変換する手順を説明します。

### 音声データについて

OnAudioFilterReadメソッドやMicGetFrameではフレーム毎の音声データをfloatの配列で取得することができます。この配列の要素数は「1フレーム当たりの時間」 × 「マイクのサンプリングレート」 × 「チャネル数」となります。floatの配列には「左右左右左右左右左右・・・」の順でデータが格納されています。

### サンプリングレートとチャネル数の調整
ます、初めにサンプリングレートとチャネル数の調整を行います。今回の例では「48Khz,ステレオ」を「16Khz,モノラル」に変換してみます。
サンプリングレートについては16Khz / 48Khz = 1/3になるため、データを等間隔に2つ飛ばして取り出せばOKです[^1]。
次にチャネル数ですが、ステレオからモノラルの場合簡単です。音声データは「左右左右・・・・」とデータが並んでいるので左ないし右の信号だけを取り出せばOKです。図にすると以下のような形です。下段は配列の添え字番号です。
これで必要なレートのデータを抽出可能です。

![image](https://qiita-image-store.s3.amazonaws.com/0/152548/38db925d-9cae-1f79-43e2-877b3a426c14.png)

[^1]: 厳密にはフィルターを通して音声データに処理を行うのがセオリーらしいです。 

### 音声データを量子化する
音声データで量子化する場合はほとんどの場合16bitになると思います。ほかのbitへの量子化もデータのサイズさえ意識すれば同じ考え方で実施できます。
16bitの量子化の場合、16bit = 2byteとなります。floatの値を2byteのデータ範囲に収まるように変換すればOKです。音声信号はプラスマイナスあるので、.NETではShort型（-32,768 ～ 32,767）に収まるように計算することになります。

```csharp
private short FloatToInt16(float value)
{
    var f = value * short.MaxValue;
    if (f > short.MaxValue) f = short.MaxValue;
    if (f < short.MinValue) f = short.MinValue;
}
```

### Waveフォーマットのヘッダを作る
最後に音声データのヘッダ部分を作成します。フォーマットはRIFF形式のものです。データサイズは上記で作成したbyte配列の要素数です。Websocketなどのリアルタイム通信を行う場合はサイズが不定になるため0で指定する場合があります。詳細は利用するサービスのリファレンスを確認してください。

例えば以下のようなメソッドでヘッダ部分をバイト配列化し、量子化したデータの最初につけておくことでWaveフォーマット形式のデータになります。あとはこの情報をファイルやサービスに送れば処理ができるようになります。

```csharp
// Copyright(c) 2018 Takahiro Miyaura
// Released under the MIT license
// http://opensource.org/licenses/mit-license.php

/// <summary>
///     Create a RIFF Wave Header
/// </summary>
/// <returns></returns>
public byte[] GetWaveHeader(short bitsPerSample, short channels, int sampleRate,
    int sampingDataByteSize)
{
    var extraSize = 0;
    var headerSize = 46;
    var blockAlign = (short) (channels * (bitsPerSample / 8));
    var averageBytesPerSecond = sampleRate * blockAlign;

    using (var stream = new MemoryStream())
    {
        var writer = new BinaryWriter(stream, Encoding.UTF8);
        writer.Write(Encoding.UTF8.GetBytes("RIFF"));
        writer.Write(headerSize + sampingDataByteSize - 8);
        writer.Write(Encoding.UTF8.GetBytes("WAVE"));
        writer.Write(Encoding.UTF8.GetBytes("fmt "));
        writer.Write(18 + extraSize);
        writer.Write((short) 1);
        writer.Write(channels);
        writer.Write(sampleRate);
        writer.Write(averageBytesPerSecond);
        writer.Write(blockAlign);
        writer.Write(bitsPerSample);
        writer.Write((short) extraSize);

        writer.Write(Encoding.UTF8.GetBytes("data"));
        writer.Write(sampingDataByteSize);

        stream.Position = 0;
        var buffer = new byte[stream.Length];
        stream.Read(buffer, 0, buffer.Length);
        return buffer;
    }
}
```


## 最後に
何種類か方法を整理してみたのですが、音声の生データを扱うならMicStreamSelectorでする方法が扱いやすい印象です。MicStreamSelector自体は特にデバイスにも制約もありません。UnityのMicrophoneでも使えるんですが、実装方法がどこかしら無理やり感が少し。。。MediaCaptureの場合は量子化済みのデータなので加工が必要な場合は扱いが難しくなることとUWPのため実装時に手間がすこしかかります。