動画で読む
4 分の動画 1 本に 11 分半。最初に疑ったのは音声合成の逐次ループだった
2026-09-13 の日曜、随筆 1 本(本文 1,531 字)を 4 章 12 段落の台本にして、4 分 00 秒の解説動画を作って YouTube に上げるまでの内訳は、音声合成 115 秒、Remotion の render 410 秒、upload とサムネイル 15 秒。合計で約 11 分 30 秒だった。
音声側のコードは tools/aether-video/scripts/build-all.ts で、章ごとの段落を for ループで順に await synthesize(...) している。VOICEVOX は audio_query と synthesis の 2 段階 API なので、段落 1 つにつき HTTP を 2 回叩く。ここを Promise.all で 2 本、4 本と並べれば 115 秒が半分になるんじゃないか。それが最初の読みだった。
ならなかった。1 秒も。
条件: 同じ 12 段落、同じ話者、4 日間動きっぱなしの同じコンテナ
比べたのは 3 条件。逐次(並列 1)、並列 2、並列 4。それぞれ 2 周して 2 周目を採用する予定だったが、結果が揃いすぎたので両方載せる。
対象は昨日の動画の台本 12 段落(1,150 字)。build-all.ts と同じく各章の最初の段落には見出しを頭に付け、話者は engineering 用の No.7(speaker 29)、speedScale 1.15 / volumeScale 1.2 も本番の設定のまま。計測スクリプトは /tmp に置いた使い捨ての TypeScript で、段落ごとに audio_query の所要 ms、synthesis の所要 ms、wav のバイト数と sha256 を記録し、開始 4 秒後に docker stats を 1 行だけ取る。
相手のコンテナはこれ。
$ docker ps --filter name=voicevox --format "{{.Names}}\t{{.Image}}\t{{.Status}}"
aether-voicevox voicevox/voicevox_engine:cpu-latest Up 4 days
$ curl -sS localhost:50021/version
"0.25.2"
$ docker inspect aether-voicevox --format "{{.State.StartedAt}} restart={{.HostConfig.RestartPolicy.Name}} NanoCpus={{.HostConfig.NanoCpus}} Memory={{.HostConfig.Memory}}"
2026-09-09T03:21:29.739270052Z restart=unless-stopped NanoCpus=0 Memory=0
$ docker image inspect voicevox/voicevox_engine:cpu-latest --format "{{.Architecture}} {{.Os}}"
amd64 linux
$ docker info --format "{{.NCPU}} cpu / {{.MemTotal}} bytes / {{.OperatingSystem}}"
4 cpu / 4127531008 bytes / Docker Desktop
$ /usr/sbin/sysctl -n hw.ncpu machdep.cpu.brand_string
8
Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
コンテナ側に CPU / メモリの制限は掛けていない。イメージは amd64、ホストも x86_64 の Intel Mac なのでエミュレーションは挟まらない。ただし Docker Desktop の VM に割り当てているのは 4 CPU / 約 3.8 GiB で、ホストの論理コア 8 のうち半分しか見えていない。ここが後で効いてくる。
手で 1 段落叩くと 8 秒。読者が追える最小の再現
スクリプトを書かなくても、curl 2 回で同じ数字が出る。第 1 章の最初の段落(見出し込み 98 字)で試したのがこれ。
$ time curl -sS -X POST "localhost:50021/audio_query?speaker=29" --get --data-urlencode "text=$TEXT" -o /tmp/vv_query.json
0.903 total
$ jq -c "{speedScale, outputSamplingRate, accent_phrases: (.accent_phrases|length)}" /tmp/vv_query.json
{"speedScale":1.0,"outputSamplingRate":24000,"accent_phrases":32}
$ jq ".speedScale = 1.15 | .volumeScale = 1.2" /tmp/vv_query.json > /tmp/vv_query115.json
$ time curl -sS -X POST "localhost:50021/synthesis?speaker=29" -H "Content-Type: application/json" -d @/tmp/vv_query115.json -o /tmp/vv_p1.wav
7.270 total
$ ls -l /tmp/vv_p1.wav
-rw-r--r-- 1 soraendo wheel 706092 Sep 14 09:05 /tmp/vv_p1.wav
audio_query が 0.9 秒なのは、この日最初のリクエストだったから。スクリプトで続けて叩くと 20 ミリ秒台に落ちる。synthesis の 7.27 秒と wav の 706,092 バイトは、スクリプトの逐次 1 周目が出した 7,336 ミリ秒 / 706,092 バイトと同じで、sha256 も一致する。
ついでに speedScale を 1.0 のままにした synthesis も投げた。8.766 秒で 818,732 バイト。1.15 倍速にすると音声が 14% 短くなり、合成時間もほぼ同じ比率で縮む。合成時間は「出力する音声の長さ」に比例していて、入力の文字数はその手前の変数に過ぎない。
逐次 91.5 秒、並列 2 で 91.7 秒、並列 4 で 92.6 秒
| 条件 | 1 周目 wall | 2 周目 wall | 生成した音声の尺 | wall ÷ 音声尺 | wav の sha256 |
|---|---|---|---|---|---|
| 逐次(1) | 91.5 s | 93.5 s | 171.2 s | 0.53 / 0.55 | 12 本とも一致 |
| 並列 2 | 91.7 s | 93.7 s | 171.2 s | 0.54 / 0.55 | 12 本とも一致 |
| 並列 4 | 92.6 s | 94.0 s | 171.2 s | 0.54 / 0.55 | 12 本とも一致 |
6 周で得た 72 本の wav は、段落ごとに見ると全部同じ sha256 だった。並列にしても音が変わらない代わりに、速くもならない。2 周目が 1 周目より 2 秒遅いのは、条件に関係なく全部そうなので、マシンの温度か裏の何かだと思う。追ってはいない。
読み替えると、この環境では音声 1 秒を作るのに 0.53〜0.55 秒かかる。段落の長さとは綺麗に比例していて、73 字の段落が 5.9 秒、120 字の段落が 9.0 秒だった。
並列にすると audio_query が 20 ミリ秒から 9 秒に化ける
wall が変わらない理由は、段落ごとの内訳に出ていた。逐次のときの audio_query は 20 ミリ秒前後。並列 2 にした途端、こうなる。
mode=par2 run=1 concurrency=2 paragraphs=12 speaker=29 speedScale=1.15
name chars query_ms synth_ms bytes sha256[0:12] start_ms end_ms
chapter1-p1 98 87 14866 706092 17fe210256af 1 14958
chapter1-p2 92 46 6896 651308 a534820b99ec 47 6991
chapter1-p3 99 7943 8291 728620 0f84ba1bca52 6991 23228
chapter2-p1 120 8241 9036 810540 7238f247b091 14958 32237
chapter2-p2 73 8953 6027 539180 d8c802daf3fd 23228 38210
...
sum: chars=1150 query_ms=76079 synth_ms=98773 bytes=8219664
wall_ms=91696 (91.7s) sum_of_per_item_ms=174852
chapter1-p3 の audio_query が 7,943 ミリ秒。テキストを形態素解析して読みを返すだけの処理に 8 秒はかからない。前の段落の synthesis が終わるまで待たされている。最初の 2 本も、p1 の synthesis が 14,866 ミリ秒で p2 の 6,896 ミリ秒の倍以上になっていて、2 本が交互に処理されているというより、片方が終わるまでもう片方が止まっている見え方だ。
並列 4 では audio_query の待ちが最大 25 秒まで伸びた。クライアント側で何本投げても、エンジンは 1 リクエストずつ順に片付けている。Promise.all は「並列に合成する」のではなく「エンジンの前に列を作る」だけだった。
エンジンが内部でロックを取っているのか、推論の実装が同時実行を許さないのかは、コードを読んでいないので分からない。README にも同時リクエストについての記述は無い。分かっているのは、この版(0.25.2)の CPU イメージに対して、HTTP を並べる意味が無いことまでだ。
CPU は何をしても 196% で止まっている
もう 1 つの手掛かりが docker stats の行で、6 周とも同じ値だった。
docker_stats@4s: aether-voicevox cpu=197.59% mem=2.039GiB / 3.844GiB (seq run1)
docker_stats@4s: aether-voicevox cpu=195.83% mem=2.067GiB / 3.844GiB (par2 run1)
docker_stats@4s: aether-voicevox cpu=196.54% mem=2.072GiB / 3.844GiB (par4 run1)
コンテナには 4 CPU 見えているのに、使っているのは 2 コア分。これは voicevox_engine の README にそのまま書いてあった。
--cpu_num_threads CPU_NUM_THREADS : 音声合成を行うスレッド数です。 CPU スレッド数が未指定の場合は、論理コア数の半分が使われます。
コンテナの起動コマンドは docker inspect で見ると gosu user /opt/voicevox_engine/run --host 0.0.0.0 で、--cpu_num_threads を渡していない。docker exec aether-voicevox nproc は 4 を返す。4 の半分で 2 スレッド。196% はぴったりその数字だ。
つまり並列化で埋まる余白は最初から無かった。埋まっていないのは残りの 2 コアと、VM の外にいるホストの 4 論理コアで、そちらは --cpu_num_threads 4 を渡すか Docker Desktop の CPU 割り当てを増やさないと届かない。
ここは試していない。試すには 2 つ目のコンテナを別ポートで立てる必要があって、今のコンテナが 3.84 GiB の VM のうち 2.08 GiB を使っている。同じ物をもう 1 つ載せると入らない可能性が高く、動画生成の本番で使っているコンテナを巻き込みたくなかった。動かしたまま数字が取れる方法を考えてから、別の日にやる。
昨日の 115 秒は wav の mtime からも同じ数字が出る
計測とは別に、動画生成が残した wav の更新時刻を秒まで見た。
$ stat -f "%Sm %z %N" -t "%H:%M:%S" public/audio/sunset-1754-light-goes-autumn-first-long/*.wav | sort
10:12:14 231980 title.wav
10:12:24 1030188 lead.wav
10:12:32 812588 chapter1-p1.wav
...
10:13:58 873516 chapter4-p3.wav
10:14:05 666156 outro.wav
$ stat -f "%Sm %z %N" -t "%H:%M:%S" public/long-script.json out/sunset-1754-light-goes-autumn-first-long.mp4
10:14:05 11346 public/long-script.json
10:20:59 40831380 out/sunset-1754-light-goes-autumn-first-long.mp4
title.wav から outro.wav まで 111 秒で 15 本。合成した音声の合計は long-script.json の durations を足すと 239.86 秒なので、比率は 0.46。今日の No.7 の 0.53〜0.55 より少し速いが、昨日の話者はずんだもん(speaker 3)で条件が違う。話者で合成速度が変わるかどうかは、今日は測っていない。
render 側は long-script.json の 10:14
から mp4 の 10:20 まで 414 秒。remotion.config.ts の setConcurrency(Math.floor(cpus().length / 2)) はホストの 8 論理コアを見るので 4 で走る。240 秒の動画に 414 秒、比率 1.7。VOICEVOX が VM の 4 コアの半分しか使わない一方で、Remotion はホスト側の 8 コアの半分を使う。同じ「半分」でも母数が違う。
これで動画 1 本の見積もりは式になる。この Mac では、音声合成 ≒ 台本の読み上げ秒数 × 0.5、render ≒ 動画の秒数 × 1.7。4 分の動画なら 2 分 + 7 分で、upload を足して 10 分強。昨日の 11 分 30 秒はこの式の範囲に収まる。
compose ではなく docker run で立てている理由は、今日叩いても再現した
コンテナに compose のラベルが無いのは、7 月 23 日にこの Mac で動画生成を動かし始めたとき、docker compose up voicevox が失敗して docker run に切り替えたからだ。今も同じかを確かめた。
$ docker compose config --services
The new 'docker compose' command is currently experimental. To provide feedback or request new features please open issues at https://github.com/docker/compose-cli
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
$ for i in 1 2 3 4 5; do docker compose config --services 2>&1 | grep -v experimental; done
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/frontend/.env: no such file or directory
open ~/projects/main/local/AetherEchoes/backend/.env: no such file or directory
$ ls -la .env ./backend/.env ./frontend/.env
ls: ./backend/.env: No such file or directory
ls: ./frontend/.env: No such file or directory
ls: .env: No such file or directory
voicevox service 自体は image と ports と restart の 3 行で env_file を持っていない。ただ同じ compose ファイルの backend と sidekiq が ./backend/.env、frontend が ./frontend/.env、db が ./.env を env_file に指定していて、compose は service を絞る前にファイル全体を検証して止まる。
どの .env で止まるかは実行ごとに変わった。上の 6 回では backend/.env が 5 回、frontend/.env が 1 回、別のタイミングで叩いた 1 回は ./.env だった。3 つとも置いていないので、どれで止まっても結果は同じだ。動画生成しかしないこの Mac には backend / frontend / db の .env をどれも置いていないので、voicevox だけ上げたくても上がらない。
逃げ方は docker run 単体で立てること。今動いているコンテナの設定を docker inspect の name / restart / port から書き戻すと docker run -d --name aether-voicevox --restart unless-stopped -p 50021:50021 voicevox/voicevox_engine:cpu-latest になる。restart と port は compose の voicevox service に書いてある 3 行と同じ値で、voicevox_engine の README の例 docker run --rm -p '127.0.0.1:50021:50021' voicevox/voicevox_engine:cpu-latest とは --rm の有無と 127.0.0.1 バインドの点で違う。compose の profile で切り分ける手もあるが、この Mac で必要なのはこのコンテナ 1 つなので、そこまでしていない。compose の設計そのものは 個人開発の docker-compose.yml をテンプレ化する に書いた形のままだ。
自分の条件での選択: for ループは触らない
比べた 3 案のうち、コードを変える価値があるものは無かった。build-all.ts の逐次 await はそのままにする。
代わりに手を付けるなら順番はこうなる。まず --cpu_num_threads 4 を渡して 2 スレッドの壁を外す(メモリの都合で未検証、効いても上限は 2 倍)。次に Docker Desktop の CPU 割り当てを 4 から 8 に上げる。どちらも音声側の 2 分を縮める話で、7 分かかっている render には効かない。動画 1 本を 11 分から縮めたければ、削る場所は音声ではなく render の方だ。
並列化して速くなると思っていた読みは、docker stats の 1 行で崩れた。測る前に直していたら、Promise.all を入れて「速くなった気がする」で終わっていたと思う。