はじめに
PDF や画像の文字を VOICEVOX で読み上げる「TextToVoicevox」という無料アプリを、ひとりで作っています。
その紹介として、「ずんだもんに『人間失格』を読ませてみた」のような縦型のショート動画を作りました。青空文庫の本文を縦書きで表示して、ずんだもんが読み上げるのに合わせて、読んだ文字から黄色く光っていく動画です。
動画編集ソフトは使っていません。Python(Pillow)で1コマずつ絵を描いて、ffmpeg に流し込んでいます。作ってみると、青空文庫のふりがなの扱いや、Pillow での縦書きなど、ちょっとした工夫がいくつか要ったので、まとめておきます。
この記事で書くこと:
- 青空文庫のルビ(ふりがな)を「表示用」と「読み上げ用」に分ける
- VOICEVOX で1文ずつ声を作り、どう読んだかをカナで確かめる
- Pillow で縦書きを描く(句読点・かぎかっこ・ぶら下げ)
- 読み上げに合わせて、文字を1文字ずつ光らせる
- 立ち絵の口パクを、音の大きさから決める
- 作ってみて分かったこと
動作確認した環境: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 でオフラインで読み上げます)。
- アプリ(無料・Windows / Mac):https://github.com/rakuyou211-blip/TextToVoicevox
- 名作シリーズの再生リスト:https://www.youtube.com/playlist?list=PLecfNGcPQW_Q
「こういう使い方をした」「ここが分からなかった」など、一言でももらえると、とても嬉しいです。
VOICEVOX:ずんだもん/立ち絵:坂本アヒル 様