Dragonborn Voice Over

Modの紹介

プレイヤーにセリフを追加します。Mathiew Mayさんの作品です。

Nexusで見る

導入手順

本体とボイスパックという構成になっています。

Dragonborn Voice Overが本体のことで、プログラム、MCM、バニラの辞書が含まれています。

Modを導入してUIを変更している場合のためのパッチがひと通り用意されています。合わせて導入します。

ボイスパックは他の方々が配布されているものを別途入手します。

アセットのロード順(MO2の左ペイン)ですが、本体とボイスパックのロード順はどこでもよく、パッチの優先度だけを最高にします。よくわからなかったら全部の優先度を最高にすればいいです。

ゲームを起動したらMCMを開き、言語を選び、使うボイスパックを選びます。

あとはバニラの会話を発生させるとプレイヤーが話すはずです。

ボイスパック

大きなもので3つあるようです。

bellaと2Bは英語音声です。カバーしている範囲がとても広く、バニラのほぼすべてのセリフに加えて、結構な数のModに対応しています。バニラは本体にある辞書で大丈夫ですが、Modは辞書がないのでセリフを日本語に翻訳している場合は話しません。

DBVO Japanese Voice Pack Proof of Conceptの方は日本語音声です。バニラのみカバーしています。ただし、長すぎる文とゲーム内で変化するセリフには対応していないようです。

動作の仕組み

データの構造から動作の仕組みを読み解いてみました。

dialoguemenu.swf

Fallout 4には会話のラインにプレイヤーのセリフがはじめから用意されています。ですから会話の際はプレイヤーが話すわけです。Skyrimには会話のラインにプレイヤーのセリフが存在しないため、ゲーム内で動的に生成して差し込むことで強引に話をさせているようです。

生成のタイミングは会話の選択肢を選んだ直後なので、会話の選択肢を実装しているdialoguemenu.swfにセリフ開始の処理を付け加えています。

本体に含まれているdialoguemenu.swfはバニラのdialoguemenu.swfに手を加えたものなので、選択肢の部分がバニラになります。当然ですが他のModで上書きされてはいけません。

ModでUIをかえている場合は、それぞれのModのdialoguemenu.swfにセリフ開始の処理を加えたものがパッチとしてひと通り用意されていますから、それを使えばModのUIを維持しつつセリフも発生するわけです。

セリフとボイスの関係

本体は英語用に開発されています。セリフが英語文章で、ボイスパックも英語音声が前提です。

処理の流れは以下のようになっています。

  • 会話で選択肢を選ぶ。
  • 選択肢の文章を取り出す。
  • 取り出した文章に一致するボイスファイルを探す。
  • ボイス(音声データと口パクデータ)を再生する。

ボイスファイルが見つからなかった場合は再生されません。ですからバニラの会話用のボイスパックしか導入していない場合は、Modで追加されたセリフは再生されないわけです。

それから、選択肢の文章を書き換えている場合は、やはりボイスファイルが見つからなくなってしまうため、セリフが再生されません。

ちなみにセリフの再生時間ですが、文章のスペースの数を元に単語の数を求めて決定されているように思います。日本語はスペースがないので1単語だとみなされ、すぐに相手のセリフが始まってしまいます。

セリフが英語以外の場合

英語以外の言語にも対応しています。例えば日本語の場合は、MCMで言語から日本語を選択すれば日本語モードで動作するようになります。

処理の流れは以下のようにかわります。

  • 会話で選択肢を選ぶ。
  • 選択肢の文章を取り出す。
  • 取り出した文章を辞書を使って日本語から英語に翻訳する。
  • 翻訳した英語の文章に一致するボイスファイルを探す。
  • ボイス(音声データと口パクデータ)を再生する。

この日本語文章から英語文章に翻訳するための辞書が必要になってきます。本体にはバニラの範囲の辞書が同梱されています。ですからバニラのセリフであれば再生されます。

