まっそのせかい

やった事のメモとか

声質変換に挑戦しました

はじめに

機械学習を用いて以前キャラクターの識別について遊んだので、次は画像以外で何か遊んでみたいなーって思い、「そうだ、声質変換に挑戦しよう」と思い立ったので挑戦しました。
地獄を見る事になるとは思いもよらなかったのであった…

声質変換とは、ある人の声を他の人の声に変換する事を言います。
これが上手くいった場合自分の声を好きな人の声にする事ができます。 夢が広がりまくりですね。 現実は非情であった

ちょっと調べてみた感じ声質変換にはいくつか種類があり、特定の人の声から特定の人へ変換する一対一変換、任意の声から特定の人の声へ変換する多対一変換、その逆の一対多、任意の声から任意の声へ変換する多対多があるようです。今回は一番シンプルそうな一対一変換に挑戦します。

今回の目標

さて、変換する声は当然自分の声になる訳ですが、誰の声に変換するかをまずは決めます。
突然ですが私、みくにゃんが大好きなんですよ。

f:id:massoumen:20180319205921p:plain

みくにゃんというのはアイドルマスターシンデレラガールズに登場する猫系アイドルで、アニメにも登場しています。
猫系アイドルなのに魚が嫌いというすさまじい性質の持ち主だったりします。あとおっぱい

今回は機械学習を用いて自分の声をみくにゃんの声に変換するプログラムを作成するのを目標にしようと思います。

結果

先に結果を載せようと思います。まずは「アイドルマスターシンデレラガールズ」と喋ったものの変換結果です。

目標(みくにゃんの声)

変換前(僕の声)

変換結果

みくにゃんに近づいてるとは思いますが、何とも言えない微妙感ですね。機械感も強いです。
激甘評価をするならまぁまぁみくにゃんです。(自分に甘い)
とは言うものの、このデータの組は機械学習の際に用いたものなので、イイ感じに変換できて当然と言えば当然です。(イイ感じ…?)
本当に大事なのは学習データに含まれていないものがどう変換されるかという所だと思うので、その結果も載せようと思います。

1つ目は「みくはお魚が大好きだにゃ」と喋ったものの変換結果です。単語単位では学習データに存在すると思いますが、1文としては存在していません。
みくにゃんは魚が苦手です。

変換結果

すごい力の抜けた感じや機械感がしますが、一応音声としては聞き取れる…?それぞれの単語は学習データに含まれているせいか、そこまで酷い結果には感じません。
(学習データすら結果が微妙じゃないかと言えばその通りなのですが…)

次は「島村卯月、頑張ります」と喋ったものの変換結果です。先ほどのものとは異なり単語単位ですら殆ど存在しないと思います。
(みくにゃんが「島村卯月」と喋っているデータを用いた記憶は無いです)

変換結果

思ったより酷くはないし音声としては(多分…?)認識できますが、それでもやはり先ほど同様すごい力の抜けた感じや機械感はします。

さて、いくつか結果を載せたところで微妙感が漂いまくりな訳ですが、私も微妙だと思います。
いろいろ試行錯誤した結果、出力が音声だと認識できるくらいにはなりましたが、どうしても微妙感が拭えません。
正直これでも結構マシな結果で、音声とは言えないような謎の出力を多数観測したりしています。
最近ニコニコ動画に投稿された結月ゆかりの声になってみた人のものの方が遥かにクオリティが高いと思います。

www.nicovideo.jp

僕ももっとクオリティアップを図りたいところですが、だいぶ力尽きてきたので一段落と言った感じでこの記事を書いています。

手順の概要

今回は以下の手順で声質変換を行いました。

  1. 同じ内容を喋っているデータを作成
  2. メルケプストラムの抽出
  3. 機械学習を用いてメルケプストラム変換用のパラメータを推定
  4. 推定結果を用いてメルケプストラムを変換し音声を得る

残念ながらそれぞれの細かい処理について、完璧に把握してるとは言えず雰囲気でやってたりします。
Pythonと各種ライブラリに任せまくっています。

データの作成

