💭

Style-Bert-VITS2のVoice Conversion機能の復活に挑戦してみた(前編)

2024/06/09に公開

はじめに

元々は画像処理系が専門分野で、2024年4月の中旬ごろから合成音声分野を勉強し始めた新参者のasapと申します。今回は、自分の学習の一環として試した内容を他の方の学習の手助けになればと思い公開いたします。
(元々は、合成音声に関してあまり詳しくないチームメンバ向けに作成した記事ですので、有識者の方から見れば冗長な記事になっているかと思いますが、ご了承いただけますと幸いです)

タイトルの通り、Style-Bert-VITS2(SBV2)のVoice Conversion(VC)機能の復活に挑戦しますが、実用性を求めるのであれば、素直にRVCなどを利用したほうが良いです。
あくまで今回は、初学者が音声合成について勉強し始めたので、自分の勉強のために、非常に高性能なText to Speech(TTS)モデルであるStyle-Bert-VITS2について色々触ってみたいと思っており、わかりやすいゴールとして、VC機能の復活に挑戦してみました。

高性能なTTSモデルであるSBV2は下記のリポジトリから簡単に使えるのでぜひつかってみてください。
(デフォルトのモデルも非常に可愛らしくて精度も高く、自分での学習も容易で非常に使いやすいです)

https://github.com/litagin02/Style-Bert-VITS2

デモは下記で用意してくださっています。至れり尽くせりですね。
https://huggingface.co/spaces/litagin/Style-Bert-VITS2-Editor-Demo

本記事は前編です。
後半はすぐに公開いたします。
申し訳ございません。実験に手こずっていてもう少し時間がかかりそうです。。。

なお、今回のコードは下記のリポジトリで準備しました。
こちらは、大元のSBV2リポジトリをforkさせていただき、作成しました。
https://github.com/personabb/Style-Bert-VITS2-VC

Text to Speech(TTS)って何?

Text to Speech(TTS)は、テキストデータを音声に変換する技術です。

例えばよく見るのは

  • ゆっくりボイス
  • VOICEBOX(ずんだもんや四国めたんなど)

などが、Youtubeなどでよく見る合成音声ではないでしょうか。
これらの音声は、事前に台本を用意して、TTSを利用してテキストから音声に変換しています。

SBV2においても、例えば話者Bの音声データによって学習された学習済み重みを利用することで、テキストデータから話者Bの声で音声を生成することができます。

Voice Conversion(VC)って何?

Voice Conversion(VC)は、ある音声を別の音声に変換する技術です。

具体的には、ある話者の音声を異なる話者の音声に変換する技術を指します。
これは、音声の特性(例えば、声の高さ、音色、話し方の特徴など)を別の話者の特性に変換することで実現されます。

Youtubeなどで、男性の方が女性の声でゲーム配信するなどは、リアルタイムでのVCとして、こちらの技術をつかっています。

例えば、既存の技術としてRetrieval-based-Voice-Conversion(RVC)と呼ばれるものがあります。
このモデルでは話者Bの音声データによって学習された学習済み重みを利用することで、全く別の話者Aの音声を話者Bによる音声に変換して出力することができます。

(Voice ConversionをVCを略すのが正しいのかはわからないですが、Retrieval-based-Voice-ConversionをRVCと略すため、この記事中ではVCを使わせていただきます。)

今回やりたいこと

今回やりたいことは、
TTSモデルであるSBV2にて、VCを実現したい!
です。
(すなわち、話者Aの音声を、話者B(モデルが学習した音声)の音声に変換することを目的とします)

そのために、下記の順番で調査と実装を進めたいと思います。

  • SBV2の前身となったVITS2、VITSについて調査をする
    • 本当の意味でのSBV2の前身はBert-VITS2だと思いますが、そちらに関してはあまり調査していないので割愛させてください。
  • SBV2におけるTTSがどのように実現されているかを確認する
  • 上記をもとに、SBV2にてVCを実現するための方法を検討する
  • 実装する

実施内容

SBV2の前身であるVITS2やVITSとは何者か

VITS(Variational Inference Text-to-Speech) とは、合成音声技術の一つで、特に自然で高品質な音声合成を実現するために開発されたモデルです。
このモデルでは、TTSとVCの両方のタスクを達成することができます。

私は下記のページを見てVITSの内容を勉強しました。非常にわかりやすいサイトなのでおすすめです。
(初めてこの記事でVITSのモデル構造を知った時は感動しました。各モジュールは画像処理分野ではよくみてきたモジュールなのですが、これをこんなふうに組み合わせると合成音声に使えるようになるのかと)
https://qiita.com/zassou65535/items/00d7d5562711b89689a8

そして上記の記事を見ながら思いました。
SBV2は内部的にVITSのモデルに似たものをつかっているはずなのに、なぜVCの機能が実装されていないのか?
VITSでVCができるのであれば、SBV2でもVCができるのではないか?

これが始まりで、タイトルの中で「復活」という表現をしている理由です。
実際、SBV2の学習時のデータフローを見れば、VCを達成するために必要なモジュールの重みが保存されていることがわかります。

また、VITSはVITS2に進化しています。
VITS2自体はあまりちゃんと勉強していません
(VITSと大きく構造は変わらない理解なのと、SBV2で同じモジュールが使われているだろうを考え、SBV2のコードを読むことを優先しました)

ちなみにVITS2は下記の記事で簡単に勉強させていただきました。VITSとの差分に関して説明いただいているため、何が変わったのか、とてもわかりやすかったです。
https://mmvc.fanbox.cc/posts/6501096

(ちなみに、ここでちゃんとVITS2のことをちゃんと勉強しておけば、VITS2がTTSに特化したモデルだから、それを受け継いでいるSBV2にてVC機能がないことがわかったはずなのですが、この時点の私はそれを理解していないまま、挑戦を進めることになります)

一応それぞれの論文とGitHubリポジトリも提示しておきます。
VITS
https://arxiv.org/abs/2106.06103
https://github.com/jaywalnut310/vits
VITS2
https://arxiv.org/abs/2307.16430
https://github.com/daniilrobnikov/vits2

SBV2でどのようにTTSが実現されているかをコードベースで確認する

モジュールとしてSBV2をTTSで利用する際に、大きく影響するコードは下記になります。
(文字数制限に引っかかったため、リンクで失礼します)

style_bert_vits2/tts_model.py
style_bert_vits2/models/infer.py
style_bert_vits2/models/models_jp_extra.py

主に関わるコードを下記で紹介します。

推論時のコード

まず、自分のpythonコード内でSBV2のTTSを利用する場合は下記のコードを実行します。
(全体は公式リポジトリにてlibrary.ipynbファイル内で解説してくださっていますので一部分だけ明示します)

library.ipynb
sr, audio = model.infer(text="こんにちは")

上記のコードが実行されると、「こんにちは」と話している合成音声がsrのサンプリングレートでaudioに格納されます。
ここではコードからわかるように。modelインスタンスのinferメソッドが呼び出されていることがわかります。したがって該当部分を確認します。

