skyrimSE/AEでMODの字幕やプレイヤーの選択肢に音声を付与する

StyleBertVits2などを使って、SkyrimAEの字幕があるが音声がないMODに音声をつける。DBVO用のボイス作成についても記述しておく。

使うもの

  • StyleBertVits2: 学習と推論で使う。学習はWebUIで、推論はCLIで直接TTSModelクラスを扱う。
  • LazyVoiceFinder: 学習用ボイスの抽出に使う。
  • UnFuzer cpp edition: LazyVoiceFinderで抽出したfuzファイルからwavファイルを取り出す。逆にwavとlipファイルをfuzにまとめることもできる。SkyrimLE向けだけどそのまま使える。旧バージョンは、AoutHotKeyで作られていたらしい。
  • xVASynth Dialogue Export: xVASynthは使わないけどセリフの抽出に使えるSSEEditのプラグイン。リンク先のページの下の方にxVASynth Dialogue Export_v3.2.zipがある。
  • FaceFXWrapper: CKでないとできないlipファイルの生成を行ってくれる。
  • DBVO Japanese Girl Voice Pack: DBVO用のボイスパック、DBVOについてはあまり触れない。Optional filesにあるDBVO JSON Converterをここではメインに使う。メインファイルの方も普段使いしていて、それのフォルダ内に作成したボイスを自作MODで追加する感じで使う。メインファイルのMODの構造が参考になる。
  • xTranslator: DBVO JSON Converterの入力に使うxTranslator用XMLファイルの生成に使う。

下の2つは、DBVO用に使う。

xVASynth自体は日本語でうまく使えるか不明だったので、使わないでおいた。

参考サイトなどで使い方を掴む

ツールの使い方などは参考サイトなどで分かった。なので、参考サイトを見ればわかることや各ソフトの配布元を見れば説明されているようなことは恐らく書かない。

LazyVoiceFinder

http://afternun.mydns.jp/blog/skyrimspecialedition/?p=8522を最初に見かけた。へえと思ったのでこれの通りにしようと思ったが、MODによってはVoiceTypeの問題があるようで、ストレージ消費が大きくなる場合があるとのこと。このサイトでは、LazyVoiceFinderで学習用データの抽出ができるということを学んだ。PHPスクリプトもリンクされており、FaceFXWrapperの存在も確認できた。

LazyVoiceFinder自体もMODページに日本語で解説があるのでわかりやすい。ESPファイルを開いて、選択して抽出などできる。

xVASynth Dialogue Export

次にhttps://www.loverslab.com/topic/168547-doublecheeseburgers-xvasynth-based-mod-voicification-thread/?do=findComment&comment=3415080を見つけた。このページ自体は英語版の生成音声MODのフォーラムから辿った。大体の流れが見えてわかりやすい。このサイトでは、SSEEditのプラグインxVASynth Dialogue Exportで、字幕というかテキストデータとVoiceType、fuzファイルの配置先などが一括してcsvとして出力できることが分かった。モデル名とVoiceTypeの関連付けには注意する。

pasスクリプトの設定部分の書き換えはおそらく必要になるのでそこにも注意する。主にout_pathの指定について必要だろう。推奨されているとおりMO2のmodsフォルダを接頭辞にする感じのパスにすると、作業後にMO2で有効にするだけなので楽になる。取り出すVoiceTypeの取捨選択できるが、pythonでCSVを読み取るときに制御するのでそのままにした。

CSVファイルは、日本語が文字化けするので、保存のし直しなど必要な場合はすること。ファイルサイズがでかいとメモ帳では開けないかもしれない。VSCodeを使った。

FaceFXWrapper

GitHubのページのリリースのところから用意した。READMEを読むとゲーム内の特定の場所に配置してねと書いてあるが、ひとまずそれを見なかったことにして、ほかの場所に置いて試した結果、問題なさそうだった。なお、FonixData.cdfも用意する必要がある。自分の場合ではCreationKitを入れたときに入ったと思われる、steamapps\common\Skyrim Special Edition\Tools\LipGen\LipGenerator\FonixData.cdfにあった。重要ではないが同じフォルダに、LipGenerator.exeという同じような目的で使うファイルがあるが、使用方法がわからないので放置。きっとCK内で使えるのだろう。