この辞書はおそらくはSteamでSkyrim日本語版をインストールした際のデータを元に作成されています。正確にはbsaに含まれるstringファイルの各国語のデータを元にされていると思われます。ですから、辞書の内容とゲームで使われている選択肢が違うと翻訳に失敗するので、セリフが再生されなくなってしまいます。

例えばImprove Japanese Translation SEを入れて書き換えていたり、USSEP等で翻訳にゆらぎがある場合などです。

tktk氏の英語版日本語化をかなり昔に行って訳が古くなっている場合もずれるので注意です。私の環境では3点リーダがピリオド3個だったり、「何だ」が「なんだ」になっていました。

セリフに日本語と英語がまざっている場合

DBVOを日本語モードにするとセリフを常に翻訳を通すようになるらしく、セリフが英語文章のままの場合は翻訳辞書にないため話さなくなります。

手っ取り早く対応するために英英辞書を作りました。DBVO本体がサポートしてくれるのが一番なのですが、現状無理なので仕方がないです。日英辞書のjsonファイルのkey、valueとなっているところを、keyをvalueで上書きしてvalue、valueとするだけです。

これを行うPHPスクリプトです。カレントディレクトリにあるjsonファイルをすべて変換します。

create_en.php (php)

<?php
$basedir = getcwd();

define('BASE_DIR', $basedir);

parse_dir('');

function parse_dir($path)
{
    $basedir = BASE_DIR . DIRECTORY_SEPARATOR . $path;

    $dh = opendir($basedir);

    if ( !$dh ) {
        throw new Exception('could not open dir - ' . $basedir);
    }

    while ( $file = readdir($dh) ) {
        if ( ($file == '.') || ($file == '..') ) {
            continue;
        }

        if ( strpos($file, '.json') === false ) {
            continue;
        }

        if ( strpos($file, '_en.json') !== false ) {
            continue;
        }

        $fullpath = $basedir . DIRECTORY_SEPARATOR . $file;

        if ( is_dir($fullpath) ) {
            continue;
        }

        echo $file . PHP_EOL;

        create_en($fullpath);
    }

    closedir($dh);
}

function create_en($path)
{
    $fp = fopen($path, 'r');

    if ( !$fp ) {
        throw new Exception('could not open file - ' . $path);
    }

    try {
        $path_new = str_replace('.json', '_en.json', $path);

        $fp_new = fopen($path_new, 'w');

        if ( !$fp_new ) {
            throw new Exception('could not open file - ' . $path_new);
        }

        fputs($fp_new, '{' . PHP_EOL);

        while ( !feof($fp) ) {
            $in_line = fgets($fp);
            $in_line = trim($in_line);

            if ( strlen($in_line) == 0 ) {
                continue;
            }

            $cols = explode(': ', $in_line);

            if ( count($cols) != 2 ) {
                continue;
            }

            $key = preg_replace('/,$/', '', $cols[1]);
            $value = $cols[1];
            $out_line = sprintf(' %s: %s', $key, $value);

            fputs($fp_new, $out_line . PHP_EOL);
        }

        fputs($fp_new, '}' . PHP_EOL);

        fclose($fp_new);
    } catch (Exception $e) {
    }

    fclose($fp);
}

UIとゲームの通信

UIで選択肢を選ぶと、UI側でSKSEのSendModEventを使ってイベントを送っています。

Dragonborn Voice OverのプログラムはDLL形式なので詳細は不明ですが、ModEventを受け取って再生しているのは間違いないでしょう。

ModEventは任意のModからRegisterすることで受け取れますので、Papyrusでコードを書いて受け取ることも可能です。

; まずRegisterする
Event OnInit()
    RegisterForModEvent("PlayDBVOTopic", "OnPlayDBVOTopic")
EndEvent

; するとセリフ選択時に内容を受け取れる
Event OnPlayDBVOTopic(string eventName, string strArg, float numArg, Form sender)
	Debug.Trace("OnPlayDBVOTopic: " + strArg)
EndEvent

第2引数のstrArgにセリフがそのまま入っています。

こうすることで、DBVOがなんという名前のファイルを再生しようとしているのかがわかります。