style_bert_vits2/tts_model.pyのTTSModelクラスのinferメソッド

style_bert_vits2/tts_model.py
style_bert_vits2/tts_model.py
    def infer(
        self,
        text: str,
        language: Languages = Languages.JP,
        speaker_id: int = 0,
        reference_audio_path: Optional[str] = None,
        sdp_ratio: float = DEFAULT_SDP_RATIO,
        noise: float = DEFAULT_NOISE,
        noise_w: float = DEFAULT_NOISEW,
        length: float = DEFAULT_LENGTH,
        line_split: bool = DEFAULT_LINE_SPLIT,
        split_interval: float = DEFAULT_SPLIT_INTERVAL,
        assist_text: Optional[str] = None,
        assist_text_weight: float = DEFAULT_ASSIST_TEXT_WEIGHT,
        use_assist_text: bool = False,
        style: str = DEFAULT_STYLE,
        style_weight: float = DEFAULT_STYLE_WEIGHT,
        given_phone: Optional[list[str]] = None,
        given_tone: Optional[list[int]] = None,
        pitch_scale: float = 1.0,
        intonation_scale: float = 1.0,
    ) -> tuple[int, NDArray[Any]]:
        """
        テキストから音声を合成する。

        Args:
            text (str): 読み上げるテキスト
            language (Languages, optional): 言語. Defaults to Languages.JP.
            speaker_id (int, optional): 話者 ID. Defaults to 0.
            reference_audio_path (Optional[str], optional): 音声スタイルの参照元の音声ファイルのパス. Defaults to None.
            sdp_ratio (float, optional): DP と SDP の混合比。0 で DP のみ、1で SDP のみを使用 (値を大きくするとテンポに緩急がつく). Defaults to DEFAULT_SDP_RATIO.
            noise (float, optional): DP に与えられるノイズ. Defaults to DEFAULT_NOISE.
            noise_w (float, optional): SDP に与えられるノイズ. Defaults to DEFAULT_NOISEW.
            length (float, optional): 生成音声の長さ(話速)のパラメータ。大きいほど生成音声が長くゆっくり、小さいほど短く早くなる。 Defaults to DEFAULT_LENGTH.
            line_split (bool, optional): テキストを改行ごとに分割して生成するかどうか (True の場合 given_phone/given_tone は無視される). Defaults to DEFAULT_LINE_SPLIT.
            split_interval (float, optional): 改行ごとに分割する場合の無音 (秒). Defaults to DEFAULT_SPLIT_INTERVAL.
            assist_text (Optional[str], optional): 感情表現の参照元の補助テキスト. Defaults to None.
            assist_text_weight (float, optional): 感情表現の補助テキストを適用する強さ. Defaults to DEFAULT_ASSIST_TEXT_WEIGHT.
            use_assist_text (bool, optional): 音声合成時に感情表現の補助テキストを使用するかどうか. Defaults to False.
            style (str, optional): 音声スタイル (Neutral, Happy など). Defaults to DEFAULT_STYLE.
            style_weight (float, optional): 音声スタイルを適用する強さ. Defaults to DEFAULT_STYLE_WEIGHT.
            given_phone (Optional[list[int]], optional): 読み上げテキストの読みを表す音素列。指定する場合は given_tone も別途指定が必要. Defaults to None.
            given_tone (Optional[list[int]], optional): アクセントのトーンのリスト. Defaults to None.
            pitch_scale (float, optional): ピッチの高さ (1.0 から変更すると若干音質が低下する). Defaults to 1.0.
            intonation_scale (float, optional): 抑揚の平均からの変化幅 (1.0 から変更すると若干音質が低下する). Defaults to 1.0.

        Returns:
            tuple[int, NDArray[Any]]: サンプリングレートと音声データ (16bit PCM)
        """

        logger.info(f"Start generating audio data from text:\n{text}")
        if language != "JP" and self.hyper_parameters.version.endswith("JP-Extra"):
            raise ValueError(
                "The model is trained with JP-Extra, but the language is not JP"
            )
        if reference_audio_path == "":
            reference_audio_path = None
        if assist_text == "" or not use_assist_text:
            assist_text = None

        if self.__net_g is None:
            self.load()
        assert self.__net_g is not None
        if reference_audio_path is None:
            style_id = self.style2id[style]
            style_vector = self.__get_style_vector(style_id, style_weight)
        else:
            style_vector = self.__get_style_vector_from_audio(
                reference_audio_path, style_weight
            )
        if not line_split:
            with torch.no_grad():
                audio = infer(
                    text=text,
                    sdp_ratio=sdp_ratio,
                    noise_scale=noise,
                    noise_scale_w=noise_w,
                    length_scale=length,
                    sid=speaker_id,
                    language=language,
                    hps=self.hyper_parameters,
                    net_g=self.__net_g,
                    device=self.device,
                    assist_text=assist_text,
                    assist_text_weight=assist_text_weight,
                    style_vec=style_vector,
                    given_phone=given_phone,
                    given_tone=given_tone,
                )
        else:
            texts = text.split("\n")
            texts = [t for t in texts if t != ""]
            audios = []
            with torch.no_grad():
                for i, t in enumerate(texts):
                    audios.append(
                        infer(
                            text=t,
                            sdp_ratio=sdp_ratio,
                            noise_scale=noise,
                            noise_scale_w=noise_w,
                            length_scale=length,
                            sid=speaker_id,
                            language=language,
                            hps=self.hyper_parameters,
                            net_g=self.__net_g,
                            device=self.device,
                            assist_text=assist_text,
                            assist_text_weight=assist_text_weight,
                            style_vec=style_vector,
                        )
                    )
                    if i != len(texts) - 1:
                        audios.append(np.zeros(int(44100 * split_interval)))
                audio = np.concatenate(audios)
        logger.info("Audio data generated successfully")
        if not (pitch_scale == 1.0 and intonation_scale == 1.0):
            _, audio = adjust_voice(
                fs=self.hyper_parameters.data.sampling_rate,
                wave=audio,
                pitch_scale=pitch_scale,
                intonation_scale=intonation_scale,
            )
        audio = self.__convert_to_16_bit_wav(audio)
        return (self.hyper_parameters.data.sampling_rate, audio)

本メソッドの中では下記の処理が行われています。

  • モデルの確認
    • 本メソッドはJP-Extraと呼ばれる日本語特化のモデルを前提としている(他にも英語や中国語を話せるモデルはあるが、それは別の場所で定義されている)ため、それ以外のモデルを弾いている。
  • モデルのロード
    • TTSに必要なnet_gのみのロード。
  • StyleVectorの設定
    • idを指定している場合はそこから。reference_audioを設定している場合はそのaudioからStyleVectorを設定。
  • モデルからTTSの推論(後述)
    • 改行ごとに分割して生成するかどうかで分岐。
  • ピッチ、抑揚調整。
  • 16 bit wavに変換。

