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?

青空文庫を縦書きで表示して、読んだ文字が光るショート動画を Python で作る(VOICEVOX・Pillow)

0
Posted at

はじめに

PDF や画像の文字を VOICEVOX で読み上げる「TextToVoicevox」という無料アプリを、ひとりで作っています。

その紹介として、「ずんだもんに『人間失格』を読ませてみた」のような縦型のショート動画を作りました。青空文庫の本文を縦書きで表示して、ずんだもんが読み上げるのに合わせて、読んだ文字から黄色く光っていく動画です。

動画編集ソフトは使っていません。Python(Pillow)で1コマずつ絵を描いて、ffmpeg に流し込んでいます。作ってみると、青空文庫のふりがなの扱いや、Pillow での縦書きなど、ちょっとした工夫がいくつか要ったので、まとめておきます。

この記事で書くこと:

  1. 青空文庫のルビ(ふりがな)を「表示用」と「読み上げ用」に分ける
  2. VOICEVOX で1文ずつ声を作り、どう読んだかをカナで確かめる
  3. Pillow で縦書きを描く(句読点・かぎかっこ・ぶら下げ)
  4. 読み上げに合わせて、文字を1文字ずつ光らせる
  5. 立ち絵の口パクを、音の大きさから決める
  6. 作ってみて分かったこと

動作確認した環境:macOS、Python 3.12、Pillow 12、VOICEVOX 0.25(エンジンを手元で起動)。

1. 青空文庫のルビを「表示用」と「読み上げ用」に分ける

青空文庫のテキストには、ふりがなが 《》 で入っています。

隴西《ろうさい》の李徴《りちょう》は博学|才穎《さいえい》、

《》 の直前の漢字の並びにかかるのが基本で、どこからかかるかを示したいときは | で始まりを示します。

このルビは、画面には出したくないけど、読み上げには使いたいものです。VOICEVOX にそのまま「隴西」を渡すと読めないことがありますが、「ろうさい」と渡せば正しく読めます。なので、同じ原文から2つの文字列を作ります。

import re

KANJI = r"[\u3400-\u9fff々〆ヶ]"

def disp(s):
    """表示用:ルビと|を外す"""
    return re.sub(r"《[^》]*》", "", s).replace("|", "")

def read(s):
    """読み上げ用:ルビの付いた漢字を、ルビの読みに置き換える"""
    s = re.sub(r"|([^《]+)《([^》]*)》", r"\2", s)       # |で範囲を指定したもの
    s = re.sub(rf"{KANJI}+《([^》]*)》", r"\1", s)        # 直前の漢字の並び
    return s

src = "隴西《ろうさい》の李徴《りちょう》は博学|才穎《さいえい》、"
print(disp(src))  # 隴西の李徴は博学才穎、
print(read(src))  # ろうさいのりちょうは博学さいえい、

| 付きのものを先に処理するのがポイントです。逆にすると「博学才穎《さいえい》」がまるごと「さいえい」になってしまいます。

ちなみに、ルビが無いところで読み間違えたときも、この仕組みがそのまま使えます。自分で |決シテ《けっして》 のように書き足せば、画面は原文のまま、読みだけ直せます(後で出てきます)。

本文は「原文に含まれているか」で確かめる

動画に出す本文は、手で写すと打ち間違えます。なので、切り出した文が原文の中にそのまま含まれているかを、毎回プログラムで確かめました。

src = open("sangetsuki.txt", encoding="utf-8").read()   # 青空文庫のルビ付きテキスト
excerpt = "隴西《ろうさい》の李徴《りちょう》は博学|才穎《さいえい》、"
assert excerpt in src, "原文と一致しない"

地味ですが、これがあると安心して先に進めます。

2. VOICEVOX で1文ずつ声を作り、どう読んだかをカナで確かめる