推論の際に、lipファイルの生成も行おうとしたが、なぜか途中でフリーズしてたので、とりあえず後回しにしておいた。なお、日本語対応していないようなので、テキスト引数に日本語テキストを渡しても意味がないように思われる。空文字指定で大丈夫らしいことが、LazyVoiceFinderの時の参考サイトにもあったので、後でまとめてもう一度生成すればいいかということになった。

一括でwavを変換することにし、Text引数には空文字を指定し、念のため実行ごとに0.5秒待つようにした。試したところ0.1秒間隔でも大丈夫そうだ。

StyleBertVits2

これは以前も興味本位で使った覚えがあり、ローカルにもあったのだが、うっかりアップデート用のbatをダブルクリックしてしまった結果、起動しなくなってしまった。なので何とか修正する工程から書かなければならないが、ここに書くのは違う気がするので、別で書くかもしれない。requirements.txtのバージョン指定を書いておいてくれれば、こんなことになっても比較的大丈夫なのに…。

使えるようになった状態なら、WebUIは正直説明がとても丁寧なのでいうことはない。CLIで使うならサーバーを起動してRESTAPIで推論できるようだが、わざわざpythonで書かれているのだから、pythonからHTTPリクエストを飛ばすのもアレな気がした。

ソースコードを眺めると、Style-Bert-VITS2\style_bert_vits2\tts_model.pyに、TTSModelというクラスがあり、これを使えばCLIから推論ができそうな気がした。渡す引数はWebUIのを参考にできる。スクリプトmyinference.pyを書いた。これは、xVASynth Dialogue Exportが出力したCSVを読み取り、game_id, voice_id, text, vocoder, out_path, pacingの6つのフィールドがあるので、今回はとりあえずmalenordをやってみようということで、voice_idがsk_malenordになっているものだけ推論するようにした。out_pathにwavファイルを出力する。

DBVO JSON Converter

これはNexusModsからDLでき、使い方も明快で、実質pythonを実行しているだけ。xVASynth Dialogue Exportとは違い、訳文をキー、原文を値としているJsonファイルを出力する。MO2上でこれを空のMODのDragonbornVoiceOver/locale_packs/jaに移せばいい。訳文から音声を生成するので、訳文をお好みで調整する。変数含んでいるとひどいことになりそうなのでその場合は特に調整した方がいい。

DBVOの場合のwavファイルの出力先は、Sound\DBVO\<使っているVoicePack名>\になる。自分の環境では、Sound\DBVO\Nanami

xTranslator

DBVO JSON Converterの入力となる翻訳用XMLファイルがない場合、オリジナルのESPと翻訳済みESPがあればxTranslatorで翻訳用XMLファイルが作成できる。xTranslatorを開いて、オリジナルのESPを開いて、上のメニューのツール > ESPを開く(比較)をクリックして翻訳済みESPを開くようにし、ダイアログで上書き設定を全て上書きでOKボタンをクリックすれば、一気に翻訳文が並ぶので、その状態で、上のメニューのファイル > 翻訳ファイルのエクスポート > XMLファイルをクリックすれば保存できる。

作業の流れ

忘れたとき用の手順の記録

TTSモデルの用意

LazyVoiceFinderなどで音声を用意して、StyleBertVits2で学習する。学習して作成されたモデルは、StyleBertVits2のmodel_assetsフォルダに指定したモデル名で入る。

NPCの字幕の場合

  1. 音声付けしたいESPをSSEEditで開く。
  2. 左側ペインで対象ESPを見つけ、右クリックメニューからのApply Script...をクリック。
  3. ダイアログが開くので、xVASynth Dialogue Exportのスクリプトを選択して、変更すべき変数を確認しつつ、OKをクリックして終了まで待つ。
  4. 設定によっては、SSEEditのEdit Scripts\xVASynth Dialogue Export\に出力があるので、そのCSVファイルパスを使う。
  5. 注意: CSVファイルの文字コードがShift-JISになっているかもしれないので、VSCodeで右下からutf8に再エンコードして保存する。
  6. 自作スクリプトmyinference.pyの変数の設定を確認し、仮想環境の有効化を確認したら、スクリプトを実行する。wavが作成される。
  7. 自作スクリプトmylipgen.pyの変数の設定を確認し、スクリプトを実行する。lipファイルが作成される。
  8. UnFuzer cpp editionでwavとlipをRefuzする。
  9. MO2で対象のMODを有効化して、作業終了。

