声つきのプレイ動画を、台本と並びの設定から作り直せるようにした。声の位置は、録画したときのログの時刻で決める
Making voiced play videos rebuildable from the script and a sequence config, with voices placed by recorded log timestamps
D31-4-T62-voiced-video-from-script・技術・日本語入力でプレイする仕組み
刑事が問いを入力していく間に、刑事の声が鳴る。
返事が出たら、相手の声が鳴る。42.3 秒の動画を、台本のファイル 3 本と並びの設定 1 本から、何度でも同じに作り直せます。
先に、はっきりさせておきます。
ゲームの中では、声は出ません。
声は、プレイ動画を作る制作の工程でだけ合成しています。
しかも読ませる文は、ゲームが実際に返した台詞の写しです。
遊んでいる間に何かを生成する場所は、この工程にもありません。
この回の位置と、前後の回#
区切り: T58〜T64 作り方と周辺(7 回のうち 5 回目)
先に読むとよい回: T49(AI に採点させる距離)
次に読む回: T63(並列のレーン)
名前 — text-to-speech#
- 正式名
- text-to-speech(TTS・音声合成)。一般名で、初出は書きません
- 使った合成エンジン
- AivisSpeech Engine(VOICEVOX ENGINE を土台にし、VOICEVOX と互換の REST API を持つ。手元で動く)
- 日本語の読みの単位
- accent phrase(アクセント句)と mora(拍)
- 音の大きさ
- loudness normalization。ffmpeg の ebur128(測る)と loudnorm(そろえる)、単位は LUFS
- 作り置き
- 中身の指紋を名前にした content-addressed cache
音声合成とは#
音声合成(text-to-speech)とは、文字の列から、声の波形を作ることです。
今の多くのエンジンは、二段に分かれています。
前段は、文を読みと抑揚に直す所(テキスト解析)。
日本語なら、漢字を読み、アクセント句に区切り、句ごとにどの拍が高いかを決めます。
後段は、その読みと抑揚から波形を作る所(音響モデルとボコーダ、あるいは一体のモデル)。
よく知られた弱点は、ほとんどが前段にあります。
- 固有名詞と数字の読み。「十五日」「10時」「番屋」は、辞書になければ別の読みになる
- 同じ字の読み分け。「昨夜」をサクヤと読むかユウベと読むか
- 記号の扱い。「……っ」のような、音にしにくい表記
VOICEVOX 系のエンジンは、この二段を API の二つの口に分けています。
audio_query で文を送ると、読みと抑揚(アクセント句・拍・高さ・長さ)の JSON が返る。
その JSON の速さや高さを書き換えて synthesis に送ると、wav が返る。
間に人やプログラムが入って、読みと調子を直せる作りです。
うちの工程 — text-to-speech#
道具は、予告編を作るために作った入口の一本です(Scripts/adv-trailer.ps1)。
動詞で回し、どれも最後の行の verdict= が合否です。
voice 並びの声を合成し、1 行ずつ位置・長さ・読みを出す
capture UE で台本を流して画面を録る(1 本ずつ)
compose 録画と声と曲をつなぐ
proof 1 秒おきの絵と、判定役への指示文を出す
並びの設定(Game/adv/trailer/voiced-01.json)は 5 つの区切りです。
- 黒地の文字「選択肢は、ない。」「あなたの言葉で、聞き込む。」3.0 秒
- 港で佐伯に昨夜を聞く(字幕「まず、アリバイを聞く。」)11.5 秒
- 番屋で女将に裏を取る(字幕「別の人に、確かめる。」)12.3 秒
- 港で佐伯に食い違いをぶつける(字幕「食い違いを、ぶつける。」)10.5 秒
- タイトルの背景に題名と案内 5.0 秒
声のある区切りは、こう書きます。
{ "src": "v01-saeki-alibi", "start": 0.0, "dur": 11.5, "caption": "まず、アリバイを聞く。",
"voice": [
{ "at": { "mark": "type", "offset": 0.4 }, "role": "detective",
"text": "事件が起きた夜、あなたは何時までどこにいましたか" },
{ "at": { "mark": "commit", "offset": 0.6 }, "role": "saeki",
"text": "十五日の夜なら、10時まで番屋で飲んどった。それだけだ" } ] }
役は 3 つで、AivisSpeech のスタイル番号と調整の数を持ちます。
刑事と佐伯は同じ声のモデルの別のスタイルで、佐伯は speed 0.95・pitch −0.08 にして低く遅くしています。
女将のハルは別のモデルです。
入れてある声のモデルのライセンスは ACML 1.0 で、営利の利用ができ、クレジットは任意、本人や公式と誤認させる使い方などは禁止、と設計書に控えてあります。
台詞はどこから来るか#
相手の台詞は、CLI の adv-sim play に同じ手の列を流し、決定(decide ACCEPT)の直後に出た最初の台詞を一字も変えずに写します。
画面に出る文と、声で読む文を同じにするためです。
返事が長いときは、最初の台詞だけを声にします。
録画の台本(v01-saeki-alibi.txt)は、ゲームに渡す手の列に、録画用の行を足したものです。
port
@ saeki
!page 6
!typespeed 0.164
!cursor off
!wait 8
!mark rec-begin
!wait 0.8
!mark type
!type 事件が起きた夜、あなたは何時までどこにいましたか
!wait 0.5
!mark commit
!ui caseui:commit
!wait 2.5
!mark rec-end
!type は入力欄に 1 字ずつ書き足し、1 字ごとに !typespeed 秒待ちます。
0.164 は、刑事の声の長さ 3.93 秒 ÷ 24 字です。入力し終わる時と、声の終わりがそろいます。
!page 6 は、返事のページを自動で送るまでの秒で、相手の声が鳴り終わるまで最初のページが残る長さにしています。
!wait 8 は、起動直後にこの PC で右上に出る知らせが消えるのを待つ分です。
!mark はログに印を出すだけの行です。
2 本目(番屋の女将)の台本には、録らない前置きとして、港で佐伯に同じ問いをする手が入っています。
動画の 3 場面を、ゲームの話の順(佐伯 → 女将 → 佐伯)のとおりに録るためです。前置きの手は、録画の始まり(rec-begin)より前に一気に流れます。
声を作る#
voice の動詞が、1 行ずつこうします。
- 読ませる文を決める。read があればそれ、無ければ text
- スタイル番号・調整の数・読ませる文を JSON にし、sha256 の頭 16 字を指紋にする
- その指紋の wav が作り置きにあれば使う。無ければ合成する
POST /audio_query?speaker=<番号>&text=<文>
返った JSON の speedScale・pitchScale などを書き換える
POST /synthesis?speaker=<番号>(本文は UTF-8 のバイト列で送る) - 長さを ffprobe で測り、大きさを ebur128 で測って、voiceLufs(−16)との差を音量の補正にする
- 位置を秒にする(次の節)
- 区切りに収まるか、ほかの声と重ならないかを確かめる
読みの確かめには、audio_query の返事を使います。
AivisSpeech の kana の欄は元の文のままで、読みになっていませんでした(10/28 に確かめました)。
そこで、アクセント句ごとに拍の音をつないで読みにします。
$kana = (@($q.accent_phrases | ForEach-Object {
(-join @($.moras | ForEach-Object { $.text })) + $(if ($_.pause_mora) { '、' } else { '' })
}) -join '/')
出てくる読みは、例えば
「ジケンガ/オキタ/ヨル,/アナタワ/ナンジマデ/ドコニ/イマシタカ」
「ジュウゴニチノ/ヨルナラ,/ジュウジマデ/バンヤデ/ノンドッタ./ソレダケダ」
です。
固有名詞と時刻と日付を、1 つずつ目で読みます。
10/28 の 1 本目で確かめた読みは、ジュウゴニチ・ジュウジ・バンヤ・サクヤ・サエキ・ハチジマエ・オカミ・ジコク。
佐伯の「……っ。誰が、そんなことを」は、「っ」が「ッ」だけの音になるので、read を「……誰が、そんなことを」にしてあります。
作り置きの wav は今 7 本で、使っているのは 6 本です。
文や調整を変えると指紋が変わり、古い wav は使われなくなるだけです。
声の位置: 数えるより、ログの時刻#
いちばん手こずったのが、声をどの秒に置くかです。
最初は、台本から数えていました。
rec-begin から、!wait の合計と、!type の字数×1 字の秒数を足していけば、印の秒が出ます。
ところが 1 回目の録画で、送信がその数えより 0.49 秒遅れました(数え 5.24 秒・ログ 5.73 秒)。
1 字ごとの待ちが、描画のコマの刻みに丸められて少しずつ伸びるからです。
そこで、録るときに UE のログの印の時刻を全部拾い、録画の隣の .check.json に書くことにしました。
"markTimes": { "rec-begin": 0.0, "type": 0.813, "commit": 5.766, "rec-end": 38.551 }
声の位置は、この値が録画の指紋と合うときだけ使います(VOICE-LINE の from=log)。
録る前は、台本の数えで見積もります(from=script)。
数えは見積もりにしか使いません。
それでも、ログの時刻と録画のコマの間には、場面ごとに −0.1〜+0.4 秒のずれが残りました。
録画の切り出しは、ログを 0.1 秒おきに読んで rec-begin を見つけて始めるからです。
1 回目の判定で、刑事の 1 行目の声の始まりのコマに、入力欄が空のまま写っていました。
対処は、入力の始まりに合わせる刑事の声を、印の 0.4 秒後にすることです(相手の声は送信の印の 0.6 秒後)。
印を置く場所にも決まりがあります。
送信より前に置く。
送信の後は、返事のページ送りの待ちが入り、ログの時刻でも次の行まで進まないからです。
並びの中の秒は、区切りの頭の秒+印の秒+offset − 区切りの start です。
2 つ目の区切りなら、3.0 + 0.813 + 0.4 = 4.213 秒に刑事の声、3.0 + 5.766 + 0.6 = 9.366 秒に佐伯の声。
6 行の位置と長さは、
刑事 4.21 秒から 3.93 秒・佐伯 9.37 から 4.66・刑事 15.71 から 2.80・ハル 19.60 から 6.52・刑事 28.01 から 4.69・佐伯 34.21 から 2.08
です。
収まりの条件は機械が見ます。
始まりが 0 以上。終わりが「区切りの長さ − フェード(0.3 秒)」以下。前の声の終わりが次の声の始まり以下。
1 行でも外れれば、voice も compose も FAIL で止まります。
つなぐ#
compose は、区切りごとの絵をつないだ映像に、曲と声を重ねます。
音の組み立ては ffmpeg の filter_complex 1 本です。
[1:a]afade=t=in:…,afade=t=out:…,loudnorm=I=-28:TP=-1.5:LRA=11,aresample=48000[bed];
[2:a]volume=0.4dB,aresample=48000,aformat=channel_layouts=stereo,adelay=4213:all=1[v0];
…(声の行の数だけ)
[v0][v1]…amix=inputs=6:normalize=0[voices];
[bed][voices]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.95[a]
曲はタイトルの曲で、声の後ろに敷くので −28 LUFS。
声は 1 行ずつ −16 LUFS にそろえ、adelay でその秒へずらします。
amix の normalize=0 は、足すときに本数で割らない指定です。割ると、声が重なっていない所でも全部小さくなります。
最後に alimiter で割れを防ぎます。
声の無い並び(予告編)のときは、この組み立てを通らず、前と同じ作り方のままです。
10/28 に予告編を同じ版でつなぎ直し、前回と同じ長さ・同じ大きさ・voices=0 で合格したことを確かめています。
できたものを、今日 ffprobe で読みました。
42.300 秒・3,799,730 B・H.264 の 1920×1080 30fps・AAC 48 kHz の 2 ch。
合格の条件と、誰が見るか#
設計書の合格の条件は 6 つです。
V1 形式(1920×1080・30fps・H.264・AAC 1 本)と長さ(並びの合計 ±1 秒・30〜60 秒) 機械
V2 声が全部作れて、区切りに収まり、重ならない 機械
V3 読み違いが無い(固有名詞・時刻・日付) 読みを AI が見て、最後はユーザーが耳で
V4 刑事の声はその文が入力欄に入っていく間に、相手の声はその台詞が出ている間に鳴る 作っていない AI の判定役
V5 マウスの矢印・ほかの窓・通知が写っていない。字幕がゲームの文に重ならない 同じ判定役
V6 声が自然で、見て面白い ユーザー
V4 と V5 は、作った本人には判定させません。
声の行ごとに「始まり+0.3 秒」と「終わり−0.3 秒」のコマを書き出し、1 秒おきの一覧の絵と一緒に、作っていない別のエージェントに渡します。
渡すのは絵の場所と、声の行の一覧(役・文・秒)と基準だけで、何を直したかは渡しません。
絵ごとに「写っているもの」を先に書かせてから判定させます(T49 と同じ距離の取り方です)。
FAIL が出て直したら、判定はまた別のエージェントにさせます。
録画の側にも機械の検査があります。
録画の最後の 1 秒の先頭と末尾のコマの PSNR が 45 以上なら、最後の画面が止まって録れている、と見ます。
1 回目は送信の後の待ちが 1 秒しかなく、最後のページの切り替わりが写って落ちました。2.5 秒にして通っています(1 本目の港の場面は 69.85)。
10/28 の 1 本目は、こう進みました。
読みの確かめが kana の欄では効かないと分かり、拍をつなぐ形に直した。
v01・v02 が最後の止まりの検査で落ち、待ちを延ばした。
送信の遅れが分かり、ログの時刻を使う形に直した。
v03 の録画が区切りより短く、最後の待ちを延ばした。
判定役の 1 回目で刑事の声が早く、offset を 0.4 にした。
2 回目の判定役で 6 行とも合格し、ユーザーが動画を見て「完璧だ」と書いた。
5 つの NG は、どれも設計の側(台本の書き方・位置の決め方)に振り分けています。
名前どおりでない所 — text-to-speech#
text-to-speech としては、名前どおりです。
合成はエンジンに任せ、こちらは読ませる文と調子の数を渡すだけです。
違うのは、合わせ方です。
声と画を合わせる一般の方法には、音声を文字に合わせる forced alignment や、映像を声に合わせて切る編集があります。
うちはどちらでもなく、画面の側(台本)を先に決めて録り、声をそのときの UE のログの時刻に置きます。
声の長さに合わせて入力の速さ(!typespeed)と、ページを送るまでの秒(!page)を台本の側で決めておく、という順番です。
loudness normalization も、使い方が狭い。
声は 1 行ずつ −16 LUFS にそろえ、曲は −28 LUFS にそろえて、足し算するだけです。
できあがった動画の全体の大きさは、どこにも合わせていません。
結果は −17.3 LUFS で、投稿先の多くが目安にする −14 前後より小さいままです(記録では未採用の改善として残してあります)。
弱点と、採らなかった案 — text-to-speech#
一つ目、刑事と佐伯が同じ声のモデルです。
スタイルと速さ・高さで分けていますが、聞けば同じ人の声の調子違いと分かります。
別の男声を入れれば分けられます。
二つ目、ずれの対処が経験の値です。
offset 0.4 と 0.6 は、1 本目の 3 場面で測ったずれ(−0.1〜+0.4 秒)から決めました。
録画の切り出しをコマの単位でログと合わせる仕組みにはしていません。
場面が増えれば、また判定役の目で確かめる必要があります。
三つ目、読みの最終判定は耳です。
AI が見るのは、読みのカタカナの列です。
抑揚の不自然さ(句の切れ目・高さ)は、カタカナの列からは分かりません。
四つ目、録画は UE が画面そのものを録ります(ffmpeg の ddagrab)。
録っている数分は、PC に触れません。ほかの窓や通知が前に出れば写ります。
V5 の判定役は、そのためにいます。
採らなかった案。
ゲームの中で声を鳴らすこと。
返事は全部書き下ろしの文なので、全部を前もって合成しておけば、技術としてはできます。
ただ、この工程の目的は「投稿するプレイ動画を作る」で、ゲームに声を足すことではありません。
いまのゲームは声を出さず、聞き込みは文字で読む作りのままです。
声を先に作り、映像をそれに合わせて切ること。
ふつうの動画の編集はこちらです。
けれど入力欄に 1 字ずつ入っていく画は、UE が台本どおりに描くもので、後から伸び縮みさせると入力の速さが不自然になります。
台本の側で速さを決めて録り、声をログの時刻に置く方を選びました。
縦長の切り出し。
TikTok などは縦が多いのですが、作者の指示「縦長はきにしない、こっちの横のまま動画にする PC ゲームとして」のとおり、1920×1080 だけを作っています。
検索語#
text-to-speech Japanese accent phrase mora
VOICEVOX ENGINE audio_query synthesis
ffmpeg loudnorm ebur128 LUFS
ffmpeg amix adelay normalize
動画に出てくる三つの問いは、『霧走町 流氷に眠る』のブラウザ版の第一章でそのまま入力して確かめられます。無料で、プロフィールのリンクから。