重要なのは下記の部分です

from style_bert_vits2.models.infer import get_net_g, infer
audio = infer(
                    text=text,
                    sdp_ratio=sdp_ratio,
                    noise_scale=noise,
                    noise_scale_w=noise_w,
                    length_scale=length,
                    sid=speaker_id,
                    language=language,
                    hps=self.hyper_parameters,
                    net_g=self.__net_g,
                    device=self.device,
                    assist_text=assist_text,
                    assist_text_weight=assist_text_weight,
                    style_vec=style_vector,
                    given_phone=given_phone,
                    given_tone=given_tone,
                )

ここを見てわかるように、style_bert_vits2.models.inferの中のinfer関数がさらに呼ばれていることがわかりますので、さらに該当部分を掘っていきます。
(改めて、コード全体は本章の最初に提示しておりますので、コード全体を確認したい場合はそちらからどうぞ)

style_bert_vits2/models/infer.pyのinfer関数

style_bert_vits2/models/infer.py
style_bert_vits2/models/infer.py
def infer(
    text: str,
    style_vec: NDArray[Any],
    sdp_ratio: float,
    noise_scale: float,
    noise_scale_w: float,
    length_scale: float,
    sid: int,  # In the original Bert-VITS2, its speaker_name: str, but here it's id
    language: Languages,
    hps: HyperParameters,
    net_g: Union[SynthesizerTrn, SynthesizerTrnJPExtra],
    device: str,
    skip_start: bool = False,
    skip_end: bool = False,
    assist_text: Optional[str] = None,
    assist_text_weight: float = 0.7,
    given_phone: Optional[list[str]] = None,
    given_tone: Optional[list[int]] = None,
):
    is_jp_extra = hps.version.endswith("JP-Extra")
    bert, ja_bert, en_bert, phones, tones, lang_ids = get_text(
        text,
        language,
        hps,
        device,
        assist_text=assist_text,
        assist_text_weight=assist_text_weight,
        given_phone=given_phone,
        given_tone=given_tone,
    )
    if skip_start:
        phones = phones[3:]
        tones = tones[3:]
        lang_ids = lang_ids[3:]
        bert = bert[:, 3:]
        ja_bert = ja_bert[:, 3:]
        en_bert = en_bert[:, 3:]
    if skip_end:
        phones = phones[:-2]
        tones = tones[:-2]
        lang_ids = lang_ids[:-2]
        bert = bert[:, :-2]
        ja_bert = ja_bert[:, :-2]
        en_bert = en_bert[:, :-2]
    with torch.no_grad():
        x_tst = phones.to(device).unsqueeze(0)
        tones = tones.to(device).unsqueeze(0)
        lang_ids = lang_ids.to(device).unsqueeze(0)
        bert = bert.to(device).unsqueeze(0)
        ja_bert = ja_bert.to(device).unsqueeze(0)
        en_bert = en_bert.to(device).unsqueeze(0)
        x_tst_lengths = torch.LongTensor([phones.size(0)]).to(device)
        style_vec_tensor = torch.from_numpy(style_vec).to(device).unsqueeze(0)
        del phones
        sid_tensor = torch.LongTensor([sid]).to(device)
        if is_jp_extra:
            output = cast(SynthesizerTrnJPExtra, net_g).infer(
                x_tst,
                x_tst_lengths,
                sid_tensor,
                tones,
                lang_ids,
                ja_bert,
                style_vec=style_vec_tensor,
                sdp_ratio=sdp_ratio,
                noise_scale=noise_scale,
                noise_scale_w=noise_scale_w,
                length_scale=length_scale,
            )
        else:
            output = cast(SynthesizerTrn, net_g).infer(
                x_tst,
                x_tst_lengths,
                sid_tensor,
                tones,
                lang_ids,
                bert,
                ja_bert,
                en_bert,
                style_vec=style_vec_tensor,
                sdp_ratio=sdp_ratio,
                noise_scale=noise_scale,
                noise_scale_w=noise_scale_w,
                length_scale=length_scale,
            )
        audio = output[0][0, 0].data.cpu().float().numpy()
        del (
            x_tst,
            tones,
            lang_ids,
            bert,
            x_tst_lengths,
            sid_tensor,
            ja_bert,
            en_bert,
            style_vec,
        )  # , emo
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
        return audio

本関数の中では下記の処理が行われています。

  • モデルversionの確認(JP-Extraモデルとそれ以外のモデルでモデル構造などが違うため確認)
  • get_text関数を用いて、モデルに入力するデータを取得
    • テキスト情報からSBV2がTTSを行う際に必要な下記の情報を取得する関数です。
      • bert:中国語の場合のbert特徴量(日本語の場合は0ベクトルが入る)
      • ja_bert:日本語の場合のbert特徴量
      • en_bert:英語の場合のbert特徴量(日本語の場合は0ベクトルが入る)
      • phones:音素(id変換後)
      • tones:アクセント(id変換後)
      • lang_ids:言語情報(日本語、英語、中国語など)(id変換後)
  • データ整形
    • 話者IDや有効音素長などの必要データの取得と行列化
    • 初めや終わりのskipがあるかどうかの判定と処理
    • 各種データのbatch_size次元の追加
  • モデル推論(後述)
    • JP-Extraモデルかどうかで分岐あり
  • モデル出力からaudioデータの取得
  • GPUからデータの削除

重要なのは下記の部分です。

from style_bert_vits2.models.models_jp_extra import (
    SynthesizerTrn as SynthesizerTrnJPExtra,
)
if is_jp_extra:
    output = cast(SynthesizerTrnJPExtra, net_g).infer(
        x_tst,
        x_tst_lengths,
        sid_tensor,
        tones,
        lang_ids,
        ja_bert,
        style_vec=style_vec_tensor,
        sdp_ratio=sdp_ratio,
        noise_scale=noise_scale,
        noise_scale_w=noise_scale_w,
        length_scale=length_scale,
    )

ここで見てわかるように、ついにSynthesizerTrnJPExtraモデルのinferメソッドにデータが流れていることがわかります。それぞれの入力データの説明は次に譲るとして、style_bert_vits2.models.models_jp_extraを確認しようと思います。

該当部分は下記になります。長いのでinferメソッドのみを記載します。
(学習時とは違うデータフローになります。学習時のデータフローはforwardメソッドをご確認ください)

style_bert_vits2/models/models_jp_extra.pyのSynthesizerTrnクラスのinferメソッド

style_bert_vits2/models/models_jp_extra.py
style_bert_vits2/models/models_jp_extra.py