今回の手法ではまず自分の声とみくにゃんの声データについて、同じ内容を喋っているもの(パラレルデータ)が必要になります。
みくにゃんのデータはセリフ集とかから持ってくるので大した問題ではありません。
問題は自分の声データです。みくにゃんと同じセリフを喋って録音する必要があります。

なので録音します。(迫真

これが…ほんとですね…気が狂いそうになるといいますかね、もうあれですよ。二度とやりたくないです。
オタクがひたすらみくにゃんのセリフを喋ってる光景、相当キツいです。
自分の事をみくにゃんだと勘違いしている精神異常者

声優ってすごいなって改めて思いました。

メルケプストラムの抽出

なんとか録音を終えたら音声データから特徴量としてメルケプストラムを抽出します。
メルケプストラム以外にも特徴量がいろいろあったりしますが、どれが良いんでしょうね。(正直良く分かりません
メルケプストラムを抽出するにあたり以下の記事の内容を参考にしました。

aidiary.hatenablog.com

wavファイルはあらかじめ16kHz・モノラルにしておきます。
メルケプストラムの抽出は上のサイトのものをそのまま利用させていただきました。

自分の声とみくにゃんの声データから得られたメルケプストラムの内、1次元目は以下のようになっています。

f:id:massoumen:20180320031829p:plain

同じ内容(アイドルマスターシンデレラガールズ)を喋っているのですが、かなり値が異なっています。
また、今後の処理のために時間方向のデータ数を揃えておきます。以下の記事の内容を参考にしました。

aidiary.hatenablog.com

時間方向に揃えると以下のようになりました。
割と合ってる箇所とそうでない箇所が見られます。

f:id:massoumen:20180320031840p:plain

機械学習

データを用意したらモデルを構築し学習を行います。ライブラリはKerasを利用しました。そこまで複雑でないモデルならシンプルに書けて好きです。
画像を扱った時はCNNを用いましたが、今回は時系列データという事でRNN(Bidirectional LSTM)を用いてみたいと思います。
自分のメルケプストラムの時刻tからt+aの値を入力として与え、みくにゃんのメルケプストラムの時刻tの値を出力するように学習します。
また、メルケプストラムは最初の次元にパワーの値が入り26次元のベクトルになっているそうですが、それを無視した方が良さげな結果になったので、今回の場合は入力が(a, 25)の行列、出力が25次元のベクトルになります。
また、時刻0からa、1からa+1、2からa+2、…とスライドさせながら入出力の組を作成します。入力データについて、スライドさせていくと終わりの部分が足りなくなるので、足りない部分は0で埋めるようにしています。
また、各次元ごとに平均0、分散1になるように正規化を行いました。平均を引いて標準偏差で割るアレです。
学習用のソースコードは以下のようになりました。

from keras.models import Sequential
from keras.layers import CuDNNLSTM, Dense, Bidirectional
from sklearn.model_selection import train_test_split
import numpy as np
import glob
import gc
import tensorflow as tf
from keras.backend import tensorflow_backend

config = tf.ConfigProto(gpu_options=tf.GPUOptions(allow_growth=True))
session = tf.Session(config=config)
tensorflow_backend.set_session(session)

max_t = 40
input_dim = 25 # 入力の次元
hidden_dim = 100 # 隠れ層の次元
output_dim = 25 # 出力の次元

# 1フレームずつずらしながら(data_num, max_t, input_dim)のデータを生成
def read_input(mcep_dir, mean, std):
    data = []
    mcep_list = glob.glob(mcep_dir + "/*")

    for mcep_path in mcep_list:
        print(mcep_path)

        mcep = np.loadtxt(mcep_path)[:, 1:] # パワーを無視

        mcep = (mcep - mean) / std

        mcep = np.r_[mcep, np.zeros((max_t - 1, input_dim))]

        # ずらしながらデータを追加(max_tに満たない場合は0を付け足して追加)
        if mcep.shape[0] >= max_t:
            for i in range(mcep.shape[0] - max_t + 1):
                data.append(mcep[i:i + max_t, :])
        else:
            zero = np.zeros((max_t - mcep.shape[0], mcep.shape[1]))
            mcep = np.r_[mcep, zero]

            data.append(mcep)

    return np.array(data).astype("float32")

# 時刻t,t+max_tのデータから時刻tの変換結果を推定する用のデータを生成
def read_teacher(mcep_dir, mean, std):
    data = []
    mcep_list = glob.glob(mcep_dir + "/*")

    for mcep_path in mcep_list:
        print(mcep_path)

        mcep = np.loadtxt(mcep_path)[:, 1:] # パワーを無視

        mcep = (mcep - mean) / std

        data.extend(mcep)

    return np.array(data).astype("float32")

def read_mcep(mcep_dir):
    data = []
    mcep_list = glob.glob(mcep_dir + "/*")

    for mcep_path in mcep_list:
        print(mcep_path)

        mcep = np.loadtxt(mcep_path)[:, 1:] # パワーを無視
        data.extend(mcep)

    return np.array(data)

# LSTMの構築(入力:(data_num, max_t, input_dim), 出力:(data_num, output_dim))
model = Sequential()
model.add(Bidirectional(CuDNNLSTM(hidden_dim, return_sequences=True), input_shape=(max_t, input_dim)))
model.add(Bidirectional(CuDNNLSTM(hidden_dim, return_sequences=True)))
model.add(Bidirectional(CuDNNLSTM(hidden_dim, return_sequences=True)))
model.add(Bidirectional(CuDNNLSTM(hidden_dim)))
model.add(Dense(output_dim))

model.compile(optimizer="adam", loss="mae")

# 全部のデータをまとめ、各次元ごとに平均と標準偏差を求める
x_temp = read_mcep("./aligned_mcep/me")
t_temp = read_mcep("./aligned_mcep/miku")
y = []
y.extend(x_temp)
y.extend(t_temp)
y = np.array(y)
mean = np.mean(y, axis=0)
std = np.std(y, axis=0)
del x_temp
del t_temp
gc.collect()

# 平均と標準偏差を保存
np.savetxt("lstm_mean.txt", mean, fmt="%0.6f")
np.savetxt("lstm_std.txt", std, fmt="%0.6f")

# 入力データと教師データの読み込み
x = read_input("./aligned_mcep/me", mean, std)
t = read_teacher("./aligned_mcep/miku", mean, std)

# 学習用データとテストデータに分離
x_train, x_test, t_train, t_test = train_test_split(x, t, test_size=0.3)

# 学習
history = model.fit(x_train, t_train, epochs=50, batch_size=256, validation_data=(x_test, t_test))

# モデルを保存
model_json_str = model.to_json()
open("lstm_model.json", "w").write(model_json_str)

# 重みを保存
model.save_weights("lstm.h5")

あとは実際に実行し、学習が終わるのを待ちます。

実際に変換

学習が終わったらそれを用いて実際に変換を行います。
流れとしては以下のようになります。

  1. 変換したい音声からメルケプストラムを抽出
  2. 学習したRNNを利用してメルケプストラムを変換
  3. 変換したメルケプストラムから音声合成

新たに書いたソースコードは以下の2つです。
RNNを利用してメルケプストラムを変換する部分とメルケプストラムから音声合成を行う部分です。

import os
import sys
import numpy as np
from keras.layers import Input, Dense
from keras.optimizers import Adam
from keras.models import Model, model_from_json
import tensorflow as tf
from keras.backend import tensorflow_backend

config = tf.ConfigProto(gpu_options=tf.GPUOptions(allow_growth=True))
session = tf.Session(config=config)
tensorflow_backend.set_session(session)

if len(sys.argv) != 3:
    print("usage: python convert_mcep.py <mcep_path> <converted_mcep_path>")
    exit()

mcep_path = sys.argv[1]
converted_mcep_path = sys.argv[2]

input_dim = 25 # 入力の次元
hidden_dim = 100 # 隠れ層の次元
output_dim = 25 # 出力の次元

max_t = 40
mcep = np.loadtxt(mcep_path)
power = mcep[:, 0]
mc = mcep[:, 1:]
mean = np.loadtxt("lstm_mean.txt")
std = np.loadtxt("lstm_std.txt")

mc = (mc - mean) / std # 正規化
mc = np.r_[mc, np.zeros((max_t - 1, input_dim))]

# 変換用のLSTMを読み込む
lstm = model_from_json(open("lstm_model.json").read())
lstm.load_weights("lstm.h5")

# LSTM入力用に整形
data = []

for i in range(mc.shape[0] - max_t + 1):
    data.append(mc[i:i + max_t, :])

data = np.array(data).astype("float32")

converted_mcep = lstm.predict(data) # LSTMで変換

converted_mcep = converted_mcep * std + mean # 正規化したものを戻す
converted_mcep = np.c_[power, converted_mcep]

np.savetxt(converted_mcep_path, converted_mcep, fmt="%0.6f")
import os
import sys
import subprocess
import struct
import numpy as np
from scipy.stats import multivariate_normal
from sklearn.externals import joblib

# メルケプストラム次数
# 実際はパワー項を追加して26次元ベクトルになる
m = 25

def extract_mcep(wav_file, mcep_file, ascii=False):
    cmd = "bcut +s -s 22 %s | x2x +sf | frame -l 400 -p 80 | window -l 400 -L 512 | mcep -l 512 -m %d -a 0.42 | x2x +fa%d > %s" % (wav_file, m, m + 1, mcep_file)
    subprocess.call(cmd, shell=True)

def extract_pitch(wav_file, pitch_file):
    cmd = "bcut +s -s 22 %s | x2x +sf | pitch -a 1 -s 16 -p 80 > %s" % (wav_file, pitch_file)
    subprocess.call(cmd, shell=True)

def synthesis(pitch_file, mcep_file, wav_file):
    cmd = "excite -p 80 %s | mlsadf -m %d -a 0.42 -p 80 %s | clip -y -32000 32000 | x2x +fs > temp.raw" % (pitch_file, m, mcep_file)
    print(cmd)
    subprocess.call(cmd, shell=True)

    cmd = "sox -e signed-integer -c 1 -b 16 -r 16000 temp.raw %s" % (wav_file)
    print(cmd)
    subprocess.call(cmd, shell=True)

    os.remove("temp.raw")

if len(sys.argv) != 4:
    print("usage: python convert_voice.py <wav_path> <converted_mcep_path> <converted_wav_path>")
    exit()

source_wav_file = sys.argv[1]
converted_wav_file = sys.argv[3]

print("extract pitch ...")
source_pitch_file = "source.pitch"
extract_pitch(source_wav_file, source_pitch_file)

source_mcep = np.loadtxt(sys.argv[2]) # 変換後のmcepを読み込む
source_mcep_file = "temp.mcep"
fp = open(source_mcep_file, "wb")

for t in range(len(source_mcep)):
    x_t = source_mcep[t]
    fp.write(struct.pack('f' * (m + 1), * x_t))

fp.close()

# 変換元のピッチと変換したメルケプストラムから再合成
print("synthesis ...")
synthesis(source_pitch_file, source_mcep_file, converted_wav_file)

# 一時ファイルを削除
os.remove(source_mcep_file)
os.remove(source_pitch_file)

変換前と変換後、みくにゃんのメルケプストラムの1次元目は以下のようになっています。
青色が変換前、オレンジが変換後、緑がみくにゃんのメルケプストラムです。
ある程度みくにゃんのものに近づいている感じはあります。(時間は揃えています)

f:id:massoumen:20180320035830p:plain

おわりに

以上が今回の声質変換の結果と大まかな流れです。
音声処理、超難しいですね…(決して画像処理が簡単という訳ではないと思いますが…)
クオリティを上げるためには音声処理や機械学習についてもっといろいろ学ぶ必要がありそうです。
今回、GANを用いたものにも挑戦してみたかったんですが、理解が及ばず今のところ試していません…
(ソースコードの複雑さが段違いである…無念)

今後も勉強しつつ何かネタを見つけてチャレンジしたり、これまで作ったものの改良をしたりしていきたいですね。