勾配降下党青年局

万国のグラーディエントよ、降下せよ!

RVCについて

 音声変換手法の一つであるRVC(Retrieval-based-Voice-Conversion)について、色々な情報と実装を流し見して何をやっているか想像してみました。想像なのであっているかどうかはわかりません。RVCの元になっているVITSの元になっているVAEから説明していきます。
参考:
【Tensorflowによる実装付き】Variational Auto-Encoder(VAE)を理解する | 楽しみながら理解するAI・機械学習入門
[2106.06103] Conditional Variational Autoencoder with Adversarial Learning for End-to-End Text-to-Speech
【機械学習】VITSでアニメ声へ変換できるボイスチェンジャー&読み上げ器を作った話 - Qiita
GitHub - ddPn08/rvc-webui: liujing04/Retrieval-based-Voice-Conversion-WebUI reconstruction project
最近のAIボイスチェンジャー(RVC、so-vits-svc)
RVCの構造についてのメモ
【図解】超高性能AIボイスチェンジャー「RVC」のしくみ・コツ
深層生成モデルを巡る旅(1): Flowベース生成モデル - Qiita
VITS学習メモ - 勾配降下党青年局

VAE(Variational Auto Encoder)


 VAEは、主に画像生成で使われるモデルです。まあ今回音声の話なので音声にしておきます。VAEは音声xの本質的な特徴をあらわす潜在変数zというものの存在を仮定する潜在変数モデルと呼ばれるものの一つです。VAEでは潜在変数の事前分布p(z)が平均0、分散1の正規分布に従うと仮定します。そして事後分布p(z|x)を予測するエンコーダq_\phi、潜在変数zが与えられたときの音声xの分布p(x|z)を予測するデコーダp_\thetaの二つを用意します。損失は音声をエンコードしてデコードしたらちゃんともとに戻るかを表す再構成誤差-\mathbb{E}_{q_\phi (z|x)}[\log p_\theta (x|z)]と、事前分布と事後分布を一致させるようにするKLダイバージェンス誤差D_{\mathrm{KL}}(q_\phi (z|x)\| p(z))を足したものになります。この損失のマイナスが実は対数尤度\log p(x)の下界となっており、またエンコーダによる事後分布の近似精度が高くなるほど対数尤度が下界に近づくため、損失の最小化⇒下界の最大化⇒対数尤度の最大化が実現できます。
 生成時はp(z|x)p(z)を一致させるよう学習していることから、平均0、分散1で潜在変数をサンプリングして、それをデコードするだけで学習データっぽいデータが生成できます。ただし生成データがどんなものになるかはコントロールできません。VAEの例ではよく人間の顔をいっぱいつくっていますが、出てくる顔はおっさんとか美少女とか小五ロリとかばらばらで、あまり役に立つものではありません。

Conditional VAE(VITS)


 素のVAEでは事前分布に何も条件がなかったので、生成物をコントロールできませんでした。Condtional VAEは事前分布にテキスト条件を付けたp(z|c)を用います。これにより条件cに基づいて生成することができます。ただし条件付けをする分事前分布を近似するモデルも作る必要があります。VITSでは通常のVAEのエンコーダにあたる事後分布を予測するモデルを事後エンコーダ、テキスト条件付きの事前分布を予測するモデルを事前エンコーダと呼んでいます。損失はKLダイバージェンス誤差が少し複雑になるだけですね。
 ただし音声が与えられたときの潜在変数の分布と、テキストが与えられたときの潜在変数の分布を考えるときに注意しなければならない点があります。それはテキストには発音時間の情報と話者固有の情報の二つが含まれない事です。
1. 話者固有の情報
テキストには話者固有の情報は含まれないので、このままでは事前分布には声質等が考慮されません。そこでVITSでは正規化フローf_\thetaを用いた確率変数の変数変換を行います。正規化フローとはVAEと同じく潜在変数モデルの一つで、確率変数の変数変換を繰り返し、複雑な分布から簡単な分布に移動していくことで、対数尤度を直接計算することができるモデルです。そのような機能を持たせるため逆変換とヤコビアンが簡単に求められるという性質を持っています。特にVITSで使うフローはヤコビアンが1になるよう設計されています。正規化フローは潜在変数から話者固有の情報を取り除くような変数変換を行う役割をになっています。これによって事後分布と事前分布を一致しやすくしています。さらにヤコビアンが1であることからKLダイバージェンス誤差の数式にも影響しません。
2. 発音時間の情報
テキストには発音時間は含まれないので、そのままでは事後分布と事前分布を一致させることが難しくなります。学習時には各音素j番目と各音声の時刻i番目でN(f_\theta(z)[i]|\mu[j],\sigma[j])の対数尤度を最大化するように、DP法を用いて音素と発音時刻の対応付けを行います。ここで\mu[j],\sigma[j]は事前エンコーダが推測した平均や分散です。そして複数の時刻にまたがる音素について、その時刻分特徴量を複製することで、時系列を一致させます。それとともにDPで求めた発音時間を予測するモデルも学習し、推論時にはそれを使います。
 デコーダ側にも工夫があって、GANの仕組みを使うことでより自然な音声が生成できるようにしているようです。
 VITSでは各モデルに話者埋め込みを入力することによって、モデルの出力を切り替えます。これにより複数話者によるテキスト読み上げが実現できます。テキスト読み上げはテキストを事前エンコーダで潜在変数にエンコードし、正規化フローの逆変換で話者情報を付け加え、デコーダに通します。通常のVAEと同じく生成時に事後エンコーダは使われないということですね。
 おまけとして正規化フローによる話者情報の入れ替えを使うことで音声変換も可能です。音声変換は事後エンコーダで音声を潜在変数にエンコードした後、正規化フローで入力話者情報を取り除き、逆変換でターゲットの話者情報を付与します。その後デコードします。

