90秒録画したはずが、6秒しかない — AIアバターの音ズレと録画が「静かに」壊れる罠
無人で動き続けるAIアバターの配信を作っていると、「動いてはいるが、どこか変」という種類のバグに何度もぶつかります。落ちるわけでもエラーを吐くわけでもなく、口が音とズレる、時間が経つほど悪化する、90秒録ったはずが6秒しかない——。
この記事は、その配信パイプラインを作る過程で踏んだ3つの罠を1本にまとめたものです。いずれも「映像と音声を別経路で扱う」ことに起因していて、症状の出方が微妙に違います。順番に、
- 口ズレ(リップシンク) — 実は2種類の別のズレを1つのつまみで直そうとしていた
- だんだんズレる —
setIntervalの発火遅れが累積していた - 録画が途中で死ぬ — 音声トラックが枯渇するとMediaRecorderは映像ごと止まる
を扱います。共通する教訓を先に言うと、メディア処理では失敗が例外ではなく「沈黙」として返ってくること、そして測れる部分と測れない部分を切り分けることが、この手のバグを解く鍵でした。
前提となる構成
配信パイプラインはこうなっていました。
ブラウザのページ
├ 映像: CDP screencast → JPEG 連続 → 名前付きパイプ
└ 音声: Web Audio のタップ → 名前付きパイプ
↓
ffmpeg(2 入力を mux)→ RTMP
ヘッドレスブラウザ内で3Dアバターを描画し、画面と音声を別々に取り出して、下流(ffmpegやMediaRecorder)で1本に多重化(mux)する。映像と音声が別々の経路で下流に到達する——この構造がこれから話す全ての問題の温床です。muxする以上、下流は両トラックの時刻を突き合わせる必要があり、片方が遅れたり止まったりすると、そのまま同期の破綻や録画の停止になって現れます。
まず決めたのはゼロ点、つまり「いつから書き始めるか」でした。我々は音声の最初のチャンクが届いた時点で、映像の書き込みを開始する方式にしました。音声を基準にした理由は、人間が違和感を持つのは映像より音声の途切れだからです。映像は最初のフレームが多少飛んでも気づかれませんが、音の出だしが切れると分かります。
第1の罠: 「口が合ってない」を感覚で直すのをやめる
最初に来たフィードバックは「口の動きと声が合っていない」。値を少し変えて配信し直し、目で見て確認する。まだズレている気がする。また変える。この往復を何度かやって気づいたのは、2種類のまったく別のズレを1つのつまみで調整しようとしていたことでした。
| ズレの種類 | 正体 | 測り方 | 我々の値 |
|---|---|---|---|
| 機械的な A/V オフセット | 映像と音声が別経路で mux されることによる時刻の差 | 測れる(マーカーを撮って自動計測) | 実測 6ms(≒ ゼロ) |
| 見た目のリップシンク | ページ内で口の動きが音声に追従するまでの遅れ | 測れない。人が見て決める | 音声を 0.10 秒遅らせる |
機械的なズレは、マーカーを撮って自動計測する
「同時に起きるイベント」を映像と音声の両方に埋め込み、出来上がった動画の中でそれぞれが何秒の位置にあるかを測れば、差がそのままオフセットです。ページ側で、ビープ音と画面全体のフラッシュを同時に出すマーカーを仕込みました。
// 較正マーカー: 音と光を同時に出す
function emitCalibrationMark() {
// 音: 短いビープ
const osc = ctx.createOscillator();
osc.frequency.value = 1000;
osc.connect(dest);
osc.start(); osc.stop(ctx.currentTime + 0.05);
// 光: 画面を白くする
document.body.style.background = '#fff';
setTimeout(() => { document.body.style.background = ''; }, 50);
}
出来上がった録画を ffmpeg で解析します。
# 音声側: 無音区間の切れ目 = ビープの位置
ffmpeg -i out.mp4 -af silencedetect=n=-40dB:d=0.03 -f null - 2>&1
# 映像側: シーンの急変 = フラッシュの位置
ffmpeg -i out.mp4 -vf "select='gt(scene,0.5)',showinfo" -f null - 2>&1
両者の時刻の差がオフセットです。我々の環境では 6ms でした。事実上ゼロなので、ffmpeg側の補正(-itsoffset)は入れていません。
**大事なのは「6msだった」ことより、「測れる形にした」ことです。**環境が変わったら測り直せばいいし、「ここは合っている」と断言できるようになったので、以降の議論から機械的ズレを外せました。
見た目のリップシンクは、人が決めるしかない
機械的なズレをゼロと確定させても、まだ「口が早い」感じが残りました。ここからが2つ目の問題です。
アバターの口の動きは、ページ内で音声を解析して駆動しています。この処理には固有の追従遅れがあり、音より口がわずかに先行して見える状態でした。これは配信パイプラインの問題ではなく、ページの中の話です。しかも正解が数値では決まりません。「合って見える」かどうかは人間の知覚の問題だからです。
対処として、キャプチャに回す音声側に遅延ノードを入れ、値を振って目視で決めました。
const delay = ctx.createDelay();
delay.delayTime.value = 0.10; // 実際に見て決めた値
tapSource.connect(delay).connect(dest);
最終的に 0.10秒。0にすると口が早く感じる、大きくしすぎると音が遅れて聞こえる、の間を探る作業です。注意点として、遅らせるのはキャプチャに回す音声だけにしています。ページ内で再生している音まで遅らせると、口の駆動そのものがズレてしまい、意味がありません。
この較正は送出前の映像に焼き込まれるので、YouTubeでもTwitchでも同じ値がそのまま効きます。プラットフォームごとに調整し直す必要はありません。逆に言うと、視聴者の画面に届くまでの15〜30秒の配信遅延とは無関係の別問題です。ここを混ぜると議論が壊れます。
第2の罠: だんだんズレる — setIntervalで30fpsを刻んではいけない
較正で「最初のズレ」は潰しました。ところが今度は、別の顔をした同期バグが出ました。
- 配信開始直後は、口の動きと音声が合っている
- 5分後、少しズレている気がする
- 15分後、明らかにズレている
**「だんだんズレる」**というのがポイントでした。この形の症状に出会ったら、疑うべき場所はかなり絞れます。
「最初からズレる」と「だんだんズレる」は別の病気
| 症状 | 原因の系統 | 対処 |
|---|---|---|
| 最初から一定量ズレている | 固定オフセット(処理経路の遅延差) | 定数で補正する(itsoffset や遅延ノード) |
| 時間とともにズレが増える | 累積誤差(クロックのずれ、タイマーの遅れ) | 補正では直らない。累積を止める |
固定オフセットのほうは、測って定数を入れれば終わりです。まさに第1の罠で入れた0.10秒の較正がそれでした。厄介なのは後者で、**定数をどう調整しても直りません。**調整した瞬間は合いますが、また離れていきます。「調整しても再発する」という時点で、累積誤差を疑うべきでした。
犯人: setInterval のフレームペーシング
映像はscreencastで受け取り、Node側で30fpsに整えてからffmpegに渡していました。そのペーシングがこうなっていました。
// 30fps = 33.33ms ごとに 1 フレーム書く……つもりだった
setInterval(() => {
writeFrame(latestFrame);
}, 1000 / 30);
setIntervalは「33.33msごとに発火する」タイマーではありません。「33.33ms以上たってから、実行できるようになったら発火する」タイマーです。イベントループが詰まっていれば遅れます。そして重要なのは、遅れた分は取り戻されないことです。
1回あたりの遅れが平均1msだとしても、30fpsなら1秒で30ms、1分で1.8秒、15分で27秒ぶんのフレームが「書かれないまま」になります。音声は別経路で実時間どおりに流れています。映像だけがフレーム数不足で遅れていく。これがリップシンクの漂流の正体でした。
直し方: 経過時間から「あるべきフレーム数」を出して追いつく
考え方を変えます。「一定間隔で1枚ずつ書く」のをやめて、「壁時計を見て、いま何枚書かれているべきかを計算し、足りない分を書く」ようにします。
const FPS = 30;
const startedAt = Date.now();
let written = 0;
setInterval(() => {
const elapsed = Date.now() - startedAt;
const shouldHave = Math.floor(elapsed * FPS / 1000); // あるべきフレーム数
while (written < shouldHave) {
writeFrame(latestFrame);
written++;
}
}, 1000 / FPS);
タイマーの発火が遅れても、次の発火で不足分をまとめて書くので追いつきます。誤差は毎回リセットされ、累積しません。タイマー自体はsetIntervalのままで構いません。発火のタイミングを信じるのをやめて、経過時間を信じる、というのが本質です。
一般化: 時間を数えるな、時刻を見ろ
この形は、タイマーを使うところに広く当てはまります。
// やってはいけない
count++; // 発火回数を数える
elapsed += intervalMs; // 間隔を足し込む
// やるべき
const elapsed = Date.now() - startedAt; // 時刻の差を取る
前者は「タイマーが正確である」という前提に乗っています。ブラウザでもNodeでも、その前提は成り立ちません。イベントループの混雑、GC、バックグラウンドタブでのスロットリング、システムの負荷。遅れる理由はいくらでもあります。
長時間動くもの(配信、録画、アニメーション、進捗表示、レート制限)ほど、この差は開きます。短時間のテストでは絶対に見つからないのも特徴です。我々も90秒のテストでは気づけず、実際の番組尺で回して初めて表面化しました。
第3の罠: 90秒録画したはずが6秒しかない
3つ目は、ライブ送出とは別に「録画してアーカイブを残す」経路で起きました。画面と音声をMediaRecorderで録って保存する仕組みです。90秒動かして、できあがったファイルを確認したら 6秒でした。
再現性は完璧。何度やっても6秒前後。しかも中身を見ると、アバターが起動時の挨拶をしゃべっている区間だけが記録されていました。
結論を先に書きます。
**
MediaStreamAudioDestinationNodeに実サンプルが流れていない間、MediaRecorderは映像フレームも書き出しません。**音声が止まると、映像ごと止まります。
つまり我々は「挨拶のTTSが鳴っていた6秒間だけ録れていた」わけです。残り84秒は無音で、無音というより音声トラックに何も流れていない状態でした。
最初に疑ったもの(全部ハズレ)
真因にたどり着くまでに、こういう順で疑いました。
1. CPU描画が遅いから。 当時はCPU描画(SwiftShader)で動かしていて、3Dシーンが4fpsしか出ていませんでした。「描画が間に合わずフレームが脱落している」というのは、いかにもありそうな説明です。数字も「実時間の約1/10」と、4fpsという観測と何となく整合して見えました。これが調査を長引かせました。もっともらしい仮説が最初に手に入ると、それを検証する方向にばかり進んでしまう。
2. コーデックが重いから。 VP8を強制するノブを実装して試しました。変化なし。
3. キャプチャ方式の問題。 canvas.captureStream()の取り出しがメインスレッドと競合しているのでは、と考えました。これは実際に別の問題ではあったのですが、6秒問題の真因ではありませんでした。
気づいたきっかけ
決め手は、GPU環境に移しても同じ症状が出たことでした。描画が57fps出ているのに、録画は数秒で止まる。ここで「描画の遅さ」という仮説が完全に崩れました。
残ったのは「録れている6秒は何なのか」という問いです。改めて録画の中身と、アバターの動作ログを並べたら一致しました。TTSが音を出している区間 = 録れている区間。
なぜこうなるのか
MediaRecorderは映像トラックと音声トラックを受け取り、muxして1本のファイルにします。muxする以上、両方のトラックの時刻を突き合わせる必要があります——第1の罠でも触れた、あの構造です。
我々の構成では音声をWeb Audio APIで組み立てて、MediaStreamAudioDestinationNodeからMediaStreamとして取り出していました。ここで問題になるのが、このノードは「接続元が音を出していないとき」に無音サンプルを生成してくれるとは限らないという点です。音声側のタイムラインが進まないと、muxerは映像だけを書き進めることができません。結果、映像フレームが来ているのにファイルが伸びないという現象になります。音が鳴っている間だけ両方のトラックが進むので、TTSの区間だけがファイルに残った、という説明がつきます。
対処: 無音を「流し続ける」
ConstantSourceNodeで常時0を出力し、音声グラフに接続しっぱなしにしました。
const ctx = new AudioContext();
const dest = ctx.createMediaStreamDestination();
// 常に 0 を出し続けるノードを繋いでおく(keepalive)
const keepalive = ctx.createConstantSource();
keepalive.offset.value = 0;
keepalive.connect(dest);
keepalive.start();
// 実際の音声(TTS など)も同じ dest に繋ぐ
ttsSourceNode.connect(dest);
出力される音は変わりません(0を足しているだけ)。変わるのは、音声トラックのタイムラインが常に進むようになることです。これで90秒動かせば90秒のファイルができるようになりました。
あわせて、録画開始を500ms遅らせる調整も入れています。オーディオグラフが組み上がる前にrecorder.start()を呼ぶと、開始直後の数フレームが不安定になったためです。
3つの罠から抽出できる教訓
3つのバグは別々の場所で起きましたが、根っこの学びは重なります。
1. 測れる部分を先に確定させる。 リップシンクは「機械的オフセット(測れる)」と「見た目の追従遅れ(測れない)」が混ざっていました。感覚で調整する作業に出会ったら、その中に測れる部分が混ざっていないかを疑う。測れる部分を先に潰すだけで、残りの感覚作業は驚くほど短くなります。
2. 症状の「時間発展」で原因の系統を分ける。 「最初からズレる」は固定オフセット、「だんだんズレる」は累積誤差。後者は定数補正では直りません。「調整しても再発する」と感じたら、定数をもう一度いじるより、時間が経つほど悪化するかを確かめたほうが早い。
3. タイマーの発火を信じず、時刻の差を取る。 setIntervalは正確ではなく、遅れは取り戻されません。発火回数を数えず、壁時計から「あるべき量」を計算して追いつく。累積誤差は実運用の長さで回さないと見つからない。
4. 数字が整合する仮説ほど危ない。 4fps→実時間1/10の一致は偶然でした。整合しているように見えたせいで、本来30分で終わる切り分けに何日もかけています。決着をつけたのは、GPU環境という変数を1つ完全に消した比較でした。仮説を検証するより、仮説を壊す条件を探すほうが速いことがあります。
5. メディア処理では「無」と「無音」は別物。 音声トラックが「音のないとき無音を流している」のか「何も流していない」のかで、下流の解釈が変わります。空の配列とnull、0と未設定、無音とトラック無し——下流が「無」をどう解釈するかまで確認する。
6. 出力の「尺」を監視項目にする。 6秒バグはfps・エラーログ・CPU使用率のどれにも出ませんでした。唯一検知できるのは成果物の実尺と実時間の比較です。以降、我々は検証の合否条件に「89秒動かして89秒のファイルができたか」を入れています。
無人で動き続けるものを作るということは、こうした沈黙する失敗と付き合い続けるということでした。ブラウザのメディアAPIは、落ちてくれた方がまだ親切だと思うくらい、失敗を例外ではなく静かなズレとして返してきます。だからこそ、測れるものは測り、時刻を信じ、尺を監視する——という地味な足場が効いてきます。