FFmpeg+SDL で音声再生 (2)
前回の続きです.
ファイルを開く
音声ストリームを持つコンテナーファイルを開くには次のようにします.
// ファイルを開く AVFormatContext* fctx; if (av_open_input_file(&fctx, "test.mp3", NULL, 0, NULL)) ...; if (av_find_stream(fctx) < 0) ...; // 音声ストリームを探す int nb_streams = fctx->nb_streams; int audio_stream_index = -1; for (int i = 0; i < nb_streams; ++i) { if (fctx->streams[i]->codec->codec_type == CODEC_TYPE_AUDIO) { audio_stream_index = i; break; } } if (audio_strea_index < 0) ...; // 対応するコーデックを開く AVCodecContext* cctx = fctx->streams[audio_stream_index]->codec; AVCodec* codec = avcodec_find_decoder(cctx->codec_id); if (!codec || avcodec_open(cctx, codec) < 0) ...;
if 文の後に ... となっているところはエラー処理です. av_open_input_file() で音声を含むメディアファイルを開きます. 後ろの NULL, 0, NULL はファイルの開き方 (フォーマットの指定, オプションバッファー, などなど) のデフォルト指定です. av_find_stream() でストリームがあるかどうかを調べます.
その後, 音声ストリームを探します. ストリーム情報は fctx->streams[] に並んでいます. 動画ストリームも探す場合は CODEC_TYPE_VIDEO となるストリームを一緒に探します.
対応するコーデック (デコーダー) は avcodec_find_decoder() で見つけて avcodec_open() で開きます. まあここら辺は関数名の通り.
パケットキュー
ストリームを開いたら, パケットを読み出してコールバック関数に送り, デコードして SDL のオーディオバッファーに書き込みます. このとき, パケットの読み込みはスレッドを分けるのが一般的です. ここでもその方法で行きます.
パケットはキューで管理します. パケット読み込みスレッドはパケットを読んでキューに次々プッシュしていきます. キューにある程度溜まったらポップされるまで待機します. コールバック関数はキューからパケットをポップし, デコードして, バッファーに書き込みます. バッファーに書き終えるまでこれを続けます.
まずはパケットキューを定義します.
class PacketQueue { public: PacketQueue() : size_(0) {} void push(AVPacket& packet) { av_dup_packet(&packet); packets_.push_back(packet); size_ += packet.size; } bool pop(AVPacket& packet) { if (packets_.empty()) return false; packet = packets_.front(); packets_.pop_front(); size_ -= packet.size; return true; } int dataSize() const { return size_; } private: std::deque<AVPacket> packets_; int size_; };
(スレッド同期に関するコードは省略しています. 本来は必須)
push() で渡される packet は後述する av_read_frame() で取得したパケットです. packet には圧縮された音声データへのポインター (packet.data) が含まれますが, av_read_frame() から取得した段階では内部バッファーを指していることがあります. この場合次の av_read_frame() 呼び出しでその領域のデータは上書きされてしまいます. よってデータを保持するためには別の領域を確保してデータをコピーする必要があります. これを行ってくれるのが av_dup_packet() です. この関数は packet が指すデータが内部バッファーかどうかを判別して, 必要な場合にだけコピーを行います.
size_ および dataSize() はキューに溜まっているパケットの総データ量を表します.
次にパケットを読み込むスレッドです.
void queueingThread() { static int const MAX_BUFFER_SIZE = ...; AVPacket packet; PacketQueue* queue = ...; // コールバック関数と共有する AVFormatContext* fctx = ...; // 先ほど取得したもの int stream_index = ...; // 先ほど取得したもの bool* quit = ...; // 終了フラグ while (!*quit) { if (queue->dataSize() > MAX_BUFFER_SIZE) { SDL_Delay(10); // キューは満タン } else if (!av_read_frame(fctx, &packet)) { // フレーム読み込みに成功 if (packet.stream_index == stream_index) { // 音声ストリーム queue->push(packet); } else { av_free_packet(&packet); // それ以外のストリームは即破棄 } } else if (url_feof(fctx->pb)) { // EOF に到達 break; } else if (!url_ferror(fctx->pb)) { // 読み込みエラー break; } else { SDL_Delay(100); } } }
いくつかの変数は他スレッドと共有しています (C++ 的にはメンバー変数と思えば OK). キューの内容量が MAX_BUFFER_SIZE を超えていたら待機します. この値は適当に決めればいいと思います (packet.size などをダンプして見て決めたり). このスレッドは av_read_frame() の呼び出しとキューへのプッシュがメインです. av_read_frame() は fctx の次のパケットを取得します. ここでは音声ストリーム以外は捨てていますが, その際に av_free_packet() を呼び出さないと場合によってはメモリーリークします.
後でこのスレッドにポーズやシークなどの処理も加えていきます.
そしていよいよコールバック関数です.
// フレームをデコードする関数 int decodeFrame(Uint8* buffer, int buffer_size) { AVPacket* packet = ...; // 毎回共有. パケットを受け取る場所. uint8_t* packet_data = ...; // 同上. 破棄のためにデータ先頭を取っておく場所. int& packet_size = ...; // 同上. 破棄のためにデータサイズを取っておく場所. Uint16* buffer = ...; // 同上. バッファー先頭へのポインター. int& buffer_size = ...; // 同上. バッファー全体のサイズ. AVCodecContext* cctx = ...; // 先ほど取得したもの. PacketQueue* queue = ...; // queueingThread() と共有. for (;;) { while (packet->size > 0) { av_init_packet(packet); // data と size 以外を初期化 int getsize = buffer_size; int len1 = avcodec_decode_audio3(cctx, buffer, &getsize, packet); if (len1 < 0) break; // エラー. フレームをスキップする. packet->data += len1; packet->size -= len1; if (getsize <= 0) continue; // 展開するフレームデータがなかった. return getsize; } if (packet_data) { // 古いパケットを破棄する packet->data = packet_data; packet->size = packet_size; av_free_packet(packet); packet_data = NULL; } if (!queue->pop(packet)) { return -1; } packet_data = packet->data; packet_size = packet->size; } } // コールバック void audioCallback(void* userdata, Uint8* stream, int len) { int& current_index = ...; // 同上. バッファー上の現在位置. int& loaded_size = ...; // 同上. バッファーに読み込まれているデータサイズ. Uint16* buffer = ...; // 同上. decodeFrame() と同じもの. int volume = ...; // ボリューム. [0, 32768] の値. int pan = ...; // 真ん中を 0 としたパン. [-16384, 16384] の値. while (len > 0) { if (current_index >= loaded_size) { int read_size = decodeFrame(); if (read_size >= 0) { loaded_size = read_size; } else { loaded_size = 1024; std::memset(buffer, 0, 1024); // 無音 } current_index = 0; } int len1 = (loaded_size - current_index) * sizeof(*buffer); if (len1 > len) len1 = len; // 実際に出力する量 Uint16* s = reinterpret_cast<Uint16*>(stream); Uint16* b = buffer; stream += len1; len -= len1; current_index += len1 / sizeof(*buffer); // 書き込み. AUDIO_S16SYS でステレオと仮定する. len1 /= 2 * sizeof(*s) / sizeof(*stream); // len1 をサンプル数に変換 if (len1 > 0) do { *s++ = *b++ * volume / 32768 * (16384 - pan) / 32768; // L *s++ = *b++ * volume / 32768 * (pan + 16384) / 32768; // $ } while (--len1); } }
ちょっとごちゃごちゃしてますが. 変数群はほとんどスレッド外部から userdata 経由で与えられるものです. C++ でオブジェクト指向っぽくやるなら, userdata にオブジェクトへのポインターを入れといてメンバー関数を呼び出せば, すぐにクラス内部に入り込めるので, これらはすべてメンバー変数だと思ってもよいです.
decodeFrame() はキューからパケットを取り出してデコードし, バッファーにデータを入れてその長さを返します. avcodec_decode_audio3() がデコード処理本体です (3 はバージョン番号で, この関数のインターフェイスが何度も変わっていることを示しています). 上の実装では, パケット内に複数のフレームがある場合にも対応するように, パケット取得時の data と size を保存しておいて, デコードの度に packet->data や packet->size を更新しています. そして破棄するときには最初の data と size で破棄します (こうしないと当然破棄に失敗します).
audioCallback() は decodeFrame() によってパケットをデコードして buffer に読み込み, それを stream に書き込んでいきます. その際, buffer には len によって要求されるサイズぴったりは入らないので, 必要な分読み込んだり, 読み込みすぎた分は次回にまわしたりします. len, len1 や current_index, loaded_size の処理がこれにあたります. この際, len はバイト単位なのに対して current_index や loaded_size は 2 バイト単位であることに注意します.
書き込み処理ではボリュームとパンも反映しています. ここら辺はアプリケーションごとに好きにいじればよいところです. stream に書き込む値は, 絶対値が大きければボリュームも大きくなります. 上の実装では volume == 32768 が最大で, それ以下だと値に応じてデータを縮小します. pan は -16384 に近ければ左チャンネルを大きく保ち, 16384 に近ければ右チャンネルを大きく保ちます.
これで FFmpeg+SDL による音声再生の基本は完了です.
結局 2 回でも終わってないですが. 次回は再生位置の取得やポーズ/シーク, 複数音声のミキシングを手書きする話などの予定.