VOICEVOX Engine for UE5プラグイン ver2.0
概要
VOICEVOX Engine for UE5プラグインはヒホさんが制作・公開されている、 VOICEVOXの非公式 UnrealEngine5対応版、VOICEVOX COREをUE5で使用するためのプラグインです。
対応プラットフォーム&エンジンバージョン
Windows11
macOS
UnrealEngine5.0~5.7
VOICEVOX CORE対応バージョン
プラグインのバージョンごとに対応するVOICEVOX COREになります。
ver2.x
VOICEVOX CORE 0.16.0
!
VOICEVOX NEMO COREは16がリリースされてないため、ver2.x以降は対象外となります。
ver1.x
VOICEVOX CORE 0.15.7
VOICEVOX NEMO CORE 0.15.0
プラグイン使用準備
Githubに記載したReadMEを見てください。
基本的な処理フロー
VOICEVOX Engine for UE5プラグインの基本的な利用フローは次のとおりです。
C++
C++ から利用する手順を説明します。
各ヘッダーファイルについて
プラグインに作成した各ヘッダーファイルの説明です。
cppファイルも公開しているので、セットで見てください。
VoicevoxUECore
UVoicevoxCoreSubsystem.h
VOICEVOX COREのAPIへ接続するためのSubsystemです。
C++で利用する場合はUVoicevoxCoreSubsystemから各VOICEVOX CORE APIを呼ぶことを想定しています。
EngineSubystemの派生クラスです。
EditorとGame両方で使用する想定
VoicevoxEngine
VoicevoxBlueprintLibrary.h
VOICE VOX COREのAPIを実行するBlueprint公開用ノードをまとめています。
VoicevoxAsyncTask.h
VOICE VOX COREのAPIを実行するBlueprint公開用ノードをまとめています。
負荷がかかる一部のAPIをLatentノードにしています。
全体の呼び出し順序
Build.cs に依存モジュールを追加する
Build.csのPublicDependencyModuleNames、もしくはPrivateDependencyModuleNamesに以下のモジュール名を追加してください。
C++のみ
VoicevoxUECore
Blueprint、もしくはEditorを使用
VoicevoxUECore
VoicevoxEngine
PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
"InputCore",
"CoreUObject",
"Engine"
}
);
PrivateDependencyModuleNames.AddRange(
new string[]
{
"VoicevoxUECore",
"VoicevoxEngine",
}
C++で実装
UE側はUVoicevoxCoreSubsystemを用いて実装します。
以下、ゲーム側実装例になります。
VOICEVOX COREの初期化
最初にVOICEVOX COREの初期化APIを必ず実行してください。
ゲーム側の利用例は次のとおりです。
#include "Engine/Engine.h"
#include "Subsystems/VoicevoxCoreSubsystem.h"
void InitializeVoicevoxExample()
{
if (!GEngine)
{
return;
}
UVoicevoxCoreSubsystem* Core =
GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>();
if (!Core)
{
return;
}
// CPU モードで初期化。全モデル一括読み込みは要求しない。
const bool bInitialized = Core->Initialize(false, 0, false);
if (!bInitialized)
{
UE_LOG(LogTemp, Error, TEXT("VOICEVOX initialization failed"));
return;
}
// vvmファイル名を渡してモデルデータを読み込む
const FString ModelPath = TEXT("0");
if (!Core->LoadVoiceModel(ModelPath))
{
UE_LOG(LogTemp, Error, TEXT("VOICEVOX model load failed"));
return;
}
}
初期化・モデル読み込みは時間がかかる可能性があるため、上記はAPIの呼び出し順序を示す最小例です。
!
ゲームのメインスレッドで実行することを推奨しません。
実際にはロード画面やワーカースレッド、非同期タスクなどでAPIを実行してください。
テキストから WAV データを生成する
RunTextToSpeech() は WAV 形式のバイト配列 TArray<uint8> を返します。
スタイルIDはロードしたVVMファイルから利用可能なIDを指定してください。
#include "Engine/Engine.h"
#include "Subsystems/VoicevoxCoreSubsystem.h"
TArray<uint8> GenerateVoicevoxWavExample()
{
if (!GEngine)
{
return {};
}
const UVoicevoxCoreSubsystem* Core =
GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>();
if (!Core || !Core->GetIsInitialize())
{
return {};
}
const VoicevoxStyleId StyleId = 0; // 実際に利用可能なスタイルIDを指定
return Core->RunTextToSpeech(
StyleId,
TEXT("こんにちは、Unreal Engine から音声を合成しています。"),
false, // 通常テキスト
true // 疑問文の調整を有効化
);
}
戻り値はWAV のバイト列です。
USoundWaveで再生する場合は変換処理が必要です。
!
合成処理をゲームのメインスレッドで実行することを推奨しません。
実際にはワーカースレッド、もしくは非同期タスクなどでAPIを実行してください。
AudioQuery を編集して合成する
音声のパラメータを調整したい場合は、まず GetAudioQuery() でAudioQueryを取得し、編集後に RunSynthesis() に渡します。
#include "Engine/Engine.h"
#include "Subsystems/VoicevoxCoreSubsystem.h"
TArray<uint8> GenerateFromQueryExample()
{
if (!GEngine)
{
return {};
}
const UVoicevoxCoreSubsystem* Core =
GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>();
if (!Core || !Core->GetIsInitialize())
{
return {};
}
const VoicevoxStyleId StyleId = 0;
FVoicevoxAudioQuery Query = Core->GetAudioQuery(
StyleId,
TEXT("音声合成のテストです。"),
false);
// 必要に応じて Query の各パラメータを変更する。
return Core->RunSynthesis(Query, StyleId, true);
}
終了処理
VOICEVOX CORE を利用しなくなったときはFinalize() を呼び出します。
if (GEngine)
{
if (UVoicevoxCoreSubsystem* Core =
GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>())
{
Core->Finalize();
}
}
UVoicevoxCoreSubsystem API一覧
UVoicevoxCoreSubsystemで公開されているAPI一覧です。
ライフサイクル・初期化
API宣言
説明
bool Initialize(bool bUseGPU, int CPUNumThreads = 0, bool bLoadAllModels = false);
VOICEVOX COREの初期化
bool GetIsInitialize() const;
初期化処理実行済みかを取得
void Finalize();
VOICEVOX COREの終了処理
モデル管理
API宣言
説明
bool LoadModel(VoicevoxStyleId StyleId) const;
スタイルIDでモデルをロード(旧APIを残しています。実際はLoadVoiceModelを使用してください)
bool LoadVoiceModel(const FString& VvmFileName) const;
指定VVMファイルをロード
bool AllLoadVoiceModel() const;
全VVMファイルをロード
bool UnloadVoiceModel(const FString& VvmFileName) const;
指定VVMファイルをアンロード
bool IsLoadedVoiceModel(const FString& VvmFileName) const;
指定VVMファイルのロード状態を確認
テキスト解析・AudioQuery
API宣言
説明
TArray<FVoicevoxAccentPhrase> OpenJTalkRcAnalyze(const FString& Text) const;
日本語テキストをアクセント句へ解析
FVoicevoxAudioQuery GetAudioQuery(VoicevoxStyleId StyleId, const FString& Message, bool bKana) const;
テキストからAudioQueryを生成
FVoicevoxAudioQuery GetAudioQueryFromAccentPhrases(const TArray<FVoicevoxAccentPhrase>& AccentPhrases) const;
アクセント句からAudioQueryを生成
TArray<FVoicevoxAccentPhrase> SynthesizerCreateAccentPhrases(VoicevoxStyleId StyleId, const FString& Text, bool bKana) const;
スタイル指定でアクセント句を生成
TArray<FVoicevoxAccentPhrase> SynthesizerReplace(EReplaceType ReplaceType, const TArray<FVoicevoxAccentPhrase>& AccentPhrases, VoicevoxStyleId StyleId) const;
アクセント句の指定パラメータを再生成
音声合成
API宣言
説明
TArray<uint8> RunTextToSpeech(VoicevoxStyleId StyleId, const FString& Message, bool bKana, bool bEnableInterrogativeUpspeak) const;
テキストからWAVバイト列を生成
TArray<uint8> RunSynthesis(const char* AudioQueryJson, VoicevoxStyleId StyleId, bool bEnableInterrogativeUpspeak) const;
AudioQueryまたはアセットからWAVバイト列を生成
TArray<uint8> RunSynthesis(const FVoicevoxAudioQuery& AudioQuery, VoicevoxStyleId StyleId, bool bEnableInterrogativeUpspeak) const;
AudioQueryまたはアセットからWAVバイト列を生成
TArray<uint8> RunSynthesis(const UVoicevoxQuery& VoicevoxQuery, bool bEnableInterrogativeUpspeak) const;
AudioQueryまたはアセットからWAVバイト列を生成
リップシンク
API宣言
説明
static TArray<FVoicevoxLipSync> GetLipSyncList(FVoicevoxAudioQuery AudioQuery, bool bIsSimple = false, float PitchModulation = 1.0f);
AudioQueryからリップシンクデータを生成
メタ情報・実行環境
API宣言
説明
TArray<FVoicevoxMeta> GetMetaList() const;
話者メタ情報を取得
TArray<FVoicevoxMeta> GetVoiceModelFileMetaList(const FString& VvmFileName) const;
指定VVMのメタ情報を取得
TArray<FVoicevoxMeta> GetAllVoiceModelFileMetaList() const;
全VVMのメタ情報を取得
FString GetMetaName(VoicevoxStyleId StyleId) const;
スタイルIDに対応する名前を取得
TMap<int, FString> GetVvmFileNameMapToStyleId() const;
スタイルIDからVVM名への対応表を取得
FString GetVoicevoxVersion(const FString& CoreName);
指定COREのバージョン取得
bool IsGpuMode(const FString& CoreName);
指定COREのGPUモードを確認
FVoicevoxSupportedDevices GetSupportedDevices(const FString& CoreName);
指定COREの対応デバイス取得
TArray<FString> GetCoreNameList();
初期化済みCORE名一覧を取得
ユーザー辞書
API宣言
説明
VoicevoxUserDictWord UserDictWordMake(const FString& Surface, const FString& Pronunciation, uintptr_t AccentType) const;
辞書単語の構造体を生成
bool UserDictInitialize(const FString& DictPath = TEXT("")) const;
ユーザー辞書を初期化
TArray<uint8_t> UserDictAddWord(const VoicevoxUserDictWord* Word) const;
辞書単語を追加しUUIDを返す
bool RewriteUserDictWord(const TArray<uint8_t>& WordUuid, const VoicevoxUserDictWord *Word) const;
辞書単語を更新
bool DeleteUserDictWord(const TArray<uint8_t>& WordUuid) const;
辞書単語を削除
TMap<FString, FVoicevoxRegisteredUserDictWord> GetUserDictWord() const;
登録済み辞書単語を取得
bool UserDictSave(const FString& Path) const;
辞書をファイルに保存
void UserDictDelete() const;
辞書を破棄
Blueprint
Blueprint から利用する手順を説明します。
Blueprint での利用フロー
処理フローの例です。
実際にゲームへ組み込む際は、次の順序を基本にします。
VOICEVOX CORE を初期化する。
必要な音声モデルをロードする。
TextToSpeechOutput() などで音声を生成する。
戻り値の USoundWave が有効なら再生する。
必要なら AudioQuery からリップシンク情報を生成し、対応する Audio Component に渡す。
終了時に CORE のリソースを解放する。
Blueprint API
同期APIと状態参照・辞書操作
Blueprintノード名
公開種別
C++宣言
VoicevoxFinalize
BlueprintCallable
static void Finalize()
GetOpenJTalkRcAnalyze
BlueprintCallable
static UPARAM(DisplayName="OpenJTalkAnalyze") TArray<FVoicevoxAccentPhrase> OpenJTalkRcAnalyze(const FString& Text)
VoicevoxUnLoadVoiceModel
BlueprintCallable
static bool UnloadVoiceModel(FString VvmFileName)
VoicevoxIsLoadedVoiceModel
BlueprintCallable
static bool IsLoadedVoiceModel(FString VvmFileName)
GetVoicevoxCoreNameList
BlueprintPure
static UPARAM(DisplayName="CoreNameList") TArray<FString> GetCoreNameList()
GetVoicevoxMetas
BlueprintPure
static UPARAM(DisplayName="Metas") TArray<FVoicevoxMeta> GetMetasToList()
GetVoicevoxModelFileMetas
BlueprintPure
static UPARAM(DisplayName="Metas") TArray<FVoicevoxMeta> GetVoiceModelFileMetaList(const FString& VvmFileName)
GetAllVoicevoxModelFileMetas
BlueprintCallable
static UPARAM(DisplayName="Metas") TArray<FVoicevoxMeta> GetAllVoiceModelFileMetaList()
GetVoicevoxSupportedDevices
BlueprintPure
static UPARAM(DisplayName="SupportedDevices") FVoicevoxSupportedDevices GetSupportedDevices(const FString& CoreName)
VoicevoxVersion
BlueprintPure
static UPARAM(DisplayName="VoicevoxCoreVersion") FString GetVoicevoxCoreVersion(const FString& CoreName)
IsVoicevoxGpuMode
BlueprintPure
static UPARAM(DisplayName="GPU Mode") bool IsVoicevoxGpuMode(const FString& CoreName)
VoicevoxTextToSpeechOutput
BlueprintCallable
static UPARAM(DisplayName="Sound") USoundWave* TextToSpeechOutput(int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true)
VoicevoxToTextAudioQueryOutput
BlueprintCallable
static UPARAM(DisplayName="Sound") USoundWave* TextToAudioQueryOutput(int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true)
GetVoicevoxAudioQuery
BlueprintCallable
static UPARAM(DisplayName="AudioQuery") FVoicevoxAudioQuery GetAudioQuery(int32 StyleId, FString Message, bool bRunKana = false)
GetVoicevoxAudioQueryFromAccentPhrases
BlueprintCallable
static UPARAM(DisplayName="AudioQuery") FVoicevoxAudioQuery GetAudioQueryFromAccentPhrases(const TArray<FVoicevoxAccentPhrase>& AccentPhrases)
GetVoicevoxAccentPhrase
BlueprintCallable
static UPARAM(DisplayName="AccentPhraseAnalyze") TArray<FVoicevoxAccentPhrase> GetSynthesizerAccentPhrase(int32 StyleId, const FString& Text, bool bKana)
VoicevoxReplace
BlueprintCallable
static UPARAM(DisplayName="AccentPhraseAnalyze") TArray<FVoicevoxAccentPhrase> SynthesizerReplace(EReplaceType ReplaceType, const TArray<FVoicevoxAccentPhrase>& AccentPhrases, int32 StyleId)
VoicevoxAudioQueryOutput
BlueprintCallable
static UPARAM(DisplayName="Sound") USoundWave* AudioQueryOutput(FVoicevoxAudioQuery AudioQuery, int32 StyleId, bool bEnableInterrogativeUpspeak = true)
VoicevoxQueryAssetOutput
BlueprintCallable
static UPARAM(DisplayName="Sound") USoundWave* VoicevoxQueryOutput(UVoicevoxQuery* VoicevoxQuery, bool bEnableInterrogativeUpspeak = true)
GetVoicevoxLipSyncList
BlueprintCallable
static UPARAM(DisplayName="LipSyncList") TArray<FVoicevoxLipSync> GetLipSyncList(FVoicevoxAudioQuery AudioQuery)
VoicevoxUserDictWordMake
BlueprintCallable
static UPARAM(DisplayName="UserDictWord") FVoicevoxCoreUserDictWord UserDictWordMake(const FString& Surface, const FString& Pronunciation, int64 AccentType)
VoicevoxCoreUserDictInitialize
BlueprintCallable
static bool UserDictInitialize(const FString& DictPath = TEXT(""))
VoicevoxUserDictAddWord
BlueprintCallable
static UPARAM(DisplayName="UUID") TArray<uint8> UserDictAddWord(const FVoicevoxCoreUserDictWord Word)
VoicevoxRewriteUserDictWord
BlueprintCallable
static bool RewriteUserDictWord(const TArray<uint8>& WordUuid, const FVoicevoxCoreUserDictWord Word)
VoicevoxDeleteUserDictWord
BlueprintCallable
static bool DeleteUserDictWord(const TArray<uint8>& WordUuid)
GetVoicevoxUserDictWord
BlueprintCallable
static TMap<FString, FVoicevoxRegisteredUserDictWord> GetUserDictWord()
VoicevoxCoreUserDictSave
BlueprintCallable
static bool UserDictSave(const FString& Path)
VoicevoxCoreUserDictDelete
BlueprintCallable
static void UserDictDelete()
非同期アクションノード
Blueprintノード名
公開種別
C++宣言
VoicevoxInitialize
BlueprintCallable
static UVoicevoxInitializeAsyncTask* Initialize(UObject* WorldContextObject, bool bUseGPU, int CPUNumThreads = 0)
VoicevoxLoadModel
BlueprintCallable
static UVoicevoxLoadModelAsyncTask* LoadModel(UObject* WorldContextObject, int32 StyleId)
VoicevoxLoadVoiceModel
BlueprintCallable
static UVoicevoxLoadVoiceModelAsyncTask* LoadVoiceModel(UObject* WorldContextObject, FString VvmFileName)
VoicevoxAllLoadVoiceModel
BlueprintCallable
static UVoicevoxLoadVoiceModelAsyncTask* AllLoadVoiceModel(UObject* WorldContextObject)
VoicevoxTextToSpeechOutputAsync
BlueprintCallable
static UVoicevoxTextToSpeechAsyncTask* TextToSpeech(UObject* WorldContextObject, int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true)
VoicevoxToTextAudioQueryOutputAsync
BlueprintCallable
static UVoicevoxTextToSpeechAsyncTask* TextToAudioQuery(UObject* WorldContextObject, int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true)
VoicevoxAudioQueryOutputAsync
BlueprintCallable
static UVoicevoxAudioQueryToSpeechAsyncTask* AudioQueryOutput(UObject* WorldContextObject, int32 StyleId, FVoicevoxAudioQuery AudioQuery, bool bEnableInterrogativeUpspeak = true)
VoicevoxQueryAssetOutputAsync
BlueprintCallable
static UVoicevoxAudioQueryToSpeechAsyncTask* VoicevoxQueryOutput(UObject* WorldContextObject, UVoicevoxQuery* VoicevoxQuery, bool bEnableInterrogativeUpspeak = true)
リップシンク付き音声再生コンポーネント
Blueprintノード名
公開種別
C++宣言
StopAudioAndLipSync
BlueprintCallable
void StopAudioAndLipSync()
PlayToText
BlueprintCallable
void PlayToText(FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true, float SpeedScale = 1.0f, float PitchScale = 0.0f, float IntonationScale = 1.0f, float VolumeScale = 1.0f, float PrePhonemeLength = 0.1f, float PostPhonemeLength = 0.1f)
PlayToAudioQuery
BlueprintCallable
void PlayToAudioQuery(const FVoicevoxAudioQuery& Query, bool bEnableInterrogativeUpspeak = true)
PlayToAudioQueryAsset
BlueprintCallable
void PlayToAudioQueryAsset(UVoicevoxQuery* VoicevoxQuery, bool bEnableInterrogativeUpspeak = true)
SetLipSyncDataToAudioQuery
BlueprintCallable
void SetLipSyncDataToAudioQuery(const FVoicevoxAudioQuery& Query)
SetLipSyncDataToAudioQueryAsset
BlueprintCallable
void SetLipSyncDataToAudioQueryAsset(UVoicevoxQuery* VoicevoxQuery)
VoicecoxCharacterLipSyncAudioComponent
キャラクターのSkeletalMesh設定
Blueprintノード名
公開種別
C++宣言
SetSkeletalMesh
BlueprintCallable
void SetSkeletalMesh(USkeletalMeshComponent* SkeletalMesh)
リップシンク
以下の記事を見てください。
VoicevoxEngine For UE リップシンクコンポーネント
エディタ拡張
VoicevoxEngineプラグインが有効な場合、メニューバーにVOICEVOXメニューが追加されます。
Editor
!
エディターはα版となります。
アクセント情報を作成、編集するエディター機能です。
保存でVoicevox AudioQueryアセットを生成、アセット書き出しでSoundWaveアセット、音声書き出しでWavファイルを作成します。
アクセント、イントネーション、長さの編集は未対応です。
もし編集したい場合は、一度Voicevox AudioQueryアセットを生成後、AccentPhaasesの項目を編集してください。
Speaker List
プラグインで利用可能なVOICEVOX モデルの一覧です。
Copyボタンを押すことで、style IDをコピーします。
利用時の注意点
初期化とモデル読み込みは音声合成より先に行う。 Initialize() の成功だけでは目的のスタイルのモデルが読み込まれているとは限りません。
重い処理をゲームスレッドに直書きしない。 初期化、モデル読み込み、AudioQuery 生成、合成は負荷を考慮し、適切な非同期実行を設計してください。
スタイルIDを確認する。 読み込んだVVMファイルに格納されているスタイルIDのみ利用可能です。
AquesTalkライクな記法をする場合はルールに従う。 AquesTalkライクな記法をする場合、ルールに違反するとクラッシュします。
例:ボク'ワ/ズ'ンダモン/ナノ'ダ
全てのカナはカタカナで記述される
アクセント句は/または、で区切る。、で区切った場合に限り無音区間が挿入される。
カナの手前に_を入れるとそのカナは無声化される
アクセント位置を'で指定する。全てのアクセント句にはアクセント位置を 1 つ指定する必要がある。
アクセント句末に?(全角)を入れることにより疑問文の発音ができる
※VOICEVOX ENGINE ReadMeより引用
ライセンス
VOICEVOX ENGINEのライセンスを継承し、LGPL v3と、ソースコードの公開が不要な別ライセンスのデュアルライセンスとなります。
利用規約
VOICEVOX Engine for UE5の利用規約はVOICEVOXのホームページに記載された利用規約を継承するため、利用前に必ず一読してください。
各スピーカーモデルのライセンスはVOICEVOX公式ホームページのキャラクター一覧を参照してください。
!
VOCEVOXの利用規約及び各キャラクターの利用規約を厳守するようにお願いいたします
問い合わせに関して
VOICEVOX Engine for UE5プラグインに関して質問があれば、Twitter、Qiitaのコメント欄で聞いてもらうか、公開したGithubのissueに記載してください。
!
間違ってもVOICEVOX CORE作者のヒホさん、VOICEVOX公式Twitterアカウント及びVOICEVOX ホームページで、VOICEVOX Engine for UE5プラグインの問い合わせをしないようにお願いいたします。
縦動画の字幕に合わせて VOICEVOX の声を置き、区間を声の長さに伸ばして、BGM を下げつつ -16 LUFS にそろえる(Python+ffmpeg)
字幕つきの縦動画(1080×1920・30fps、30秒前後)を何本も作っています。映像は台本の JSON から書き出す仕組みがすでにあるので、そこに読み上げ音声と BGM を足す make_voice.py(340行ほど)を書きました。
難しかったのは、声の長さが字幕の表示時間に収まらないことです。字幕は「読む速さ」で時間を決めていたので、声に出すと足りない場所がほとんどでした。そこで、声を縮めるのではなく、声が入るように映像の区間のほうを伸ばすことにしました。
この記事で書くこと:
- VOICEVOX ENGINE の HTTP API(audio_query → synthesis、speedScale、accent_phrases で読みを確かめる)
- 字幕ごとの区間を、声の長さに合わせて伸ばす計算
- BGM を声の下だけ下げる(ダッキング)、loudnorm を2回使って -16 LUFS にそろえる
- 読み間違いの直し方(「行」「千件」「20日分」など)
- ハマったこと(一時 PNG でディスクがいっぱい、空の wav キャッシュ)
確認した環境:macOS 14、Python 3.12(numpy)、VOICEVOX ENGINE 0.26.0(VOICEVOX アプリに入っているもの)、ffmpeg 7.1。
1. 全体の流れ
1本の動画につき、次の順で処理します。
1. 読み上げ文のテキストを読む(1行=字幕1つ)。なければ字幕から下書きを作る
2. 読み上げ文ごとに VOICEVOX で wav を作る(同じ文・同じ速さならキャッシュを使う)
3. 字幕の区間を、声が入る長さまで伸ばした台本を作る
4. 伸ばした台本で映像を書き出す(既存の仕組み)
5. 声を置いた音と BGM を numpy でまぜ、ダッキングする
6. ffmpeg の loudnorm で測って → そろえて、映像と合わせる(AAC 192k・faststart)
7. 確認用の表(report.txt)と、1秒ごとのコマを並べた確認シートを出す
読み上げ文のファイルは、こういう形です。| の左が元の台本の字幕の秒、右が読み上げる文です。
0-1.8|書類が、千枚。この中から、1枚を探す。
1.8-5.2|前から、1枚ずつ、順番に見ていくと…
16.6-18.7|せんけんなら、最大、10回で見つかる。
字幕と読み上げ文を分けたのは、画面の文字と読みを別々に直したいからです。画面は「千件」のまま、声だけ「せんけん」にできます(5章)。
使い方は2段です。まず --kana で読みだけ確かめ、直し終わったら全部を回します。
python3 make_voice.py scripts/12_password_length.json --kana # 1) 読みの確認だけ(数秒)
python3 make_voice.py scripts/12_password_length.json # 2) 全部(書き出し込みで数分)
2. VOICEVOX ENGINE の HTTP API
VOICEVOX アプリを入れると、音声合成のエンジンが http://127.0.0.1:50021 で動きます。アプリを開いていないときは、make_voice.py がエンジン単体を起動して /version が返るまで待ちます。
声を作るのは2回の POST です。
1. POST /audio_query?text=...&speaker=14 … 文から「読み・アクセント・長さ」の JSON(AudioQuery)を作る
2. POST /synthesis?speaker=14 … その JSON を本文に送ると、wav が返る
speaker=14 は「冥鳴ひまり(ノーマル)」のスタイル ID です。
def post(path, params, body=None):
url = ENGINE + path + '?' + urllib.parse.urlencode(params)
data = json.dumps(body).encode() if body is not None else b''
req = urllib.request.Request(url, data=data, method='POST', headers={'Content-Type': 'application/json'})
return urllib.request.urlopen(req, timeout=120).read()
def audio_query(text):
return json.loads(post('/audio_query', {'text': text, 'speaker': SPEAKER}))
audio_query で返った JSON を、synthesis に送る前に書き換えられます。ここで速さやサンプリングレートを決めます。
def synth(text, speed, cache_dir):
"""読み上げ文 → 48kHz モノラルの numpy(前後の無音は切る)。同じ文・速さなら作り直さない"""
key = hashlib.sha1(f'{SPEAKER}|{speed}|{text}'.encode()).hexdigest()[:16]
wav = os.path.join(cache_dir, f'v_{key}.wav')
if not os.path.exists(wav) or os.path.getsize(wav) == 0: # 途中で止まった空ファイルは作り直す
q = audio_query(text)
q.update(speedScale=speed, prePhonemeLength=0.05, postPhonemeLength=0.1, outputSamplingRate=SR, outputStereo=False)
open(wav, 'wb').write(post('/synthesis', {'speaker': SPEAKER}, q))
x = load(wav, 1)[:, 0]
thr = 10 ** (-50 / 20); idx = np.where(np.abs(x) > thr)[0]
a, b = max(0, idx[0] - int(.01 * SR)), min(len(x), idx[-1] + int(.03 * SR))
return x[a:b]
- speedScale:話す速さ。1.1 にしています(オプションで 1.0〜1.15)
- prePhonemeLength / postPhonemeLength:文の前後の無音(秒)。間は自分で置くので短めに
- outputSamplingRate=48000:動画の音声に合わせて 48kHz
- 返った音は、-50dB より小さい前後を切ります。あとの「区間を伸ばす計算」で、声の長さを正確に使いたいからです
- キャッシュ名は「話者・速さ・文」のハッシュです。読みを1文だけ直したとき、ほかの文は作り直しません
accent_phrases で読みを確かめる
audio_query の JSON には、エンジンが付けた読みが入っています。kana は AquesTalk 風の記法(' がアクセント、/ が区切り、_ が無声化)、accent_phrases[].moras[] は1拍(モーラ)ずつの読みです。
make_voice.py の --kana は kana をファイルに書き出すだけですが、拍ごとに見たいときは次の小さいスクリプトを使いました。
import json, sys, urllib.parse, urllib.request
ENGINE = 'http://127.0.0.1:50021'
SPEAKER = 14 # 冥鳴ひまり(ノーマル)
def audio_query(text):
url = ENGINE + '/audio_query?' + urllib.parse.urlencode({'text': text, 'speaker': SPEAKER})
req = urllib.request.Request(url, data=b'', method='POST')
return json.loads(urllib.request.urlopen(req, timeout=60).read())
for text in sys.argv[1:]:
q = audio_query(text)
moras = ['/'.join(m['text'] for m in ap['moras']) for ap in q['accent_phrases']]
print(text)
print(' kana :', q['kana'])
print(' moras:', ' | '.join(moras))
千件なら、最大、10回で見つかる。
kana : セン'ゲンナラ、サイダイ'、ジュッ'カイデ/ミ_ツカル'
moras: セ/ン/ゲ/ン/ナ/ラ | サ/イ/ダ/イ | ジュ/ッ/カ/イ/デ | ミ/ツ/カ/ル
「千件」が「センゲン」になっているのが分かります。直し方は5章で書きます。
3. 声の長さに合わせて区間を伸ばす
決まり
字幕 k の区間を「字幕 k の t0 から、次の字幕の t0 まで(最後は動画の終わりまで)」とします。この区間に
- 字幕が出てから話し始めるまで 0.15秒
- 声の長さ
- 次の字幕までの間 0.3秒(最後の字幕だけ余韻の 0.9秒)
が入らなければ、入る長さまで伸ばします。縮めはしません。もともと長い区間は、そのままです。
LEAD, GAP, TAIL = 0.15, 0.30, 0.9 # 字幕が出てから話し始めるまで/次の字幕までの間/最後の余韻
計算
plan() の中心はこの部分です。bounds は字幕の t0 を並べ、最後に元の長さを足したものです。seg_voice[k] は字幕 k に対応する読み上げ文で、r['d'] が声の長さ(秒)です。
nb = [0.0]; segs = []
for k in range(len(subs)):
L = bounds[k + 1] - bounds[k]; last = k == len(subs) - 1
r = seg_voice.get(k)
newL = max(L, LEAD + r['d'] + (TAIL if last else GAP)) if r else L
nb.append(round(nb[-1] + newL, 2)); segs.append((bounds[k], bounds[k + 1], nb[k], nb[k + 1]))
if r: r.update(oa=bounds[k], ob=bounds[k + 1], na=nb[k], nb=nb[k + 1], place=round(H + nb[k] + LEAD, 3))
- newL:伸ばしたあとの区間の長さ。max なので縮まない
- nb:新しい区間の境目を、頭から積み上げたもの
- place:声を置く時刻。新しい区間の頭+0.15秒(H は冒頭の問いの長さで、ない台本では 0)
台本の中の「秒」を動かす
区間を伸ばすと、字幕だけでなく、図のアニメーションの開始時刻なども一緒に動かす必要があります。古い秒を新しい秒に変える関数 remap を作り、区間の中では比例で動かします。
def remap(t):
if t >= bounds[-1]: return round(t - bounds[-1] + nb[-1], 3)
if t <= 0: return t
for oa, ob, na, nb_ in segs:
if t <= ob: return round(na + (t - oa) * (nb_ - na) / (ob - oa), 3) if ob > oa else na
台本 JSON の中で「秒」を表すキー(t0・t1・start・end など)を集合で持っておき、JSON をたどって remap をかけます。一方、typeT・dur は「t0 からの秒」(相対)なので動かしません。知らないキーで秒らしい名前(〜T や t数字)が出てきたら、動かさずに名前を表示するようにしました。黙って動かす・黙って残す、のどちらも事故のもとだったからです。
結果
二分探索の説明動画(字幕9つ)の report.txt です。元の長さ 24.00秒が、33.06秒になりました。
元の長さ 24.00秒 → 新しい長さ 33.06秒(MP4 33.06秒)
| # | 文 | 置いた時刻 | 区間(元→新) | 声の長さ | 字幕の表示 | 収まり |
|---|---|---|---|---|---|---|
| 1 | 書類が、千枚。この中から、1枚を探す。 | 0.15 | 0.00-1.80 → 0.00-4.00 | 3.55 | 0.00-4.00 | OK |
| 2 | 前から、1枚ずつ、順番に見ていくと… | 4.15 | 1.80-5.20 → 4.00-7.51 | 3.06 | 4.00-7.51 | OK |
| 3 | 探しものが一番最後なら、最悪、千回。 | 7.66 | 5.20-8.00 → 7.51-11.33 | 3.37 | 7.51-11.33 | OK |
| 4 | 小さい順に並んでいれば、真ん中を見て、半分を捨てる。 | 11.48 | 8.00-10.40 → 11.33-15.58 | 3.80 | 11.33-15.58 | OK |
| 5 | 見るたびに、残りが、半分になる。 | 15.73 | 10.40-14.30 → 15.58-19.48 | 2.56 | 15.58-19.48 | OK |
| 6 | 10回で、1件まで、しぼれた。 | 19.63 | 14.30-16.60 → 19.48-22.40 | 2.47 | 19.48-22.40 | OK |
| 7 | せんけんなら、最大、10回で見つかる。 | 22.55 | 16.60-18.70 → 22.40-25.59 | 2.74 | 22.40-25.59 | OK |
| 8 | 件数が千倍になっても、回数は、10回増えるだけ。 | 25.74 | 18.70-20.90 → 25.59-29.87 | 3.83 | 25.59-29.68 | OK |
| 9 | 並んでいるから、半分を捨てられる。 | 30.02 | 20.90-24.00 → 29.87-33.06 | 2.14 | 29.87-33.06 | OK |
1行目を例にすると、元の区間は 1.80秒、声は 3.55秒です。0.15+3.55+0.30=4.00秒が必要なので、区間は 0.00〜4.00秒に伸びています。5行目は元の区間が 3.90秒あって、声(2.56秒)が入るので伸ばしていません。
「収まり」は、声の終わりが字幕の表示時間の中(0.05秒まで許す)に入っているかどうかです。これまでに作った21本は、すべて OK でした。
4. ダッキングと loudnorm 2パス
声を置く・BGM を下げる
声を置く場所も BGM の音量も、すべて自分で決めた時刻なので、音は numpy でまぜています。ffmpeg のフィルタで書くなら adelay(遅らせる)+ amix(まぜる)、ダッキングは sidechaincompress(声の大きさに反応して BGM を下げる)や volume の式になります。ただ、声のある時刻は分かっているので、反応を待つコンプレッサーより、時刻どおりの音量カーブのほうが狙いどおりになります。サンプル単位でずれないことも確かめやすくなります。
まず、声だけのトラックと BGM だけのトラックを、それぞれ -16 LUFS にそろえます(測り方は次の節と同じ loudnorm の測定値)。そのうえで、BGM の音量カーブを作ります。
BED, DUCK, RAMP = -12.0, -20.0, 0.15 # BGM:声のない所 -12dB、声の下 -20dB(-16 LUFS にそろえたBGMから)
t = np.arange(N) / SR; g = np.full(N, BED)
for r in rows:
a, b = r['place'] - 0.1, r['place'] + r['spoken'] + 0.1
down = np.clip((t - (a - RAMP)) / RAMP, 0, 1); up = np.clip(((b + RAMP) - t) / RAMP, 0, 1)
g = np.minimum(g, BED + (DUCK - BED) * np.minimum(down, up))
gain = 10 ** (g / 20) * np.clip(t / 0.5, 0, 1) * np.clip((total - t) / 0.8, 0, 1)
mix = narr[:, None] + bgm * gain[:, None]
- 声のない所は -12dB、声の前後 0.1秒を含む範囲は -20dB
- 下げる・上げるは 0.15秒かけて直線で動かす(down と up)
- 声が続くときは np.minimum で低いほうを取るので、文と文の間で BGM が一瞬だけ戻ることがない
- 頭の 0.5秒でフェードイン、最後の 0.8秒でフェードアウト
BGM は、権利を持っている手持ちの曲の歌のない区間(38.8秒ぶん)を使っています。動画のほうが長いときは、同じ区間を 2秒の等パワーのクロスフェード(cos と sin)でつないで延ばします。
loudnorm を2回使う
どの動画も同じ大きさで聞こえるように、目標を I=-16 LUFS・TP=-1.5 dBTP・LRA=11 にそろえました。
loudnorm は1回だけ使うと、ダイナミックモード(音量を時間ごとに上げ下げする)になります。せっかく作ったダッキングの差を崩したくないので、1回目で測り、2回目でその測定値を渡して linear=true にします。
1回目(測るだけ):
def loudness(path, extra_af=''):
af = (extra_af + ',' if extra_af else '') + f'loudnorm=I={TARGET_I}:TP={TARGET_TP}:LRA={TARGET_LRA}:print_format=json'
e = subprocess.run([FFMPEG, '-hide_banner', '-nostats', '-i', path, '-vn', '-af', af, '-f', 'null', '-'], capture_output=True, text=True).stderr
return json.loads(e[e.rindex('{'):e.rindex('}') + 1])
print_format=json にすると、標準エラーの最後に JSON が出ます。最後の {〜} を切り出して読みます。
2回目(測定値を渡して、映像と合わせて書き出す):
m1 = loudness(mixwav)
ln = (f"loudnorm=I={TARGET_I}:TP={TARGET_TP}:LRA={TARGET_LRA}:measured_I={m1['input_i']}:measured_TP={m1['input_tp']}:"
f"measured_LRA={m1['input_lra']}:measured_thresh={m1['input_thresh']}:offset={m1['target_offset']}:linear=true:print_format=json")
この ln を -af に渡し、映像は -c:v copy、音は -ar 48000 -c:a aac -b:a 192k、最後に -movflags +faststart を付けて MP4 にします。loudnorm は内部で 192kHz にアップサンプルするので、-ar を付けないと出力のサンプリングレートが変わってしまいます。
書き出した MP4 を、もう一度 loudness() で測り直して report.txt に残します。パスワードの長さの動画の例です。
loudnorm 1回目(まぜた直後): I=-13.00 TP=0.02 LRA=2.60
loudnorm 2回目(書き出し時): 出力 I=-15.93 TP=-3.00 LRA=2.30 方式=linear
できあがりMP4を測り直し: I=-16.02 TP=-2.99 LRA=2.50
確認シートで字幕と声が合わない秒: なし
「方式=linear」は、2回目の JSON の normalization_type です。ここが dynamic になっていたら、測定値の渡し方か、TP の余裕が足りないことを疑います。
21本の測り直しの値は、I が -16.00〜-16.02 LUFS、TP が -2.96〜-4.77 dBTP の範囲でした。
5. 読み間違いの直し方
VOICEVOX は漢字の読みを自動で付けます。たいていは正しいのですが、数字と助数詞・同じ字で読みが違う語で、ときどき外れます。--kana で kana を見て、外れていたら読み上げ文のほうだけひらがなにします。
| 書いた文 | エンジンの読み | 直した文 | 直したあとの読み |
|---|---|---|---|
| 千件なら、最大、10回で見つかる。 | セン'ゲンナラ… | せんけんなら、最大、10回で見つかる。 | センケンナラ'… |
| 違う行なら、自動で合流。 | チガウ'/クダリナラ… | 違うぎょうなら、自動で合流。 | チガウ'/ギョ'ウナラ… |
| 20日分の売上。 | ハ_ツカ'ブンノ/ウリアゲ' | (直さない) | — |
- 「千件」:「センゲン」と濁ってしまうので「せんけん」と書きます
- 「行」:「違う行」は「クダリ」と読まれました。git の行の話なので「ぎょう」と書きます。同じ動画では「にぎょうめ」「おなじぎょう」のように、行はすべてひらがなにしました
- 「20日分」:「ハツカブン」と正しく読まれたので、漢字のままにしています
逆に、ひらがなにすると崩れることもあります。
8文字と比べて
kana : ハチ'モジト/クラベテ'
はちもじと比べて
kana : ワ'/チモジト'/クラベテ'
「はちもじと」は、先頭の「は」が助詞の「ワ」として読まれてしまいました。ここは「8文字」のまま残します。ほかにも「みっつめ」や「イースリー」で切れ目がおかしくなったので、そういうときは数字・漢字に戻すか、読点で区切ります。
決まりとしては、
1. 数字・記号・英字は、読みをひらがな・カタカナで書く(3バイト→さんバイト、UTF-8→ユーティーエフエイト、.exe→ドット、イーエックスイー)
2. --kana で読みを見る
3. おかしい所だけ直す。ひらがなで崩れたら、元の表記か読点に戻す
4. 直したら、もう一度 --kana
の繰り返しです。--kana は合成をしないので数秒で終わります。
6. ハマったこと
一時 PNG でディスクがいっぱいになる
映像は、ブラウザで1コマずつ描いて連番 PNG にしてから、ffmpeg で MP4 にしています。1080×1920 の PNG は1コマ 1.2〜1.4MB ほどあり、33秒の動画(約990コマ)で 約1.3GB、44秒なら1.8GB 近くになります。
声を入れると動画が 1.2〜2倍に伸びるうえ、何本か同時に書き出すと、空きが数GBのディスクではすぐに足りなくなりました。
- PNG のフォルダは、MP4 にしたらすぐ消す(残すのはオプションを付けたときだけ)
- 同時に書き出す本数は、空き容量を見て決める
- 声だけ直したときは --no-render で、前回の映像を使い回す(音だけ作り直す)
空の wav がキャッシュとして残る
合成の途中で止めると、wav が 0バイトのまま残ることがありました。最初は os.path.exists(wav) だけでキャッシュを判定していたので、次の実行で空のファイルを読みにいって失敗します。
if not os.path.exists(wav) or os.path.getsize(wav) == 0: # 途中で止まった空ファイルは作り直す
サイズが 0 のときも作り直すようにしました。
確認シート
最後に、1秒ごとのコマを6列に並べた画像を出しています。各コマの下に「何秒・どの字幕が出ているか・どの声が鳴っているか」を書き、合わない秒は赤にします。report.txt の表と合わせて、書き出した動画を全部見なくても、ずれがないかを確かめられます。
7. クレジットと利用規約
声は VOICEVOX の「冥鳴ひまり(ノーマル)」を使っています。
- VOICEVOX の利用規約では、商用・非商用を問わず使えますが、VOICEVOX を使ったことが分かるクレジット表記が必要です。あわせて、各音声ライブラリ(キャラクター)の規約にも従う必要があります
- VOICEVOX 利用規約:voicevox.hiroshiba.jp
- VOICEVOX Q&A(「VOICEVOX:キャラクター名」の表記、動画では概要欄や動画内に記載):voicevox.hiroshiba.jp
- 冥鳴ひまりは、商用・非商用ともに利用でき、利用の際はクレジット表記が必要です。表記は VOICEVOX:冥鳴ひまり にしています。公序良俗に反する使い方などは禁止されています
- 冥鳴ひまり(VOICEVOX):voicevox.hiroshiba.jp
- 冥鳴ひまり 利用規約:meimeihimari.com
make_voice.py は、投稿文の最後の行に VOICEVOX:冥鳴ひまり がなければ足したファイルを作るようにしています。付け忘れを防ぐためです。
なお、macOS の say コマンドの声(システム音声)は、使用許諾契約で「個人的な非商用の利用」に限られ、公開や商用での利用は認められていません(macOS Sonoma の使用許諾契約 2.F「Voices; Live Captions」:apple.com )。試作には便利ですが、公開する動画には使っていません。
まとめ
- VOICEVOX ENGINE は audio_query → synthesis の2回の POST。speedScale などは AudioQuery を書き換えて渡す
- 読みは kana や accent_phrases[].moras で確かめ、外れた所だけ読み上げ文をひらがなにする(画面の字幕はそのまま)
- 声を縮めずに、区間を「0.15秒+声+0.3秒」まで伸ばす。台本の中の秒は区間ごとに比例で動かす
- 声の時刻は分かっているので、ダッキングは時刻どおりの音量カーブで作る
- loudnorm は 1回目で測り、2回目で measured_* と linear=true。21本とも -16.00〜-16.02 LUFS
- 一時 PNG は 1本で 1GB を超えるので、すぐ消す。空のキャッシュはサイズも見る
参考:
- ffmpeg フィルタのドキュメント(loudnorm):ffmpeg.org
- ffmpeg フィルタのドキュメント(adelay / amix / sidechaincompress / volume):ffmpeg.org ・ ffmpeg.org ・ ffmpeg.org ・ ffmpeg.org
- VOICEVOX ENGINE(HTTP API):VOICEVOX/voicevox_engine
---
🎉 #VOICEVOX 音声合成コアがストリーミングトークに対応しました!
音声を合成しながら再生する機能をプログラムへ導入できます。
C API、Python、Rust、iOS、Android用のライブラリをご用意しています。
▼ Key Changes
github.com/VOICEVOX/voicevox_core/blob/main/docs/…
▼ サンプルコード t.co
242 likes · 77 reposts · 7,489 views
子音の長さ調整ができるとサ行の無声化や巻き舌の調声などがかなりしやすくなります
自分はカバー動画ばっかり作ってる人間なので、ソング・ハミング機能にしか触れていませんが、トーク機能の方にも結構便利そうな機能が実装されているので是非お試しを
github.com/VOICEVOX/voicevox/releases/tag/0.26.0
11 views
📣 サードパーティアプリ開発者の方向けのご案内 📣
10月24日に予定している VOICEVOX 0.26 アップデートで、インストーラー版VOICEVOXソフトウェアのエンジンのファイルパスが変わります。
こちらのIssueで詳細や、新旧の保存先に対応する方法などをご案内しています。
voicevox#3172
64 likes · 26 reposts · 3,386 views
#VOICEVOX バージョン 0.26 を先行配布します。
ストリーミングトーク・ソングボリューム/音素タイミング編集・エンジン分離などが実装されています。
不具合等ございましたらご報告頂けますと幸いです。
(エンジン同梱でない「インストーラー版」がおすすめです)
github.com/VOICEVOX/voicevox/releases/tag/0.26.0
141 likes · 71 reposts · 5,608 views
voicevox_core でMIGraphXをサポートしてみる実験
voicevox_core#1459
1 like · 67 views
Mentions
VOICEVOX Engine for UE5プラグイン ver2.0 概要 VOICEVOX Engine for UE5プラグインはヒホさんが制作・公開されている、 VOICEVOXの非公式 UnrealEngine5対応版、VOICEVOX COREをUE5で使用するためのプラグインです。 対応プラットフォーム&エンジンバージョン Windows11 macOS UnrealEngine5.0~5.7 VOICEVOX CORE対応バージョン プラグインのバージョンごとに対応するVOICEVOX COREになります。 ver2.x VOICEVOX CORE 0.16.0 ! VOICEVOX NEMO COREは16がリリースされてないため、ver2.x以降は対象外となります。 ver1.x VOICEVOX CORE 0.15.7 VOICEVOX NEMO CORE 0.15.0 プラグイン使用準備 Githubに記載したReadMEを見てください。 基本的な処理フロー VOICEVOX Engine for UE5プラグインの基本的な利用フローは次のとおりです。 C++ C++ から利用する手順を説明します。 各ヘッダーファイルについて プラグインに作成した各ヘッダーファイルの説明です。 cppファイルも公開しているので、セットで見てください。 VoicevoxUECore UVoicevoxCoreSubsystem.h VOICEVOX COREのAPIへ接続するためのSubsystemです。 C++で利用する場合はUVoicevoxCoreSubsystemから各VOICEVOX CORE APIを呼ぶことを想定しています。 EngineSubystemの派生クラスです。 EditorとGame両方で使用する想定 VoicevoxEngine VoicevoxBlueprintLibrary.h VOICE VOX COREのAPIを実行するBlueprint公開用ノードをまとめています。 VoicevoxAsyncTask.h VOICE VOX COREのAPIを実行するBlueprint公開用ノードをまとめています。 負荷がかかる一部のAPIをLatentノードにしています。 全体の呼び出し順序 Build.cs に依存モジュールを追加する Build.csのPublicDependencyModuleNames、もしくはPrivateDependencyModuleNamesに以下のモジュール名を追加してください。 C++のみ VoicevoxUECore Blueprint、もしくはEditorを使用 VoicevoxUECore VoicevoxEngine PublicDependencyModuleNames.AddRange( new string[] { "Core", "InputCore", "CoreUObject", "Engine" } ); PrivateDependencyModuleNames.AddRange( new string[] { "VoicevoxUECore", "VoicevoxEngine", } C++で実装 UE側はUVoicevoxCoreSubsystemを用いて実装します。 以下、ゲーム側実装例になります。 VOICEVOX COREの初期化 最初にVOICEVOX COREの初期化APIを必ず実行してください。 ゲーム側の利用例は次のとおりです。 #include "Engine/Engine.h" #include "Subsystems/VoicevoxCoreSubsystem.h" void InitializeVoicevoxExample() { if (!GEngine) { return; } UVoicevoxCoreSubsystem* Core = GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>(); if (!Core) { return; } // CPU モードで初期化。全モデル一括読み込みは要求しない。 const bool bInitialized = Core->Initialize(false, 0, false); if (!bInitialized) { UE_LOG(LogTemp, Error, TEXT("VOICEVOX initialization failed")); return; } // vvmファイル名を渡してモデルデータを読み込む const FString ModelPath = TEXT("0"); if (!Core->LoadVoiceModel(ModelPath)) { UE_LOG(LogTemp, Error, TEXT("VOICEVOX model load failed")); return; } } 初期化・モデル読み込みは時間がかかる可能性があるため、上記はAPIの呼び出し順序を示す最小例です。 ! ゲームのメインスレッドで実行することを推奨しません。 実際にはロード画面やワーカースレッド、非同期タスクなどでAPIを実行してください。 テキストから WAV データを生成する RunTextToSpeech() は WAV 形式のバイト配列 TArray<uint8> を返します。 スタイルIDはロードしたVVMファイルから利用可能なIDを指定してください。 #include "Engine/Engine.h" #include "Subsystems/VoicevoxCoreSubsystem.h" TArray<uint8> GenerateVoicevoxWavExample() { if (!GEngine) { return {}; } const UVoicevoxCoreSubsystem* Core = GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>(); if (!Core || !Core->GetIsInitialize()) { return {}; } const VoicevoxStyleId StyleId = 0; // 実際に利用可能なスタイルIDを指定 return Core->RunTextToSpeech( StyleId, TEXT("こんにちは、Unreal Engine から音声を合成しています。"), false, // 通常テキスト true // 疑問文の調整を有効化 ); } 戻り値はWAV のバイト列です。 USoundWaveで再生する場合は変換処理が必要です。 ! 合成処理をゲームのメインスレッドで実行することを推奨しません。 実際にはワーカースレッド、もしくは非同期タスクなどでAPIを実行してください。 AudioQuery を編集して合成する 音声のパラメータを調整したい場合は、まず GetAudioQuery() でAudioQueryを取得し、編集後に RunSynthesis() に渡します。 #include "Engine/Engine.h" #include "Subsystems/VoicevoxCoreSubsystem.h" TArray<uint8> GenerateFromQueryExample() { if (!GEngine) { return {}; } const UVoicevoxCoreSubsystem* Core = GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>(); if (!Core || !Core->GetIsInitialize()) { return {}; } const VoicevoxStyleId StyleId = 0; FVoicevoxAudioQuery Query = Core->GetAudioQuery( StyleId, TEXT("音声合成のテストです。"), false); // 必要に応じて Query の各パラメータを変更する。 return Core->RunSynthesis(Query, StyleId, true); } 終了処理 VOICEVOX CORE を利用しなくなったときはFinalize() を呼び出します。 if (GEngine) { if (UVoicevoxCoreSubsystem* Core = GEngine->GetEngineSubsystem<UVoicevoxCoreSubsystem>()) { Core->Finalize(); } } UVoicevoxCoreSubsystem API一覧 UVoicevoxCoreSubsystemで公開されているAPI一覧です。 ライフサイクル・初期化 API宣言 説明 bool Initialize(bool bUseGPU, int CPUNumThreads = 0, bool bLoadAllModels = false); VOICEVOX COREの初期化 bool GetIsInitialize() const; 初期化処理実行済みかを取得 void Finalize(); VOICEVOX COREの終了処理 モデル管理 API宣言 説明 bool LoadModel(VoicevoxStyleId StyleId) const; スタイルIDでモデルをロード(旧APIを残しています。実際はLoadVoiceModelを使用してください) bool LoadVoiceModel(const FString& VvmFileName) const; 指定VVMファイルをロード bool AllLoadVoiceModel() const; 全VVMファイルをロード bool UnloadVoiceModel(const FString& VvmFileName) const; 指定VVMファイルをアンロード bool IsLoadedVoiceModel(const FString& VvmFileName) const; 指定VVMファイルのロード状態を確認 テキスト解析・AudioQuery API宣言 説明 TArray<FVoicevoxAccentPhrase> OpenJTalkRcAnalyze(const FString& Text) const; 日本語テキストをアクセント句へ解析 FVoicevoxAudioQuery GetAudioQuery(VoicevoxStyleId StyleId, const FString& Message, bool bKana) const; テキストからAudioQueryを生成 FVoicevoxAudioQuery GetAudioQueryFromAccentPhrases(const TArray<FVoicevoxAccentPhrase>& AccentPhrases) const; アクセント句からAudioQueryを生成 TArray<FVoicevoxAccentPhrase> SynthesizerCreateAccentPhrases(VoicevoxStyleId StyleId, const FString& Text, bool bKana) const; スタイル指定でアクセント句を生成 TArray<FVoicevoxAccentPhrase> SynthesizerReplace(EReplaceType ReplaceType, const TArray<FVoicevoxAccentPhrase>& AccentPhrases, VoicevoxStyleId StyleId) const; アクセント句の指定パラメータを再生成 音声合成 API宣言 説明 TArray<uint8> RunTextToSpeech(VoicevoxStyleId StyleId, const FString& Message, bool bKana, bool bEnableInterrogativeUpspeak) const; テキストからWAVバイト列を生成 TArray<uint8> RunSynthesis(const char* AudioQueryJson, VoicevoxStyleId StyleId, bool bEnableInterrogativeUpspeak) const; AudioQueryまたはアセットからWAVバイト列を生成 TArray<uint8> RunSynthesis(const FVoicevoxAudioQuery& AudioQuery, VoicevoxStyleId StyleId, bool bEnableInterrogativeUpspeak) const; AudioQueryまたはアセットからWAVバイト列を生成 TArray<uint8> RunSynthesis(const UVoicevoxQuery& VoicevoxQuery, bool bEnableInterrogativeUpspeak) const; AudioQueryまたはアセットからWAVバイト列を生成 リップシンク API宣言 説明 static TArray<FVoicevoxLipSync> GetLipSyncList(FVoicevoxAudioQuery AudioQuery, bool bIsSimple = false, float PitchModulation = 1.0f); AudioQueryからリップシンクデータを生成 メタ情報・実行環境 API宣言 説明 TArray<FVoicevoxMeta> GetMetaList() const; 話者メタ情報を取得 TArray<FVoicevoxMeta> GetVoiceModelFileMetaList(const FString& VvmFileName) const; 指定VVMのメタ情報を取得 TArray<FVoicevoxMeta> GetAllVoiceModelFileMetaList() const; 全VVMのメタ情報を取得 FString GetMetaName(VoicevoxStyleId StyleId) const; スタイルIDに対応する名前を取得 TMap<int, FString> GetVvmFileNameMapToStyleId() const; スタイルIDからVVM名への対応表を取得 FString GetVoicevoxVersion(const FString& CoreName); 指定COREのバージョン取得 bool IsGpuMode(const FString& CoreName); 指定COREのGPUモードを確認 FVoicevoxSupportedDevices GetSupportedDevices(const FString& CoreName); 指定COREの対応デバイス取得 TArray<FString> GetCoreNameList(); 初期化済みCORE名一覧を取得 ユーザー辞書 API宣言 説明 VoicevoxUserDictWord UserDictWordMake(const FString& Surface, const FString& Pronunciation, uintptr_t AccentType) const; 辞書単語の構造体を生成 bool UserDictInitialize(const FString& DictPath = TEXT("")) const; ユーザー辞書を初期化 TArray<uint8_t> UserDictAddWord(const VoicevoxUserDictWord* Word) const; 辞書単語を追加しUUIDを返す bool RewriteUserDictWord(const TArray<uint8_t>& WordUuid, const VoicevoxUserDictWord *Word) const; 辞書単語を更新 bool DeleteUserDictWord(const TArray<uint8_t>& WordUuid) const; 辞書単語を削除 TMap<FString, FVoicevoxRegisteredUserDictWord> GetUserDictWord() const; 登録済み辞書単語を取得 bool UserDictSave(const FString& Path) const; 辞書をファイルに保存 void UserDictDelete() const; 辞書を破棄 Blueprint Blueprint から利用する手順を説明します。 Blueprint での利用フロー 処理フローの例です。 実際にゲームへ組み込む際は、次の順序を基本にします。 VOICEVOX CORE を初期化する。 必要な音声モデルをロードする。 TextToSpeechOutput() などで音声を生成する。 戻り値の USoundWave が有効なら再生する。 必要なら AudioQuery からリップシンク情報を生成し、対応する Audio Component に渡す。 終了時に CORE のリソースを解放する。 Blueprint API 同期APIと状態参照・辞書操作 Blueprintノード名 公開種別 C++宣言 VoicevoxFinalize BlueprintCallable static void Finalize() GetOpenJTalkRcAnalyze BlueprintCallable static UPARAM(DisplayName="OpenJTalkAnalyze") TArray<FVoicevoxAccentPhrase> OpenJTalkRcAnalyze(const FString& Text) VoicevoxUnLoadVoiceModel BlueprintCallable static bool UnloadVoiceModel(FString VvmFileName) VoicevoxIsLoadedVoiceModel BlueprintCallable static bool IsLoadedVoiceModel(FString VvmFileName) GetVoicevoxCoreNameList BlueprintPure static UPARAM(DisplayName="CoreNameList") TArray<FString> GetCoreNameList() GetVoicevoxMetas BlueprintPure static UPARAM(DisplayName="Metas") TArray<FVoicevoxMeta> GetMetasToList() GetVoicevoxModelFileMetas BlueprintPure static UPARAM(DisplayName="Metas") TArray<FVoicevoxMeta> GetVoiceModelFileMetaList(const FString& VvmFileName) GetAllVoicevoxModelFileMetas BlueprintCallable static UPARAM(DisplayName="Metas") TArray<FVoicevoxMeta> GetAllVoiceModelFileMetaList() GetVoicevoxSupportedDevices BlueprintPure static UPARAM(DisplayName="SupportedDevices") FVoicevoxSupportedDevices GetSupportedDevices(const FString& CoreName) VoicevoxVersion BlueprintPure static UPARAM(DisplayName="VoicevoxCoreVersion") FString GetVoicevoxCoreVersion(const FString& CoreName) IsVoicevoxGpuMode BlueprintPure static UPARAM(DisplayName="GPU Mode") bool IsVoicevoxGpuMode(const FString& CoreName) VoicevoxTextToSpeechOutput BlueprintCallable static UPARAM(DisplayName="Sound") USoundWave* TextToSpeechOutput(int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true) VoicevoxToTextAudioQueryOutput BlueprintCallable static UPARAM(DisplayName="Sound") USoundWave* TextToAudioQueryOutput(int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true) GetVoicevoxAudioQuery BlueprintCallable static UPARAM(DisplayName="AudioQuery") FVoicevoxAudioQuery GetAudioQuery(int32 StyleId, FString Message, bool bRunKana = false) GetVoicevoxAudioQueryFromAccentPhrases BlueprintCallable static UPARAM(DisplayName="AudioQuery") FVoicevoxAudioQuery GetAudioQueryFromAccentPhrases(const TArray<FVoicevoxAccentPhrase>& AccentPhrases) GetVoicevoxAccentPhrase BlueprintCallable static UPARAM(DisplayName="AccentPhraseAnalyze") TArray<FVoicevoxAccentPhrase> GetSynthesizerAccentPhrase(int32 StyleId, const FString& Text, bool bKana) VoicevoxReplace BlueprintCallable static UPARAM(DisplayName="AccentPhraseAnalyze") TArray<FVoicevoxAccentPhrase> SynthesizerReplace(EReplaceType ReplaceType, const TArray<FVoicevoxAccentPhrase>& AccentPhrases, int32 StyleId) VoicevoxAudioQueryOutput BlueprintCallable static UPARAM(DisplayName="Sound") USoundWave* AudioQueryOutput(FVoicevoxAudioQuery AudioQuery, int32 StyleId, bool bEnableInterrogativeUpspeak = true) VoicevoxQueryAssetOutput BlueprintCallable static UPARAM(DisplayName="Sound") USoundWave* VoicevoxQueryOutput(UVoicevoxQuery* VoicevoxQuery, bool bEnableInterrogativeUpspeak = true) GetVoicevoxLipSyncList BlueprintCallable static UPARAM(DisplayName="LipSyncList") TArray<FVoicevoxLipSync> GetLipSyncList(FVoicevoxAudioQuery AudioQuery) VoicevoxUserDictWordMake BlueprintCallable static UPARAM(DisplayName="UserDictWord") FVoicevoxCoreUserDictWord UserDictWordMake(const FString& Surface, const FString& Pronunciation, int64 AccentType) VoicevoxCoreUserDictInitialize BlueprintCallable static bool UserDictInitialize(const FString& DictPath = TEXT("")) VoicevoxUserDictAddWord BlueprintCallable static UPARAM(DisplayName="UUID") TArray<uint8> UserDictAddWord(const FVoicevoxCoreUserDictWord Word) VoicevoxRewriteUserDictWord BlueprintCallable static bool RewriteUserDictWord(const TArray<uint8>& WordUuid, const FVoicevoxCoreUserDictWord Word) VoicevoxDeleteUserDictWord BlueprintCallable static bool DeleteUserDictWord(const TArray<uint8>& WordUuid) GetVoicevoxUserDictWord BlueprintCallable static TMap<FString, FVoicevoxRegisteredUserDictWord> GetUserDictWord() VoicevoxCoreUserDictSave BlueprintCallable static bool UserDictSave(const FString& Path) VoicevoxCoreUserDictDelete BlueprintCallable static void UserDictDelete() 非同期アクションノード Blueprintノード名 公開種別 C++宣言 VoicevoxInitialize BlueprintCallable static UVoicevoxInitializeAsyncTask* Initialize(UObject* WorldContextObject, bool bUseGPU, int CPUNumThreads = 0) VoicevoxLoadModel BlueprintCallable static UVoicevoxLoadModelAsyncTask* LoadModel(UObject* WorldContextObject, int32 StyleId) VoicevoxLoadVoiceModel BlueprintCallable static UVoicevoxLoadVoiceModelAsyncTask* LoadVoiceModel(UObject* WorldContextObject, FString VvmFileName) VoicevoxAllLoadVoiceModel BlueprintCallable static UVoicevoxLoadVoiceModelAsyncTask* AllLoadVoiceModel(UObject* WorldContextObject) VoicevoxTextToSpeechOutputAsync BlueprintCallable static UVoicevoxTextToSpeechAsyncTask* TextToSpeech(UObject* WorldContextObject, int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true) VoicevoxToTextAudioQueryOutputAsync BlueprintCallable static UVoicevoxTextToSpeechAsyncTask* TextToAudioQuery(UObject* WorldContextObject, int32 StyleId, FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true) VoicevoxAudioQueryOutputAsync BlueprintCallable static UVoicevoxAudioQueryToSpeechAsyncTask* AudioQueryOutput(UObject* WorldContextObject, int32 StyleId, FVoicevoxAudioQuery AudioQuery, bool bEnableInterrogativeUpspeak = true) VoicevoxQueryAssetOutputAsync BlueprintCallable static UVoicevoxAudioQueryToSpeechAsyncTask* VoicevoxQueryOutput(UObject* WorldContextObject, UVoicevoxQuery* VoicevoxQuery, bool bEnableInterrogativeUpspeak = true) リップシンク付き音声再生コンポーネント Blueprintノード名 公開種別 C++宣言 StopAudioAndLipSync BlueprintCallable void StopAudioAndLipSync() PlayToText BlueprintCallable void PlayToText(FString Message, bool bRunKana = false, bool bEnableInterrogativeUpspeak = true, float SpeedScale = 1.0f, float PitchScale = 0.0f, float IntonationScale = 1.0f, float VolumeScale = 1.0f, float PrePhonemeLength = 0.1f, float PostPhonemeLength = 0.1f) PlayToAudioQuery BlueprintCallable void PlayToAudioQuery(const FVoicevoxAudioQuery& Query, bool bEnableInterrogativeUpspeak = true) PlayToAudioQueryAsset BlueprintCallable void PlayToAudioQueryAsset(UVoicevoxQuery* VoicevoxQuery, bool bEnableInterrogativeUpspeak = true) SetLipSyncDataToAudioQuery BlueprintCallable void SetLipSyncDataToAudioQuery(const FVoicevoxAudioQuery& Query) SetLipSyncDataToAudioQueryAsset BlueprintCallable void SetLipSyncDataToAudioQueryAsset(UVoicevoxQuery* VoicevoxQuery) VoicecoxCharacterLipSyncAudioComponent キャラクターのSkeletalMesh設定 Blueprintノード名 公開種別 C++宣言 SetSkeletalMesh BlueprintCallable void SetSkeletalMesh(USkeletalMeshComponent* SkeletalMesh) リップシンク 以下の記事を見てください。 VoicevoxEngine For UE リップシンクコンポーネント エディタ拡張 VoicevoxEngineプラグインが有効な場合、メニューバーにVOICEVOXメニューが追加されます。 Editor ! エディターはα版となります。 アクセント情報を作成、編集するエディター機能です。 保存でVoicevox AudioQueryアセットを生成、アセット書き出しでSoundWaveアセット、音声書き出しでWavファイルを作成します。 アクセント、イントネーション、長さの編集は未対応です。 もし編集したい場合は、一度Voicevox AudioQueryアセットを生成後、AccentPhaasesの項目を編集してください。 Speaker List プラグインで利用可能なVOICEVOX モデルの一覧です。 Copyボタンを押すことで、style IDをコピーします。 利用時の注意点 初期化とモデル読み込みは音声合成より先に行う。 Initialize() の成功だけでは目的のスタイルのモデルが読み込まれているとは限りません。 重い処理をゲームスレッドに直書きしない。 初期化、モデル読み込み、AudioQuery 生成、合成は負荷を考慮し、適切な非同期実行を設計してください。 スタイルIDを確認する。 読み込んだVVMファイルに格納されているスタイルIDのみ利用可能です。 AquesTalkライクな記法をする場合はルールに従う。 AquesTalkライクな記法をする場合、ルールに違反するとクラッシュします。 例:ボク'ワ/ズ'ンダモン/ナノ'ダ 全てのカナはカタカナで記述される アクセント句は/または、で区切る。、で区切った場合に限り無音区間が挿入される。 カナの手前に_を入れるとそのカナは無声化される アクセント位置を'で指定する。全てのアクセント句にはアクセント位置を 1 つ指定する必要がある。 アクセント句末に?(全角)を入れることにより疑問文の発音ができる ※VOICEVOX ENGINE ReadMeより引用 ライセンス VOICEVOX ENGINEのライセンスを継承し、LGPL v3と、ソースコードの公開が不要な別ライセンスのデュアルライセンスとなります。 利用規約 VOICEVOX Engine for UE5の利用規約はVOICEVOXのホームページに記載された利用規約を継承するため、利用前に必ず一読してください。 各スピーカーモデルのライセンスはVOICEVOX公式ホームページのキャラクター一覧を参照してください。 ! VOCEVOXの利用規約及び各キャラクターの利用規約を厳守するようにお願いいたします 問い合わせに関して VOICEVOX Engine for UE5プラグインに関して質問があれば、Twitter、Qiitaのコメント欄で聞いてもらうか、公開したGithubのissueに記載してください。 ! 間違ってもVOICEVOX CORE作者のヒホさん、VOICEVOX公式Twitterアカウント及びVOICEVOX ホームページで、VOICEVOX Engine for UE5プラグインの問い合わせをしないようにお願いいたします。
縦動画の字幕に合わせて VOICEVOX の声を置き、区間を声の長さに伸ばして、BGM を下げつつ -16 LUFS にそろえる(Python+ffmpeg) 字幕つきの縦動画(1080×1920・30fps、30秒前後)を何本も作っています。映像は台本の JSON から書き出す仕組みがすでにあるので、そこに読み上げ音声と BGM を足す make_voice.py(340行ほど)を書きました。 難しかったのは、声の長さが字幕の表示時間に収まらないことです。字幕は「読む速さ」で時間を決めていたので、声に出すと足りない場所がほとんどでした。そこで、声を縮めるのではなく、声が入るように映像の区間のほうを伸ばすことにしました。 この記事で書くこと: - VOICEVOX ENGINE の HTTP API(audio_query → synthesis、speedScale、accent_phrases で読みを確かめる) - 字幕ごとの区間を、声の長さに合わせて伸ばす計算 - BGM を声の下だけ下げる(ダッキング)、loudnorm を2回使って -16 LUFS にそろえる - 読み間違いの直し方(「行」「千件」「20日分」など) - ハマったこと(一時 PNG でディスクがいっぱい、空の wav キャッシュ) 確認した環境:macOS 14、Python 3.12(numpy)、VOICEVOX ENGINE 0.26.0(VOICEVOX アプリに入っているもの)、ffmpeg 7.1。 1. 全体の流れ 1本の動画につき、次の順で処理します。 1. 読み上げ文のテキストを読む(1行=字幕1つ)。なければ字幕から下書きを作る 2. 読み上げ文ごとに VOICEVOX で wav を作る(同じ文・同じ速さならキャッシュを使う) 3. 字幕の区間を、声が入る長さまで伸ばした台本を作る 4. 伸ばした台本で映像を書き出す(既存の仕組み) 5. 声を置いた音と BGM を numpy でまぜ、ダッキングする 6. ffmpeg の loudnorm で測って → そろえて、映像と合わせる(AAC 192k・faststart) 7. 確認用の表(report.txt)と、1秒ごとのコマを並べた確認シートを出す 読み上げ文のファイルは、こういう形です。| の左が元の台本の字幕の秒、右が読み上げる文です。 0-1.8|書類が、千枚。この中から、1枚を探す。 1.8-5.2|前から、1枚ずつ、順番に見ていくと… 16.6-18.7|せんけんなら、最大、10回で見つかる。 字幕と読み上げ文を分けたのは、画面の文字と読みを別々に直したいからです。画面は「千件」のまま、声だけ「せんけん」にできます(5章)。 使い方は2段です。まず --kana で読みだけ確かめ、直し終わったら全部を回します。 python3 make_voice.py scripts/12_password_length.json --kana # 1) 読みの確認だけ(数秒) python3 make_voice.py scripts/12_password_length.json # 2) 全部(書き出し込みで数分) 2. VOICEVOX ENGINE の HTTP API VOICEVOX アプリを入れると、音声合成のエンジンが http://127.0.0.1:50021 で動きます。アプリを開いていないときは、make_voice.py がエンジン単体を起動して /version が返るまで待ちます。 声を作るのは2回の POST です。 1. POST /audio_query?text=...&speaker=14 … 文から「読み・アクセント・長さ」の JSON(AudioQuery)を作る 2. POST /synthesis?speaker=14 … その JSON を本文に送ると、wav が返る speaker=14 は「冥鳴ひまり(ノーマル)」のスタイル ID です。 def post(path, params, body=None): url = ENGINE + path + '?' + urllib.parse.urlencode(params) data = json.dumps(body).encode() if body is not None else b'' req = urllib.request.Request(url, data=data, method='POST', headers={'Content-Type': 'application/json'}) return urllib.request.urlopen(req, timeout=120).read() def audio_query(text): return json.loads(post('/audio_query', {'text': text, 'speaker': SPEAKER})) audio_query で返った JSON を、synthesis に送る前に書き換えられます。ここで速さやサンプリングレートを決めます。 def synth(text, speed, cache_dir): """読み上げ文 → 48kHz モノラルの numpy(前後の無音は切る)。同じ文・速さなら作り直さない""" key = hashlib.sha1(f'{SPEAKER}|{speed}|{text}'.encode()).hexdigest()[:16] wav = os.path.join(cache_dir, f'v_{key}.wav') if not os.path.exists(wav) or os.path.getsize(wav) == 0: # 途中で止まった空ファイルは作り直す q = audio_query(text) q.update(speedScale=speed, prePhonemeLength=0.05, postPhonemeLength=0.1, outputSamplingRate=SR, outputStereo=False) open(wav, 'wb').write(post('/synthesis', {'speaker': SPEAKER}, q)) x = load(wav, 1)[:, 0] thr = 10 ** (-50 / 20); idx = np.where(np.abs(x) > thr)[0] a, b = max(0, idx[0] - int(.01 * SR)), min(len(x), idx[-1] + int(.03 * SR)) return x[a:b] - speedScale:話す速さ。1.1 にしています(オプションで 1.0〜1.15) - prePhonemeLength / postPhonemeLength:文の前後の無音(秒)。間は自分で置くので短めに - outputSamplingRate=48000:動画の音声に合わせて 48kHz - 返った音は、-50dB より小さい前後を切ります。あとの「区間を伸ばす計算」で、声の長さを正確に使いたいからです - キャッシュ名は「話者・速さ・文」のハッシュです。読みを1文だけ直したとき、ほかの文は作り直しません accent_phrases で読みを確かめる audio_query の JSON には、エンジンが付けた読みが入っています。kana は AquesTalk 風の記法(' がアクセント、/ が区切り、_ が無声化)、accent_phrases[].moras[] は1拍(モーラ)ずつの読みです。 make_voice.py の --kana は kana をファイルに書き出すだけですが、拍ごとに見たいときは次の小さいスクリプトを使いました。 import json, sys, urllib.parse, urllib.request ENGINE = 'http://127.0.0.1:50021' SPEAKER = 14 # 冥鳴ひまり(ノーマル) def audio_query(text): url = ENGINE + '/audio_query?' + urllib.parse.urlencode({'text': text, 'speaker': SPEAKER}) req = urllib.request.Request(url, data=b'', method='POST') return json.loads(urllib.request.urlopen(req, timeout=60).read()) for text in sys.argv[1:]: q = audio_query(text) moras = ['/'.join(m['text'] for m in ap['moras']) for ap in q['accent_phrases']] print(text) print(' kana :', q['kana']) print(' moras:', ' | '.join(moras)) 千件なら、最大、10回で見つかる。 kana : セン'ゲンナラ、サイダイ'、ジュッ'カイデ/ミ_ツカル' moras: セ/ン/ゲ/ン/ナ/ラ | サ/イ/ダ/イ | ジュ/ッ/カ/イ/デ | ミ/ツ/カ/ル 「千件」が「センゲン」になっているのが分かります。直し方は5章で書きます。 3. 声の長さに合わせて区間を伸ばす 決まり 字幕 k の区間を「字幕 k の t0 から、次の字幕の t0 まで(最後は動画の終わりまで)」とします。この区間に - 字幕が出てから話し始めるまで 0.15秒 - 声の長さ - 次の字幕までの間 0.3秒(最後の字幕だけ余韻の 0.9秒) が入らなければ、入る長さまで伸ばします。縮めはしません。もともと長い区間は、そのままです。 LEAD, GAP, TAIL = 0.15, 0.30, 0.9 # 字幕が出てから話し始めるまで/次の字幕までの間/最後の余韻 計算 plan() の中心はこの部分です。bounds は字幕の t0 を並べ、最後に元の長さを足したものです。seg_voice[k] は字幕 k に対応する読み上げ文で、r['d'] が声の長さ(秒)です。 nb = [0.0]; segs = [] for k in range(len(subs)): L = bounds[k + 1] - bounds[k]; last = k == len(subs) - 1 r = seg_voice.get(k) newL = max(L, LEAD + r['d'] + (TAIL if last else GAP)) if r else L nb.append(round(nb[-1] + newL, 2)); segs.append((bounds[k], bounds[k + 1], nb[k], nb[k + 1])) if r: r.update(oa=bounds[k], ob=bounds[k + 1], na=nb[k], nb=nb[k + 1], place=round(H + nb[k] + LEAD, 3)) - newL:伸ばしたあとの区間の長さ。max なので縮まない - nb:新しい区間の境目を、頭から積み上げたもの - place:声を置く時刻。新しい区間の頭+0.15秒(H は冒頭の問いの長さで、ない台本では 0) 台本の中の「秒」を動かす 区間を伸ばすと、字幕だけでなく、図のアニメーションの開始時刻なども一緒に動かす必要があります。古い秒を新しい秒に変える関数 remap を作り、区間の中では比例で動かします。 def remap(t): if t >= bounds[-1]: return round(t - bounds[-1] + nb[-1], 3) if t <= 0: return t for oa, ob, na, nb_ in segs: if t <= ob: return round(na + (t - oa) * (nb_ - na) / (ob - oa), 3) if ob > oa else na 台本 JSON の中で「秒」を表すキー(t0・t1・start・end など)を集合で持っておき、JSON をたどって remap をかけます。一方、typeT・dur は「t0 からの秒」(相対)なので動かしません。知らないキーで秒らしい名前(〜T や t数字)が出てきたら、動かさずに名前を表示するようにしました。黙って動かす・黙って残す、のどちらも事故のもとだったからです。 結果 二分探索の説明動画(字幕9つ)の report.txt です。元の長さ 24.00秒が、33.06秒になりました。 元の長さ 24.00秒 → 新しい長さ 33.06秒(MP4 33.06秒) | # | 文 | 置いた時刻 | 区間(元→新) | 声の長さ | 字幕の表示 | 収まり | |---|---|---|---|---|---|---| | 1 | 書類が、千枚。この中から、1枚を探す。 | 0.15 | 0.00-1.80 → 0.00-4.00 | 3.55 | 0.00-4.00 | OK | | 2 | 前から、1枚ずつ、順番に見ていくと… | 4.15 | 1.80-5.20 → 4.00-7.51 | 3.06 | 4.00-7.51 | OK | | 3 | 探しものが一番最後なら、最悪、千回。 | 7.66 | 5.20-8.00 → 7.51-11.33 | 3.37 | 7.51-11.33 | OK | | 4 | 小さい順に並んでいれば、真ん中を見て、半分を捨てる。 | 11.48 | 8.00-10.40 → 11.33-15.58 | 3.80 | 11.33-15.58 | OK | | 5 | 見るたびに、残りが、半分になる。 | 15.73 | 10.40-14.30 → 15.58-19.48 | 2.56 | 15.58-19.48 | OK | | 6 | 10回で、1件まで、しぼれた。 | 19.63 | 14.30-16.60 → 19.48-22.40 | 2.47 | 19.48-22.40 | OK | | 7 | せんけんなら、最大、10回で見つかる。 | 22.55 | 16.60-18.70 → 22.40-25.59 | 2.74 | 22.40-25.59 | OK | | 8 | 件数が千倍になっても、回数は、10回増えるだけ。 | 25.74 | 18.70-20.90 → 25.59-29.87 | 3.83 | 25.59-29.68 | OK | | 9 | 並んでいるから、半分を捨てられる。 | 30.02 | 20.90-24.00 → 29.87-33.06 | 2.14 | 29.87-33.06 | OK | 1行目を例にすると、元の区間は 1.80秒、声は 3.55秒です。0.15+3.55+0.30=4.00秒が必要なので、区間は 0.00〜4.00秒に伸びています。5行目は元の区間が 3.90秒あって、声(2.56秒)が入るので伸ばしていません。 「収まり」は、声の終わりが字幕の表示時間の中(0.05秒まで許す)に入っているかどうかです。これまでに作った21本は、すべて OK でした。 4. ダッキングと loudnorm 2パス 声を置く・BGM を下げる 声を置く場所も BGM の音量も、すべて自分で決めた時刻なので、音は numpy でまぜています。ffmpeg のフィルタで書くなら adelay(遅らせる)+ amix(まぜる)、ダッキングは sidechaincompress(声の大きさに反応して BGM を下げる)や volume の式になります。ただ、声のある時刻は分かっているので、反応を待つコンプレッサーより、時刻どおりの音量カーブのほうが狙いどおりになります。サンプル単位でずれないことも確かめやすくなります。 まず、声だけのトラックと BGM だけのトラックを、それぞれ -16 LUFS にそろえます(測り方は次の節と同じ loudnorm の測定値)。そのうえで、BGM の音量カーブを作ります。 BED, DUCK, RAMP = -12.0, -20.0, 0.15 # BGM:声のない所 -12dB、声の下 -20dB(-16 LUFS にそろえたBGMから) t = np.arange(N) / SR; g = np.full(N, BED) for r in rows: a, b = r['place'] - 0.1, r['place'] + r['spoken'] + 0.1 down = np.clip((t - (a - RAMP)) / RAMP, 0, 1); up = np.clip(((b + RAMP) - t) / RAMP, 0, 1) g = np.minimum(g, BED + (DUCK - BED) * np.minimum(down, up)) gain = 10 ** (g / 20) * np.clip(t / 0.5, 0, 1) * np.clip((total - t) / 0.8, 0, 1) mix = narr[:, None] + bgm * gain[:, None] - 声のない所は -12dB、声の前後 0.1秒を含む範囲は -20dB - 下げる・上げるは 0.15秒かけて直線で動かす(down と up) - 声が続くときは np.minimum で低いほうを取るので、文と文の間で BGM が一瞬だけ戻ることがない - 頭の 0.5秒でフェードイン、最後の 0.8秒でフェードアウト BGM は、権利を持っている手持ちの曲の歌のない区間(38.8秒ぶん)を使っています。動画のほうが長いときは、同じ区間を 2秒の等パワーのクロスフェード(cos と sin)でつないで延ばします。 loudnorm を2回使う どの動画も同じ大きさで聞こえるように、目標を I=-16 LUFS・TP=-1.5 dBTP・LRA=11 にそろえました。 loudnorm は1回だけ使うと、ダイナミックモード(音量を時間ごとに上げ下げする)になります。せっかく作ったダッキングの差を崩したくないので、1回目で測り、2回目でその測定値を渡して linear=true にします。 1回目(測るだけ): def loudness(path, extra_af=''): af = (extra_af + ',' if extra_af else '') + f'loudnorm=I={TARGET_I}:TP={TARGET_TP}:LRA={TARGET_LRA}:print_format=json' e = subprocess.run([FFMPEG, '-hide_banner', '-nostats', '-i', path, '-vn', '-af', af, '-f', 'null', '-'], capture_output=True, text=True).stderr return json.loads(e[e.rindex('{'):e.rindex('}') + 1]) print_format=json にすると、標準エラーの最後に JSON が出ます。最後の {〜} を切り出して読みます。 2回目(測定値を渡して、映像と合わせて書き出す): m1 = loudness(mixwav) ln = (f"loudnorm=I={TARGET_I}:TP={TARGET_TP}:LRA={TARGET_LRA}:measured_I={m1['input_i']}:measured_TP={m1['input_tp']}:" f"measured_LRA={m1['input_lra']}:measured_thresh={m1['input_thresh']}:offset={m1['target_offset']}:linear=true:print_format=json") この ln を -af に渡し、映像は -c:v copy、音は -ar 48000 -c:a aac -b:a 192k、最後に -movflags +faststart を付けて MP4 にします。loudnorm は内部で 192kHz にアップサンプルするので、-ar を付けないと出力のサンプリングレートが変わってしまいます。 書き出した MP4 を、もう一度 loudness() で測り直して report.txt に残します。パスワードの長さの動画の例です。 loudnorm 1回目(まぜた直後): I=-13.00 TP=0.02 LRA=2.60 loudnorm 2回目(書き出し時): 出力 I=-15.93 TP=-3.00 LRA=2.30 方式=linear できあがりMP4を測り直し: I=-16.02 TP=-2.99 LRA=2.50 確認シートで字幕と声が合わない秒: なし 「方式=linear」は、2回目の JSON の normalization_type です。ここが dynamic になっていたら、測定値の渡し方か、TP の余裕が足りないことを疑います。 21本の測り直しの値は、I が -16.00〜-16.02 LUFS、TP が -2.96〜-4.77 dBTP の範囲でした。 5. 読み間違いの直し方 VOICEVOX は漢字の読みを自動で付けます。たいていは正しいのですが、数字と助数詞・同じ字で読みが違う語で、ときどき外れます。--kana で kana を見て、外れていたら読み上げ文のほうだけひらがなにします。 | 書いた文 | エンジンの読み | 直した文 | 直したあとの読み | |---|---|---|---| | 千件なら、最大、10回で見つかる。 | セン'ゲンナラ… | せんけんなら、最大、10回で見つかる。 | センケンナラ'… | | 違う行なら、自動で合流。 | チガウ'/クダリナラ… | 違うぎょうなら、自動で合流。 | チガウ'/ギョ'ウナラ… | | 20日分の売上。 | ハ_ツカ'ブンノ/ウリアゲ' | (直さない) | — | - 「千件」:「センゲン」と濁ってしまうので「せんけん」と書きます - 「行」:「違う行」は「クダリ」と読まれました。git の行の話なので「ぎょう」と書きます。同じ動画では「にぎょうめ」「おなじぎょう」のように、行はすべてひらがなにしました - 「20日分」:「ハツカブン」と正しく読まれたので、漢字のままにしています 逆に、ひらがなにすると崩れることもあります。 8文字と比べて kana : ハチ'モジト/クラベテ' はちもじと比べて kana : ワ'/チモジト'/クラベテ' 「はちもじと」は、先頭の「は」が助詞の「ワ」として読まれてしまいました。ここは「8文字」のまま残します。ほかにも「みっつめ」や「イースリー」で切れ目がおかしくなったので、そういうときは数字・漢字に戻すか、読点で区切ります。 決まりとしては、 1. 数字・記号・英字は、読みをひらがな・カタカナで書く(3バイト→さんバイト、UTF-8→ユーティーエフエイト、.exe→ドット、イーエックスイー) 2. --kana で読みを見る 3. おかしい所だけ直す。ひらがなで崩れたら、元の表記か読点に戻す 4. 直したら、もう一度 --kana の繰り返しです。--kana は合成をしないので数秒で終わります。 6. ハマったこと 一時 PNG でディスクがいっぱいになる 映像は、ブラウザで1コマずつ描いて連番 PNG にしてから、ffmpeg で MP4 にしています。1080×1920 の PNG は1コマ 1.2〜1.4MB ほどあり、33秒の動画(約990コマ)で 約1.3GB、44秒なら1.8GB 近くになります。 声を入れると動画が 1.2〜2倍に伸びるうえ、何本か同時に書き出すと、空きが数GBのディスクではすぐに足りなくなりました。 - PNG のフォルダは、MP4 にしたらすぐ消す(残すのはオプションを付けたときだけ) - 同時に書き出す本数は、空き容量を見て決める - 声だけ直したときは --no-render で、前回の映像を使い回す(音だけ作り直す) 空の wav がキャッシュとして残る 合成の途中で止めると、wav が 0バイトのまま残ることがありました。最初は os.path.exists(wav) だけでキャッシュを判定していたので、次の実行で空のファイルを読みにいって失敗します。 if not os.path.exists(wav) or os.path.getsize(wav) == 0: # 途中で止まった空ファイルは作り直す サイズが 0 のときも作り直すようにしました。 確認シート 最後に、1秒ごとのコマを6列に並べた画像を出しています。各コマの下に「何秒・どの字幕が出ているか・どの声が鳴っているか」を書き、合わない秒は赤にします。report.txt の表と合わせて、書き出した動画を全部見なくても、ずれがないかを確かめられます。 7. クレジットと利用規約 声は VOICEVOX の「冥鳴ひまり(ノーマル)」を使っています。 - VOICEVOX の利用規約では、商用・非商用を問わず使えますが、VOICEVOX を使ったことが分かるクレジット表記が必要です。あわせて、各音声ライブラリ(キャラクター)の規約にも従う必要があります - VOICEVOX 利用規約:voicevox.hiroshiba.jp - VOICEVOX Q&A(「VOICEVOX:キャラクター名」の表記、動画では概要欄や動画内に記載):voicevox.hiroshiba.jp - 冥鳴ひまりは、商用・非商用ともに利用でき、利用の際はクレジット表記が必要です。表記は VOICEVOX:冥鳴ひまり にしています。公序良俗に反する使い方などは禁止されています - 冥鳴ひまり(VOICEVOX):voicevox.hiroshiba.jp - 冥鳴ひまり 利用規約:meimeihimari.com make_voice.py は、投稿文の最後の行に VOICEVOX:冥鳴ひまり がなければ足したファイルを作るようにしています。付け忘れを防ぐためです。 なお、macOS の say コマンドの声(システム音声)は、使用許諾契約で「個人的な非商用の利用」に限られ、公開や商用での利用は認められていません(macOS Sonoma の使用許諾契約 2.F「Voices; Live Captions」:apple.com )。試作には便利ですが、公開する動画には使っていません。 まとめ - VOICEVOX ENGINE は audio_query → synthesis の2回の POST。speedScale などは AudioQuery を書き換えて渡す - 読みは kana や accent_phrases[].moras で確かめ、外れた所だけ読み上げ文をひらがなにする(画面の字幕はそのまま) - 声を縮めずに、区間を「0.15秒+声+0.3秒」まで伸ばす。台本の中の秒は区間ごとに比例で動かす - 声の時刻は分かっているので、ダッキングは時刻どおりの音量カーブで作る - loudnorm は 1回目で測り、2回目で measured_* と linear=true。21本とも -16.00〜-16.02 LUFS - 一時 PNG は 1本で 1GB を超えるので、すぐ消す。空のキャッシュはサイズも見る 参考: - ffmpeg フィルタのドキュメント(loudnorm):ffmpeg.org - ffmpeg フィルタのドキュメント(adelay / amix / sidechaincompress / volume):ffmpeg.org ・ ffmpeg.org ・ ffmpeg.org ・ ffmpeg.org - VOICEVOX ENGINE(HTTP API):VOICEVOX/voicevox_engine ---
🎉 #VOICEVOX 音声合成コアがストリーミングトークに対応しました! 音声を合成しながら再生する機能をプログラムへ導入できます。 C API、Python、Rust、iOS、Android用のライブラリをご用意しています。 ▼ Key Changes github.com/VOICEVOX/voicevox_core/blob/main/docs/… ▼ サンプルコード t.co
子音の長さ調整ができるとサ行の無声化や巻き舌の調声などがかなりしやすくなります 自分はカバー動画ばっかり作ってる人間なので、ソング・ハミング機能にしか触れていませんが、トーク機能の方にも結構便利そうな機能が実装されているので是非お試しを github.com/VOICEVOX/voicevox/releases/tag/0.26.0
📣 サードパーティアプリ開発者の方向けのご案内 📣 10月24日に予定している VOICEVOX 0.26 アップデートで、インストーラー版VOICEVOXソフトウェアのエンジンのファイルパスが変わります。 こちらのIssueで詳細や、新旧の保存先に対応する方法などをご案内しています。 voicevox#3172
#VOICEVOX バージョン 0.26 を先行配布します。 ストリーミングトーク・ソングボリューム/音素タイミング編集・エンジン分離などが実装されています。 不具合等ございましたらご報告頂けますと幸いです。 (エンジン同梱でない「インストーラー版」がおすすめです) github.com/VOICEVOX/voicevox/releases/tag/0.26.0
voicevox_core でMIGraphXをサポートしてみる実験 voicevox_core#1459