🕌

AWS PollyとFFmpegで1つの動画を10言語対応にした技術解説

に公開

title: "AWS PollyとFFmpegで1つの動画を10言語対応にした技術解説"
emoji: "🎬"
type: "tech"
topics: ["FFmpeg", "Polly", "動画処理", "音声合成", "HLS"]
published: false

はじめに

ChatGPTやStable Diffusionなど生成AIが話題になる以前、AWS PollyとFFmpegを駆使して「1つの動画を10言語対応させる」システムを構築しました。この記事では、動画・音声処理の技術的な実装に焦点を当てて、工夫したポイントを紹介します。

背景

当時、WebRTC技術を使った動画ベースの情報共有サービスを開発していました。画面を共有しながら、画面に表示される質問やテーマに対して回答する様子を録画し、チーム内で共有できる「Slackの動画版」のようなサービスです。

このサービスのグローバル展開を見据え、多国籍のチーム間での情報共有を実現するための仕組みが必要でした。日本、アメリカ、ヨーロッパ、インドなど、異なる言語を話すメンバー間でも同じ動画コンテンツを共有できるよう、動画の多言語化システムを構築することにしました。

実際に、インドのエンジニアチームとのマネジメントにこのシステムを活用していました。

ここで重要だったのが、各言語ごとに別々の動画を作るのではなく、1つの動画に複数の音声トラックを埋め込むというアプローチです。Netflixのように、視聴者が再生中に言語を切り替えられる仕組みを実現することで、動画管理を簡素化し、情報共有を円滑化することを目指しました。

実現したかったこと:

  • 1つの動画から10言語対応版を自動生成
  • 視聴者が再生中に言語を切り替え可能

当時の音声合成技術

当時(2020年頃)は、まだ生成AIブーム前。Text-to-Speechサービスとしては:

  • AWS Polly
  • Google Cloud Text-to-Speech
  • Azure Cognitive Services

といった選択肢があり、品質とコストのバランスからAWS Pollyを採用しました。

処理の全体像

動画の仕組み

まず、このサービスの動画がどういう構造になっているかを説明します。

画面には「今週の進捗を教えてください」という質問テキストが表示されています。それに対してユーザーが「今週は新機能の実装を進めました」と話している様子が動画です。

つまり:

  • 質問: 画面に表示されるテキスト
  • 回答: ユーザーが話す音声

多言語化では、質問テキストは翻訳して表示を変えるだけ。音声合成が必要なのは、ユーザーが話した回答の部分だけです。

処理フロー

入力: 動画ファイル + 質問と回答の配列(日本語)
  ↓
[ステップ1: 音声生成]
  ├─ 回答テキストをDeepLで翻訳
  ├─ AWS Pollyで各言語の音声合成
  └─ FFmpegで複数音声を時間調整して結合
  ↓
[ステップ2: HLS動画生成]
  └─ FFmpegでマルチオーディオトラックHLS動画に変換
  ↓
出力: 10言語切り替え可能なHLS動画

対応言語

日本語、英語、ドイツ語、スペイン語、フランス語、イタリア語、オランダ語、ポルトガル語、ロシア語、中国語の10言語に対応しています。

ステップ1: 音声生成処理

入力データの形式

入力データは質問と回答のペアの配列です:

{
  videoId: "video123",
  language: "ja",
  texts: [
    {
      question: "今週の進捗を教えてください",
      answer: "今週は新機能の実装を進めました",
      startTime: 0.0
    },
    {
      question: "課題はありますか",
      answer: "はい、テストケースの作成が遅れています",
      startTime: 5.0
    },
    {
      question: "次週の予定は",
      answer: "リリース準備を進めます",
      startTime: 10.0
    }
  ]
}

各ペアのanswer部分を、AWS Pollyで音声合成していきます。

SSML生成の工夫

AWS Pollyに渡すSSMLを生成する際、amazon:max-duration属性を使って音声の長さを動画の尺に合わせるのが最大のポイントです:

