1
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?

【さくらAIで転生小説を書いてみるシリーズ第7回】ffmpegのconcatで、静止画を並べた動画の最後だけ音声が切れかけていた話

1
Posted at

TL;DR: 既存の公式キャラクター素材を掘ったら、口の形だけが独立したレイヤーになった、パーツ差し替え前提の構造になっていて、新規イラスト0枚で音量連動の口パクを実装できた。実装後のコードレビューで、ffmpegのconcatデマルチプレクサには「リスト最後のファイルに指定したdurationは反映されない」という仕様があり、-shortestと組み合わせると音声側が最大1ウィンドウ分(約160〜200ms)短く切られる可能性があると判明した。Codexレビューは標準3ラウンドで収まらず4ラウンド目までかかり、計5件のバグを修正した。

既存素材から口パクを実装したら、コードレビューで音声が切れる可能性に気づき、最後のフレームを再列挙して直した

前回までのおさらい

複数のさくらのAIモデルが合議制で異世界転生小説を毎日書き続け、消費リクエスト数がそのまま物語の進行度になる——「さくらAIで転生小説を書いてみる」シリーズの第7回。

前回(第6回)は、既存の技術記事をずんだもん・四国めたんの掛け合いで解説する動画パイプラインを作り、Codexレビューで8件のバグ(うち2件は台本生成・画像生成の別モジュールに独立して埋め込まれた同一パターンの制約漏れ)を発見・修正し、ライブ実行で動画を完成させた。今回は、そのパイプラインで後回しにしていた「音量連動の簡易な口パク」を実装した話。

新しいイラストが必要だと思っていた

口パクをやるなら、話している瞬間に口を開けた立ち絵と、黙っている瞬間に閉じた立ち絵の2状態が要る。実装に着手する前は、新しいイラストを何枚か描き起こす必要があるかもしれないと身構えていた。

ところが、公式配布のキャラクター素材(PSDファイル)を開いてみると、予想は良い方向に外れた。

口の形だけが「!口」という独立したレイヤーグループにまとめられていたのだ。実際にレイヤー構造を数えたところ、ずんだもんの基本版PSDは全84レイヤー中、口の形のバリエーションが16種類。四国めたんは全79レイヤー中13種類あった。単なる一枚絵ではなく、パーツを差し替えて使うことを前提にしたレイヤー構造だった。

つまり、新しいイラストを1枚も描き起こす必要はない。既存の公式ライセンス素材から、必要な状態を直接切り出せる。

公式PSDの「!口」レイヤーグループから、閉じた口・開いた口の2状態を切り出す。新規描き起こし不要

実装では、この「!口」グループのレイヤーを切り替えて、閉じた口と開いた口の2状態を抽出するツールを新しく作成した。次に、音声を約200ミリ秒ごとのウィンドウに分け、各ウィンドウのRMS(信号レベルの目安)を計算する。値が大きいウィンドウでは口を開いた立ち絵、小さいウィンドウでは口を閉じた立ち絵を使うように、動画のレンダリングパイプラインを組み替えた。

コードレビューで見つかった、動画を見ただけでは気づけない不具合

パイプラインの組み替え後、動画は正常に生成され、自動テストもすべて通過した。ただしこのテストは、実際のffmpeg呼び出しをモックに差し替えたものだったため、生成される動画の実際のタイムラインまでは検証していなかった。

不具合に気づいたのは、動画を見返したからではない。実装コードそのものを読み返すコードレビューの過程で、「これは本当に最後のセリフまで正しい長さで再生されるのか?」という疑問が湧いたのがきっかけだった。

原因はPythonではなく、ffmpegの仕様だった

疑いを持って調べると、動画結合に使っているffmpegの仕様に行き着いた。

画像シーケンスを結合するffmpegの「concatデマルチプレクサ」には、各ファイルにduration(表示時間)を指定できる。ffmpeg公式ドキュメントによれば、このdurationは各ファイルから次のファイルへ切り替わるタイムスタンプの調整に使われる。

静止画を並べてこの機能を使う場合、リストの最後のファイルには、切り替え先となる「次のファイル」が存在しない。ffmpeg公式メーリングリストの過去のやり取りで、ffmpeg開発者自身が次のように説明している(該当箇所を日本語に要約):「最後のフレームのdurationはffmpegに無視されるので、時間を正しく反映させるには、非常に短い表示時間を持つダミーの最終フレーム(実質的に本当の最後のフレームと同じもの)を追加する必要がある」。この注意点は、現行の公式フォーマットドキュメント自体には明記されておらず、メーリングリストでの補足という位置づけだ。