DBVOの場合

翻訳用XMLファイルがないときは、上述のようにxTranslatorで作成する。

  1. 翻訳用XMLをDBVO JSON ConverterフォルダのxTranslator (XML to JSON)\inputに配置して、Run_Convert.batを実行する。
  2. outputフォルダにできる、Jsonファイルを、MO2で作った空のMODにDragonbornVoiceOver\locale_packs\jaを作り、この中に入れる。
  3. 必要なら、Jsonファイルを調整する。
  4. 自作スクリプトmydbvoinference.pyの変数の設定を確認して、仮想環境の有効化を確認したら、スクリプトを実行する。wavが作成される。
  5. 自作スクリプトmylipgen.pyの変数の設定を確認して、スクリプトを実行する。lipファイルが作成される。
  6. UnFuzer cpp editionでwavとlipをRefuzする。
  7. MO2で対象のMODを有効化して、作業終了。

おわり

空のMODに入れるのは、MODのアップデートで上書きされて消滅するのを防ぐため。明快にもなる。

生成AIにも尋ねたりするが、調査は甘いようで、lipファイルの情報はCKかxVASynthしかなかった。FaceFXWrapperも教えてよ、もう。

DBVO JSON Converterは、プレイヤーの文字抽出、xVASynth Dialogue Exportは、NPCの文字抽出という風に使い分ければいいだろう。

一度Refuzするときに、失敗することがあった。wavファイルが消滅したので、再生成して、unfuzerをcpp editionのものにして、再トライしたら成功した。wavが悪いのか、lipが悪いのか、古いunfuzerが悪いのかはわからない。

Seed-VCなどのボイス変換を利用すると、時間がかかるモデルの生成の工程を飛ばせるかもしれないと思った。結局ボイスは必要だが、この場合は自分の声で行くという最終手段が使える。

スクリプト中のパスの指定の仕方に一貫性がない。許して。情報がまばらになってしまっている気もして申し訳ない。

以降は使ったスクリプトを掲載しておく。

使用したスクリプト

スクリプトはすべて、StyleBertVits2のルートディレクトリに配置している。

lipファイル生成用スクリプト、mylipgen.py

"""
myinferenece.pyで生成したwavファイルから、対応するlipファイルを生成するためのスクリプト。
"""

from pathlib import Path
import subprocess
import time


FaceFXWrapper_PATH = Path(
    r"<環境依存>\FaceFXWrapper.0.41\Tools\Audio\FaceFXWrapper.exe"
)
FONIX_DATA_CDF_PATH = Path(
    r"<環境依存>\FaceFXWrapper.0.41\Tools\Audio\Processing\FonixData.cdf"
)

# MO2のMODフォルダに直接配置すると楽なようだ。LLのサイトの指示より。なお、meta.iniがなくてもMO2は認識してくれる様子
# このフォルダにwavファイルがある。必要に応じてフォルダ名を変える
TARGET_FOLDER = Path(
    r"<PATH_TO_MO2>\mods\<MY_MOD_NAME>\sound\Voice\<ESP_TO_BE_VOICED>.esp\MaleNord"
)


def generate_lip_file(audio_path: Path, lip_sync_path: Path):
    # FaceFXWrapperをコマンドラインで呼び出してlipファイルを生成する
    TYPE = "Skyrim"
    LANG = "USEnglish"
    command = [
        str(FaceFXWrapper_PATH),
        TYPE,
        LANG,
        FONIX_DATA_CDF_PATH,
        str(audio_path),  # wavファイルのパス
        str(lip_sync_path), # 作成されるlipファイルのパス
        "",  # 空文字にするとwavだけで作ってくれる
    ]
    try:
        _ = subprocess.run(command, check=True, capture_output=True, text=True)
    except subprocess.CalledProcessError as e:
        print(f"Error running FaceFXWrapper: {e.stderr}")