const createSsml = (texts, videoDuration) => {
  return texts.map((v, index) => {
    // 次のテキストまでの時間を計算
    let duration = videoDuration - v.startTime;
    if (texts[index + 1]) {
      duration = texts[index + 1].startTime - v.startTime;
    }

    // SSMLで音声の長さを制御
    const prosodyStart = `<prosody amazon:max-duration="${duration}s">`;
    const prosodyEnd = `</prosody>`;

    // 特殊文字の置換処理
    const text = v.text.replace(/|&/g, ' and ');

    return {
      startTime: v.startTime,
      duration,
      text: `<speak>${prosodyStart}${text}${prosodyEnd}</speak>`
    };
  });
};

この処理により、例えば「今週は新機能の実装を進めました」という回答を、0秒〜5秒の間に収めるように調整されます。

生成されるSSMLの例:

<speak>
  <prosody amazon:max-duration="5.0s">
    今週は新機能の実装を進めました
  </prosody>
</speak>

Pollyが自動的に読み上げ速度を調整し、5秒以内に収めてくれます。

FFmpegでの音声結合処理

AWS Pollyで生成した複数の音声ファイルを、適切なタイミングで配置して1つの音声ファイルに結合します。これが技術的に最も難しい部分でした。

const concatVoice = (videoId, ssml, outputPath, videoDuration) => {
  // 各音声ファイルを入力として指定
  const inputCommand = ssml
    .map((v, index) => `-i /tmp/${videoId}-${index}.mp3`)
    .join(' ');

  // 各音声の開始タイミングを計算(ミリ秒)
  const delays = ssml.map((v, index) => {
    let delay = ssml[0].startTime;
    for (let i = 0; i < index; i++) {
      delay += ssml[i].duration;
    }
    return delay * 1000;  // ミリ秒に変換
  });

  // adelayフィルタで各音声の開始タイミングを調整
  const delayCommand = delays
    .map((v, index) => `[${index}]adelay=${v}|${v}[a${index}];`)
    .join(' ');

  // amixで音声をミックス
  const amixCommand = delays
    .map((v, index) => `[a${index}]`)
    .join('');

  const tmpPath = `/tmp/tmp.mp3`;

  // ステップ1: 各音声を遅延させてミックス
  const command1 = `ffmpeg -y ${inputCommand} \
    -filter_complex "${delayCommand} ${amixCommand}amix=inputs=${delays.length}:duration=longest[a]" \
    -map "[a]" ${tmpPath}`;

  // ステップ2: 動画の長さに合わせてパディング
  const command2 = `ffmpeg -y -i ${tmpPath} \
    -af "apad=whole_dur=${videoDuration}" ${outputPath}`;

  return executeCommand(`${command1} && ${command2}`);
};

FFmpegコマンドの解説

上記のコードが生成するFFmpegコマンドを分解してみます。

ステップ1: 音声の遅延とミックス

例えば3つの音声ファイルがある場合:

ffmpeg -y \
  -i /tmp/video123-0.mp3 \
  -i /tmp/video123-1.mp3 \
  -i /tmp/video123-2.mp3 \
  -filter_complex "[0]adelay=0|0[a0]; [1]adelay=3500|3500[a1]; [2]adelay=8000|8000[a2]; [a0][a1][a2]amix=inputs=3:duration=longest[a]" \
  -map "[a]" /tmp/tmp.mp3

フィルタの詳細:

  • [0]adelay=0|0[a0]: 1つ目の音声を0ms遅延(左右チャンネル両方)
  • [1]adelay=3500|3500[a1]: 2つ目の音声を3500ms(3.5秒)遅延
  • [2]adelay=8000|8000[a2]: 3つ目の音声を8000ms(8秒)遅延
  • [a0][a1][a2]amix=inputs=3:duration=longest[a]: 3つの音声をミックス、最長の長さに合わせる