def infer(
        self,
        x: torch.Tensor,
        x_lengths: torch.Tensor,
        sid: torch.Tensor,
        tone: torch.Tensor,
        language: torch.Tensor,
        bert: torch.Tensor,
        style_vec: torch.Tensor,
        noise_scale: float = 0.667,
        length_scale: float = 1.0,
        noise_scale_w: float = 0.8,
        max_len: Optional[int] = None,
        sdp_ratio: float = 0.0,
        y: Optional[torch.Tensor] = None,
    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, tuple[torch.Tensor, ...]]:
        # x, m_p, logs_p, x_mask = self.enc_p(x, x_lengths, tone, language, bert)
        # g = self.gst(y)
        if self.n_speakers > 0:
            g = self.emb_g(sid).unsqueeze(-1)  # [b, h, 1]
        else:
            assert y is not None
            g = self.ref_enc(y.transpose(1, 2)).unsqueeze(-1)
        x, m_p, logs_p, x_mask = self.enc_p(
            x, x_lengths, tone, language, bert, style_vec, g=g
        )
        logw = self.sdp(x, x_mask, g=g, reverse=True, noise_scale=noise_scale_w) * (
            sdp_ratio
        ) + self.dp(x, x_mask, g=g) * (1 - sdp_ratio)
        w = torch.exp(logw) * x_mask * length_scale
        w_ceil = torch.ceil(w)
        y_lengths = torch.clamp_min(torch.sum(w_ceil, [1, 2]), 1).long()
        y_mask = torch.unsqueeze(commons.sequence_mask(y_lengths, None), 1).to(
            x_mask.dtype
        )
        attn_mask = torch.unsqueeze(x_mask, 2) * torch.unsqueeze(y_mask, -1)
        attn = commons.generate_path(w_ceil, attn_mask)

        m_p = torch.matmul(attn.squeeze(1), m_p.transpose(1, 2)).transpose(
            1, 2
        )  # [b, t', t], [b, t, d] -> [b, d, t']
        logs_p = torch.matmul(attn.squeeze(1), logs_p.transpose(1, 2)).transpose(
            1, 2
        )  # [b, t', t], [b, t, d] -> [b, d, t']

        z_p = m_p + torch.randn_like(m_p) * torch.exp(logs_p) * noise_scale
        z = self.flow(z_p, y_mask, g=g, reverse=True)
        o = self.dec((z * y_mask)[:, :, :max_len], g=g)
        return o, attn, y_mask, (z, z_p, m_p, logs_p)

本メソッド(モデル)は下記の入力から音声を生成しています

  • x:音素
  • x_lengths:有効な音素の長さ
  • sid:話者ID
  • tone:アクセント情報
  • language:言語情報(日本語とか)
  • bert:bert特徴量(今回は日本語)
  • style_vec:スタイルベクトル
    • style_vectors.npyに保存されているStyleVectorから取得されたベクトル値
    • 初期値はNeutral(id化して上記のファイルから該当のベクトル値を取得したもの)
  • noise_scale:テキストから生成される分布パラメータのうち分散にかかるスケール値
    • 初期値は0.667
    • なんでこれが必要なのかは正直わからない。単純にTextEncoderから得られる分散log_pをそのまま使うのではダメなのか?
  • length_scale:生成される発話音声の速さを指定するパラメータ(小さいほど早口になる)
    • 初期値は1.0
    • 0.9などに指定すると、各音素の発話時間が全体的に0.9倍になる。
  • noise_scale_w:SDPという各音素の発話の長さを制御するモジュールに与えられるノイススケール
    • 初期値は0.8
  • max_len:生成された音声(の波形)のうち、どこまでを出力するか
    • 初期値はNone
    • 潜在表現の時間軸方向の要素数の値を指定するため、音声波形自体の時間や要素数を指定するわけではないことに注意
  • sdp_ratio:SDPやDPという各音素の発話の長さを制御するモジュールの比率を指定するパラメータ
    • 初期値は0
    • この場合は、SDPは使われず、DPのみが使われる
  • y:参照音声
    • この音声から話者の埋め込み情報を取得することもできる

本メソッドの中では下記の処理が行われています。

  • embeddingモジュールself.emb_gにより、話者IDを埋め込みベクトルに変換
  • TextEncoderself.enc_pにより、分布パラメータm_plog_pを出力する
    • それぞれVAEで言う平均と分散に対応している
    • 同時に、有効音素を示すマスクであるx_maskも出力する
  • DP、SDPにより各音素がどの程度の発話時間になるかを推定し、発話時間の長さを表すパスを取得
  • 発話時間の全体の長さから、最終的に生成する音声の長さパラメータy_lengthsとマスクy_maskを取得する
  • 各音素の発話時間の長さ分、分布パラメータm_plog_pを複製
  • 分布パラメータm_plog_pから潜在表現z_pをサンプリング
  • 話者パラメータにより条件付けられたFlowモジュールにより、潜在表現z_pを潜在表現zに変換する
    • VITSによれば、このFlowモジュールにより、話者情報が潜在表現に付与される
    • 学習時のデータフローを確認すれば理由がわかりやすい(別の記事で書こうかなと思う)
  • 潜在表現zからデコーダモジュールself.decによって音声波形oが生成される

以上から、SBV2ではテキスト情報から音声波形を生成しています。

SBV2にてVCを実現するための方法を検討

SBV2がTTSを達成するためには下記のような処理を経由していました。
(SBV2のモデル重みは話者Bによる音声から学習されたものであることを前提とします)

テキスト情報
↓
TTSModelクラスのinferメソッド(モデル入力の整形)
↓
infer.pyのinfer関数(モデル入力の整形)
↓
SynthesizerTrnクラスのinferメソッド
↓
infer.pyのinfer関数(モデル出力の整形)
↓
TTSModelクラスのinferメソッド(モデル出力の整形)
↓
話者Bの声色による生成波形

上記のような流れで、テキスト情報からモデルに入力する入力情報に変換し、モデルから生成波形を取得しています。

つまり、SBV2にてVCを実現するためには下記のような処理を達成できれば良いことになります
(SBV2のモデル重みは話者Bによる音声から学習されたものであることを前提とします)

話者Aの音声波形
↓
TTSModelクラスのinfer_audioメソッド(モデル入力の整形)
↓
infer.pyのinfer_audio関数(モデル入力の整形)
↓
SynthesizerTrnクラスのinfer_audioメソッド
↓
TTSModelクラスのinfer_audioメソッド(モデル出力の整形)
↓
infer.pyのinfer_audio関数(モデル出力の整形)
↓
話者Bの音声波形

したがってそれぞれのinfer_audioメソッドを作成していきます。

実装する

前章の順番と逆順で実装していきます。
したがって下記の順番で実装していきます

SynthesizerTrnクラスのinfer_audioメソッド
↓
TTSModelクラスのinfer_audioメソッド(モデル出力の整形)
↓
infer.pyのinfer_audio関数(モデル出力の整形)

また、
既存のモジュールをなるべく壊さないように、追加する形で実装します
(今まで使えてたコードが壊れると困るので)

style_bert_vits2/models/models_jp_extra.pyのSynthesizerTrnクラスのinfer_audioメソッド

