縦動画の字幕に合わせて 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
---
Mentions
縦動画の字幕に合わせて 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 ---