ステップ2: 動画の長さに合わせてパディング

ffmpeg -y -i /tmp/tmp.mp3 \
  -af "apad=whole_dur=15.5" \
  /tmp/video123-output.mp3
  • apad=whole_dur=15.5: 音声の終わりに無音を追加して、全体を15.5秒にする

これにより、動画の長さとぴったり合う音声ファイルが完成します。

ステップ2: HLS動画生成処理

HLS形式とは

HLS (HTTP Live Streaming) は、Appleが開発した動画配信プロトコルです。重要な特徴として:

  • 複数の音声トラックを持てる
  • 視聴者が再生中に言語を切り替えられる
  • プレイリスト(.m3u8)とセグメント(.ts)で構成

FFmpegでのマルチオーディオトラックHLS生成

元動画と各言語の音声ファイルから、マルチオーディオトラック対応のHLS動画を生成します:

const executeHls = (mp4FileName, mp3FileNames, inputDir, outputDir) => {
  // 各言語の音声ファイルを入力として指定
  const inputCommand = mp3FileNames
    .map((v) => `-i ${v}`)
    .join(' ');

  // 各音声トラックをAACエンコード
  const mapCommand = mp3FileNames
    .map((v, index) => `-map ${index + 1}:a -c:a aac`)
    .join(' ');

  // 各音声トラックに言語情報を付与
  const streamMapCommand = mp3FileNames
    .map((v, index) => {
      const lang = v.replace(`${inputDir}/`, '').replace('.mp3', '');
      return `a:${index + 1},agroup:aud_low,language:${lang},name:${lang}`;
    })
    .join(' ');

  const command = `mkdir -p ${outputDir} && ffmpeg -i ${mp4FileName} ${inputCommand} \
    -map 0:a -c:a copy ${mapCommand} -map 0:v -c:v copy -f hls \
    -hls_playlist_type vod \
    -var_stream_map "a:0,agroup:aud_low,default:yes,language:default,name:default ${streamMapCommand} v:0,agroup:aud_low" \
    -master_pl_name master.m3u8 \
    ${outputDir}/out_%v.m3u8`;

  return executeCommand(command);
};

実際のFFmpegコマンド例

10言語対応動画を生成する場合の実際のコマンド(簡略化):

ffmpeg \
  -i /tmp/input/video123.mp4 \
  -i /tmp/input/ja.mp3 \
  -i /tmp/input/en.mp3 \
  -i /tmp/input/de.mp3 \
  # ... 他の言語 ... \
  -map 0:a -c:a copy \
  -map 1:a -c:a aac \
  -map 2:a -c:a aac \
  -map 3:a -c:a aac \
  # ... 他の言語 ... \
  -map 0:v -c:v copy \
  -f hls \
  -hls_playlist_type vod \
  -var_stream_map "a:0,agroup:aud_low,default:yes,language:default,name:default a:1,agroup:aud_low,language:ja,name:ja a:2,agroup:aud_low,language:en,name:en v:0,agroup:aud_low" \
  -master_pl_name master.m3u8 \
  /tmp/output/out_%v.m3u8

重要なオプション解説:

  • -map 0:a -c:a copy: 元動画の音声トラックをそのままコピー
  • -map 1:a -c:a aac: 日本語音声をAACエンコード
  • -map 0:v -c:v copy: 元動画の映像トラックをそのままコピー(再エンコードしない)
  • -f hls: HLS形式で出力
  • -hls_playlist_type vod: VOD用プレイリスト
  • -var_stream_map: 各ストリームの言語情報を定義

-var_stream_mapの詳細:

a:0,agroup:aud_low,default:yes,language:default,name:default  # オリジナル音声(デフォルト)
a:1,agroup:aud_low,language:ja,name:ja                        # 日本語音声
a:2,agroup:aud_low,language:en,name:en                        # 英語音声
v:0,agroup:aud_low                                            # 動画ストリーム
  • a:0, a:1: 音声ストリームのインデックス
  • agroup:aud_low: オーディオグループ名(同じグループ内で切り替え可能)
  • language:ja: 言語コード
  • name:ja: 表示名
  • default:yes: デフォルトで選択される音声