まずは、VCが実現可能なようなモデルを実装していきます。
必要なモジュールは、上でも紹介した下記の記事を参考に設定しています。
https://qiita.com/zassou65535/items/00d7d5562711b89689a8
また、学習時のデータフローであるforwardメソッドを参考に実装しています。

VCにおいて必要なモジュールとデータフローは下記になります。
(モデルの重みは話者Bの音声によって学習された学習済み重みを利用することを前提とします)

話者Aの音声波形(から得られたモデル入力データ(スペクトログラムなど))
↓
posterior encoder:潜在表現の生成
↓
Flow:潜在表現から話者Aの特徴削除
↓
Flow(Reverse):潜在表現に話者Bの特徴量を追加
↓
Decoder:話者Bの潜在表現から波形生成
↓
話者Bの音声波形

上記に基づいた実装コードは下記になります

style_bert_vits2/models/models_jp_extra.py
    def infer_audio(
        self,
        y:torch.Tensor,
        y_lengths:torch.Tensor,
        t_sid:torch.Tensor,
        max_len: Optional[int] = None,

    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, tuple[torch.Tensor, ...]]:

        self.ref_enc = ReferenceEncoder(self.spec_channels, self.gin_channels)        

        #音声の特徴量を取得する
        #学習済み音声に関しては、登録済みの話者idを利用する
        g = self.emb_g(t_sid).unsqueeze(-1)  # [b, h, 1]

        #入力音声の話者idはないので、音声から話者特徴を抽出する
        g_ref = self.ref_enc(y.transpose(1, 2)).unsqueeze(-1)

        #posterior encoder 
        z, m_q, logs_q, y_mask = self.enc_q(y, y_lengths, g=g_ref)
        #flowに変換前の話者情報を入れて、話者情報を潜在表現から取り除く
        z_p = self.flow(z, y_mask, g=g_ref)
        #逆順にflowに入力し、変換後の話者情報を付与する
        z = self.flow(z_p, y_mask, g=g, reverse=True)

        o = self.dec((z * y_mask)[:, :, :max_len], g=g)

        return o, y_mask, (z, z_p)

本モデルの入力は下記になります。

  • y:入力音声のスペクトログラム
    • 音声波形をスペクトルグラムに変換する方法は後述します
  • y_lengths:有効なスペクトログラム長
  • t_sid:変換後の話者ID(事前にモデルに学習ずみの話者ID)
  • max_len: 生成された音声(の波形)のうち、どこまでを出力するか
    • 初期値はNone
    • 潜在表現の時間軸方向の要素数の値を指定するため、音声波形自体の時間や要素数を指定するわけではないことに注意

出力は主に音声波形であるoのみを利用します。

本実装ではコードの通り、話者Aの話者特徴量を取得するためにself.ref_encを使っています。

self.ref_enc = ReferenceEncoder(self.spec_channels, self.gin_channels)
g_ref = self.ref_enc(y.transpose(1, 2)).unsqueeze(-1)    

上記の通り、入力音声スペクトログラムyから話者特徴量の埋め込みg_refに変換します
self.spec_channelsは入力スペクトログラムの次元数でself.gin_channelsは話者特徴量の埋め込みの次元数です
(実はこのReferenceEncoderは意図通りに機能しません・・・今の時点の著者はそれを知らないまま進めています)

話者Bの話者特徴量は学習時に指定した話者IDからself.emb_gによって取得できます。

style_bert_vits2/models/infer.pyのinfer_audio関数

続いて、上で定義したモデルを呼び出すinfer_audio関数を実装します。
本関数は、上で説明した同じファイル内のinfer関数を参考に実装しました。
(また、私の怠慢でJP-Extraのモデルの場合に限定して実装しました)

style_bert_vits2/models/infer.py
def infer_audio(
    audio: NDArray[Any],
    t_sid: int,  # In the original Bert-VITS2, its speaker_name: str, but here it's id
    hps: HyperParameters,
    net_g: Union[SynthesizerTrn, SynthesizerTrnJPExtra],
    device: str,
    sample_rate = 44100,
):
    is_jp_extra = hps.version.endswith("JP-Extra")
    spec = get_audio(audio, hps, sample_rate)

    with torch.no_grad():
        input_spec = spec.to(device).unsqueeze(0)
        input_spec_lengths = torch.LongTensor([spec.size(1)]).to(device)
        t_sid_tensor = torch.LongTensor([t_sid]).to(device)

        if is_jp_extra:
            output = cast(SynthesizerTrnJPExtra, net_g).infer_audio(
                input_spec,
                input_spec_lengths,
                t_sid_tensor,
            )
        else:
            raise ValueError(
                f" Sorry, infer_audio is only implemented for JP-Extra model"
            )
        audio = output[0][0, 0].data.cpu().float().numpy()
        del (
            spec,
            input_spec_lengths,
            t_sid_tensor,
            )
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
        return audio

TTSの時のinfer関数と同様の流れで下記の処理をしています。

  • モデルversionの確認(JP-Extraモデルとそれ以外のモデルでモデル構造などが違うため確認)
    • 私の怠慢で、JP-Extraモデルでない場合はエラーを返します。
  • get_audio関数を用いて、モデルに入力するデータを取得
    • 参照音声波形からSBV2がVCを行う際に必要な情報を取得する関数(後述)
  • データ整形
    • 話者IDや有効スペクトログラム長などの必要データの取得と行列化
    • 各種データのbatch_size次元の追加
  • モデル推論(前節で定義)
  • モデル出力からaudioデータの取得
  • GPUからデータの削除

ここで、
VCでは、TTSの場合と異なり、入力テキストデータからモデル入力データ(音素やアクセント、bert特徴量など)を作成するのではなく、入力音声波形からモデル入力データ(スペクトログラムなど)を作成する必要があります。

スペクトログラム

したがって、入力音声波形からモデル入力データ(スペクトログラムなど)を作成する関数としてget_audio関数を実装します。

style_bert_vits2/models/infer.pyのget_audio関数

get_audio関数はinfer_audio関数内で下記のように使われます。

spec = get_audio(audio, hps, sample_rate)

したがって、入力音声波形とsample_rateから音声のスペクトログラムを作成します。

本関数は、学習時に学習データのDataLoaderを作成するために使われるTextAudioSpeakerLoaderクラスのget_audioメソッドを参考に作成しました。(詳細は割愛します。train_ms_jp_extra.pyをご覧ください。)

該当のメソッドを下記に示します。