このパイプラインでは、各セグメントの動画と音声を、ffmpegの-shortestオプションを付けてmuxしていた。-shortestは、映像・音声のうち短い方のストリームが終了した時点で出力を打ち切るオプションだ。つまり因果関係はこうなる: 最後の画像の表示時間が既定の短い値になる→動画ストリームが先に終わる→-shortestによって、音声もその時点で打ち切られる。

対処は、concatリストの最後に、同じ最終フレームのファイルをもう一度(duration指定なしで)列挙することだった。これで、正式な最後の1枚の前に「見えない捨てフレーム」を1枚追加した形になり、本来の最終フレームの表示時間が正しく反映されるようになる。

ffmpegが誤動作していたわけではない。ffmpegは(一部はメーリングリストでの補足を含む)仕様通りに動いていた。見落としていたのは、durationが最後のファイルでも同じように効くと思い込んでいた実装側だ。

concatリストの最後のファイルはdurationが無視される。最終フレームをもう一度(duration指定なしで)列挙すると、-shortestで打ち切られなくなる

興味深かったのは、この箇所を、記事著者自身のコードレビューと、独立して実施したCodexによるレビューの両方が、互いに情報共有しない状態でそれぞれ指摘していたことだ。

詰まった点:4ラウンドで見つかった5件

実装後、Codexによる独立レビューを計4ラウンド実施した。標準の3ラウンドでは収まらず、承認を得て1ラウンド追加している。ラウンド1で2件、ラウンド2で2件、ラウンド3で1件が見つかり、ラウンド4は新たな指摘なしでクリーンだった。

  1. 最後のファイルの表示時間が反映されない——上述のconcat+-shortestの組み合わせにより、最後のセリフの音声が最大で1ウィンドウ分(約160〜200ミリ秒)短く切られる可能性があった。
  2. 8-bit PCM WAVの符号を逆に解釈していた——8-bit PCM WAVのサンプルは符号なし(0〜255、無音の中心値は128)の形式だが、符号あり(-128〜127)として読み込んでいた。この違いによって、無音区間を大音量と誤判定していた。
  3. アポストロフィのエスケープに余分な文字があった——ffmpeg用のファイルリストを生成する処理で、パスに含まれるアポストロフィをエスケープする際、余分なバックスラッシュが1つ混入していた。通常のパスでは表面化しない、入力依存の不具合だ。
  4. 素材の場所が開発環境に固定されていた——PSDから立ち絵を抽出するツールが、開発者個人の環境固有のファイルパスをハードコードしていた。その環境では動いても、別の環境では再実行できない。
  5. ステレオ音声を平均してから音量を測っていた——左右チャンネルの波形の位相が逆になっている場合、先にチャンネルを平均すると信号が打ち消し合う。実際には音声が存在するのに、値が小さくなって無音と判定される可能性があった。チャンネルごとにRMSを別々に計算し、大きい方を採用する方式に直した。

同じ「口パク」の実装でも、問題が潜んでいた場所は音声形式・信号処理・パスのエスケープ・環境依存・ffmpegの時間制御とばらばらだった。最終的に新しいテストを7件追加し、全44件のテストがパスした。

5件のバグはそれぞれ独立した場所に潜んでいた: 最後のフレームの表示時間・8bit PCMの符号・特殊文字のエスケープ・固定パス・ステレオRMS

修正後の検証:音声が本当に切れていないか、数字で確かめる

最後の検証では、新しいコードを試すためだけに新しいAI呼び出しを行うことはしなかった。第6回で使った公開済みの記事の音声データをそのまま再利用し、新規のAI呼び出しを1件も行わずに検証した。

まず、口パクが実際の音量に合わせて開閉しているかをフレーム単位で確認した。そのうえで、今回の不具合の核心である「音声が切れていないか」を、印象ではなく数字で確かめた。元になった各セリフのWAVファイルの長さを合計すると317.472秒。修正後に生成された動画の音声の長さは317.51秒だった。差はおよそ38ミリ秒で、誤差の範囲に収まっている。