OnPlayDBVOTopic: I'd_like_to_rent_a_room._

さらに、ModEventを送信することも可能です。

SendModEvent("PlayDBVOTopic", "ここにセリフを入れる")

任意のタイミングで任意のセリフをプレイヤーに言わせることが可能です。

ただし、この方法では字幕は出ないようです。

改造

セリフの時間を調整する

時間を決めるロジックがdialoguemenu.swfの中にありました。

JPEXSで開き、左ペインの以下の場所にあります。

scripts/__Packages/<default package>/DialogueMenu

これのstartTopicClickedTimerメソッドです。

var _loc4_ = Math.round(_loc3_.split(" (")[0].split(" ").length * 60 / 300 * 1000) + 1400;

_loc4_が待機時間のようです。文章をスペースで分割して、単語の数から決めているようでした。単位はmsでしょうか。

以下のように書き換えました。

var _loc4_ = Math.round(_loc3_.split(" (")[0].length * 200);

単純に文字数に200を掛けたものです。

ActionScriptに詳しくないのですが、lengthはマルチバイトに対応しているようです。よって全角は1文字とカウントされるようです。

必要なのは読みの長さであって文字数ではないため、「承る」は2文字としてカウントされてしまいますが、本当に必要なのは「うけたまわる」の6文字なので、待機時間が短くなってしまいます。これを何とかするには形態素解析エンジンが必要になってくるので、これで妥協です。

リバーウッドでテストしただけですが、概ねいい感じになりました。あまりにも長すぎる文章は時間が足りないこともあります。

やっつけなので、セリフに英語と日本語が入り混じっている場合はおかしくなります。また、セリフがあってもなくても待ち時間が発生するので、Modのセリフはほとんどの場合に無駄な間ができることになります。

そもそも足りないセリフを補う

DBVO Japanese Voice Pack Proof of Conceptを使っていると、再生されないセリフがそこそこあります。そこで、そもそも用意されていないセリフはbella voice DBVOで話すようにしました。

方法は単純で、bella voice DBVOのリソースのパスを書き換えるだけです。

Sound\DBVO\voicebella

これを以下に書き換えます。

Sound\DBVO\c1122ksm

MO2の左ペインで、bella voice DBVOが先、DBVO Japanese Voice Pack Proof of Conceptが後になるようにします。

動的に変化するセリフを補う

英語環境かどうかで変わります。日本語環境の場合、デフォルトの辞書が対応できていないので話しません。

例えば、宿屋で部屋を借りる際のセリフは、以下のようになっています。

I'd like to rent a room. (<Global=RoomCost> gold)