data_utils.py
data_utils.py
    def get_audio(self, filename):
        audio, sampling_rate = load_wav_to_torch(filename)
        if sampling_rate != self.sampling_rate:
            raise ValueError(
                f"{filename} {sampling_rate} SR doesn't match target {self.sampling_rate} SR"
            )
        audio_norm = audio / self.max_wav_value
        audio_norm = audio_norm.unsqueeze(0)
        spec_filename = filename.replace(".wav", ".spec.pt")
        if self.use_mel_spec_posterior:
            spec_filename = spec_filename.replace(".spec.pt", ".mel.pt")
        try:
            spec = torch.load(spec_filename)
        except:
            if self.use_mel_spec_posterior:
                spec = mel_spectrogram_torch(
                    audio_norm,
                    self.filter_length,
                    self.n_mel_channels,
                    self.sampling_rate,
                    self.hop_length,
                    self.win_length,
                    self.hparams.mel_fmin,
                    self.hparams.mel_fmax,
                    center=False,
                )
            else:
                spec = spectrogram_torch(
                    audio_norm,
                    self.filter_length,
                    self.sampling_rate,
                    self.hop_length,
                    self.win_length,
                    center=False,
                )
            spec = torch.squeeze(spec, 0)
            if config.train_ms_config.spec_cache:
                torch.save(spec, spec_filename)
        return spec, audio_norm

上記を参考にしつつ、スペクトログラムのキャッシュが不要であったり、クラスメソッドから関数に変わる関係での変更点を考慮して、get_audio関数を作成しました。

style_bert_vits2/models/infer.py

from mel_processing import mel_spectrogram_torch, spectrogram_torch
import numpy as np

def get_audio(audio_numpy: NDArray[Any],hps: HyperParameters,sample_rate=44100):

    audio = torch.FloatTensor(audio_numpy.astype(np.float32))
    if sample_rate != hps.data.sampling_rate:
            raise ValueError(
                f" {sample_rate} SR doesn't match target 44100 SR"
            )

    max_wav_value = hps.data.max_wav_value
    use_mel_spec_posterior = hps.model.use_mel_posterior_encoder
    filter_length = hps.data.filter_length
    n_mel_channels = hps.data.n_mel_channels
    
    hop_length = hps.data.hop_length
    win_length = hps.data.win_length
    mel_fmin = hps.data.mel_fmin
    mel_fmax = hps.data.mel_fmax

    audio_norm = audio / max_wav_value
    audio_norm = audio_norm.unsqueeze(0)

    if use_mel_spec_posterior:
        spec = mel_spectrogram_torch(
            audio_norm,
            filter_length,
            n_mel_channels,
            sample_rate,
            hop_length,
            win_length,
            mel_fmin,
            mel_fmax,
            center=False,
        )
    else:
        spec = spectrogram_torch(
            audio_norm,
            filter_length,
            sample_rate,
            hop_length,
            win_length,
            center=False,
        )
    spec = torch.squeeze(spec, 0)

    return spec

上記の関数は下記のような処理をします。

  • 入力信号の両端にpaddingを適用します。
  • 入力された音声波形をTorch.Tensor形に変換
  • 入力された音声データと、学習済み重みと共に保存されているconfig.jsonに記載されているsample_rateが一致することを確認する
    • 基本的には44100Hzを使う
  • config.jsonから必要なパラメータを取得する
  • 入力音声の波形をモデルが処理しやすいように0から1の範囲に正規化する
  • spectrogram_torch関数により、入力音声からスペクトログラム化する。

ここで重要なのはspectrogram_torch関数になります。こちらはすでに実装済みの関数をそのまま利用するので今回の趣旨とはずれますが、簡単に説明します。
興味のある方は、下記を展開してご確認ください。

spectrogram_torch
mel_processing.py

hann_window = {}
def spectrogram_torch(y, n_fft, sampling_rate, hop_size, win_size, center=False):
    if torch.min(y) < -1.0:
        print("min value is ", torch.min(y))
    if torch.max(y) > 1.0:
        print("max value is ", torch.max(y))

    global hann_window
    dtype_device = str(y.dtype) + "_" + str(y.device)
    wnsize_dtype_device = str(win_size) + "_" + dtype_device
    if wnsize_dtype_device not in hann_window:
        hann_window[wnsize_dtype_device] = torch.hann_window(win_size).to(
            dtype=y.dtype, device=y.device
        )

    y = torch.nn.functional.pad(
        y.unsqueeze(1),
        (int((n_fft - hop_size) / 2), int((n_fft - hop_size) / 2)),
        mode="reflect",
    )
    y = y.squeeze(1)

    spec = torch.stft(
        y,
        n_fft,
        hop_length=hop_size,
        win_length=win_size,
        window=hann_window[wnsize_dtype_device],
        center=center,
        pad_mode="reflect",
        normalized=False,
        onesided=True,
        return_complex=False,
    )

    spec = torch.sqrt(spec.pow(2).sum(-1) + 1e-6)
    return spec

本関数は下記の処理を行っています。

  • 入力音声が正規化されているかどうかの確認
  • 入力音声のwin_size(初期値は2048)、dtype(float32など)、device(現時点ではまだ'cpu')をkeyとする辞書にtorch.hann_window関数を使用してハミング窓関数を作成・格納し、データ型などを揃える。
    • ハミング窓関数は信号処理でよく使われる窓関数の一つで、主に信号の端の処理を滑らかにするために使用します。
    • この関数を適応しないと、STFTをする際にwindowの両端で急激に音声が変化することになるため、周波数成分において、アーティファクトが発生します。
  • 入力音声の両端に反射パディングを適応します。
    • torch.stft関数内でも反射paddingが適応されているはずなので、2重でpaddingされているように見える。なぜなんだろうか?
  • 短時間フーリエ変換(STFT)を適用します。
  • 各時間において、振幅スペクトル(パワースペクトルの平方根)を取得します。
  • 以上の処理からスペクトログラムが取得でき、それをreturnします。
STFT

短時間フーリエ変換(STFT)
下記にSTFTの処理を簡単に記載します。

  • 信号を小さなフレームに分割します。n_fftの値で分割します。
    • SBV2での初期値は2048
  • 分割する際に、一部オーバラップさせて分割します。hop_lengthの値でオーバラップします
    • SBV2での初期値は512
  • 分割したフレームに窓関数を適応して、両端を滑らかにします。
  • 分割したフレームに対してフーリエ変換を適応して、周波数成分を得ます。
  • 最後に、得られた周波数成分を並べてスペクトログラムを得ます。

style_bert_vits2/tts_model.pyのTTSModelクラスのinfer_audioメソッド

最後です。(疲れた)
推論時に最初にモジュールとして呼び出す際のTTSModelクラスのinfer_audioメソッドを実装します。

こちらは同じくTTSModelクラスのinferメソッドを参考に実装しました。
下記に実装したコードを示します。

