深層学習による音声処理：音声から声優さんが誰かを判別させよう

# まえがき

先日、「現場で使える！TensorFlow開発入門」という本を買った。
とてもわかり易い本で、画像分類からGANまで広くに渡って解説していた。
ちょっとスクリプトが古く、TensorFlow 1.x で書いているため、現在のTensorFlow 2.x だと動かないところも多いが、
Kerasに関しては、ほぼ互換性が保たれているため、4章以降はほぼ問題なく動かせる。

さて、実際に本を読んで勉強したのだから、何らかのアウトプットをしたいと考えた。
だけど、ただ、同じことをやるのは面白くない。そこで、今回は個人的に今興味を持っている音声解析を、Kerasを利用してやってみようと思う。

自分としては、[become-yukarin](https://github.com/Hiroshiba/become-yukarin/tree/master/become_yukarin)のように、声を変換するようなものを作りたかったのだが、最初にやるには少し難しすぎるので、今回は入門として、
『**音声から声優さんが誰かを判別させよう**』
というテーマに決めた。

上記の本は、画像解析がほとんどだったので、音声を画像に変換して、学習する、という流れで実装していくことにした。
なお、筆者は今まで音声解析について学んだことがなかったため、おかしな点があると思う。その時は、コメントお願いします。

## 環境
- Windows 10
- python 3.7.5
- TensorFlow 2.3.0
- jupyter

## アウトライン

上で書いたように、今回は画像(=二次元データ)に変換するアプローチを取っていく。
そこで、以下のような流れで実装していこう。

1. データを集める
2. 音声をMFCCに変換
    - ここで、画像データに変換することができる。
3. 画像データを整形・調整
4. モデルを構築して学習

# 実装
## 1. データを集める
今回は、[日本声優統計学会](https://voice-statistics.github.io/)というサイトのデータセットを利用する。

土谷麻貴さん、上村彩子さん、藤東知夏さんの三人の声優(アナウンサー)が「通常」「喜び」「怒り」という3つの感情で100個のセリフを無音室で録音したデータセットである。
100セリフ×3人×3感情で、計900ものデータがおいてある。
(Wikipediaを読み上げているため、意味のないセリフで喜び・怒りの感情を表現しているのを聞くと、声優さんってすごいって気持ちになる)

今回は、この三人を見分けられるモデルを作ってみよう。
データは`*.tar.gz`だが、解凍ソフトを利用すればwindowsでも解凍できる。

## 2. 音声をMFCCに変換
では、次にデータをMFCC(メル周波数ケプストラム係数)というものに変換しよう。
MFCCについて詳しく説明すると、長くなるので、「声の特徴量」とだけ説明しておく。
音声データ(wav)をMFCCという数値に変換するのは、`librosa`というものを利用する。

```
pip install librosa
```
でインストールできる。

### 2.1. データの読み込み

まず、データを読み込ませよう。
例として`fujitou_normal_001.wav`を読み込ませる。

```python
from matplotlib import pyplot as plt
import librosa
import librosa.display
import os

# データのパスを書く
WAV_DATA_PATH = os.path.join("Dataset","fujitou_normal","fujitou_normal_001.wav") 

x, fs = librosa.load(WAV_DATA_PATH, sr=44100)

librosa.display.waveplot(x, sr=fs, color='blue');
```

![wavdata.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485802/5c896fc5-04e6-1e73-f87a-5c5ad4d5a8f9.png)


`librosa.load`の返り値のxがデータ(形式は`numpy`)で、`fs`はサンプリングレートである。
これでWavデータの取り込みができた。

### 2.2. MFCCへの変換

このデータを、MFCCに変換してみよう。

```python
mfccs = librosa.feature.mfcc(x, sr=fs)
librosa.display.specshow(mfccs, sr=fs, x_axis='time')
plt.colorbar();
```

![mfcc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485802/c6ec01ae-2fe1-5455-de0d-27cdf3f70436.png)

MFCCをは横軸が時間、縦軸が20次元の値となっている。

```python
print(mfccs.shape) # -> (20, 630)
```

### 2.3. データの整形

1次元目のデータ(グラフの一番下)は除こう。
これも詳しくは説明しないが、グラフを見てもらえれば分かる通り、1次元目のデータのせいでデータ範囲が大きくなってしまっており、1次元より上の値の特徴量がつかみにくくなっている。

```python
mfccs = mfccs[1:]
librosa.display.specshow(mfccs, sr=fs, x_axis='time')
plt.colorbar();
```
![mfcc_skip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485802/7f2b2259-74ec-28c5-8306-b181b407196a.png)


さらに、wavの方のデータを見てもらえれば分かる通り、7.5秒以降はほぼ無音だ。
この辺も邪魔なのでカットしてしまおう。

```python
import numpy as np

def cut_silence(wavdata, eps=0.01):
    st = 0
    gl = len(wavdata)
    data = np.abs(wavdata)
    threshold = np.max(data) * eps
    for i,a in enumerate(data):
        if a > threshold:
            st = i - 1
            break

    for i,a in reversed(list(enumerate(data))):
        if a > threshold:
            gl = i
            break
    return wavdata[st:gl]
```

今回は、「0秒からデータの最大値×0.01以上の値になるまで」と「最後からデータの最大値×0.01以上の値になるまで」の値をカットした。

```python
x = cut_silence(x)
librosa.display.waveplot(x, sr=fs, color='blue');
```

これによって、グラフは次のようになった。
![wav_cut.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485802/34f6810d-2fae-1385-69c9-5fbfb9284e9c.png)

もう一度、mfccsに変換すると、次のようになる。

```python
mfccs = librosa.feature.mfcc(x, sr=fs)
mfccs = mfccs[1:]
librosa.display.specshow(mfccs, sr=fs, x_axis='time')
plt.colorbar();
```
![mfcc_skip_cut.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485802/e6ed9114-4754-7851-2476-423141e6087b.png)


これで画像データにすることができた。

### 2.4. 全データを変換して保存

MFCCは意外と時間がかかる処理のため、`librosa.feature.mfcc`を利用したあとは、numpyデータ(*.npy)として保存してたほうがあとあと楽だと思う。
すべてのデータを変換して、numpyデータに保存しよう。
以下のようなディレクトリ構造を仮定している。

```
.
|-Dataset
|   |-fujitou_angry
|   |   |-fujitou_angry_001.wav
|   |   |-fujitou_angry_002.wav
|   |   |-fujitou_angry_003.wav
|   |   |-...
|   |
|   |-fujitou_happy
|   |   |-fujitou_happy_001.wav
|   |   |-...
|   |
|   |-...
|
|-ImageData
|   |-fujitou_angry
|   |   |-fujitou_angry_001.npy
|   |   |-fujitou_angry_002.npy
|   |   |-fujitou_angry_003.npy
|   |   |-...
|   |
|   |-fujitou_happy
|   |   |-fujitou_happy_001.npy
|   |   |-...
|   |
|   |-...
|
```

まず、`os.listdir`を利用してすべてのデータのパスを取得する。

```python
import os
import random

DATASET_DIR="Dataset"

wavdatas = []

dirlist = os.listdir(DATASET_DIR)
for d in dirlist:
    d = os.path.join(DATASET_DIR, d)
    datalist = os.listdir(d)
    y = [d[d.find("\\")+1:d.find("_")], d[d.find("_") + 1:]] # ファイル名から正解データの決定
    datalist = [[os.path.join(d,x), y] for x in datalist]
    wavdatas.extend(datalist)
```

次に、numpyデータを配置するディレクトリを作る。

```python
IMAGE_DATA = "ImageData"

dirlist = os.listdir(DATASET_DIR)
for d in dirlist:
    os.makedirs(os.path.join(IMAGE_DATA, d), exist_ok=True)
```

そして、全部のデータを変換して`np.save`しよう。

```python
def get_mfcc(datadir):
    x, fs = librosa.load(datadir, sr=44100)
    x = cut_silence(x)
    mfccs = librosa.feature.mfcc(x, sr=fs)
    mfccs = mfccs[1:]
    return mfccs, x, fs

nn = len(wavdatas)
for i, data in enumerate(wavdatas):
    path_list = data[0].split("\\")
    path_list[0] = IMAGE_DATA
    path_list[2] = path_list[2].replace(".wav", ".npy")
    image_path = "\\".join(path_list)
    mfcc,x,fs = get_mfcc(data[0])
    if i%10 == 0:
        print(i, "/", nn)
    np.save(image_path, mfcc)
```

これで、下の画像のように、データができるはずだ。

![directory.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485802/463dc5de-cce8-7d64-056d-50df3e9df5a4.png)


## 3. 画像データを整形・調整

これで、データを二次元データにすることができた。
次に、二次元データを扱いやすいように整形していこう。

### 3.1. データの読み込み

まず、上で保存したnumpyデータを読み込もう。

```python
IMAGE_DATA = "ImageData"
numpy_datas = []

dirlist = os.listdir(IMAGE_DATA)
for d in dirlist:
    d = os.path.join(IMAGE_DATA, d)
    datalist = os.listdir(d)
    datalist = [[np.load(os.path.join(d,x)), os.path.join(d,x)] for x in datalist]
    numpy_datas.extend(datalist)
```

### 3.2. 正規化

まず、上のデータは-200~100くらいの値の範囲になっている。
これを0~1の範囲に正規化しよう。

```python
# データ全体の最大値と最小値を取得
data = numpy_datas[0][0]
maximum = np.max(data)
minimum = np.min(data)
for i, data in enumerate(numpy_datas):
    M = np.max(data[0])
    m = np.min(data[0])
    if maximum < M:
        maximum = M
    if minimum > m:
        minimum = m

# 0~1の範囲に
normalize = lambda x: (x - minimum)/(maximum - minimum)
for i, data in enumerate(numpy_datas):
    numpy_datas[i][0] = normalize(data[0])
```

### 3.3. データの大きさを揃える
2節で作ったデータは$19\times T$の大きさとなっている。ここで、$T$は時間(正確には、秒×サンプリングレート)である。
単純にニューラルネットワークに突っ込むためには、データの大きさをすべてそろえたほうがわかりやすい。

```python
from PIL import Image
import numpy as np

img_datas = []

for i,data in enumerate(numpy_datas):
    imgdata = Image.fromarray(data[0])
    imgdata = imgdata.resize((512,19))
    numpy_datas[i][0] = np.array(imgdata)
```

ここではすべて、512×19のデータに変換した。

### 3.4. 画像データの保存

2節と同じように、データを保存する。

まず、ディレクトリを作る。

```python
NORMALIZE_DATA = "NormalizeData"

dirlist = os.listdir(DATASET_DIR)
for d in dirlist:
    os.makedirs(os.path.join(NORMALIZE_DATA, d), exist_ok=True)
```

そして、保存。

```python
for i, data in enumerate(numpy_datas):
    path_list = data[1].split("\\")
    path_list[0] = NORMALIZE_DATA
    image_path = "\\".join(path_list)
    np.save(image_path, data[0])
```

## 4. モデルを構築して学習
それでは、ディープラーニングで学習させてみよう

### 4.1. 訓練データとテストデータの分割
900個のデータを訓練データとテストデータ、検証データに分割しよう。

```python
import numpy as np
import random, os

NORMALIZE_DATA="NormalizeData"

N_TRAIN = 0.8
N_TEST = 0.1
N_VALID = 0.1

train_data = []
test_data = []
valid_data = []

dirlist = os.listdir(NORMALIZE_DATA)
for d in dirlist:
    d = os.path.join(NORMALIZE_DATA, d)
    datalist = os.listdir(d)
    y = [d[d.find("\\")+1:d.find("_")], d[d.find("_") + 1:]] # ファイル名から正解データの決定
    datalist = [[np.load(os.path.join(d,x)), y, os.path.join(d,x)] for x in datalist]
    random.shuffle(datalist)
    train_data.extend(datalist[:int(len(datalist)*N_TRAIN)])
    test_data.extend(datalist[int(len(datalist)*N_TRAIN): int(len(datalist)*N_TRAIN) + int(len(datalist)*N_TEST)])
    valid_data.extend(datalist[int(len(datalist)*N_TRAIN) + int(len(datalist)*N_TEST): ])

random.shuffle(train_data)
random.shuffle(test_data)
random.shuffle(valid_data)
```

全データを訓練データ:テストデータ:検証データ=0.8:0.1:0.1になるようにした。
900個のデータを利用しているため、720:90:90 となっている。
また、うまく分散させるために2回シャッフルしている。
一つのディレクトリ内のデータを取得後、一回シャッフル、最後に結合後にもう一度シャッフルしている。

- 入力データ: `train_datas[i][0]`
- 正解データ: `train_datas[i][1]`
    - 声優名: `train_datas[i][1][0]`
    - 感情: `train_datas[i][1][1]`

というようなデータ構造になっている。

### 4.2. 入力と正解データを学習用に変換
tensorflowのkerasで学習する前に、入力と正解データを使いやすいように変換しておこう。
今回は、声優名を分類するため、`train_datas[i][1][0]`が正解データである。

```python
# numpyに変換
input_data_train = np.array([train_data[i][0] for i in range(len(train_data))])
input_data_test = np.array([test_data[i][0] for i in range(len(test_data))])
input_data_valid = np.array([valid_data[i][0] for i in range(len(valid_data))])

# 正解データをリストに
label_data_train = [train_data[i][1][0] for i in range(len(train_data))]
label_data_test = [test_data[i][1][0] for i in range(len(test_data))]
label_data_valid = [valid_data[i][1][0] for i in range(len(valid_data))]
```

正解データは1-hotに変更しておこう。

```python
from tensorflow.keras.utils import to_categorical

label_dict={"tsuchiya": 0, "fujitou":1, "uemura":2}

label_no_data_train = np.array([label_dict[label] for label in label_data_train])
label_no_data_test = np.array([label_dict[label] for label in label_data_test])
label_no_data_valid = np.array([label_dict[label] for label in label_data_valid])

label_no_data_train = to_categorical(label_no_data_train, 3)
label_no_data_test = to_categorical(label_no_data_test, 3)
label_no_data_valid = to_categorical(label_no_data_valid, 3)
```

### 4.3. モデルの構築・学習
それでは、ニューラルネットワークのモデルを構築しよう。
鉄則のようなものは知らないので、とりあえず畳み込み演算と正規化を繰り返すことにした。
活性化関数はreluを利用している。

```python
from tensorflow.keras.layers import Input, Conv2D, Conv2DTranspose,\
                                    BatchNormalization, Dense, Activation,\
                                    Flatten, Reshape, Dropout
from tensorflow.keras.models import Model

inputs = Input((19,512))
x = Reshape((19,512,1), input_shape=(19,512))(inputs)
x = Conv2D(12, (1,4), strides=(1,2), padding="same")(x)
x = BatchNormalization()(x)
x = Activation("relu")(x)
x = Conv2D(12, (1,4), strides=(1,2), padding="same")(x)
x = BatchNormalization()(x)
x = Activation("relu")(x)
x = Conv2D(12, (2,2), strides=(1,1), padding="same")(x)
x = BatchNormalization()(x)
x = Activation("relu")(x)
x = Conv2D(12, (2,2), strides=(1,1), padding="same")(x)
x = BatchNormalization()(x)
x = Activation("relu")(x)
x = Flatten()(x)
x = Dense(3)(x)
output = Activation("softmax")(x)

model = Model(inputs=inputs, outputs=output)
model.summary()
```

model.summary()の結果は次のとおりだ。

```
Model: "functional_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         [(None, 19, 512)]         0         
_________________________________________________________________
reshape (Reshape)            (None, 19, 512, 1)        0         
_________________________________________________________________
conv2d (Conv2D)              (None, 19, 256, 12)       60        
_________________________________________________________________
batch_normalization (BatchNo (None, 19, 256, 12)       48        
_________________________________________________________________
activation (Activation)      (None, 19, 256, 12)       0         
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 19, 128, 12)       588       
_________________________________________________________________
batch_normalization_1 (Batch (None, 19, 128, 12)       48        
_________________________________________________________________
activation_1 (Activation)    (None, 19, 128, 12)       0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 19, 128, 12)       588       
_________________________________________________________________
batch_normalization_2 (Batch (None, 19, 128, 12)       48        
_________________________________________________________________
activation_2 (Activation)    (None, 19, 128, 12)       0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 19, 128, 12)       588       
_________________________________________________________________
batch_normalization_3 (Batch (None, 19, 128, 12)       48        
_________________________________________________________________
activation_3 (Activation)    (None, 19, 128, 12)       0         
_________________________________________________________________
flatten (Flatten)            (None, 29184)             0         
_________________________________________________________________
dense (Dense)                (None, 3)                 87555     
_________________________________________________________________
activation_4 (Activation)    (None, 3)                 0         
=================================================================
Total params: 89,571
Trainable params: 89,475
Non-trainable params: 96
_________________________________________________________________
```

学習させる。

```python
model.compile(
    optimizer="adam",
    loss="categorical_crossentropy"
)

model.fit(
    input_data_train,
    label_no_data_train,
    batch_size=30,
    epochs=50,
    validation_data=(input_data_valid, label_no_data_valid)
)
```

### 4.4. テストデータで精度予測
最後に、最初に作ったテストデータで予測してみよう。

```python
out = model.predict(input_data_test)
predict = np.argmax(out, axis=1)
answer =np.argmax(label_no_data_test, axis=1)

print("correct:", np.sum(predict == answer), "/", len(predict))
print("rate:", np.sum(predict == answer)/len(predict) * 100, "%")
```
```
correct: 90 / 90
rate: 100.0 %
```

なんと、全問正解できた。素晴らしい。

何回か試してみたが、大体90%以上の精度が出ている。
もうちょっとモデルをチューニングすれば、確実に100%になるようにできると思う。

### 4.5. 感情の分類
せっかくだから、声優名だけでなく、感情も分類できるか試してみよう。

再掲
> - 入力データ: `train_datas[i][0]`
> - 正解データ: `train_datas[i][1]`
>    - 声優名: `train_datas[i][1][0]`
>    - 感情: `train_datas[i][1][1]`

から、`train_datas[i][1][1]`が正解データとしてみよう。
結果は、
```
correct: 88 / 90
rate: 97.77777777777777 %
```
と、同じような結果になった。


# まとめ
今回は、声から声優を分類するということで、音声を画像に変換して、畳込み層をつかったニューラルネットワークを使った。
結果は90%以上ということで、うまく言ったと思う。

今後は、アニメやラジオの声を利用して、小倉唯とかの声を聞き分けられるようにしたい。