字幕と翻訳

配信中の音声を外部の音声認識サービス(OpenAI・whisper.cpp)へ送って字幕を作り、Anthropic・DeepL・OpenAI で翻訳して、言語ごとの WebVTT として HLS で配信する方法です。

加工と字幕

このページの見出し
  1. 仕組み
  2. 設定(ストリームの captions)
  3. SENCA が送るリクエスト
  4. 音声の切り出しと認識
  5. HLS の字幕
  6. 遅延の扱い
  7. うまく動かないとき
  8. 関連するページ

配信中の音声を外部の音声認識サービスに送って字幕を作り、必要なら翻訳して、言語ごとの WebVTT(Web 用の字幕ファイルの形式)として HLS で配信します。たとえば日本語の講義に、英語と中国語の字幕を付けられます。

音声認識には openai(OpenAI 互換のサーバーも含む)か、社内に置ける whisper.cpp のサーバー(whisper_cpp)を使います。翻訳は anthropic・deepl・openai から選べます。視聴者が字幕付きで見るには、再生 URL の index.m3u8 を master.m3u8 に置き換えるだけです。

映像は遅らせないので、字幕は話してから数秒〜十数秒遅れて出ます。

例で使う API・TOKEN・H は、取り込みと再生 と同じです。

仕組み

字幕ができるまでの流れです。

字幕ができるまでの流れ。音声を 5 秒の窓に詰め替えて音声認識し、元の言語の WebVTT と、翻訳した言語ごとの WebVTT を作って HLS の字幕にする

ストリームに captions を設定しない限り、外部へは何も送りません。 設定したときだけ、指定した送信先へ音声とテキストを送ります。

音声はデコードしません。AAC は音声だけの MP4(.m4a)に、Opus は Ogg Opus(.ogg)に中身を変えずに詰め替えて送り、デコードは送信先に任せます。そのため G.711 のようにほかの形式の音声は字幕にできません。

字幕の処理は配信者が接続したときに始まり、配信が終わったとき(再接続の猶予時間が過ぎたとき)に止まります。配信中に captions を変えても、効くのは次の配信からです。

クラスターでは、配信を受けたノード(ストリームの所有者)だけが字幕を作ります。複製から配信しているノードは、字幕のファイル(master.m3u8・sub_{lang}.m3u8・sub_{lang}_{n}.vtt)を所有者から取ってきて返すので、どのノードの再生 URL でも字幕は出ます。取ってきたファイルは、プレイリストなら 1 秒、字幕のセグメントなら 30 秒使い回します。

設定(ストリームの captions)

OpenAI で日本語を認識し、Anthropic で英語と中国語に翻訳する例です。API キーを先にシークレットへ保存し、それからストリームの captions を設定します。

curl -s -X POST "$API/secrets" "${H[@]}" -d '{"name":"openai","value":"sk-..."}'
curl -s -X POST "$API/secrets" "${H[@]}" -d '{"name":"anthropic","value":"sk-ant-..."}'

curl -s -X PATCH "$API/streams/$SID" "${H[@]}" -H "If-Match: $ETAG" -d '{
  "captions": {
    "asr_provider": "openai",
    "asr_endpoint": "https://api.openai.com/v1",
    "asr_credential_ref": "secret:openai",
    "language": "ja",
    "translate_provider": "anthropic",
    "translate_endpoint": "https://api.anthropic.com",
    "translate_credential_ref": "secret:anthropic",
    "targets": ["en", "zh"]
  }
}'
フィールド既定指定できる値意味
asr_provider(必須)openai・whisper_cpp音声認識サービスの種類です
asr_endpoint(必須)http:// か https:// の URL音声認識の送信先の基本 URL です(パスを含めてもかまいません)
asr_credential_refなしsecret:{name}(同じテナントのシークレット)API キーです(openai で使います)
asr_modelwhisper-1openai で使うモデルの名前です
languageja1〜8 文字の英数字と -話されている言語です(ISO 639-1)。音声認識に渡すほか、元の言語の字幕の言語にもなります
translate_providerなしanthropic・deepl・openai翻訳サービスの種類です。指定すると翻訳します
translate_endpointなしtranslate_provider があれば必須翻訳の送信先の基本 URL です
translate_credential_refなしsecret:{name}API キーです(anthropic・deepl では必須)
translate_modelanthropic では claude-haiku-5-5、openai では gpt-4o-mini翻訳に使うモデルの名前です。deepl では使いません
targets[]8 個まで。書き方は language と同じ翻訳先の言語です