生成されるファイル構成

output/
├── master.m3u8       # マスタープレイリスト
├── out_0.m3u8        # オリジナル音声トラックのプレイリスト
├── out_1.m3u8        # 日本語音声トラックのプレイリスト
├── out_2.m3u8        # 英語音声トラックのプレイリスト
├── ...
├── out_11.m3u8       # 動画トラックのプレイリスト
├── out_00.ts         # オリジナル音声セグメント
├── out_01.ts
├── out_10.ts         # 日本語音声セグメント
├── out_11.ts
└── ...

master.m3u8の中身(一部):

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud_low",NAME="default",DEFAULT=YES,LANGUAGE="default",URI="out_0.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud_low",NAME="ja",LANGUAGE="ja",URI="out_1.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud_low",NAME="en",LANGUAGE="en",URI="out_2.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO="aud_low"
out_11.m3u8

動画プレイヤーはこのマスタープレイリストを読み込み、言語切り替えUIを表示します。

技術的な工夫ポイント

1. SSMLのamazon:max-durationで尺を調整

AWS Pollyのamazon:max-duration属性を使うことで、音声の長さを動画の尺に合わせています。

なぜ必要か:

  • 音声が早すぎる → 次のセリフまで間が空く
  • 音声が遅すぎる → 次のセリフと重なる

max-durationを指定することで、Pollyが自動的に読み上げ速度を調整し、指定時間内に収めてくれます。

2. FFmpegのadelayフィルタで音声タイミング制御

複数の音声を正確なタイミングで配置するため、adelayフィルタを使用:

[0]adelay=0|0[a0];
[1]adelay=3500|3500[a1];
[2]adelay=8000|8000[a2];
  • 第1引数: 左チャンネルの遅延(ミリ秒)
  • 第2引数: 右チャンネルの遅延(ミリ秒)

ステレオ音声の場合、両チャンネルに同じ遅延を適用します。

3. HLSのvar_stream_mapで言語切り替え実現

HLSのvar_stream_mapオプションで、各音声トラックに言語情報を付与:

-var_stream_map "a:0,agroup:aud_low,default:yes,language:default,name:default a:1,agroup:aud_low,language:ja,name:ja v:0,agroup:aud_low"

これにより:

  • Safari、Chrome、iOSなどのプレイヤーで言語切り替えUIが表示される
  • ユーザーが再生中に好きな言語に切り替えられる

4. 動画の再エンコード回避

映像トラックは -c:v copy で再エンコードせずにコピーしています:

メリット:

  • 処理時間の大幅短縮(数分 → 数十秒)
  • 画質劣化なし
  • CPUリソース節約

音声トラックのみを差し替えるため、映像の再エンコードは不要です。

5. apadフィルタで長さ調整

apad=whole_dur=${videoDuration} で音声の終わりに無音を追加:

-af "apad=whole_dur=15.5"

これにより、音声が動画より短い場合でも、最後まで音声トラックが存在します。

苦労した点・ハマりポイント

1. FFmpegコマンドの複雑さ

複数の音声ファイルを適切なタイミングで配置するFFmpegコマンドを組み立てるのが最大の難関でした:

理解が必要だった概念:

  • フィルタグラフ: [0], [a0] などのラベリング
  • ストリームマッピング: -map でどのストリームを出力に含めるか
  • 動的なコマンド生成: 言語数によって変わるコマンドの組み立て

何度もローカルでテストして、正しいコマンドを見つけました。

デバッグのコツ:

  • まず2つの音声で試す
  • -loglevel debug でFFmpegの詳細ログを確認
  • 中間ファイルを保存して確認

2. AWS Pollyの文字数制限

AWS Pollyは1リクエストで3000文字までという制限があります。