if __name__ == "__main__":
    # TARGET_FOLDER内の全てのwavファイルに対してlipファイルを生成する
    for audio_file in TARGET_FOLDER.glob("*.wav"):
        lip_sync_file = audio_file.with_suffix(".lip")
        generate_lip_file(audio_file, lip_sync_file)
        time.sleep(
            0.5
        )  # 連続で呼び出すとエラーになることがあるかもしれないので、少し待つ

wav作成用スクリプト、myinference.py

"""
このコードは、Style-Bert-VITS2のTTSモデルを直接呼び出して音声を生成するためのスクリプト
同時にlipファイルの生成も行う

CSV_FILEはSSEEditのApplyAcript...からでxVASynth Dialogue Exportで出力されたCSVファイルのパスを指定する。
"""

import csv
from pathlib import Path
import subprocess
import torch
from scipy.io import wavfile
from style_bert_vits2.tts_model import TTSModel, Languages

# ここに書くのもあれだが、CSVファイルは文字コードを変えて読み直す必要がある。
CSV_FILE = r"<環境依存>\xEdit.4.1.5f\SSEEdit 4.1.5f_for_skyrimSE\Edit Scripts\xVASynth Dialogue Export\<ESP名>-1.csv"
# モデルファイル (.safetensors) と設定ファイル (config.json) とStyleVecのパスを指定する
MODEL_PATH = Path("model_assets/male_nord/male_nord_e100_s8106.safetensors")
MODEL_CONFIG_PATH = Path("model_assets/male_nord/config.json")
MODEL_STYLE_VEC_PATH = Path("model_assets/male_nord/style_vectors.npy")
TARGET_VOICE_ID = "sk_malenord"  # CSV内のvoice_idで、推論したいものを指定する

FaceFXWrapper_PATH = Path(
    r"<環境依存>\FaceFXWrapper.0.41\Tools\Audio\FaceFXWrapper.exe"
)
FONIX_DATA_CDF_PATH = Path(
    r"<環境依存>\FaceFXWrapper.0.41\Tools\Audio\Processing\FonixData.cdf"
)


