『ディープラーニングの力で結月ゆかりの声になるリポジトリ』の性能アップ (音質+速度)

#1.概要
『ディープラーニングの力で結月ゆかりの声になるリポジトリ』
https://github.com/Hiroshiba/become-yukarin
の性能をアップするための方法について解説します。

前回([パラメータチューニング(第一段階編)](https://qiita.com/sunnyyone/items/f85c26d6149a200ed119) )の続きになります。

性能アップについては当初、第二段階のみ書くつもりでしたが第一段階についても、合わせて記事にすることにしました。

本稿での性能アップとは、音質の改善と推定速度(音声変換時間)の短縮のことを指しています。
次の内容について解説していきます。

+ 第ゼロ段階の実施
+ 第一段階のパラメータ調整
+ 第二段階の学習データ削減
+ 第二段階を用いたノイズ学習

#2.導入方法
前回に引き続き、同環境にて実施するため、導入方法は[前回](https://qiita.com/sunnyyone/items/f85c26d6149a200ed119) を参照。

#3.実行環境
こちらも前回に引き続き、同環境にて実施するため、[前回](https://qiita.com/sunnyyone/items/f85c26d6149a200ed119) の実行環境を参照。

#4.学習データ音声と検証方法
第一段階、第二段階の学習に使う音声はパラレルデータを用意します。
分かり易いよう二つの音声を一つにしたものが以下になります。
左側音声が筆者、右側音声が変換先です。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">←地声｜変換先→<br>グラフ：1ms毎にスペクトログラムの値(0-1に正規化)を表示 <a href="https://t.co/5n5RChyuqF">pic.twitter.com/5n5RChyuqF</a></p>&mdash; ひろ (@Khirotake) <a href="https://twitter.com/Khirotake/status/1124143741789327360?ref_src=twsrc%5Etfw">2019年5月3日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

本稿ではパラレルデータの地声の音声を音声変換し、どのように音質が改善したか検証します。
※実際に処理するときは学習データのパラレルデータを数百ファイル程度用意して処理します。

#5.第ゼロ段階の実施
この章は第一段階の学習の結果、大きなノイズが出てしまう場合に実施してください。
変換音声にノイズが乗る程度であれば、6章へ進めてください。

前提：第一段階、第二段階の学習と推定(変換の実施)が終わっていること。

[動作環境]
become-yukarin
　├dat
　│　├in_1st_my_wav ・・・ 地声の音声が格納されている
　│　└in_1st_yukari_wav ・・・ 変換先の音声が格納されている
　├test_data_sr ・・・ 第二段階の音声変換のためのインプットディレクトリ
　└output
　 　└yukari_2nd ・・・ 第二段階の変換後の音声が格納されるディレクトリ

第二段階の処理を使い、学習データのデータクレンジングを行います。
なぜ、第二段階の処理を使っているかと言うと、データクレンジング処理を作るノウハウがないため暫定でしています。
第二段階の処理ではエンコード、デコード処理が入っておりノイズ除去に使われているオートエンコーダーと同等の処理を行うことができると考えます。
※実施する前は「in_1st_my_wav」、「in_1st_yukari_wav」をバックアップしておいてください。

地声の音声のデータクレンジングを実施
1.「in_1st_my_wav」のファイルを「test_data_sr」にコピーする。
2. 第二段階の処理を実施する。
3.「output/yukari_2nd」のファイルを「in_1st_my_wav」に上書きする。

変換先の音声のデータクレンジングを実施
1.「in_1st_yukari_wav」のファイルを「test_data_sr」にコピーする。
2. 第二段階の処理を実施する。
3.「output/yukari_2nd」のファイルを「in_1st_yukari_wav」に上書きする。

データクレンジングが終わったら、第一段階の処理を実施し変換後の音声に大きなノイズがないこと確認する。

#6.第一段階のパラメータ調整
音声を分解するときのパラメータを以下にします。詳細は[前回](https://qiita.com/sunnyyone/items/f85c26d6149a200ed119) を参照。
frame_periodを10としているのはフレーム間隔が長くなっても第一段階の学習に影響しないためです。
orderを30としているのはメルケプストラムの次数(order)を上げると声質が含まれているデータのデータ量が単純に多くなるため音質が良くなります。
[動作環境]
become-yukarin
　├become-yukarin
　│　└param.py
　└dat
　　　└config.json

```python:param.py(抜粋)
class AcousticFeatureParam(NamedTuple):
    frame_period: int = 10
    order: int = 30
```
config.jsonのin_channelsとout_channelsをorder+2とする。

```json:config.json(抜粋)
{
  "model": {
    "in_channels": 32,
    "out_channels": 32
}
```
第一段階の学習のパイパーパラメータをOptunaを使い自動調整して、音質改善を試みます。
Optunaはchainerを作った会社が提供しているフレームワークで、学習結果が良くなるまでハイパーパラメータの最適値を探してくれます。
[ハイパーパラメータ自動最適化ツール「Optuna」公開](https://research.preferred.jp/2018/12/optuna-release/)


Optunaをインストールします。

```bash:command(インストール)
$ sudo pip install optuna
```

第一段階のソースコード(train.py)にOptunaを組み込みます。
参考：[賢いパラメータ探索: Optuna入門 with Chainer](https://qiita.com/jin0g/items/2c0d35e71dcde763ddf3)

[動作環境]
become-yukarin
　├train.py ・・・ 第一段階学習のソースコード
　├train_optuna.py ・・・ 第一段階学習のソースコードにOputunaを組み込んだソースコード(新規作成)

```python:train_optuna.py
import argparse
from functools import partial
from pathlib import Path

from chainer import cuda
from chainer import optimizers
from chainer import training
from chainer.dataset import convert
from chainer.iterators import MultiprocessIterator
from chainer.training import extensions
from chainerui.utils import save_args

from become_yukarin.config.config import create_from_json
from become_yukarin.dataset import create as create_dataset
from become_yukarin.model.model import create
from become_yukarin.updater.updater import Updater

import optuna
from optuna.integration import ChainerPruningExtension

parser = argparse.ArgumentParser()
parser.add_argument('config_json_path', type=Path)
parser.add_argument('output', type=Path)
arguments = parser.parse_args()

config = create_from_json(arguments.config_json_path)
arguments.output.mkdir(exist_ok=True)
config.save_as_json((arguments.output / 'config.json').absolute())

def objective(trial):
    # model
    if config.train.gpu >= 0:
        cuda.get_device_from_id(config.train.gpu).use()
    predictor, discriminator = create(config.model)
    models = {
        'predictor': predictor,
        'discriminator': discriminator,
    }

    # dataset
    dataset = create_dataset(config.dataset)
    batchsize = trial.suggest_int('batchsize', 1, 128)
    train_iter = MultiprocessIterator(dataset['train'], batchsize)
    test_iter = MultiprocessIterator(dataset['test'], batchsize, repeat=False, shuffle=False)
    train_eval_iter = MultiprocessIterator(dataset['train_eval'], batchsize, repeat=False, shuffle=False)


    # optimizer
    def create_optimizer(model):
        alpha = trial.suggest_loguniform('alpha', 1e-5, 1e-2)
        beta1 = trial.suggest_uniform('beta1', 0, 1)
        optimizer = optimizers.Adam(alpha, beta1, beta2=0.999)
        optimizer.setup(model)
        return optimizer


    opts = {key: create_optimizer(model) for key, model in models.items()}

    # updater
    converter = partial(convert.concat_examples, padding=0)
    updater = Updater(
        loss_config=config.loss,
        predictor=predictor,
        discriminator=discriminator,
        device=config.train.gpu,
        iterator=train_iter,
        optimizer=opts,
        converter=converter,
    )

    # trainer
    trigger_log = (config.train.log_iteration, 'iteration')
    trigger_snapshot = (config.train.snapshot_iteration, 'iteration')

    trainer = training.Trainer(updater, out=arguments.output)

    ext = extensions.Evaluator(test_iter, models, converter, device=config.train.gpu, eval_func=updater.forward)
    trainer.extend(ext, name='test', trigger=trigger_log)
    ext = extensions.Evaluator(train_eval_iter, models, converter, device=config.train.gpu, eval_func=updater.forward)
    trainer.extend(ext, name='train', trigger=trigger_log)

    trainer.extend(extensions.dump_graph('predictor/loss'))

    ext = extensions.snapshot_object(predictor, filename='predictor_{.updater.iteration}.npz')
    trainer.extend(ext, trigger=trigger_snapshot)

    trainer.extend(extensions.LogReport(trigger=trigger_log))

    trainer.extend(ChainerPruningExtension(trial, 'validation/main/loss', (1, 'iteration')))

    save_args(arguments, arguments.output)
    trainer.run()

if __name__ == '__main__':
    study = optuna.study.create_study()
    study.optimize(objective, n_trials=100)
```


+ 実行例

```bash:command
$ python train_optuna.py dat/config.json dat/model/yukari_1st
```

+ 結果

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">第一段階パラメータ調整後の音声<br>グラフ：1ms毎にスペクトログラムの値(0-1に正規化)を表示 <a href="https://t.co/BRyDlLLnOs">pic.twitter.com/BRyDlLLnOs</a></p>&mdash; ひろ (@Khirotake) <a href="https://twitter.com/Khirotake/status/1124168814780796928?ref_src=twsrc%5Etfw">2019年5月3日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


+ パラメータ調整前の音声

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">第一段階パラメータ調整前の音声<br>グラフ：1ms毎にスペクトログラムの値(0-1に正規化)を表示 <a href="https://t.co/h3wUOiMO8n">pic.twitter.com/h3wUOiMO8n</a></p>&mdash; ひろ (@Khirotake) <a href="https://twitter.com/Khirotake/status/1124177729862004736?ref_src=twsrc%5Etfw">2019年5月3日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


聞き比べてもらうと、第一段階の音質が改善したことがわかると思います。

#7.第二段階の学習データ削減
第二段階の学習では、学習データとして変換先のスペクトログラムのみを使用して学習しています。
6章の結果のグラフは音声のスペクトログラムを表示しています。
筆者は音声分野の研究をしている訳ではないので理由はわかりませんが、人の声の特徴はスペクトログラムの低次元側に集まっているらしいです。
このため、学習するスペクトログラムは下記、spectrogram(2次元配列)の2次元目にある513個のうち前半部分を使用します。

[動作環境]
become-yukarin
　└become-yukarin
　　　├dateset
　　　│ └dataset.py ・・・ データ加工処理
　　　└super_resolution.py ・・・ 第二段階の変換処理


```python:dataset.py(抜粋)
・・・
class AcousticFeatureProcess(BaseDataProcess):
        ・・・
        spectrogram = pyworld.cheaptrick(x, f0, t, fs)
```

第二段階の学習データを加工している処理のspectrogramを前1/4に削る。

```python:dataset.py(抜粋)
・・・
class AcousticFeatureProcess(BaseDataProcess):def create_sr(config: SRDatasetConfig):
    data_process_base = ChainProcess([
        LowHighSpectrogramFeatureLoadProcess(validate=True),
        SplitProcess(dict(
            #input=LambdaProcess(lambda d, test: numpy.log(d.low[:, :-1])),
            #target=LambdaProcess(lambda d, test: numpy.log(d.high[:, :-1])),
            # add 1/4
            input=LambdaProcess(lambda d, test: numpy.log(d.low[:, :(int)((d.low.shape[1])/4)])),
            target=LambdaProcess(lambda d, test: numpy.log(d.high[:, :(int)((d.high.shape[1])/4)])),
        )),
    ])
    ・・・
```

第二段階の変換処理をしている箇所で、インプットデータの前1/4に学習を適応して反映する。

```python:super_resolution.py(抜粋)
    ・・・
    def convert(self, input: numpy.ndarray) -> numpy.ndarray:
        # add 1/4 inputを変更
        input = input[:, :(int)(input.shape[1]/4)+1]
    ・・・
    def convert_to_audio(
            self,
            input: numpy.ndarray,
            acoustic_feature: AcousticFeature,
            sampling_rate: int,
    ):
        acoustic_feature = acoustic_feature.astype_only_float(numpy.float64)
        # add 1/4 spectrogramを変更
        spectrogram = acoustic_feature.spectrogram
        spectrogram[:, :(int)(input.shape[1]/4)+1] = input[:, :(int)(input.shape[1]/4)+1]
        out = pyworld.synthesize(
            f0=acoustic_feature.f0.ravel(),
            #spectrogram=input.astype(numpy.float64),
            spectrogram=spectrogram.astype(numpy.float64),
            aperiodicity=acoustic_feature.aperiodicity,
            fs=sampling_rate,
            frame_period=self._param.acoustic_feature_param.frame_period,
        )

```

学習時と変換時のスペクトログラムを前1/4にすることで、データ量を1/4にすることができます。
これにより、第二段階でネックとなっていた音声変換時の速度が4倍となリます。

#8.第二段階を用いたノイズ学習
第二段階の学習はスペクトログラムに対して行なっていますが、高音質化のキモはノイズを学習させているところにあります。
第二段階の学習に使う中間データを作成しているコードを見ていきます。

[動作環境]
become-yukarin
　├become-yukarin
　│　└param.py ・・・ 第二段階の中間データ作成用にパラメータを変更する
　├scripts
　│　├extract_spectrogram_pair.py ・・・ 第二段階の中間データ作成
　│　└extract_spectrogram_pair_noise.py ・・・ 第二段階の中間データ作成(新規作成)
　├test_data_sr ・・・ 第二段階の音声変換のためのインプットディレクトリ
　└output
　 　└yukari_2nd ・・・ 第二段階の変換後の音声が格納されるディレクトリ


```python:extract_spectrogram_pair.py(抜粋)
・・・
def generate_file(path):
    ・・・
    feature = acoustic_feature_process(wave, test=True).astype_only_float(numpy.float32)
    high_spectrogram = feature.spectrogram

    fftlen = pyworld.get_cheaptrick_fft_size(arguments.sample_rate)
    low_spectrogram = pysptk.mc2sp(
        feature.mfcc,
        alpha=arguments.alpha,
        fftlen=fftlen,
    )
```

第二段階の中間データ作成(extract_spectrogram_pair.py)では、変換先の音声からスペクトログラムを取り出したものをhigh_spectrogramとし、変換先の音声のmfccをスペクトログラムに変換したものをlow_spectrogramとしています。
この二つのスペクトログラムを学習データとして第二段階で処理します。

端的に第二段階でしていることを言えば、音声に合成するときの『mfccからスペクトログラムへの変換誤差のノイズ』をディープラーニングで補正しています。
mfccは第一段階で処理する学習データ(の一部)となっており、第二段階では第一段階の誤りを補正していると言えます。

実際に第二段階を試した人は分かると思いますが、上記の誤差補正は大幅に音質が良くなるものではないようです。
そこで、low_spectrogram側に大きなノイズを入れた音声と差し替えて、ノイズを学習させることにします。

low_spectrogramを10~15のorderで作成したスペクトログラムに差し替えます。


```extract_spectrogram_pair_noise.py
"""
extract low and high quality spectrogram data.
"""

import argparse
import multiprocessing
from pathlib import Path
from pprint import pprint

import numpy
import pysptk
import pyworld
from tqdm import tqdm

from become_yukarin.dataset.dataset import AcousticFeatureProcess
from become_yukarin.dataset.dataset import WaveFileLoadProcess
from become_yukarin.param import AcousticFeatureParam
from become_yukarin.param import VoiceParam
import random

base_voice_param = VoiceParam()
base_acoustic_feature_param = AcousticFeatureParam()

parser = argparse.ArgumentParser()
parser.add_argument('--input_directory', '-i', type=Path)
parser.add_argument('--output_directory', '-o', type=Path)
parser.add_argument('--sample_rate', type=int, default=base_voice_param.sample_rate)
parser.add_argument('--top_db', type=float, default=base_voice_param.top_db)
parser.add_argument('--pad_second', type=float, default=base_voice_param.pad_second)
parser.add_argument('--frame_period', type=int, default=base_acoustic_feature_param.frame_period)
parser.add_argument('--order', type=int, default=base_acoustic_feature_param.order)
parser.add_argument('--alpha', type=float, default=base_acoustic_feature_param.alpha)
parser.add_argument('--f0_estimating_method', default=base_acoustic_feature_param.f0_estimating_method)
parser.add_argument('--enable_overwrite', action='store_true')
arguments = parser.parse_args()

pprint(dir(arguments))


def generate_file(path):
    out = Path(arguments.output_directory, path.stem + '.npy')
    if out.exists() and not arguments.enable_overwrite:
        return

    # load wave and padding
    wave_file_load_process = WaveFileLoadProcess(
        sample_rate=arguments.sample_rate,
        top_db=arguments.top_db,
        pad_second=arguments.pad_second,
    )
    wave = wave_file_load_process(path, test=True)

    # make acoustic feature
    acoustic_feature_process = AcousticFeatureProcess(
        frame_period=arguments.frame_period,
        order=arguments.order,
        alpha=arguments.alpha,
        f0_estimating_method=arguments.f0_estimating_method,
    )

    # make acoustic feature
    acoustic_feature_process_noise = AcousticFeatureProcess(
        frame_period=arguments.frame_period,
        order=random.randint(10, 15),
        alpha=arguments.alpha,
        f0_estimating_method=arguments.f0_estimating_method,
    )

    feature = acoustic_feature_process(wave, test=True).astype_only_float(numpy.float32)
    high_spectrogram = feature.spectrogram
    feature_noise = acoustic_feature_process_noise(wave, test=True).astype_only_float(numpy.float32)
    low_spectrogram = feature_noise.spectrogram

    # save
    numpy.save(out.absolute(), {
        'low': low_spectrogram,
        'high': high_spectrogram,
    })


def main():
    paths = list(sorted(arguments.input_directory.glob('*')))
    arguments.output_directory.mkdir(exist_ok=True)

    pool = multiprocessing.Pool()
    list(tqdm(pool.imap(generate_file, paths), total=len(paths)))


if __name__ == '__main__':
    main()
```

第二段階の中間データを作成するときのパラメータを以下にします。

```python:param.py(抜粋)
class AcousticFeatureParam(NamedTuple):
    frame_period: int = 1
    order: int = 30
```

第二段階の中間データ作成時にはフレーム間隔を1msにすることで学習データを増やしています。

+ 実行例

```bash:command
$ python scripts/extract_spectrogram_pair_noise.py -i dat/in_2nd_yukari_many_wav -o dat/out_2nd_yukari_many_npy
$ python train_sr.py dat/config_sr.json dat/model/yukari_2nd
```

第二段階の音声変換時には以下のパラメータに戻します。

```python:param.py(抜粋)
class AcousticFeatureParam(NamedTuple):
    frame_period: int = 10
    order: int = 30
```

第二段階の変換元の音声には、第一段階のパラメータ調整前で作成した音声を使用しました。
第一段階のパラメータ調整後ではノイズがあまりないため、検証のためノイズが大きい音声を使用しています。

「test_data_sr」ディレクトリに第一段階のパラメータ調整前で作成した音声を格納し、以下を実行する。

+ 実行例

```bash:command
$ python scripts/super_resolution_test.py yukari_2nd -md dat/model -iwd dat/in_2nd_yukari_many_wav -it 10000
```

+ 第二段階の中間データ変更後の音声(extract_spectrogram_pair_noise.py)

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">第二段階変更後の音声<br>グラフ：1ms毎にスペクトログラムの値(0-1に正規化)を表示 <a href="https://t.co/6TrTJhwlMc">pic.twitter.com/6TrTJhwlMc</a></p>&mdash; ひろ (@Khirotake) <a href="https://twitter.com/Khirotake/status/1124271628030840832?ref_src=twsrc%5Etfw">2019年5月3日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

+ 第二段階の中間データ変更前の音声(extract_spectrogram_pair.py)

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">第二段階変更前の音声<br>グラフ：1ms毎にスペクトログラムの値(0-1に正規化)を表示 <a href="https://t.co/4f5i2uoslU">pic.twitter.com/4f5i2uoslU</a></p>&mdash; ひろ (@Khirotake) <a href="https://twitter.com/Khirotake/status/1124270948415201280?ref_src=twsrc%5Etfw">2019年5月3日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


#9.まとめ
『ディープラーニングの力で結月ゆかりの声になるリポジトリ』の性能アップということで、実際に試して効果があった方法を解説してきました。

第一段階では大きく音質を改善することができましたが、第二段階ではノイズが低減したことが分からなかったと思います。
第二段階のインプット音声に消せるノイズがあまりなく、第二段階の効果が出なかったと思われます。

実際に第二段階を試していたときは、データクレンジングする前で大きなノイズがある状態でしていましたので、大きなはノイズは除去できていました。

ただし、速度面では第二段階の変換時のデータ量を1/4にし、パラメータのframe_periodを5から10にすることでデータ量を1/8(速度8倍)にできました。

第一段階で音質が改善できたと言ってもまだ、音声の輪郭がぼやけている感じがしますので、
今後は、より人の声になるような第二段階の処理に改善する必要があると思います。

最後に、素晴らしいコードを公開してくださった作者のヒホ氏に感謝します。

間違いなどありましたらコメントで教えてもらえると幸いです。