style_bert_vits2/tts_model.py
    def infer_audio(
        self,
        input_audio: NDArray[Any],
        language: Languages = Languages.JP,
        tar_speaker_id: int = 0,
        pitch_scale: float = 1.0,
        intonation_scale: float = 1.0,
        sample_rate: int = 44100,
    ) -> tuple[int, NDArray[Any]]:
        """
        テキストから音声を合成する。

        Args:
            text (str): 読み上げるテキスト
            language (Languages, optional): 言語. Defaults to Languages.JP.
            tar_speaker_id (int, optional): 話者 ID. Defaults to 0.
            pitch_scale (float, optional): ピッチの高さ (1.0 から変更すると若干音質が低下する). Defaults to 1.0.
            intonation_scale (float, optional): 抑揚の平均からの変化幅 (1.0 から変更すると若干音質が低下する). Defaults to 1.0.
            sample_rate (int, optional): 入力音声のサンプリングレート. Defaults to 44100.

        Returns:
            tuple[int, NDArray[Any]]: サンプリングレートと音声データ (16bit PCM)
        """

        logger.info(f"Start generating audio data from audio")
        if language != "JP" and self.hyper_parameters.version.endswith("JP-Extra"):
            raise ValueError(
                "The model is trained with JP-Extra, but the language is not JP"
            )

        if self.__net_g is None:
            self.load()
        assert self.__net_g is not None

        with torch.no_grad():
                audio = infer_audio(
                    audio=input_audio,
                    t_sid=tar_speaker_id,
                    hps=self.hyper_parameters,
                    net_g=self.__net_g,
                    device=self.device,
                    sample_rate=sample_rate,
                )
        
        logger.info("Audio data generated successfully")
        if not (pitch_scale == 1.0 and intonation_scale == 1.0):
            _, audio = adjust_voice(
                fs=self.hyper_parameters.data.sampling_rate,
                wave=audio,
                pitch_scale=pitch_scale,
                intonation_scale=intonation_scale,
            )
        audio = self.__convert_to_16_bit_wav(audio)
        return (self.hyper_parameters.data.sampling_rate, audio)

元となったinferメソッドと同様に、本メソッドの中では下記の処理が行われています。

  • モデルの確認
    • 本メソッドはJP-Extraと呼ばれる日本語特化のモデルを前提としている(他にも英語や中国語を話せるモデルはあるが、それは別の場所で定義されている)ため、それ以外のモデルを弾いている。
  • モデルのロード
    • TTSに必要なnet_gのみのロード。
  • モデルからVCの推論
    • style_bert_vits2/models/infer.pyのinfer_audio関数を呼び出す
  • ピッチ、抑揚調整。
  • 16 bit wavに変換。

これ以上説明することはありません。

VC推論用コード

ここまでで必要なモジュールは作成できたため、VC実施用のコードを提示します。
SBV2リポジトリの直下においてください。

voice_conversion_sample.py

import csv
import re
import warnings
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from tqdm import tqdm

from config import get_path_config
from style_bert_vits2.logging import logger
from style_bert_vits2.tts_model import TTSModel
import sounddevice as sd

import numpy as np
from scipy.io import wavfile
from scipy.io.wavfile import write

warnings.filterwarnings("ignore")

path_config = get_path_config()

#"model_assets"フォルダ内の学習済み重みが格納されているフォルダ名を指定
model_name = "sample"
#GPUで動かす場合は"cuda"を指定
device = "cpu"
#net_gの重みを読み込む。
weight_name = "G_xxxxx.pth"
#変換前の入力音声
audio_file = "inputs.wav"

model_path = path_config.assets_root / model_name
model_file = model_path / weight_name

def get_model(model_file: Path):
    return TTSModel(
        model_path=model_file,
        config_path=model_path/ "config.json",
        style_vec_path=model_path/ "style_vectors.npy",
        device=device,
    )

model = get_model(model_file)

# ファイルパスを指定
file_path = model_path / audio_file
# wavファイルを読み込み
sample_rate, record_audio = wavfile.read(file_path)

# 推論
sr, audio = model.infer_audio(record_audio,sample_rate = sample_rate)

#生成された音声の再生
sd.play(audio, sr)
sd.wait()

# numpyの音声データをWAVファイルとして保存
output_filename = model_path / 'vc_output.wav'
write(output_filename, sr, audio)

学習済み重みデータは"model_assets"フォルダに入っていることを前提とし、下記のファイルが必要です。

  • net_gの学習済み重み:G_xxxxx.pth
    • よく使う.safetensorファイルではないことに注意してください。
      • safetensorファイルは開発者様のご厚意でファイルサイズを小さくするために、TTSでは不要なPosterior Encoderモジュールの重みが削除されて保存されていますが、VCではこちらのモジュールを利用します。
      • したがって、削除される前の重みファイルであるG_xxxxx.pthをご利用ください。
    • xxxの部分はstep数が入っています
    • G_xxxxx.pthファイルは学習後、Data/{model_name}/modelフォルダに格納されています。
    • D_xxxxx.pthやWD_xxxx.pth、DUR_xxxx.pthは、推論時には利用しないモジュールの重みファイルのため無視して大丈夫です。
  • コンフィグファイル:config.json
  • スタイルベクトル:style_vectors.npy

上記のコードは下記のように処理をします。

  • get_model関数でSBV2のモデルを読み込みます
  • 変換前音声であるinputs.wavを読み込み、音声データrecord_audiosample rateを取得します。
    • 音声ファイルは重みと同じ場所に置きます
  • 'model.infer_audio'メソッドに音声データrecord_audiosample rateを入力し、VC変換後の音声であるaudioを取得します
  • 取得した音声を再生・保存します
    • 出力音声は重みと同じ場所に保存されます。

動作確認

SBV2の学習

SBV2の学習は、下記で音声データを配布してくださっている、「あみたろ」さんの音声データのうち、ライブ配信データの「04_対談・元気にパズル」の音声データを利用して学習させていただきました。
非常に質の高い音声データをアップロードいただき、誠にありがとうございます。
https://amitaro.net/

学習方法としては下記の手順で実施しました

VCの推論結果

上記で学習されたSBV2モデルに対して、VOICEBOXの「四国めたん」の音声を保存したwavファイルを入力しました。

比較として
学習したあみたろさんの音声は下記から確認できます。
https://amitaro.net/voice/corpus-list/ita/

また、入力した「四国めたん」の音声は下記になります。
https://youtu.be/ifvsarJjvsw

出力結果は下記になりました。
https://youtu.be/rCFPNYC3DO0

出力音声を確認いただいた通り、結果はあまりうまくいきませんでした。
次の章で考察と改善案を記載します。

考察

結果に対しての考察

本来VCで達成したいのは
「四国めたんの音声」ー「四国めたんの特徴量」+「あみたろさんの特徴量」=「あみたろさんの音声」
です。

一方、今回の音声を確認すると、
「四国めたん」と「あみたろ」さんの両方の音声の中間のような音声になっていることがわかります。

したがって、上記の式の
+「あみたろさんの特徴量」は機能しているが、
ー「四国めたんの特徴量」が機能していないように見えています。

そこで、改めて、実装したVCのデータフローを確認しました。下記に提示します。