VOICEVOX のエンジンは、手元で HTTP の API として動きます(既定は http://127.0.0.1:50021)。audio_query で読み方を作り、synthesis で音声にする、の2段階です。

import json, urllib.parse, urllib.request

URL = "http://127.0.0.1:50021"
SPEAKER = 3   # ずんだもん(ノーマル)

def synth(text):
    q = urllib.request.urlopen(urllib.request.Request(
        f"{URL}/audio_query?" + urllib.parse.urlencode({"text": text, "speaker": SPEAKER}),
        method="POST"))
    query = json.load(q)
    req = urllib.request.Request(
        f"{URL}/synthesis?speaker={SPEAKER}", data=json.dumps(query).encode(),
        headers={"Content-Type": "application/json"}, method="POST")
    wav = urllib.request.urlopen(req).read()
    return wav, query["kana"]

wav, kana = synth("恥の多い生涯を送って来ました。")
print(kana)   # ハジ'ノ/オ'オイ/ショ'オガイオ/オクッテ'/キマ'_シタ

大事なのは query["kana"] です。エンジンがその文をどう読んだかがカナで返ってくるので、作った音声を全部聞かなくても、読み間違いを目で探せます。

実際に、こんな読み間違いが見つかりました。

原文 VOICEVOX の読み 直し方
決シテ瞋ラズ(雨ニモマケズ) キマシテ |決シテ《けっして》
よく気をつけて(茶わんの湯) ヨクケオ |気《き》
咲いている蓮《はす》の花 イルワ スノ |蓮《ハス》(かたかなで渡す)
御歩きになって(蜘蛛の糸) ゴアルキ |御歩き《おあるき》

3つめは面白くて、ルビどおり「はす」とひらがなで渡すと、直前の「いる」とつながって「いるは」の「は」が助詞(ワ)として読まれてしまいました。読みをかたかなで渡すと直ります。

3. Pillow で縦書きを描く

Pillow は、libraqm があれば縦書き用の字形を使えますが、手元の環境には入っていませんでした。なので、1文字ずつ升目に置いていく方法で描いています。

from PIL import Image, ImageDraw, ImageFont

CS = 50          # 文字の大きさ
PITCH_Y = 56     # 縦の送り
COL_W = 84       # 列の幅
ROWS = 13        # 1列の文字数
font = ImageFont.truetype("/System/Library/Fonts/ヒラギノ明朝 ProN.ttc", CS)

VERTICAL = {"「": "﹁", "」": "﹂", "『": "﹃", "』": "﹄"}

def draw_char(d, x, y, ch):
    ch = VERTICAL.get(ch, ch)
    if ch in "、。":
        # 縦書きの句読点は、升目の右上に寄せる
        d.text((x - CS * 0.5 + CS * 0.55, y - CS * 0.55), ch, font=font, fill="black")
    else:
        w = d.textlength(ch, font=font)
        d.text((x - w / 2, y), ch, font=font, fill="black")

工夫は3つです。

  • 句読点は升目の右上に寄せる:横書きの「、」は左下にあるので、そのまま縦に並べると変な位置に出ます。
  • かぎかっこは縦書き用の文字に置き換える:Unicode には縦書き用の形(﹁﹂﹃﹄、U+FE41〜U+FE44)があるので、それを使います。
  • 句読点は列の下にぶら下げる:列の頭に「、」が来ないよう、句読点と閉じかっこだけは1文字はみ出して前の列に置きます。
cells = []                      # (x, y, 文字, 何番目の文か, 文の中の位置)
col, row = 0, 1                 # 段落の頭は一字下げ
for sid, sentence in enumerate(sentences):
    for k, ch in enumerate(sentence):
        if row >= ROWS and not (ch in "、。」』)" and row == ROWS):
            col += 1; row = 0   # 次の列へ(句読点だけはぶら下げる)
        cells.append((RIGHT - col * COL_W, TOP + row * PITCH_Y, ch, sid, k))
        row += 1

列は右から左へ進むので、x 座標は RIGHT - col * COL_W です。

4. 読み上げに合わせて、文字を1文字ずつ光らせる

各文の音声の長さが分かれば、その文を読んでいる途中のどこまで来たかを、時間の割合で決められます。

# lines[i] = {"text": 文, "t0": 読み始め(秒), "t1": 読み終わり(秒)}
def highlight_upto(tt, ln):
    n = len(ln["text"])
    if tt < ln["t0"]: return 0
    if tt >= ln["t1"]: return n
    return int(n * (tt - ln["t0"]) / (ln["t1"] - ln["t0"])) + 1

for (x, y, ch, sid, k) in cells:
    if k < highlight_upto(tt, lines[sid]):
        d.rectangle((x - CS * 0.62, y - 6, x + CS * 0.62, y + PITCH_Y - 4), fill=(255, 226, 92, 150))

本当は音素ごとの時間(audio_query の accent_phrases に入っています)を使えばもっと正確に合わせられますが、ショート動画ではこの「時間の割合」で十分それらしく見えました。

5. 立ち絵の口パクを、音の大きさから決める

ずんだもんの立ち絵(口を閉じた絵・開いた絵・まばたきの絵)を、音声の大きさで切り替えています。WAV を動画の1コマ分ずつに区切って、音の大きさ(RMS)を出しておきます。

import array, math, wave

FPS = 30

def rms_track(path):
    with wave.open(path) as f:
        sr = f.getframerate()
        a = array.array("h", f.readframes(f.getnframes()))
    step = sr // FPS
    return [math.sqrt(sum(x * x for x in a[i:i + step]) / max(1, len(a[i:i + step])))
            for i in range(0, len(a), step)]

# 描くとき
key = "open" if level > 900 else "close"
if key == "close" and (tt % 3.1) < 0.12:
    key = "blink"          # 約3秒に1回まばたき

しきい値(900)は、ずんだもんの声を何本か見て決めました。これだけでも、けっこう「しゃべっている」感じになります。

6. 動画にする

1コマずつ描いた絵を、ffmpeg の標準入力に生の画素として流し込み、音声と合わせて mp4 にしています。

import subprocess

p = subprocess.Popen(["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
                      "-s", "1080x1920", "-r", "30", "-i", "-", "-i", "voice.wav",
                      "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
                      "-shortest", "-movflags", "+faststart", "out.mp4"],
                     stdin=subprocess.PIPE)
for i in range(n_frames):
    p.stdin.write(frame(i).convert("RGB").tobytes())
p.stdin.close(); p.wait()

25秒前後の動画が、手元の Mac で1本数十秒で書き出せます。

作ってみて分かったこと

  • 著作権の切れた作品だけを使う:青空文庫の作品は、作者が亡くなってから長い時間がたって著作権が切れたものです。最近亡くなった作家の作品は、たとえ冒頭だけでも、読み上げた動画を公開すると著作権の侵害になります。日本国憲法の前文も使いましたが、これは法令が著作権の対象外(著作権法13条)だからです。
  • VOICEVOX のクレジットを入れる:声を公開するときは「VOICEVOX:ずんだもん」のような表記が必要です。動画の中と説明欄の両方に入れています。
  • Shorts の説明欄のリンクは押せない:YouTube の Shorts は、説明欄やコメントのリンクがクリックできません。押せるのはチャンネルのプロフィールのリンクなので、動画の最後で「チャンネル名 → プロフィールのリンクから」と案内するようにしました。
  • 1日にまとめて出すより、1日1本:同じ日に何本も公開したら、後半に出したものがほとんど再生されませんでした。原因ははっきりしませんが、今は1日1本ずつ予約公開しています。

おわりに

縦書きも、読んだ文字が光るのも、口パクも、ひとつひとつは単純な仕組みです。組み合わせると、ちゃんと「名作を読んでいる」動画になったのが楽しかったです。

この記事の読み上げ部分は、自分で作っている無料アプリ TextToVoicevox の中身と同じ仕組みです(PDF・画像・青空文庫の日本語を、VOICEVOX でオフラインで読み上げます)。

「こういう使い方をした」「ここが分からなかった」など、一言でももらえると、とても嬉しいです。

VOICEVOX:ずんだもん/立ち絵:坂本アヒル 様

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?