RVC


 VITSの音声変換で不便なところは、変換先のみならず変換元の話者情報まで学習しなければいけないことです。そこでRVCではテキストではなくHuBERTの特徴量を入力にして音声生成ができるように学習します。つまり事前エンコーダへの入力を音素トークンの埋め込みベクトルからHuBERT特徴量に置き換えて、HuBERT特徴量条件付き事前分布p(z|h)を近似するよう学習します。入力が音声になるため、発音時間の情報については考慮する必要がありません。次に話者情報ですが、学習時は変換先話者の音声特徴量を入力し、推論時は変換元話者の音声特徴量を入力することになります。これでは学習時と推論時で入力の分布が変わってしまい、うまく生成できません。そこでRVCでは学習データのHuBERT特徴量を残しておき、推論時に入力した音声特徴量に近いものを検索して混ぜ合わせます。これによって推論時の入力を学習時と近づけることによってうまく生成できるようにしています。ただし近いものといってもおっさんの声を美少女の声(声のかわいさと見た目はあまり関係ないはずなのだが、どうして美少女と言ってしまうのだろうか?)を変換するときに、近いものが見つかるわけがありません。そこでピッチ変換をつかっているようです。
最終的に損失関数は、

  1. GANの損失(Adversarial loss)
  2. GANの認識器で中間層の出力が真贋で同じになるようにする項(Feature matching loss)
  3. VAEの再構成誤差(Reconstruction loss)
  4. VAEのKLダイバージェンス項(KL loss)

の4つになります。

一応実装をみておく

実装を何とか理解できる部分だけ紹介してみます。
学習時の計算っぽいところ

def forward(
    self, phone, phone_lengths, pitch, pitchf, y, y_lengths, ds
):  # 这里ds是id,[bs,1]
    # print(1,pitch.shape)#[bs,t]
    g = self.emb_g(ds).unsqueeze(-1)  # [b, 256, 1]##1是t,广播的
    m_p, logs_p, x_mask = self.enc_p(phone, pitch, phone_lengths)
    z, m_q, logs_q, y_mask = self.enc_q(y, y_lengths, g=g)
    z_p = self.flow(z, y_mask, g=g)
    z_slice, ids_slice = commons.rand_slice_segments(
        z, y_lengths, self.segment_size
    )
    # print(-1,pitchf.shape,ids_slice,self.segment_size,self.hop_length,self.segment_size//self.hop_length)
    pitchf = commons.slice_segments2(pitchf, ids_slice, self.segment_size)
    # print(-2,pitchf.shape,z_slice.shape)
    o = self.dec(z_slice, pitchf, g=g)
    return o, ids_slice, x_mask, y_mask, (z, z_p, m_p, logs_p, m_q, logs_q)

HuBERTの特徴量とピッチ情報、音声スペクトログラム、話者IDを入力としています。1行目のgは話者IDで事後エンコーダやデコーダ、フローに入力されます。enc_pが事前エンコーダ、enc_qが事後エンコーダになっていて、事前エンコーダにはHuBERT特徴量とピッチ情報が入力され、平均m_pと対数分散logs_pが出力されます。事後エンコーダには音声が入力され、潜在変数zが出力され、さらにその出力はフローを通って話者情報が取り除かれたz_pになります。デコーダにはzをランダムにスライスしたz_sliceが入力されます。これはメモリ節約のためでVITSと同じです。最終出力oはGANの認識器へ入力されるとともに、再構成誤差の計算に使われ、z_p, m_p, logs_p, logs_qはKL lossに使われます。maskはよく分かりません。

推論時の計算っぽいところ

def infer(self, phone, phone_lengths, pitch, nsff0, sid, max_len=None):
    g = self.emb_g(sid).unsqueeze(-1)
    m_p, logs_p, x_mask = self.enc_p(phone, pitch, phone_lengths)
    z_p = (m_p + torch.exp(logs_p) * torch.randn_like(m_p) * 0.66666) * x_mask
    z = self.flow(z_p, x_mask, g=g, reverse=True)
    o = self.dec((z * x_mask)[:, :, :max_len], nsff0, g=g)
    return o, x_mask, (z, z_p, m_p, logs_p)

推論はHuBERT特徴量とピッチ情報、話者IDを入力としています。話者IDに関しては学習時と同じです。事前エンコーダによって平均と分散を出力し、そこからz_pをサンプリングします。そのあと逆flowによってz_pを話者情報を含む潜在変数zに変換し、デコーダを通します。当たり前ですが学習時と違って推論時はスライスは行いません。