字幕をやめるときは captions: null を送ります。

参照するシークレットが存在しなければ 400 です。字幕から参照されている間、そのシークレットは削除できません(409)。シークレットを作るには tenant:admin の権限が要ります(入力と出力 を参照)。

注意したいのは、API の段階では見つからない誤りがあることです。anthropic・deepl なのに translate_credential_ref がない、シークレットを復号できない、といった場合は設定が通ってしまい、配信が始まった時点で字幕の処理だけがログに理由を書いて止まります。配信そのものは続くので、字幕が出ないときはログを見てください。

SENCA が送るリクエスト

SENCA は asr_endpoint・translate_endpoint の後ろに次のパスを付けて POST します。送信先の URL を決めるときや、互換サーバーを自分で用意するときの参考にしてください。

種類パス認証本文
openai(音声認識){asr_endpoint}/audio/transcriptionsAuthorization: Bearer {key}(キーがあれば)multipart: file、model、response_format=verbose_json、timestamp_granularities[]=segment、language、prompt(直前の認識結果の末尾 200 文字)
whisper_cpp(音声認識){asr_endpoint}/inferenceなしmultipart: file、response_format=verbose_json、language、prompt
anthropic(翻訳){translate_endpoint}/v1/messagesx-api-key、anthropic-version: 2023-06-01JSON
deepl(翻訳){translate_endpoint}/v2/translateAuthorization: DeepL-Auth-Key {key}JSON
openai(翻訳){translate_endpoint}/chat/completionsAuthorization: Bearer {key}(キーがあれば)JSON

つまり、送信先の URL はパスの手前までを書きます。

  • OpenAI の音声認識: https://api.openai.com/v1(OpenAI 互換のサーバーなら、その /v1 まで)
  • whisper.cpp のサーバー: http://whisper.internal:8080
  • Anthropic: https://api.anthropic.com
  • DeepL: https://api.deepl.com(Free プランは https://api-free.deepl.com)
  • OpenAI の翻訳: https://api.openai.com/v1

1 回のリクエストは 60 秒でタイムアウトします。API キーがログやエラーメッセージに出ることはありません。

字幕の送信先も、入力・出力と同じく名前解決したあとの IP アドレスで検査されます。社内の whisper.cpp のようにプライベートアドレスのサーバーを使うなら、--egress-allow でその範囲を許可してください(入力と出力 を参照)。

社内の whisper.cpp で認識し、DeepL で英語に翻訳する例です。

curl -s -X POST "$API/secrets" "${H[@]}" -d '{"name":"deepl","value":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:fx"}'
curl -s -X POST "$API/streams" "${H[@]}" -d '{
  "name": "lecture",
  "captions": {"asr_provider": "whisper_cpp", "asr_endpoint": "http://10.0.0.5:8080", "language": "ja",
               "translate_provider": "deepl", "translate_endpoint": "https://api-free.deepl.com",
               "translate_credential_ref": "secret:deepl", "targets": ["en"]}
}'

音声の切り出しと認識

音声は 5 秒の窓で切り出し、窓を 1 秒ずつ重ねながらずらしていきます。窓の境目は AU の境目に合わせます。切り出した 1 つ分(チャンク)は先頭の時刻を持っていて、認識結果の時刻はこれを使ってストリームの時刻に直します。不連続やタイムスタンプの飛びがあれば、そこで切って新しい窓を始めます。

