0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

縦動画の字幕に合わせて VOICEVOX の声を置き、区間を声の長さに伸ばして、BGM を下げつつ -16 LUFS にそろえる(Python+ffmpeg)

0
Posted at

字幕つきの縦動画(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:冥鳴ひまり にしています。公序良俗に反する使い方などは禁止されています

make_voice.py は、投稿文の最後の行に VOICEVOX:冥鳴ひまり がなければ足したファイルを作るようにしています。付け忘れを防ぐためです。

なお、macOS の say コマンドの声(システム音声)は、使用許諾契約で「個人的な非商用の利用」に限られ、公開や商用での利用は認められていません(macOS Sonoma の使用許諾契約 2.F「Voices; Live Captions」:https://www.apple.com/legal/sla/docs/macOSSonoma.pdf )。試作には便利ですが、公開する動画には使っていません。

まとめ

  • 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 を超えるので、すぐ消す。空のキャッシュはサイズも見る

参考:


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?