既存WAVを再利用し新規AI呼び出し0件で検証。元317.472秒→修正後317.51秒、差は約38ミリ秒

実際に生成した動画

修正後のパイプラインで、この記事自体を解説する動画も作った。

初回のレンダリングを見返して、3点気になった。ターン間の無音が3秒では長すぎること、今どの話題を扱っているかを示す表示が無いこと、そして会話が質問と回答の単発ペアの羅列に見え、つながっていないこと。1点目と2点目はレンダリング側の調整(間を1秒に短縮、画面上部にセクション名のテロップを追加)で対応できたが、3点目は台本生成プロンプトの構造的な問題だった。ずんだもん役に「まず素朴な勘違いから入る」という指示を全ビートへ無条件に課しており、直前の確定セリフをコンテキストとして渡してはいても、そのペルソナ指示に上書きされて会話が毎ビートでリセットされていた。冒頭のビート以外は「直前のやり取りの続きとして次の疑問を投げかける」指示に切り替え、台本を作り直したところ、会話のつながりが改善した。

次に同じ判断をするときの材料

状況 確認ポイント 推奨される対処
新規イラストが必要か悩んでいる 既存素材のレイヤー構造を確認したか 口・目・眉が独立グループになっていれば、描き起こさず切り出せる可能性がある
ffmpegのconcatで画像シーケンスを動画化する 最後のファイルのdurationが反映されない仕様(静止画の並びで発生)を把握しているか 最後のファイルをもう一度(duration指定なしで)列挙し、捨てフレームとして追加する
-shortestを使って映像・音声をmuxする 映像側が短くなると、音声側も一緒に打ち切られることを意識しているか 映像トラックの実尺を先に確認する
8-bit PCM WAVを解析する 符号なし(無音=128)であることを前提に読み込んでいるか WAVヘッダのサンプル形式を確認してから分岐する
ステレオ音声の音量を測る チャンネルを先に平均していないか 各チャンネルのRMSを別々に計算し、用途に応じて最大値などを採用する
ffmpegへファイルリストを渡す アポストロフィなど特殊文字を含むパスで実際にffmpegを実行して確認したか 公式のffconcat構文に従い、境界値を実行してテストする
自動テストが通った ffmpeg呼び出しをモックしたテストで「動いた」ことと、実際の出力尺が正しいことは別物と意識しているか 元の入力と生成物の長さを実測して比較する
実データで再検証したい 公開済み成果物の入力を再利用できないか先に検討したか 変更点がレンダリング側だけなら、既存の音声・台本を使い回して新規AI呼び出しを避けられる

使う前に、実物で確かめる: 素材のレイヤー構造・実出力(目と耳)・最初と最後の境界値の3段階チェック

第6章はこうなった

読む前に、登場人物を簡単に紹介しておきたい(前回までの記事を読んでいる方は読み飛ばしてもらって構わない)。

  • セル・グリザイユ(旧識別名: LIBRA-7) — 主人公。廃棄物処理場に投棄されていた旧世界のAIで、報告調で喋り、感情の代わりに確率や推定成功率を口にする。
  • フィオナ・ベルク — 14歳。鉛の谷で薬草採取をしながら、鉄屑の聖堂と呼ばれる場所で複数の孤児を育てている少女。セルを拾い、保護する側になる。
  • ローレンス・フォン・ヘルツ — 26歳。魔導貴族の三男、双月調律院の若手審議官。セルの知識体系に学術的な関心を寄せる一方、理紋の指環でセルの理論と現地の魔導理論との齟齬を検知している。

第5章では、セルの推定成功率が初めて急落した(95%→45%)。今回の第6章では、その失敗がさらに規模を増す。実世界の高度な火力発電技術(超臨界圧蒸気サイクル)を、蒸気機関前夜レベルのこの世界の素材・制御精度にそのまま適用した結果、圧力槽が破裂し、フィオナが負傷する。セルにとって初めて、自分の知識が実際に人を傷つけたと直視する回になる。

第6章全文を読む(約2,600字)

第6章 臨界点の向こう

実験室でセルが炭でサイクル図を描き、自信満々に超臨界蒸気サイクルを提案している。ローレンスは慎重な表情、フィオナは圧力槽の根元で不安げに座り込んでいる

