声は、あとから自分で入れられる
『加速する夢 ― The Great Acceleration』という、25分のアニメーションをつくりました。地球の誕生から現在までの46億年を、Three.js で描いた映像エッセイです。
台詞とナレーションは全部で68行あります。最初は、そのすべてを Gemini TTS で読ませていました。
ただ、そのうちの7行だけは、最終的に私が自分で演じています。星と星のあいだの暗闇から地球の電波を聞いている、人間ではない知性「シグナル」の声です。
この記事では、TTS で仮置きしていた声を自分の声に差し替えるまでの手順と、気をつけたことをまとめます。
特別な録音ソフトは使っていません。録音は Mac に最初から入っている QuickTime Player だけです。
制作の全体については、note に別の記事を書いています。
先に TTS で全部読ませておく
いきなり自分で録らずに、まず TTS で全部の台詞を読ませたのには理由があります。
- 尺が決まる:台詞が何秒かかるかが分からないと、映像の長さもカットの切り替えも決まりません。仮の声でも、まず全部を並べて時間を測れます
- 何度でも作り直せる:台詞を書き直すたびに録り直すのは大変です。TTS なら、直した行だけ作り直せます
- 演技の手本になる:あとで自分が録るときに、仮の声を聞いて間の取り方をつかめます
声の役は3つで、Gemini TTS の声をそれぞれ割り当てました。
| 役 | 行数 | Gemini TTS の声 |
|---|---|---|
| 観測者(語り手) | 59行 | Achird |
| 語り部 | 2行 | Algenib |
| シグナル | 7行 | Charon → 自分の声 |
便利な反面、使ってみて分かった制約もあります。Gemini TTS には1日あたりの回数の上限があり、上限を超えると 429 が返ってきます。
何度かやり直しながら進めたので一度には作り切れず、数日に分けて生成しました。
自分で演じる役には、シグナルを選びました。観測者は59行あり、さすがに分量が多すぎます。
シグナルは7行だけで、しかも人間ではない声なので、声を加工する前提で考えられます。素人の声でもなじませやすい役です。
差し替えられる形にしておく
声を入れ替えても映像には手を入れずに済むよう、台詞は ID で管理しています。
line_id,shot,at,character,text,spoken,direction
P47,07-08,3.0,signal,……あの静かな星から、なにか聞こえる。,……あのしずかなほしから、なにか、きこえる。,遠くから、目覚めたばかりのように。
P48,07-08,13.4,observer,聞こえる? あれは、わたしたちの声だよ。,きこえる? あれは、わたしたちのこえだよ。,少し嬉しそうに。
P49,07-09,3.7,signal,四十六億年、黙っていた星が。なぜ、いまになって。,よんじゅうろくおくねん、だまっていたほしが。なぜ、いまになって。,低く、困惑して。
at は、ショットの始まりから何秒後に話し始めるかです。音を組み立てる Python のスクリプトは、ID ごとに次の順で声のファイルを探します。
# 優先順:本人の収録(voice_user/ready/ に下ごしらえ済みのもの)> Gemini TTS > macOS の代用
user = HERE / 'voice_user' / 'ready' / f"{r['line_id']}.wav"
real = HERE / 'voice' / f"{r['line_id']}.wav"
path = user if user.exists() else real if real.exists() else HERE / 'voice_placeholder' / f"{r['line_id']}.wav"
自分で録った声を voice_user/ready/P47.wav のように置けば、その行だけが入れ替わります。TTS の声は消さずに残るので、いつでも戻せます。
録る前に、台本と「上限の秒数」をつくる
録音の前に、収録用の台本を1枚の表にまとめました。
| ID | 直前の台詞 | 台詞 | 演技の指示 | 目安 | 上限 |
|---|---|---|---|---|---|
| P47 | (観測者)言葉が、光の速さで走りはじめた。 | ……あの静かな星から、なにか聞こえる。 | 遠くから、目覚めたばかりのように。 | 7.4秒 | 9.4秒 |
| P49 | (観測者)聞こえる? あれは、わたしたちの声だよ。 | 四十六億年、黙っていた星が。なぜ、いまになって。 | 低く、困惑して。 | 8.9秒 | 8.8秒 |
| N06 | (観測者)つながると、速くなる。 | なぜ、そんなに急ぐ。 | 遠くから、低く、ゆっくり。困惑と不安。 | 3.7秒 | 4.0秒 |
(7行のうち3行を抜粋)
ここで大事なのは上限の列です。
- 目安:TTS の声が話していた長さ
- 上限:次の台詞が始まるまでの時間から、あとで足すこだまの尾(約0.8秒)を引いた長さ。これを超えると、次の台詞と重なる
上限が分かっていれば、録りながら「長すぎないか」を確かめられます。太字の行は余裕がほとんどないので、特に気をつけて読みました。
「直前の台詞」の列も、あると助かります。会話の途中の1行だけを読むと、どうしても気持ちが途切れます。相手が何を言ったあとの一言なのかが分かると、声の温度を合わせやすくなります。
QuickTime Player で録る
録音は QuickTime Player で行いました。
- QuickTime Player を開き、「ファイル」→「新規オーディオ収録」
- 録音ボタンの横の「∨」を押して、使うマイクを選ぶ
- 赤い録音ボタンで録り始め、もう一度押して止める
- 「ファイル」→「保存」(
⌘S)で、1行ずつp47.m4aのような名前で保存
保存したファイルを ffprobe で見ると、次のようになっていました。
codec_name=aac sample_rate=48000 channels=1 bit_rate=約240kbps
AACによる圧縮音声ですが、今回の用途では十分な品質だと判断しました。
録るときに気をつけたいことです。
- 素の声で録る:残響やノイズ除去などの加工はかけません。加工はあとで、作品に合わせてまとめてかけます。録音の時点でかかっていると、あとから外せません
- 響かない部屋で録る:部屋の響きが入ると、あとでかけるこだまと混ざって濁ります。エアコンや換気扇も止めます
- 最初に、何も話さない10秒を録る:部屋の雑音の大きさを測る手がかりになります(ルームトーン)
冒頭に録ったルームトーンを測定すると、約−69 LUFS でした。深夜に録ったので、かなり静かです。
一方で、声そのものも小さめでした。いちばん大きいところでも −22〜−11 dB ほどで、あとで +9〜+18 dB 持ち上げています。
今回は部屋が静かだったので問題になりませんでしたが、上げた分だけ雑音も大きくなります。
私はMacの内蔵マイクを使いました。声が小さめだったのは、マイクとの距離のせいかもしれません。外部マイクを口の近くに置くほうが良いのかもしれません。
m4a を WAV に変換する
QuickTime で保存した m4a は、そのままだと音の処理に使いにくいので、WAV に変換します。
これも Mac に最初から入っている afconvert コマンドでできます。
# m4a → 48kHz、16bit、モノラルの WAV
afconvert -f WAVE -d LEI16@48000 -c 1 p47.m4a P47.wav
LEI16 は「リトルエンディアンの16bit整数」の意味で、普通の WAV の形式です。
私の作品では、変換と同時に次の下ごしらえもしたかったので、実際には ffmpeg(brew install ffmpeg で入ります)でまとめて処理しました。
下ごしらえ:TTS の声と同じ土俵にそろえる
自分の声を TTS の声と並べると、大きさも、前後の無音の長さもばらばらです。そこで、次の処理を1本のスクリプトにまとめました。
- 60Hz より低い音を切る(机の振動や空調のうなり。低い声なので、切るのは低めにする)
- 前後の無音を切る(−50 dB より小さい部分。声の前後に0.15秒だけ残す)
- 音量を、TTS の声と同じ −22 LUFS にそろえる
- 大きすぎる瞬間だけ抑える(リミッター)
- 48kHz、16bit、モノラルの WAV に書き出す
#!/bin/zsh
# 本人が収録した声の下ごしらえ:animatic/voice_user/*.m4a → animatic/voice_user/ready/ID.wav
set -e
cd "$(dirname "$0")/../animatic/voice_user"
mkdir -p ready
# 元のファイルの冒頭を切る秒数(声の前の雑音)。P49:0.6秒あたりにクリック音、声は1.8秒から
typeset -A SKIP=(P49 1.4)
IDS=($@); (( $#IDS )) || IDS=(P47 P49 P50 N06 N08 N15 N16)
# 前の無音を切る → 反転して、うしろの無音も同じように切る → 元に戻す
TRIM="silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.15,areverse,silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.15,areverse"
for id in $IDS; do
src=$(ls $id.wav $id.m4a ${(L)id}.wav ${(L)id}.m4a 2>/dev/null | head -1)
[[ -z $src ]] && { echo "$id:ファイルなし"; continue; }
tmp=ready/_$id.wav
ffmpeg -loglevel error -y -ss ${SKIP[$id]:-0} -i $src -af "highpass=f=60:poles=2,$TRIM" -ac 1 -ar 48000 -c:a pcm_f32le $tmp
# いまの音量(LUFS)を測って、−22 LUFS との差だけ上げ下げする
lufs=$(ffmpeg -hide_banner -nostats -i $tmp -af ebur128 -f null - 2>&1 | awk '/^ +I:/{v=$2} END{print v}')
gain=$(( -22.0 - lufs ))
ffmpeg -loglevel error -y -i $tmp -af "volume=${gain}dB,alimiter=limit=0.89:level=false" -c:a pcm_s16le ready/$id.wav
rm $tmp
done
音量の調整には、ffmpeg の loudnorm フィルタを使う方法もあります。ただし、ダイナミックモードでは音量の変化が調整されるため、声の抑揚にも影響する可能性があります。今回は声の抑揚をなるべく残したかったので、LUFS を測定し、その差の分だけ全体を一律に上げ下げする方法としました。
元の m4a はそのまま残し、整えたものを ready/ に書き出します。録り直した行があれば、tools/prep_voice_user.sh N15 のように、その行だけ処理し直せます。
声の前に入った「カチッ」を探して切る
7行のうち1行(P49)だけ、聞くと冒頭に小さな「カチッ」という音が入っていました。何の音かははっきりしませんが、声の前に入った雑音です。
波形と、25ミリ秒ごとの音量を並べると、こうなります。
0.6秒あたりに、とがった山が1本あります。声は1.8秒からです。
前後の無音を切る処理は、−50 dB を超えた瞬間を「音の始まり」とみなします。このクリックは一瞬だけ −50 dB を超えるので、無音の切り取りはここで止まってしまい、クリックがそのまま残っていました。
クリックかどうかは、25ミリ秒ごとに次の値を比べて見分けました。
- 音量:周りより急に大きい
- ゼロ交差の多さ:波形が0をまたぐ回数が多い(高い音を含む)
- 音の重心(スペクトル重心):高い方に偏っている
- 周期性:声は同じ形がくり返す(自己相関が高い)が、クリックはくり返さない
有声音には周期性がありますが、クリック音は短く突発的で、高い周波数成分も多いため、数値でもはっきり分かれます。
あとは、クリックと声のあいだの1.4秒から使うように、SKIP の表に書いておくだけです。切った前の版のファイルは、retired/ に移して残しました。
切ったら、間を測り直す
クリックを切ると、その行は話し始めが1秒ほど早くなります。前後の無音を切る処理が、クリックではなく声の直前で止まるようになるためです。
その結果、直前の観測者の台詞「聞こえる? あれは、わたしたちの声だよ。」との間が詰まりすぎて聞こえました。そこで、台本の at を 2.9 から 3.7 に変え、話し始めを0.8秒遅らせています。
声を入れ替えたり、整え直したりしたら、前後の台詞との間を必ず測り直す。これがいちばんの教訓でした。
長さを上限と比べる
整えた7行の長さを、台本の上限と比べました。
| ID | 長さ | 上限 |
|---|---|---|
| P47 | 5.1秒 | 9.4秒 |
| P49 | 7.9秒 | 8.8秒 |
| P50 | 11.9秒 | 22.8秒 |
| N06 | 3.1秒 | 4.0秒 |
| N08 | 1.3秒 | 11.0秒 |
| N15 | 10.2秒 | 11.2秒 |
| N16 | 12.1秒 | 99秒 |
すべて上限に収まりました。P49 は、クリックを切る前は前後の余白込みで9.1秒あり、ぎりぎりでした。
人間ではない声にする
最後に、シグナルらしい声に加工します。
シグナルは一人ではなく、群れのような知性で、自分のことを「私たち」と呼びます。そこで、録った声の音程を下げたものを、ほんの少しずらして重ねました。
長さを変えずに音程だけ下げる
ffmpeg の asetrate で再生の速さを変えると、音程が下がる代わりに長さも伸びます。そこで atempo で速さだけ元に戻します。
def shift(x, semis):
"""長さを変えずに音程だけ変える(ffmpeg の asetrate+atempo。声の質も一緒に低くなる)"""
r = 2 ** (semis / 12)
y = subprocess.run(['ffmpeg', '-loglevel', 'error', '-f', 'f32le', '-ar', str(SR), '-ac', '1', '-i', '-',
'-af', f'asetrate={SR * r:.0f},aresample={SR},atempo={1 / r:.5f}', '-f', 'f32le', '-'],
input=x.astype(np.float32).tobytes(), capture_output=True, check=True).stdout
y = np.frombuffer(y, np.float32)
return np.pad(y, (0, max(0, len(x) - len(y))))[:len(x)]
この方法では、音程と一緒に声の質(声道の響き)も低くなります。人間の声を自然に下げたいなら欠点ですが、人間離れした声にしたい今回は、むしろ好都合でした。
重ねる声の数を聞き比べる
重ね方を3通りつくり、聞き比べました。
# シグナルの多声:元の声に、音程を下げて少しずらした声を重ねる(半音、ずらす秒、音量)
SIGNAL_LAYERS = {
'A': [],
'B': [(-4, 0.030, 0.6)],
'C': [(-5, 0.035, 0.55), (-12, 0.015, 0.4), (3, 0.060, 0.2)],
}
def multivoice(x, layers):
if not layers:
return x
out = np.concatenate([x, np.zeros(int(0.1 * SR), np.float32)])
for semis, d, g in layers:
i = int(d * SR)
out[i:i + len(x)] += g * shift(x, semis)
return out * (np.sqrt((x ** 2).mean()) / np.sqrt((out ** 2).mean())) # 重ねても音量は元と同じに
| 案 | 重ねる声 | 聞こえ方 |
|---|---|---|
| A | なし(こだまだけ) | 一人の声が遠くから響く |
| B | 半音4つ下げた声を、0.03秒ずらして重ねる | 声に低い影がつく |
| C | 半音5つ下げた声、1オクターブ下げた声、半音3つ上げた小さな声の3つ | 群れが声をそろえて話す |
聞き比べるときは、音量をそろえることが大切です。声を重ねると、そのぶん音が大きくなります。大きい方が良く聞こえてしまうので、multivoice の最後で、重ねたあとの音量を元の声と同じに戻しています。
比べた結果、B にしました。C は群れの感じがいちばん強いのですが、言葉が濁って聞き取りにくくなりました。台詞が伝わることを優先しています。
最後に、TTS のときと同じこだま(0.19秒の間隔で4回)をかけて完成です。
おわりに
Gemini TTS は非常に便利ですが、一度、自分の声を入れてみたらどうなるのか試してみました。
実際にやってみると、録音のときは緊張しますし、作品に入れてみると Gemini TTS のころよりずっと聞き取りにくくなりました。あたりまえのことですが、素人とプロには大きな差があると感じます。
一方で、TTS で仮置きした声をあとから差し替えられるようにしておいたことで、映像を作り直すことなく、自分の声を試せました。
最初からすべてを自分で録音しなくても、まず TTS で作品を完成させ、気になる部分だけ自分で演じてみる。そういう作り方もできるのだと分かりました。