英語環境の場合、( の後が消されて、以下のセリフに変換されます。

I'd like to rent a room.

このセリフにて音声データの検索が行われるので、特に問題なく再生されるはずです。

一方、日本語環境の場合、セリフがそのまま翻訳されるようです。

翻訳辞書では以下のようになっています。

"部屋を借りたい(_Global=RoomCost__ゴールド)": "I'd_like_to_rent_a_room._",

_Global=RoomCost_ の部分に実際の金額が入ります。金額は環境でかわります。最終的に金額の入ったセリフが画面に表示され、DBVOに送られるセリフも金額入りのセリフになります。

Requiem環境では50ゴールドになるので、辞書を以下のように書き換えるか、新たに行を追加すれば話すようになります。

"部屋を借りたい(50_ゴールド)": "I'd_like_to_rent_a_room._",

DBVO Japanese Voice Pack Proof of Conceptにはこのセリフの音声データがないので話せませんが、馬車の行き先のセリフの音声データは用意されているので、辞書を整備すれば話すようにできます。

翻訳辞書を作る

xTranslator用の英日翻訳辞書のXMLファイルを元にDragonborn Voice Over用の日英翻訳辞書を作るのがおそらくは一番簡単でしょう。

これを行うPHPスクリプトです。カレントディレクトリにあるxmlファイルをすべて変換します。

xml2json.php (php)

<?php
$basedir = getcwd();

define('BASE_DIR', $basedir);

parse_dir('');

function parse_dir($path)
{
    $basedir = BASE_DIR . DIRECTORY_SEPARATOR . $path;

    $dh = opendir($basedir);

    if ( !$dh ) {
        throw new Exception('could not open dir - ' . $basedir);
    }

    while ( $file = readdir($dh) ) {
        if ( ($file == '.') || ($file == '..') ) {
            continue;
        }

        if ( strpos($file, '.xml') === false ) {
            continue;
        }

        $fullpath = $basedir . DIRECTORY_SEPARATOR . $file;

        if ( is_dir($fullpath) ) {
            continue;
        }

        echo $file . PHP_EOL;

        xml2json($fullpath);
    }

    closedir($dh);
}

function xml2json($path)
{
    $fp = fopen($path, 'r');

    if ( !$fp ) {
        throw new Exception('could not open file - ' . $path);
    }

    try {
        $path_new = str_replace('.xml', '.json', $path);

        $fp_new = fopen($path_new, 'w');

        if ( !$fp_new ) {
            throw new Exception('could not open file - ' . $path_new);
        }

        $in_ary = [];
        $in_rec = false;
        $tmp_source = '';
        $tmp_dest = '';

        while ( !feof($fp) ) {
            $in_line = fgets($fp);
            $in_line = trim($in_line);

            if ( strlen($in_line) == 0 ) {
                continue;
            }

            if ( $in_rec ) {
                if ( strpos($in_line, '</String>') !== false ) {
                    if ( $tmp_source && $tmp_dest ) {
                        $in_ary[$tmp_source] = $tmp_dest;
                    }

                    $tmp_source = '';
                    $tmp_dest = '';
                    $in_rec = false;
                } else if ( preg_match('!<Source>(.*)</Source>!', $in_line, $matches) ) {
                    $tmp_source = $matches[1];
                    $tmp_source = fix_string($tmp_source);
                } else if ( preg_match('!<Dest>(.*)</Dest>!', $in_line, $matches) ) {
                    $tmp_dest = $matches[1];
                    $tmp_dest = fix_string($tmp_dest);
                }
            } else {
                if ( strpos($in_line, '<REC>DIAL:FULL</REC>') !== false ) {
                    $in_rec = true;
                }
            }
        }

        fputs($fp_new, '{' . PHP_EOL);

        ksort($in_ary);
        $max = count($in_ary);
        $counter = 0;

        foreach ($in_ary as $key => $value) {
            $counter++;
            $out_line = sprintf(' "%s": "%s"', $value, $key);

            if ( $counter < $max) {
                $out_line .= ',';
            }

            fputs($fp_new, $out_line . PHP_EOL);
        }

        fputs($fp_new, '}' . PHP_EOL);

        fclose($fp_new);
    } catch (Exception $e) {
    }

    fclose($fp);
}

function fix_string($str)
{
    $str = preg_replace('/\(.+?\)$/', '', $str);
    $str = rtrim($str, ' ');

    $str = str_replace(' ', '_', $str);
    $str = str_replace('/', '_', $str);
    $str = str_replace("\\", '_', $str);
    $str = str_replace(':', '_', $str);
    $str = str_replace('*', '_', $str);
    $str = str_replace('?', '_', $str);
    $str = str_replace('"', '_', $str);
    $str = str_replace('<', '_', $str);
    $str = str_replace('>', '_', $str);
    $str = str_replace('|', '_', $str);
    $str = str_replace('"', "_", $str);
    $str = str_replace(''', "'", $str);

    return $str;
}

自分でセリフを作る

DBVO Japanese Voice Pack Proof of Conceptがずんだもん(の中の人)のようでしたので、ずんだもんで作ってみました。

必要なツールは以下になります。

  • VOICEVOX ずんだもん(wavファイルを作成する)
  • CK(lipファイルを作成する)
  • YakitoriAudioConverter(wavファイルとlipファイルからfuzファイルを作成する)

まずはVOICEVOXでwavファイルを作成します。操作手順についてはこちらの【2023年最新版】無料音声合成ソフト「VOICEVOX」を完全解説【基礎知識~使い方】が参考になりました。

次にlipファイルを作成します。いくつか方法があるようですが、ツールを見つけられなかったり、うまく動作しないこともあって、結局CKで作りました。CKでlipファイルを都合よく作る方法はたぶんないので、会話のクエストを用意してNPCが話すセリフの部分にVOICEVOXで作ったwavファイルを当てはめて、データ一式をでっちあげる必要があります。とても面倒な作業になるのでオススメできません。適当なlipファイルを使ってとりあえず口が動いていればよしとするのが楽です。

SSE CreationKit FixesのFaceFXWrapperで作成できるようです。使い方はこちらにあります。

カレントフォルダのwavファイルから一括でlipファイルを作成するPHPスクリプトがこちらです。

create_lip.php (php)

<?php
define('FACEFXWRAPPER', 'E:\Steam\steamapps\common\Skyrim Special Edition\Tools\Audio\FaceFXWrapper.exe');
define('ARG_TYPE', 'Skyrim');
define('ARG_LANG', 'USEnglish');
define('FONIXDATAPATH', 'E:\Steam\steamapps\common\Skyrim Special Edition\Data\Sound\Voice\Processing\FonixData.cdf');

try {
    $dir = getcwd();

    parse_dir($dir);
} catch (Exception $e) {
    echo $e->GetMessage() . PHP_EOL;
}

function parse_dir($dir)
{
    $dh = opendir($dir);

    if ( !$dh ) {
        throw new Exception('could not open dir - ' . $dir);
    }

    $pattern = '/\.wav$/';

    while ( $file = readdir($dh) ) {
        if ( $file == '.' ) {
            continue;
        }

        if ( $file == '..' ) {
            continue;
        }

        if ( preg_match($pattern, $file) ) {
            $fullpath = $dir . '/' . $file;
            create_lip($fullpath);
        }
    }

    closedir($dh);
}

function create_lip($wav_filename)
{
    $lip_filename = str_replace('.wav', '.lip', $wav_filename);

    if ( file_exists($lip_filename) ) {
        return;
    }

    // FaceFXWrapper [Type] [Lang] [FonixDataPath] [ResampledWavPath] [LipPath] [Text]
    $cmd = sprintf(
        '"%s" %s %s %s %s %s ""',
        FACEFXWRAPPER,
        escapeshellarg(ARG_TYPE),
        escapeshellarg(ARG_LANG),
        escapeshellarg(FONIXDATAPATH),
        escapeshellarg($wav_filename),
        escapeshellarg($lip_filename)
    );

    exec($cmd);
}

最後にYakitoriAudioConverterでfuzファイルを作成します。

これで素材が用意できたので、あとは辞書を作って配置するだけです。

辞書を作る上での注意事項ですが、fuzファイルのファイル名は英語でないとダメなようです。VOICEVOXが吐き出すwavファイルの名前は日本語まじりになるでしょうから、リネームしないとなりません。jsonのファイル名は何でもいいようなので、英語で他とかぶらないファイル名で作ればいいでしょう。

足りないセリフを補うのもいいですし、自作Modから任意のタイミングでセリフを言わせるのもいいでしょう。

ところで、同じずんだもんでもDBVO Japanese Voice Pack Proof of Conceptとはかなり違う声になります。そこで、DBVO Japanese Voice Pack Proof of Conceptの音声を機械学習させてボイスチェンジャーを通してみたところ、だいぶ近づきました。ボイスチェンジャーについてはこちらで解説しています。

気をつけること

JSONファイル

書式が正しいか確認しましょう。特に最後の行は行末に , があってはいけません。

fuzファイル

ファイル名はクセがあるので注意しましょう。

' は使えますが、´ は使えないようです。DBVOに同梱のバニラ辞書には ´ が全くありませんが、Mod用のボイスパックにはファイル名に ´ を使ってしまっているものがあります。ファイル名と辞書の双方で ´' に変換する必要があります。

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