class TTSInference:
    """TTSModelを保持し、CSVの読み取り、推論、ファイル保存も行う"""

    def __init__(self):
        device = "cuda" if torch.cuda.is_available() else "cpu"
        print(f"Using device: {device}")

        # TTSModelクラスを直接インスタンス化する
        model = TTSModel(
            model_path=MODEL_PATH,
            config_path=MODEL_CONFIG_PATH,
            device=device,
            style_vec_path=MODEL_STYLE_VEC_PATH,
        )
        self.model = model

    def infer_and_save(self, text: str, output_path: Path):
        # inferメソッドの引数はGradioのUIとほぼ共通みたい
        # sr: サンプリングレート, audio: 音声データ (numpy array)
        sr, audio = self.model.infer(
            text=text,
            language=Languages.JP,
            sdp_ratio=0.2,
            line_split=True,
            split_interval=0.5,
            assist_text=None,
            assist_text_weight=0.7,
            style="Neutral",
            style_weight=1.0,
        )
        wavfile.write(output_path, sr, audio)

    def process_row(self, row: dict[str, str]):
        """
        1行分の処理。必要情報を取り出し、渡す
        """
        # print(f"Processing row with voice_id: {row['voice_id']}, text: {row['text']}")
        # 出力パスを取得
        output_path = Path(row["out_path"])
        # テキストを取得
        text = row["text"]
        # 出力パスの親ディレクトリを準備する
        output_path.parent.mkdir(parents=True, exist_ok=True)
        # 推論と保存を実行
        self.infer_and_save(text, output_path)

    def infer_by_voice_id_from_csv(self):
        """
        CSVから1行ずつ処理するが、特定voice_id以外は無視する。
        """
        with open(CSV_FILE, mode="r", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            count = 0
            for row in reader:
                if row["voice_id"] == TARGET_VOICE_ID:
                    self.process_row(row)
                    count += 1
                    # if count > 5:  # 最初の5行だけ表示してみる
                    #     break
        print(f"Total rows with voice_id '{TARGET_VOICE_ID}': {count}")


if __name__ == "__main__":
    tts_inference = TTSInference()
    tts_inference.infer_by_voice_id_from_csv()

DBVO用のwav作成用スクリプト、mydbvoinference.py

"""
Style-Bert-VITS2のTTSモデルを直接呼び出して音声を生成するためのスクリプト
DBVO用なので、myinferece.pyと比べて、csvを読み取るのではなく、DBVO Voice converterが出力するjsonを読み取る。

出力ファイル名規則が違う点も注意。英文そのものがファイル名になる。
"""

import json
from pathlib import Path
import torch
from scipy.io import wavfile
from style_bert_vits2.tts_model import TTSModel, Languages

# DBVO Json converterが出力するjsonファイルのパスと、出力先フォルダのパスのタプル形式のリストで指定する。
JSON_OUTPUT_LIST = [
    (
        r"<MO2のmodsフォルダパス>\<MOD名>\DragonbornVoiceOver\locale_packs\ja\<ESP名>_english_japanese.json",
        Path(
            r"<MO2のmodsフォルダパス>\<MOD名>\Sound\DBVO\<使っているvoicepack名>"
        ),
    ),
    # 複数ある場合は追加する
]
# モデルファイル (.safetensors) と設定ファイル (config.json) とStyleVecのパスを指定してください
MODEL_PATH = Path(r"model_assets\<モデル名>\<モデル名など>.safetensors")
MODEL_CONFIG_PATH = Path(r"model_assets\<モデル名>\config.json")
MODEL_STYLE_VEC_PATH = Path(r"model_assets\<モデル名>\style_vectors.npy")


class TTSInference:
    """TTSModelを保持し、Jsonの読み取り、推論、ファイル保存も行う"""

    def __init__(self):
        device = "cuda" if torch.cuda.is_available() else "cpu"
        print(f"Using device: {device}")

        model = TTSModel(
            model_path=MODEL_PATH,
            config_path=MODEL_CONFIG_PATH,
            device=device,
            style_vec_path=MODEL_STYLE_VEC_PATH,
        )
        self.model = model

    def infer_and_save(self, text: str, output_path: Path):
        # sr: サンプリングレート, audio: 音声データ (numpy array)
        sr, audio = self.model.infer(
            text=text,
            language=Languages.JP,
            sdp_ratio=0.2,
            line_split=True,
            split_interval=0.5,
            assist_text=None,
            assist_text_weight=0.7,
            style="Neutral",
            style_weight=1.0,
        )
        # 4. ファイルの保存
        wavfile.write(output_path, sr, audio)
        # print(f"Successfully saved to {output_path}")

    def run(self):
        # JSON_OUTPUT_LISTに基づいて、jsonファイルを読み取り、推論して、wavファイルを保存する
        for json_file, output_folder in JSON_OUTPUT_LIST:
            # 出力先が存在しない場合は作成する
            output_folder.mkdir(parents=True, exist_ok=True)
            with open(json_file, mode="r", encoding="utf-8") as f:
                data = json.load(f)
                count = 0
                # "訳文":"原文"が連想配列形式で入っている
                for translated_text, original_text in data.items():
                    # translated_textが[で始まる文字列と、_だけの文字列、空の文字列はスキップする
                    if (
                        translated_text.startswith("[")
                        or translated_text == "_"
                        or translated_text == ""
                        or translated_text == "..."
                        or translated_text == "…"
                        # TODO: さらなる追加は正規表現を使う予定
                    ):
                        continue
                    # 出力パスを生成する(例: OUTPUT_FOLDER/translated_text.wav)
                    output_path = output_folder / f"{original_text}.wav"
                    # 出力パスの親ディレクトリを準備する
                    output_path.parent.mkdir(parents=True, exist_ok=True)
                    # 推論と保存を実行
                    self.infer_and_save(translated_text, output_path)
                    count += 1
                print(f"Processed {count} entries from JSON.")


if __name__ == "__main__":
    tts_inference = TTSInference()
    tts_inference.run()

以上です。

タイトルとURLをコピーしました