まえがき
先日、「現場で使える!TensorFlow開発入門」という本を買った。
とてもわかり易い本で、画像分類からGANまで広くに渡って解説していた。
ちょっとスクリプトが古く、TensorFlow 1.x で書いているため、現在のTensorFlow 2.x だと動かないところも多いが、
Kerasに関しては、ほぼ互換性が保たれているため、4章以降はほぼ問題なく動かせる。
さて、実際に本を読んで勉強したのだから、何らかのアウトプットをしたいと考えた。
だけど、ただ、同じことをやるのは面白くない。そこで、今回は個人的に今興味を持っている音声解析を、Kerasを利用してやってみようと思う。
自分としては、become-yukarinのように、声を変換するようなものを作りたかったのだが、最初にやるには少し難しすぎるので、今回は入門として、
『音声から声優さんが誰かを判別させよう』
というテーマに決めた。
上記の本は、画像解析がほとんどだったので、音声を画像に変換して、学習する、という流れで実装していくことにした。
なお、筆者は今まで音声解析について学んだことがなかったため、おかしな点があると思う。その時は、コメントお願いします。
環境
- Windows 10
- python 3.7.5
- TensorFlow 2.3.0
- jupyter
アウトライン
上で書いたように、今回は画像(=二次元データ)に変換するアプローチを取っていく。
そこで、以下のような流れで実装していこう。
- データを集める
- 音声をMFCCに変換
- ここで、画像データに変換することができる。
 
- 画像データを整形・調整
- モデルを構築して学習
実装
1. データを集める
今回は、日本声優統計学会というサイトのデータセットを利用する。
土谷麻貴さん、上村彩子さん、藤東知夏さんの三人の声優(アナウンサー)が「通常」「喜び」「怒り」という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を読み込ませる。
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');
librosa.loadの返り値のxがデータ(形式はnumpy)で、fsはサンプリングレートである。
これでWavデータの取り込みができた。
2.2. MFCCへの変換
このデータを、MFCCに変換してみよう。
mfccs = librosa.feature.mfcc(x, sr=fs)
librosa.display.specshow(mfccs, sr=fs, x_axis='time')
plt.colorbar();
MFCCをは横軸が時間、縦軸が20次元の値となっている。
print(mfccs.shape) # -> (20, 630)
2.3. データの整形
1次元目のデータ(グラフの一番下)は除こう。
これも詳しくは説明しないが、グラフを見てもらえれば分かる通り、1次元目のデータのせいでデータ範囲が大きくなってしまっており、1次元より上の値の特徴量がつかみにくくなっている。
mfccs = mfccs[1:]
librosa.display.specshow(mfccs, sr=fs, x_axis='time')
plt.colorbar();
さらに、wavの方のデータを見てもらえれば分かる通り、7.5秒以降はほぼ無音だ。
この辺も邪魔なのでカットしてしまおう。
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以上の値になるまで」の値をカットした。
x = cut_silence(x)
librosa.display.waveplot(x, sr=fs, color='blue');
もう一度、mfccsに変換すると、次のようになる。
mfccs = librosa.feature.mfcc(x, sr=fs)
mfccs = mfccs[1:]
librosa.display.specshow(mfccs, sr=fs, x_axis='time')
plt.colorbar();
これで画像データにすることができた。
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を利用してすべてのデータのパスを取得する。
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データを配置するディレクトリを作る。
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しよう。
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)
これで、下の画像のように、データができるはずだ。
3. 画像データを整形・調整
これで、データを二次元データにすることができた。
次に、二次元データを扱いやすいように整形していこう。
3.1. データの読み込み
まず、上で保存したnumpyデータを読み込もう。
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の範囲に正規化しよう。
# データ全体の最大値と最小値を取得
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$は時間(正確には、秒×サンプリングレート)である。
単純にニューラルネットワークに突っ込むためには、データの大きさをすべてそろえたほうがわかりやすい。
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節と同じように、データを保存する。
まず、ディレクトリを作る。
NORMALIZE_DATA = "NormalizeData"
dirlist = os.listdir(DATASET_DIR)
for d in dirlist:
    os.makedirs(os.path.join(NORMALIZE_DATA, d), exist_ok=True)
そして、保存。
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個のデータを訓練データとテストデータ、検証データに分割しよう。
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]が正解データである。
# 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に変更しておこう。
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を利用している。
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
_________________________________________________________________
学習させる。
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. テストデータで精度予測
最後に、最初に作ったテストデータで予測してみよう。
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%以上ということで、うまく言ったと思う。
今後は、アニメやラジオの声を利用して、小倉唯とかの声を聞き分けられるようにしたい。