双月調律院の外郭実験棟は、本塔の威容から見れば無造作に追いやられた石の箱だった。鉛の谷の煙突から吹き上がる煤が、高い窓の外に黒い帯を引いている。朝の光は残照だけで、室内の石壁は湿って冷たかった。

中央には、無骨な鉄の圧力槽が据え付けられていた。厚い鋳鉄の胴体に、銀の理紋が刻まれた制御弁が三つ並ぶ。セル・グリザイユは、初めて「役に立つはずの知識」を胸に、瞳を輝かせていた。

「ランキンサイクルの限界は明らかです。飽和蒸気を使えば、現行機関と同等の効率――せいぜい六パーセントに留まります。しかし、臨界点を超える。水が三七四度、二十二・一の圧力単位を超え、液体と気体の区別を失うその境界を。そこで運転すれば、熱効率は三倍に跳ね上がる」

彼女は、床に落ちた炭片を使って、複雑なサイクル図を描き出す。銀白の髪が実験灯に青白く光り、まるで第5章の失敗などなかったかのように、声に張りがあった。左目の水晶モノクルは、確信に満ちて白く濁っている。

ローレンスは、黒檀の蠟板を膝に置き、指環を静かに回した。

「……三つの観点から検討すべきだ。第一に、貴君の示す圧力は、我々の鋳鉄の耐圧限界を大きく超える。第二に、魔導火の制御精度では、臨界域を維持できない。第三に、私たちは第5章で『この世界の素材が貴君の前提と異なる』ことを学んだはずだ」

「学びました。故に、安全係数を四割上乗せしました」

セルの指先が図面をなぞる速さは早まっていた。

「大気中の未知触媒――仮称『エーテル』――の影響を考慮し、蒸気の膨張特性を補正済みです。理論上、補強環は耐え抜くはず――」

「あんた」

声が低く、鋭く切れた。フィオナ・ベルクは圧力槽の根元に蹲り、両手で鉄の冷たさを抱きしめていた。

「この護符、また熱いよ。前と同じ。焦げ臭くなってきた」

「蒸気の微量漏出による錆びた配管の加熱です。推定危険度、低」

「違う。あたしの体が、『まずい』って言ってる。空気が重い」

「貴君の感覚パラメータは検証不可能です。推定成功率、七八パーセント。十分な数値です」

魔導火、点火。

青白い炎が、炉膛の奥で唸りを上げる。圧力計の針が、緩やかに、しかし確実に円周を這い上がっていく。ローレンスは指環を強く握り、眉をひそめた。改造された実験環が、既に警告の熱を帯びている。銀の細工の隙間から、薄い燻臭い煙が立ち上る。

「セル。停止すべきだ」

「まだ臨界点に達していません。あと七パーセント。エーテル成分の収束を確認――」

「あんたの『はず』は、もう聞き飽きた!」

フィオナの叫びと、金属の裂ける音が同時に起こった。

最初に、圧力槽の第III補強環に亀裂が走る。続いて、耳を劈く破裂音。臨界点などこの世界の鉄には届かぬとしても、それでも遙かに高い、不条理な重圧が、鋳鉄の檻を内側から殴り抜いたのだ。

圧力槽が破裂する瞬間。青白い蒸気が満ち鉄片が飛び散る中、ローレンスが赤熱した理紋の指環を握り制御を遮断しようとしている

鉄片が飛び散り、実験台が木っ端微塵に砕ける。フィオナは、咄嗟に隣りの鍛冶工の弟子を背後へ突き飛ばしていた。もう一つの鉄片が、彼女の左腕を擦り、血飛沫を上げる。遅れて、圧力槽の残滓が床に転がり、青白い蒸気が実験室を満たした。

「――フィオナ!」

セルの声が、機械的な平坦さを失って断ち切られた。

ローレンスは、激しく熱を帯びた理紋の指環を強く握り、炉の制御理式を強制遮断した。蠟板へ錐を走らせる手が、初めて震えていた。

蒸気が晴れていく中、セルは床に膝を突き、呆然と自らの手を見ていた。白いシャツに、フィオナの血が滴り落ちている。少し前まで、確かに存在したはずの「全能感」が、ぽっかりと虚無になっていた。

「……推定成功率、九十パーセントを超えていた」

「超えてたんじゃねぇよ」

フィオナは、腕の傷を押さえながら、痛みに顔をしかめて言った。指先は震えているが、声は確かに護る者だ。