対応策:

  • 長いテキストは複数に分割
  • 分割したSSMLをそれぞれPollyで合成
  • FFmpegで結合(上記のadelay処理)

3. 言語ごとの音声の癖

言語によって音声の速度や間の取り方が異なります:

  • 英語: 比較的高速
  • 日本語: ゆっくりめ
  • 中国語: 抑揚が大きい

max-durationだけでは完璧に合わない場合もあり、言語ごとに微調整が必要なケースもありました。

調整方法:

  • <prosody rate="slow">: 読み上げ速度を調整
  • <break time="0.5s">: 明示的な間を追加

4. HLSセグメント長の調整

HLSのデフォルトセグメント長(6秒)では、短い動画の場合にセグメント数が少なすぎる問題が発生:

-hls_time 2  # セグメント長を2秒に設定

5. FFmpegのメモリ消費

長尺動画や高画質動画を処理する際、FFmpegは大量のメモリを消費します。

対策:

  • 動画の解像度を事前に確認
  • 必要に応じて -preset ultrafast で処理速度優先
  • メモリ不足エラーが出たら -max_muxing_queue_size 1024 を追加

振り返りと今後

今ならどう実装するか

2024年の今、同じシステムを作るなら選択肢が増えました:

1. 音声合成の選択肢

サービス 特徴 使い所
AWS Polly コスパ良、速度制御◎ 大量処理、長尺動画
OpenAI TTS 自然、感情表現◎ 高品質重視
ElevenLabs 超高品質、声クローン プレミアムコンテンツ
Google Cloud TTS Wavenet品質高 バランス型

速度制御の観点では、SSMLが使えるPollyが依然として優秀です。

2. 翻訳技術の進化

元の日本語台本から各言語の台本を生成する方法も進化しています:

当時(2020年頃):

  • DeepLで翻訳
  • 精度は高いが、動画の文脈を考慮した調整が必要

今(2024年):

  • ChatGPTで文脈を考慮した翻訳が可能
  • プロンプトで「動画のナレーション用」などの指定ができる
プロンプト: 以下の日本語を、動画ナレーション用の自然な英語に翻訳してください。
元の台本: "こんにちは。今日は良い天気ですね。"
↓
英語: "Hello. It's a beautiful day today, isn't it?"

生成AIの登場で、より自然で文脈に適した翻訳が手軽に得られるようになりました。

生成AIとの比較

観点 従来のTTS (Polly) 生成AI TTS
音声品質 機械的だが明瞭 より自然
感情表現 限定的 豊か
速度制御 SSMLで細かく制御◎ 制御が難しい
タイミング制御 max-durationで秒単位指定◎ 困難
コスト 安価 高価
安定性 高い APIの変更リスク

動画の尺に合わせる必要がある場合、SSMLによる細かい制御ができるPollyは今でも有力な選択肢です。

まとめ

生成AIが台頭する前、AWS PollyとFFmpegを組み合わせて動画の多言語化を実現しました。主なポイント:

音声生成

  • SSMLのamazon:max-duration で音声の長さを動画の尺に調整
  • FFmpegのadelayフィルタ で複数音声を正確なタイミングで配置
  • apadフィルタ で動画の長さに合わせてパディング

HLS動画生成

  • var_stream_map でマルチオーディオトラックを定義
  • -c:v copy で動画を再エンコードせず高速処理
  • 言語メタデータ を付与してプレイヤーで切り替え可能に

技術的な学び

  • FFmpegのフィルタグラフとストリームマッピングの理解
  • 動画処理におけるメモリ・CPU管理
  • 音声合成の言語ごとの特性

今では生成AIの登場でさらに自然な音声が手軽に使えますが、動画の尺に合わせた精密な制御が必要な場合は、SSMLとFFmpegの組み合わせが依然として有効です。

この記事が、動画処理や音声合成に取り組む方の参考になれば幸いです。

参考リンク

Discussion