style_bert_vits2/models/models_jp_extra.py
    def infer_audio(
        self,
        y:torch.Tensor,
        y_lengths:torch.Tensor,
        t_sid:torch.Tensor,
        max_len: Optional[int] = None,

    ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, tuple[torch.Tensor, ...]]:

        self.ref_enc = ReferenceEncoder(self.spec_channels, self.gin_channels)        

        #音声の特徴量を取得する
        #学習済み音声に関しては、登録済みの話者idを利用する
        g = self.emb_g(t_sid).unsqueeze(-1)  # [b, h, 1]

        #入力音声の話者idはないので、音声から話者特徴を抽出する
        g_ref = self.ref_enc(y.transpose(1, 2)).unsqueeze(-1)

        #posterior encoder 
        z, m_q, logs_q, y_mask = self.enc_q(y, y_lengths, g=g_ref)
        #flowに変換前の話者情報を入れて、話者情報を潜在表現から取り除く
        z_p = self.flow(z, y_mask, g=g_ref)
        #逆順にflowに入力し、変換後の話者情報を付与する
        z = self.flow(z_p, y_mask, g=g, reverse=True)

        o = self.dec((z * y_mask)[:, :, :max_len], g=g)

        return o, y_mask, (z, z_p)

入力音声の話者特徴量を抽出する箇所は下記です

self.ref_enc = ReferenceEncoder(self.spec_channels, self.gin_channels)
#入力音声の話者idはないので、音声から話者特徴を抽出する
g_ref = self.ref_enc(y.transpose(1, 2)).unsqueeze(-1)

抽出した話者特徴量を取り除く部分は下記です。
(gというのが話者特徴量による条件づけです。)

#flowに変換前の話者情報を入れて、話者情報を潜在表現から取り除く
z_p = self.flow(z, y_mask, g=g_ref)

おそらく、このどちらかの処理に誤りがあるはずと思っていろいろ調査を行いました。
その中で学習時に利用するforwardメソッドを確認したところ、下記の記述がありました。

if self.n_speakers > 0:
            g = self.emb_g(sid).unsqueeze(-1)  # [b, h, 1] #話者IDの埋め込みの取得
        else:
            g = self.ref_enc(y.transpose(1, 2)).unsqueeze(-1)

あれ?もしかしてself.ref_encって一度も学習されていない?

と思い、他のコードなども調査しましたが、このモジュールが学習されている形跡がありませんでした・・・
当初の想定では、self.emb_g(sid)と同じような出力になるようにself.ref_enc(y.transpose(1, 2))が学習されていて、入力音声から話者特徴量を抽出できると考えていましたが、それが間違いだったようです。

したがって、VCを達成するにはなんらかの方法で話者特徴量を取得する必要があります。

そして、上記の調査をしている際にもう一つ(自分の間違いに)気づきました

該当部分は下記です。
場所はforwardメソッドです。

#text encoder
x, m_p, logs_p, x_mask = self.enc_p(
    x, x_lengths, tone, language, bert, style_vec, g=g
)

上記はTTSの時に利用するText Encoderです。
テキスト情報(正確には音素、アクセント、bert特徴量など)から、Flowに入力する潜在表現(の分布パラメータ)を出力するモジュールです。

私はVITSの解説記事を見て、Text Encoderからは、テキスト情報から得られる情報のみで潜在表現が生成され、FlowDecoderにおいて、潜在表現に話者特有の情報(声色や抑揚など)が付与されて、最終的に音声波形が生成されると思っていました。

しかし上記のコードを見ると
Text Encoderが話者情報で条件づけられている!?
ことがわかります・・・
(gというのが話者特徴量による条件づけです。)

そこでVITS2の解説記事を改めてちゃんと読んだところ下記のような記述がありました。

  1. Speaker-Conditioned Text Encoder
    特定のスピーカーの独特な発音やイントネーションのような一部の特徴が、各スピーカーの音声特性の表現に大きく影響を与えるが、入力テキストには含まれていないことを考慮し、各スピーカーのさまざまな音声特性をよりよく模倣するために、スピーカー情報で条件付けられたテキストエンコーダを設計します(直訳)
    具体的な手法は Text Encoderの Transformer Block 3層目に話者情報を与える。

したがって、Text Encoderにも話者情報が埋め込まれてしまっていることがわかります。
つまり私の想定ではTTSでは

テキスト情報
↓
Text Encoder(話者情報なし潜在表現)
↓
Flow(話者情報追加)
↓
Decoder(話者情報追加)
↓
完全な音声波形

となっていると思っており、FlowDecoderさえあれば、話者情報を完全に復元できると思っていましたが、
実際には、

テキスト情報
↓
Text Encoder(話者情報あり潜在表現)
↓
Flow(話者情報追加)
↓
Decoder(話者情報追加)
↓
完全な音声波形

だったため、Text Encoderを使わないVCでは完全に話者特徴量を再現できないことがわかりました。

まとめると、

  • self.ref_encが学習されていないため、入力音声の話者特徴量を取得できない。
    • 入力音声から話者特徴量を取り除くことができない
  • VCで使わないText Encoderモジュールにも話者特徴量が条件付けられている。
    • Text Encoderなしでは完全に話者特徴量を再現することができない。

改善案

では、上手くいかなかった理由がわかったので、上手くいくように改善してみます。

まずは1つめの原因について考えます。

  • self.ref_encが学習されていないため、入力音声の話者特徴量を取得できない。
    • 入力音声から話者特徴量を取り除くことができない

ここでは変換前の音声の話者特徴量を取得することができれば上記の課題はクリアになります。
現在検討している話者特徴量の取得方法としては、
SBV2のself.ref_enc以外のモジュールの重みを固定して、入力音声とその文字起こしテキストから出力波形を再構成する学習を行うことで、入力音声にfitした話者特徴量が取得できるはずなので、それを用いてVCを行えば、完全に入力音声から話者特徴量を消去できるのではと思っています。

一旦、上記の仮説が正しいことを簡単に確かめるために、モデルを2話者で学習させ、片方を変換前話者、片方を変換後話者としてモデルを学習させることで、変換前と変換後の話者特徴量を取得しようと思います。

続いて2つめの原因について考えます。

  • VCで使わないText Encoderモジュールにも話者特徴量が条件付けられている。
    • Text Encoderなしでは完全に話者特徴量を再現することができない。

これは、もうText Encoderの話者重み付けモジュールを廃止することを考えています。
(なるべくSVB2のTTSモデルを壊さないように実装したかったですが・・・。実験ということで一旦廃止して進めてみようと思います)

当然、Text Encoderの話者重み付けモジュールを廃止するということは、事前学習重みが崩壊することになりますが、事前学習を1からできるほど計算リソースも学習データも持っていないので、一旦上記のデメリットは無視することにします。

したがって、改善策として
Text Encoderの話者重み付けモジュールを廃止した状態で、片方を変換前話者、片方を変換後話者として2話者でモデルを学習させようと思います。

まとめ

上記の改善策も本記事にまとめたかったのですが、記事の上限文字数に引っかかりそうだったので、続きは後編としたいと思います。
後編はすぐに更新すると思いますので、少々お待ちいただけますと幸いです。
申し訳ございません。実験に手こずっていてもう少し時間がかかりそうです。。。

それでは、ここまで読んでいただきありがとうございます!

Discussion