認識は受信とは別の処理で、1 チャンクずつ順に行います。認識が実際の時間に追いつかず、待っているチャンク(2 個まで)があふれたら、古いほうから捨てます。欠けるのは字幕だけで、配信は止まりません。

窓が重なった部分の認識結果は、重複を取り除いて 1 本につなぎます。1 つの字幕(cue)は 0.7〜7 秒の長さで、1 行 64 桁・2 行までに収まるように分けます。桁数は日本語の文字の表示幅を考えて数えます。

翻訳は、確定した字幕を最大 6 個・1500 文字までまとめて 1.5 秒ごとに送り、直前の字幕 3 個を文脈として添えます。失敗したらログに残り、その分の訳は欠けます。

HLS の字幕

字幕はライブの HLS にだけ付きます。ライブの再生 URL と同じ署名付きのパスの下に、次のファイルができます。

ファイル内容
master.m3u8映像と字幕をまとめたプレイリスト(multivariant playlist)です。言語ごとの EXT-X-MEDIA:TYPE=SUBTITLES(グループは subs。language の言語が DEFAULT)と、映像の index.m3u8 を指す EXT-X-STREAM-INF:BANDWIDTH=3000000,SUBTITLES="subs" が入ります
sub_{lang}.m3u8言語ごとの字幕のプレイリスト(media playlist)です
sub_{lang}_{n}.vtt字幕のセグメントです。番号と区切りは、映像のセグメントと同じです。X-TIMESTAMP-MAP で、TS・fMP4 の時間軸に合わせます

字幕付きで再生するには、再生 URL の index.m3u8 を master.m3u8 に置き換えて開きます。署名はファイル名を含まないので、同じ署名のまま使えます。master.m3u8 ができるのは、字幕の処理が始まって最初の映像のセグメントが確定したときで、それまでは 404 です。字幕のセグメントはメモリに最大 256 個まで残します。

再生 URL から master.m3u8 と英語の字幕のプレイリストを取得する例です。

HLS=$(curl -s -X POST "$API/streams/$SID/playback-url" "${H[@]}" | python3 -c "import json,sys;print(json.load(sys.stdin)['playback']['hls'])")
MASTER=${HLS%index.m3u8}master.m3u8
curl -s "$MASTER"
curl -s "${HLS%index.m3u8}sub_en.m3u8"

録画には字幕を保存しないので、VOD と MP4 のエクスポートには字幕が付きません。WebRTC(WHEP)や RTMP/SRT/RTSP での再生にも付きません。

遅延の扱い

SENCA は映像を遅らせず、字幕のほうが遅れて出ることを受け入れています。映像を遅らせて字幕と合わせる方式はとっていません。

字幕が出るのは、音声が窓にたまり(最大 5 秒)、認識(と翻訳)の結果が返ってきてからです。認識にはふつう数秒かかるので、話してから字幕が出るまで数秒〜十数秒かかります。

結果が届いた時点で、その時刻の映像のセグメントがもう確定していることもあります。その場合は、字幕の開始時刻をまだ確定していないセグメントの時刻までずらして出します。表示時間は元の長さと 0.5 秒のうち長いほうで、プレイヤーは今のセグメントの中で表示します。

うまく動かないとき

字幕で何か起きると、ノードのログに captions {stream}: ... という行が出ます。まずはこれを探してください。

ログ考えられる原因
asr failed: ...音声認識の送信先の誤り、認証の失敗、またはタイムアウトです。その分の字幕が欠けます
translation failed: ...翻訳に失敗しました
... は許されていない宛先です送信先がプライベートアドレスです(--egress-allow での許可が必要です)
翻訳の資格情報がありません などanthropic・deepl なのに API キーがありません。字幕の処理は始まりません
何も出ない音声が AAC・Opus ではない、または配信の途中で captions を設定した(次の配信から反映されます)

関連するページ

この文書の原文は、リポジトリの docs/guide/ja/08-captions.md にあります。内容は現在の版(v0.0.1)に合わせています。