Mozilla TTS (Tacotron2) を使って日本語音声合成

主に、機械学習とかよくわからないけど、とにかく TTS したい方向けのメモです（筆者がそれです）。Google Colab だけで試しています。

**2021/09/25 追記:**
Mozilla TTS の後継 coqui TTS を利用したい方は下記記事も参照ください

https://qiita.com/tset-tset-tset/items/f6897e8e703c7c751ab3

↓以降の内容は、2020/09/08 時点での情報になります。

## 参考文献
- [Tacotron2で始める日本語音声合成](https://qiita.com/mana_b/items/a0dd5f76a30d781d0fa3)
    - 具体的な入力がイメージしやすく、参考になりました。
- [Tacotron2系における日本語のunidecodeの不確かさ](https://qiita.com/nishiha/items/6e2a2ddaafe03fa7f924)
    - テキストデータ作成方法が参考になりました。
- [月ノ美兎さんの音声合成ツール(Text To Speech) を作ってみた](https://qiita.com/K2_ML/items/2804594454b39180c909)
    - 音声データ作成方法が参考になりました。

# 1. データの準備

## 1.1. データフォーマットについて

当初 [NVIDIA/tacotron2](https://github.com/NVIDIA/tacotron2) を使うことだけ考えていましたが、その後 [xcmyz/FastSpeech](https://github.com/xcmyz/FastSpeech) や [ming024/FastSpeech2](https://github.com/ming024/FastSpeech2) や [mozilla/TTS](https://github.com/mozilla/TTS) を試してみて、[LJSpeech](https://keithito.com/LJ-Speech-Dataset/) をそのまま使えるサンプルコードが多いことに気付きました。データは [LJSpeech](https://keithito.com/LJ-Speech-Dataset/) と同じフォーマットで作っておいて、必要に応じて各実装向けの変更を行うという形が、実装をいろいろ試す際は楽そうです。

後述しますが、テキスト部分はローマ字にするケース(大半)、フリガナそのままが使うケース(for [mozilla/TTS](https://github.com/mozilla/TTS))がありそうです。

### 1.1.1. NVIDIA/Tacotron2 フォーマット
NVIDIA リポジトリ上のファイル:  https://github.com/NVIDIA/tacotron2/blob/master/filelists/ljs_audio_text_test_filelist.txt

リストファイルの 1 行を見てみると:

```
DUMMY/LJ045-0096.wav|Mrs. De Mohrenschildt thought that Oswald,
```

パイプで区切られたカラムの中身はそれぞれ

1. wav ファイルへのパス(ファイル名を指定するので、拡張子 .wav も必要)
2. テキスト文

で、[README](https://github.com/NVIDIA/tacotron2) 通り、DUMMY 部は自分が置いた wav の場所を指定する必要があります。[NVIDIA/tacotron2](https://github.com/NVIDIA/tacotron2) だけ試すのであればこのファイルだけで良いかもです。

### 1.1.2. LJSpeech フォーマット(?)
データ落とし元: https://keithito.com/LJ-Speech-Dataset/
metadata だけ見たい: https://www.kaggle.com/dromosys/ljspeech

フォーマットというか

- リストファイルの構造(パイプ区切りで3カラム)
- リストファイルのファイル名(metadata.csv)
- ディレクトリ構造(metadata.csv と同じディレクトリ下に wavs/ ディレクトリがあって、そこに *.wav がずらっと入る)

を真似しておけば、それを想定して書かれたサンプルコードをそのまま流用することができ楽でした。

- LJSpeech-1.1/
    - wavs/
        - LJ001-0001.wav
        - LJ001-0002.wav
        - ...
    - metadata.csv

metadata.csv の 1 行を見てみると:

```
LJ001-0002|in being comparatively modern.|in being comparatively modern.
```

パイプで区切られたカラムの中身はそれぞれ

1. wav ファイルの ID(wavs 内のファイルから .wav を省いたものと同じ)
2. テキスト文
3. ノーマライズしたテキスト文

です。イチから作成するのであればとりあえず 2 と 3 は同じものを入れれば良いと思います。

## 1.2. データの作成方法について

音声ファイルを適当に分割するところ、metadata.csv のテキストを作るところ、それぞれ時間がかかりそうです。

手元に数時間～数十時間の音声があるとしても、それを [Audacity](https://www.audacityteam.org/download/) なりで適当に分割して、1つ1つ聞き取りローマ字で記載する…　のはかなりの手間です。やり方はいろいろあると思いますが、以下の方法でデータを作りました。

### 1.2.1. 前処理

音楽被りのデータ・複数話者の区間をザックリ省きます。音楽被りは [deezer/spleeter](https://github.com/deezer/spleeter) などで抽出できると思います。

### 1.2.2. ASR 音声文字認識で日本語と分割区間を得る

最初、[SoX の silence 分割](http://sox.sourceforge.net/sox.html) で wav を小さく分割後、それぞれに音声文字認識しようとしましたが、長すぎ・短すぎとバラバラになる上、拗音などでも容易に途切れてしまうため、難しかったです。

結果、[Speech-to-Text](https://cloud.google.com/speech-to-text?hl=ja) など、タイミング付きの音声文字認識結果を利用して、wav ファイルを分割する方法をとりました。

これもいろいろ方法はあると思いますが、YouTube の自動生成された日本語字幕をダウンロードして、字幕の 1 文ごとに wav を分割するのが楽そうです(字幕表示タイミングから次の字幕表示タイミングまでがだいたいその字幕の音声時間)。その際、LJSpeech と同じ mono 22050 に変換もしておきます。

(参考) youtube-dl を使ったサンプルは以下です。

```bash:youtube2metadata.sh

## Usage: sh youtube2metadata.sh URL

URL=${1:-https://www.youtube.com/watch?v=CqnXqy6aAIg}

#youtube-dl --dump-json --flat-playlist https://www.youtube.com/channel/UCOy5sLcFLqYNqZ1iurp4dCg/videos > nishino.json
#cat nishino.json | jq -r '"sh youtube2metadata.sh https://www.youtube.com/watch?v=" + .id' > list.sh
#head list.sh

rm -f *.wav *.ja.srv1
mkdir -p wavs/

youtube-dl --sub-lang ja --sub-format srv1 --write-auto-sub --skip-download ${URL}
youtube-dl --extract-audio --audio-format wav ${URL}
perl -0777 -ne 'BEGIN { ($id) = ($ARGV[0] =~ m{(.{11}).ja.srv1}); } while (m{<text start="([^"]+)"[^>]+>([^<]+)</text>}g) { if ($b_s) { $j = sprintf("%03d", ++$i); print qq{ffmpeg -ss $b_s -to $1 -i *.wav -af loudnorm -ar 22050 -ac 1 -y wavs/${id}_$j.wav ; echo "${id}_$j|$b_t" >> metadata_org.csv\n}; } $b_s = $1; $b_t = $2; }' *.ja.srv1 > convert.sh
head convert.sh
  # ffmpeg -ss 2.83 -to 7.07 -i *.wav -af loudnorm -ar 22050 -ac 1 -y wavs/CqnXqy6aAIg_001.wav ; echo "CqnXqy6aAIg_001|皆さんはじめましてキングコングの西野亮廣と申します" >> metadata_org.csv
  # ffmpeg -ss 7.07 -to 11.66 -i *.wav -af loudnorm -ar 22050 -ac 1 -y wavs/CqnXqy6aAIg_002.wav ; echo "CqnXqy6aAIg_002|えっとまず自己紹介をさせていただきますと自分は" >> metadata_org.csv
  # ffmpeg -ss 11.66 -to 14.99 -i *.wav -af loudnorm -ar 22050 -ac 1 -y wavs/CqnXqy6aAIg_003.wav ; echo "CqnXqy6aAIg_003|オーライデビュー行ったり絵本書いたりどう" >> metadata_org.csv
  # ffmpeg -ss 14.99 -to 20.72 -i *.wav -af loudnorm -ar 22050 -ac 1 -y wavs/CqnXqy6aAIg_004.wav ; echo "CqnXqy6aAIg_004|未だ投入しな黄色エンタメ研究所という国内最大の9さんを運営したり" >> metadata_org.csv
  # ffmpeg -ss 20.72 -to 27.06 -i *.wav -af loudnorm -ar 22050 -ac 1 -y wavs/CqnXqy6aAIg_005.wav ; echo "CqnXqy6aAIg_005|まあ美術館作ったり街を作ったりエンターテイメントに関するお仕事をしている人間で" >> metadata_org.csv
sh convert.sh

sort -u metadata_org.csv -o metadata_org.csv
```


### 1.2.3. 自動生成された日本語からフリガナやローマ字に変換

↑で wav ファイル群と日本語の音声文字認識結果が得られたので、それを [Mecab](https://taku910.github.io/mecab/) を利用してフリガナにしました。([mecab-ipadic-neologd](https://github.com/neologd/mecab-ipadic-neologd) 辞書などで、よりフリガナにしやすくなりそうです)
また、音声から変換された文章には句読点が無かったので、[Mecab](https://taku910.github.io/mecab/) の品詞解析結果などから句点の位置をなんとなく推測して EOS ( . ) も付加しました。

この処理は、最後に学習済のモデルを使って音声合成する時に、入力の日本語にも同様に行います。

フリガナを得た後、[Tacotron2系における日本語のunidecodeの不確かさ](https://qiita.com/nishiha/items/6e2a2ddaafe03fa7f924) があるようなので、[uconv](https://linux.die.net/man/1/uconv) を利用してローマ字も作っておきます。

一応、[mozilla/TTS](https://github.com/mozilla/TTS) では、フリガナそのまま入力がいけます ([bootphon/phonemizer](https://github.com/bootphon/phonemizer) が [espeak-ng/espeak-ng](https://github.com/espeak-ng/espeak-ng) を利用して、直接音素を得ます) が、やってみた感じローマ字の方が結果が良い気が…？このあたりはデータやチューニングによって違うかも知れません。

(参考) 学習とかは試してないですが、[JSUT](https://sites.google.com/site/shinnosuketakamichi/publication/jsut) を[変換してみたサンプルコードです。](https://github.com/tset-tset-tset/hoge/blob/master/JSUT.ipynb)

----

音声文字認識は完璧でないので、このままでは汚いデータも含んでいる状態です。
ただ点検・修正には時間がかかるため、一旦この状態でどのくらいの品質になるか確認したく、汚いまま学習を行いました。

# 2. データを使った学習 → 音声合成

今の所 学習 → 音声合成 までしたのは [NVIDIA/tacotron2](https://github.com/NVIDIA/tacotron2) と [mozilla/TTS](https://github.com/mozilla/TTS) です。

## 2.1. [NVIDIA/tacotron2](https://github.com/NVIDIA/tacotron2) を利用した学習

ほぼ [NVIDIA/tacotron2](https://github.com/NVIDIA/tacotron2) の README 通りでした。
[Tacotron2で始める日本語音声合成](https://qiita.com/mana_b/items/a0dd5f76a30d781d0fa3) を参考に `basic_cleaners` を使いました。
バージョンが全体的に古いのか Google Colab で `pip install -r requirements.txt` すると警告が出るものも多いです。

### 2.1.1. 学習後音声

https://tset-tset-tset.github.io/wavs/

これでも十分日本語としてはわかりそう、ノイズが多めなのは Vocoder のチューニングによりそうです。

## 2.2. [mozilla/TTS](https://github.com/mozilla/TTS) を利用した学習

[NVIDIA/tacotron2](https://github.com/NVIDIA/tacotron2) と比べると、実行しやすい印象でした。(Google Colab 上でサンプル手順を実行してそのまま動きやすい)

~~[mozilla/TTS](https://github.com/mozilla/TTS) を見て最初に目につくのは現行の master ブランチを利用した方法 (TTS パッケージ) ですが~~ (2020/09/07追記: [後述の dev ブランチが master にマージされました](https://github.com/mozilla/TTS/pull/415))、[コミッタの erogol さんがブログで説明している DDC を使った学習](https://erogol.com/solving-attention-problems-of-tts-models-with-double-decoder-consistency/)向けの[サンプルレシピが erogol さんのリポジトリ](https://github.com/erogol/TTS_recipes)にあり、それが dev ブランチ (mozilla_voice_tts パッケージ) を使っているので、dev ブランチで学習しておいた方が良さそうと思いました。

DDC 学習手順は[サンプルレシピの train_model.sh](https://github.com/erogol/TTS_recipes/blob/master/LJSpeech/DoubleDecoderConsistency/train_model.sh) をそのまま実行すれば良いだけですが、設定などでハマった/気にした部分を以下に書いています。

### 2.2.1. model_config.json

TTS (Tacotron2) を学習する用の設定です、LJSpeech 向けの [model_config.json](https://github.com/erogol/TTS_recipes/blob/master/LJSpeech/DoubleDecoderConsistency/model_config.json) からの変更点は以下です:

| json  | リポジトリの値 | 注意点 |
| ---- | ---- | ---- |
| .audio.stats_path | "./scale_stats.npy" | 計算にそこそこ時間がかかるファイルなので、Google Colab だと Google Drive で永続化推奨です | 
| .gradual_training | [[0, 7, 64], [1, 5, 64], [50000, 3, 32], [130000, 2, 32], [290000, 1, 32]] | データ量が LJSpeech の 13,100 件ぐらいを想定している値なので、極端に少なかったりする時はいじる必要がありそうです(eval が頻繁に走って全然 step が進まないため) |
| .test_sentences_file | null | 指定がなければコード内にある英文が使われ、テスト音声ファイルもその入力で作成されるようなので、metadata.csv 同様の入力で書かれたファイルを指定するのが良さそうです |
| .text_cleaner | "phoneme_cleaners" | 後述する `use_phonemes` を `true` にするのであれあば、`"basic_cleaner"` 推奨です(`phoneme_cleaners` だと、`phonemize` に渡す前に `unidecode` がかかって ASCII になってしまうなどがあるため) |
| .output_path | "tts_model/" | これが欲しいもの、Google Colab だと Google Drive で永続化推奨です |
| .use_phonemes| true | 恐らく `true` 推奨、入力のフリガナに漢字や英字が含まれていると、`[WARNING] extra phones may appear in the "ja" phoneset` が出るので、全てカタカナに直すか、そのデータを省いてしまった方が良さそうです |
| .phoneme_language | "en-us" | 使うのであれば恐らく `"ja"` 推奨、ただし [train_model.sh](https://github.com/erogol/TTS_recipes/blob/master/LJSpeech/DoubleDecoderConsistency/train_model.sh) で install している `espeak` には ja が無いので、代わりに `espeak-ng` を入れる必要があり、更に 2020/09/07 の最新版ではフリガナに対応できないパターンがあるので下のソースビルドを参考に入れる必要があります |
| .phoneme_cache_path | "phoneme_cache/" | 計算にそこそこ時間がかかるファイル群なので、Google Colab だと Google Drive で永続化推奨です | 
| .datasets[0].path | "LJSpeech-1.1/" | 自分のデータ構造用に変更します |

他、細かい最適化があると思いますが未着手です。

### 2.2.1.1 (フリガナをそのまま使う用) データからフリガナ以外を取り除く

2020/09/07 現在、`[WARNING] extra phones may appear in the "ja" phoneset` が入ってくると変なゴミが入るので、データから取り除いた方が良さそうです(アラビア数字、漢字や英字を置換・削除するなど)。

```bash
## ゴミが入るかどうかは以下で試せます
echo 'NBAを見ました' | espeak-ng -x -v ja
echo 'NBAを見ました' | phonemize -l ja
echo 'NBAを見ました' | phonemize -l ja --language-switch remove-flags
```

#### 2.2.1.2. (ローマ字・フリガナで use_phonemes を使う用) espeak-ng のインストール

上述の `[WARNING] extra phones may appear` が入ってくると `(en)` などの文字が入ってしまうので、[PR したところマージしてもらえました](https://github.com/mozilla/TTS/pull/507)。[サンプルレシピの train_model.sh](https://github.com/erogol/TTS_recipes/blob/master/LJSpeech/DoubleDecoderConsistency/train_model.sh) では古めのバージョンをチェックアウトしているので、最新の master か[マージ以降の版](https://github.com/mozilla/TTS/commit/ee18679e477e8a83a2802a40a1a11cfbaa0b90d3)を利用するのが良いと思います。

また、`phoneme_language: "ja"` のためには espeak-ng を入れるのですが、データによってはいくつか難点がありました。[修正しようと PR も投げていますが](https://github.com/espeak-ng/espeak-ng/pull/810)、2020/09/07 現在「ウェー」などの扱いで議論があるようで fix に時間かかりそうなため、フリガナ入力が必要あれば筆者のリポジトリもご参考ください。

**2023/05/06 追記:**
その後 [PR](https://github.com/espeak-ng/espeak-ng/pull/810) が [espeak-ng/espeak-ng](https://github.com/espeak-ng/espeak-ng) 本体にマージされたので、2022/04/03 にリリースされている [バージョン 1.51](https://github.com/espeak-ng/espeak-ng/releases/tag/1.51) を利用すれば問題ありません。


```bash
## apt-get で入る espeak-ng は、ローマ字の変換には問題ないですが、フリガナ用には日本語の対応が不十分なので、ソースからインストールします
## https://packages.ubuntu.com/search?keywords=espeak-ng
#sudo apt-get install espeak-ng

sudo apt-get install automake libtool
#git clone https://github.com/espeak-ng/espeak-ng
git clone https://github.com/tset-tset-tset/espeak-ng -b tset-tset-tset-patch-1
cd espeak-ng/; ./autogen.sh && ./configure --libdir=/usr/lib/x86_64-linux-gnu
cd espeak-ng/; make
cd espeak-ng/; sudo make install

## 以下がゴミなく変換できれば成功
echo 'スウェーデン' | espeak-ng -x -v ja
```

### 2.2.2. vocoder_config.json

Vocoder (MelGAN) を学習する用の設定のようです。LJSpeech 向けの [vocoder_config.json](https://github.com/erogol/TTS_recipes/blob/master/LJSpeech/DoubleDecoderConsistency/vocoder_config.json) からの変更点は主に:

| json  | リポジトリの値 | 注意点 |
| ---- | ---- | ---- |
| .audio.stats_path | "./scale_stats.npy" | 計算にそこそこ時間がかかるファイルなので、Google Colab だと Google Drive で永続化推奨です | 
| .data_path | "LJSpeech-1.1/wavs/" | 自分のデータ構造用に変更、音が無い wav などが含まれてると落ちます |
| .output_path | "vocoder_model/" | これが欲しいもの、Google Colab だと Google Drive で永続化推奨です |

他、細かい最適化があると思いますが未着手です。

### 2.2.3. restore_path, continue_path
[サンプルレシピの train_model.sh](https://github.com/erogol/TTS_recipes/blob/master/LJSpeech/DoubleDecoderConsistency/train_model.sh) では初回起動方法しかないですが、```continue_path``` で前回実行時の続きができるのが、Google Colab で実行する時に便利でした。

```bash
## 初回
python TTS/mozilla_voice_tts/bin/train_tts.py --config_path model_config.json

## 既存のモデルから学習
python TTS/mozilla_voice_tts/bin/train_tts.py --config_path model_config.json --restore_path /path/to/training_dir/model.tar

## 前回の学習を継続 (config はディレクトリから読まれる)
python TTS/mozilla_voice_tts/bin/train_tts.py --continue_path /path/to/training_dir/
```

### 2.2.4. 学習後音声

ローマ字をそのまま利用・ローマ字を音素にして利用・フリガナを音素にして利用、した 3 パターンを比較のために並べています。

```python
import phonemizer
from phonemizer.phonemize import phonemize
seperator = phonemizer.separator.Separator(' |', '', '|')

## ローマ字を音素にして利用した場合の内部構造
ph = phonemize("tasukete kudasai.", separator=seperator, strip=False, njobs=1, backend='espeak', language='ja', preserve_punctuation=True, language_switch="remove-flags")
print("tasukete kudasai. (ja) (remove-flags)\t" + ph)
  # tasukete kudasai. (ja) (remove-flags)	||t|iː| ||eɪ| ||ɛ|s| ||j|uː| ||k|eɪ| ||iː| ||t|iː| ||iː| ||k|eɪ| ||j|uː| ||d|iː| ||eɪ| ||ɛ|s| ||eɪ| ||aɪ|| |.

## フリガナを音素にして利用した場合の内部構造
ph = phonemize("タスケテ クダサイ.", separator=seperator, strip=False, njobs=1, backend='espeak', language='ja', preserve_punctuation=True, language_switch="remove-flags")
print("タスケテ クダサイ. (ja) (remove-flags)\t" + ph)
  # タスケテ クダサイ. (ja) (remove-flags)	t|ä|s|ɯᵝ|k|e̞|t|e̞| |k|ɯᵝ|d|ä|s|ä|i| |.
```

ローマ字をそのまま利用した際はアルファベットがそのまま使われるイメージですが、ローマ字を音素にした場合はあたかも「ティー・エー・エス・ユー・…」と喋ったかのように変換されます。ローマ字入力を音素にしたのは元々設定をミスって学習してしまっただけだったんですが、

https://tset-tset-tset.github.io/wavs/

学習結果を聴く感じ、なんとなく

- uconv 後のローマ字そのまま
   - 速度は NVIDIA/Tacotron2 に近いものの、喋れてない語もあります。
- uconv 後のローマ字を phonemize
   - しゃべる速度がゆっくりになってそうですが、未知語に強いような…。
- フリガナを phonemize
    - ロジック的には元データをストレートに表現できていそうですが、結果を見るとムニャムニャ率が高めでした。汚いデータの影響を受けやすいからなのか、日本語に合ってないのか、など詳細は未確認です。

と、いった結果になっているような気がします。あと 100k と 200k を比べると、100k ぐらいの方が結果としてはマシに聴こえるものも。

# 3. まとめ

完全にデータを自動生成に頼っても、聞こえなくはないレベルの音声自動合成はできそうです。