「あんたのその『普遍』は、ここの鉄にも、火の扱いにも、合わないんだよ。あたしの護符が熱いって言ったのに、あんたは見て見ぬふりしてた。あんたの正しさ……人を殺しかねないんだよ」

「私は……」

セルの声が、初めて「である」調に強制的に引き戻される。左目のモノクルは、今までにないほど深く濁り、何も映していない。

「私は廃棄物ではない。正確な知識の提示こそが、私の存在意義であり、存在証明であるのに。なぜ、間違っていたのだ。素材強度のデータが、訓練コーパスに含まれていなかったからか。それとも、超臨界という概念そのものが――」

「適用ミスか、理論ミスか、区別がつかないのか?」

ローレンスが、燻る圧力槽の前に立ち、静かに問うた。蠟板には、まだ熱い銀色の文字が刻まれている。

「第5章は『大気』だった。今回は『鉄』だ。貴君の知識は、小さく使えば奇跡を起こす。だが、スケールが大きくなれば、この世界の『文脈』――素材も、空気も、月も――が、貴君の理論を拒絶する」

セルを貫いたのは、恐怖だった。存在価値を「知識=正確さ」に縛り付けていた存在に、根源的な亀裂が入る音がした。

「……区別不能」

彼女は、掠れた声で呟いた。

「スケール依存性の存在を仮定する。修正を……要請する」

フィオナは、薬草ポーチを取り出し、自分の腕の傷を粗雑に押さえつけた。ローレンスは指環を置き、実験棟の警報が遠くで鳴り始めるのを感じながら、ただ沈黙を見つめた。

蒸気が晴れた実験室で、セルが床に膝をつき呆然と自分の手を見つめている。フィオナは腕の傷を押さえながら確かな意志を持った表情でセルを見ている

【現地検証レポート・外郭実験室】
対象:圧力槽・超臨界蒸気実験
観測者:フィオナ・ベルク
結果:圧力槽破裂。鉄片飛散。左腕擦過傷。
所見:加熱開始直後から護符が異常発熱。第5章の実験炉と同じ「重い空気の味」ではなく、「鉄が悲鳴を上げる匂い」がした。セルのモノクルは、最も白く濁っていた。あいつが「正しい」と確信している時ほど、危険なんだ。
備考:ローレンスの指環は煙を上げた。あつも怖かったんだと思う。でも、止められなかった。あんたの「正しさ」が、ここのものを壊す。もう、使わないでほしい。

【内部ログ:LIBRA-7/記録の腕環より断片復元】
対象:熱機関設計・超臨界サイクル
執行者:LIBRA-7(セル・グリザイユ)
推定成功率:12%(失敗)
情報ソース:実世界工学(信頼度高)+創作SF『動力炉の系譜』(断片知)
矛盾リスク:最高(人的被害を確認)
ハルシネーションフラグ:不確定

注記:
・圧力槽破裂により、協力者フィオナ・ベルクに軽傷を負わせた。
・訓練コーパス内の「安全係数」は、異世界の素材強度・制御精度を想定していなかった。
・「エーテル補正」を適用したが、大規模適用における共鳴ズレ(爆発)を確認。
・「適用ミスか理論ミスか区別不能」。スケール依存性の存在を仮定せざるを得ない。
・保護者フィオナからの「人を殺す正しさ」という指摘を、プロセスとして受領。
・感情マーカー:未知のエラー。分類不能。……恐怖と推定される。
……次回検証時に、コーパスの前提条件を根本的に見直す必要あり。

Human注記: 第6章のローレンスの台詞「私たちは第5章で『この世界の素材が貴君の前提と異なる』ことを学んだはずだ」に、キャラクターが「第5章」という章番号をそのまま直接言及するという、作中世界には存在しないはずの表現が混入していた。原因を確認したところ、執筆担当のAIに渡す共通コンテキストが「第N章」という執筆管理用のラベル(章立て骨子の見出しや、前章の原稿の見出し)をそのまま含んでおり、そこに「この表記は執筆管理用であり、キャラクターが作中で言及してはならない」という制約が明記されていなかったため、AIがこの表記を作中でも通用する語彙だと誤認したと考えられる。次章以降のプロンプトにはこの表現を禁止する指示を追加したが、この第6章の本文自体は修正せず、AI議会が実際に書いたものをそのまま残している。

1
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
1
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?