Runner in the High

技術のことをかくこころみ

Goでfdkaacを用いてm4a/mp4のraw aacなデータをデコードする

github.com

fdk-aacとそのgoバインディングgo-fdkaacを使ってやってみたので、試行錯誤過程のメモ。

ffmpegみたいなツールを用いた変換方法はネット上にゴロゴロしているのに、スタンドアロンなライブラリを使ってデコードをするとなると情報がめちゃくちゃ限られてくる。

特にaacデコーダ実装はデータ部にADTSというフレーム情報を保持するヘッダがあることが前提の話ばかりで、m4a/mp4に含まれるraw aacというヘッダ情報のないaacのデコードに関する情報はかなり見つけるのが難しい。

m4aとraw aacの関係性

まず大前提としてmpeg4の構造を知る必要がある。以下の記事が詳しかった。

qiita.com

データ構造のうちmdatと呼ばれる部分にaacなデータが含まれているが、これがADTSヘッダを持たないraw aacと呼ばれるものになっている。なので、単純なデータ操作(例えばddコマンドなど)でmdatからデータを抜き出してaacファイルとして再生しようとしても、これでは単なる不正なデータとして扱われてしまう*1。ADTSヘッダがないということは、当然代わりの相当するデータがm4aのデータ構造の中に書き込まれているはずで、m4aで言うとstblがそれにあたる。

特にfdk-aacにおいてはフレームサイズというのが非常に重要で、結果を受け取るためにデコーダのメソッドへ参照渡しされるバイト配列のサイズがフレームサイズとイコールで扱われているため、アロケーションされているバイト配列のサイズがフレームサイズと異なっているとエラーがでてしまいデコーダを動かすことができない。フレームサイズが1024とか2048みたいな固定長のサイズならまだしも、実際にはストリーム中のフレームサイズは数バイト単位で変動するため「どのフレームが何バイトか」という情報がなければデコード処理は実行できないということになる。

ADTSであればヘッダ情報の一部としてフレーム長が含まれているのだが、m4aではraw aacなのでそうはいかない。ではどうすればいいのかというと、ADTSヘッダの代わりにstbl配下のstszに格納されているフレームサイズの情報を参照することになる。

デマルチプレクサ

まったく専門外なので正しいことを言えているかは微妙だが、どうやらメディアデータの変換周りではデマルチプレクサと呼ばれるものが基本的には必要らしい。たとえばlibavformatが最もオールインワンなものとして有名っぽい。libav以外にも、いろんな人があるフォーマットに特化したデマルチプレクサを作っていたりする。Node.jsで動くts/mp4/flv用のデマルチプレクサもあった。

Goにもlibavのバインディング実装(imkira/go-libav)があり、そこそこ頑張っているっぽかった。しかし、libavの機能をcgoでラップするという壮大な世界観がメンテしきなかったのかここ数年は放置されているのが現状で、getしてみると実際にはビルドが通らず使いモノにならなかった。もしもlibavを使ってデコーダを実装するなら、自前でcgoを使ってラッパーを書くほうがましかもしれない。aacデコーダも内蔵されているので、デコーディングライブラリに拘らないのならばfdk-aacを使う必要すらない。

いずれにしても今回のケースではm4a/mp4用のデマルチプレクサだけが使えればよく、そのためだけにlibavを使うのはライブラリ自体を使うために求められる知識が多すぎて疲弊してしまう。

mp4リーダー

内部的にやっていることはlibavと同じなのかもしれないが、もう少しmp4に特化したものがある。例えばサイバーエージェントからはabema/go-mp4がでているし、他にもalfg/mp4というやつもある。どちらのライブラリも、上のQiita記事で解説されているatomsの情報をmp4のバイナリデータからパースして、ある程度参照しやすいような形(構造体とか)に変換してくれる機能*2を持っている。

これとfdk-aacを組み合わせれば、raw aacのデコードに必要な情報(フレームサイズ)をstszから読み取ることができる。今回はalfg/mp4の方を使ってみた*3

stszからフレームサイズを取り出す部分の実装は次のようになる。

// stszセクションから取り出したraw aacのフレームサイズ情報を保持する構造体
type frameSizes struct {
    frameOffset uint
    size        uint
    buffer      []byte
}

// atom.Mp4Readerを用いてstszセクションのデータを読み取り、ヘッダをスキップしたデータ部をframeSizes構造体として抜き出す
func newFrameSizes(reader *atom.Mp4Reader) (*frameSizes, error) {
    const stszHeaderOffset = 12 + 8 // stszのヘッダ部分をskipする分のオフセットサイズ

    stsz := reader.Moov.Traks[0].Mdia.Minf.Stbl.Stsz
    stszBuffer := make([]byte, stsz.Size)st
    if _, err := io.NewSectionReader(
        stsz.Reader.Reader,
        stsz.Start+int64(stszHeaderOffset),
        stsz.Size-int64(stszHeaderOffset),
    ).Read(stszBuffer); err != nil {
        return nil, err
    }

    return &frameSizes{
        frameOffset: 0,
        buffer:      stszBuffer,
    }, nil
}

// stszのデータ部にはビッグエンディアンで4バイトごとのデータとして格納されている
// binary.BigEndian.Uint32で変換してint64に変換することで10進数データとしてフレームサイズが計算できる。
func (v *frameSizes) Next() *int64 {
    const uint32byteSize = 4

    if len(v.buffer) < int(v.frameOffset) {
        return nil
    }

    frameSize := int64(binary.BigEndian.Uint32(v.buffer[v.frameOffset : v.frameOffset+uint32byteSize]))
    v.frameOffset += uint32byteSize
    return &frameSize
}

Nextメソッドで次のフレームサイズが計算されて返される間、forループでデコード処理を継続する。

以下抜粋。

offset := int64(mdatOffset)

for {
    nextFrameSize := frameSizes.Next()
    if nextFrameSize == nil {
        break
    }

    // 計算されたフレームサイズのぶんだけmdatからデータを読み取る
    part := make([]byte, *nextFrameSize)
    readCount, err := io.NewSectionReader(v.Mdat.Reader.Reader, offset, *nextFrameSize).Read(part)
    if err == io.EOF {
        break
    } else if err != nil {
        panic(err)
    }

    // mdatから読み取ったデータに対してデコード処理を実行する
    if pcm, err = d.Decode(part[:readCount]); err != nil {
        panic(err)
    }

    offset += *nextFrameSize
    if len(pcm) == 0 {
        continue
    }

    pcmWriter.Write(pcm)
}

go-fdkaacのDecodeメソッドはフレームサイズが正しくないと0x4002(AAC_DEC_PARSE_ERROR)をエラーコードとして吐き出してくるが、具体的に何が原因なのかを教えてくれないので非常に難しい。このサンプルコードでは、音声と動画が入っているmp4ファイルのことは考慮していないため、もし音声以外のストリームが含まれていた場合フレームに含まれるデータはもっと複雑なものになる。

fdk-aacソースコード中にあるエラーコードのコメントはほとんど参考にならないので、エラーが出てしまうとC言語で書かれたfdk-aacの実装を読むほかなくなってしまう。が、もちろんソースコードを読んでわかることの方が少ない...

ASC(Audio Specific Config)の値について

raw aacのデコードでフレームサイズの次に重要なのがASC(Audio Specific Config)と呼ばれる16進数値の組み合わせだ。

ADTSヘッダを持つaacなデータであれば、デコードに必要なオーディオタイプ、サンプリング周波数、チャネル設定などがすべてADTSに保存されているが、raw aacの場合にはデコーダに対して明示的に対象のraw aacをどのようにデコードするのか伝えてやらねばならない。

今回のコードで言うと、以下のデコーダを初期化する箇所で与えている16進数のデータがそれにあたる。

// AAC LC/44100Hz/2channelsなASCの設定でfdk-aacのデコーダを初期化
// (Ref: https://wiki.multimedia.cx/index.php/MPEG-4_Audio#Audio_Specific_Config)
d := fdkaac.NewAacDecoder()
if err := d.InitRaw([]byte{0x12, 0x10}); err != nil {
    panic(err)
}
defer d.Close()

この16進数はビットフラグでデコードの設定を表しているもので、ASCのビットフラグの説明は以下のものしかネットでは見つからない。

5 bits: object type
if (object type == 31)
    6 bits + 32: object type
4 bits: frequency index
if (frequency index == 15)
    24 bits: frequency
4 bits: channel configuration
var bits: AOT Specific Config

もちろんこのビットフラグを自分で組み立ててもいいが、自分でASCのビットフラグを組み立てなくてもffprobeを使えばmp4/m4aに含まれるaacのASCを知ることができる。

$  ffprobe -show_data -show_streams -hide_banner default.m4a

... 省略 ...

[STREAM]
index=0
codec_name=aac
codec_long_name=AAC (Advanced Audio Coding)
profile=LC
codec_type=audio
codec_time_base=1/44100
codec_tag_string=mp4a
codec_tag=0x6134706d
sample_fmt=fltp
sample_rate=44100
channels=2
channel_layout=stereo
bits_per_sample=0
id=N/A
r_frame_rate=0/0
avg_frame_rate=0/0
time_base=1/44100
start_pts=0
start_time=0.000000
duration_ts=2015987
duration=45.713991
bit_rate=128424
max_bit_rate=128424
bits_per_raw_sample=N/A
nb_frames=1970
nb_read_frames=N/A
nb_read_packets=N/A
extradata=
00000000: 1210 56e5 00                             ..V..

DISPOSITION:default=1
DISPOSITION:dub=0
DISPOSITION:original=0
DISPOSITION:comment=0
DISPOSITION:lyrics=0
DISPOSITION:karaoke=0
DISPOSITION:forced=0
DISPOSITION:hearing_impaired=0
DISPOSITION:visual_impaired=0
DISPOSITION:clean_effects=0
DISPOSITION:attached_pic=0
DISPOSITION:timed_thumbnails=0
TAG:language=und
TAG:handler_name=SoundHandler
[/STREAM]

ffprobeから出力される結果のうち以下の箇所がASCになっている。

extradata=
00000000: 1210 56e5 00                             ..V..

このデータのうち 1210 という箇所を 0x12 0x10 にしてやればよい。先頭のふたつだけがASCになる。

ffprobeでこういうデータが分かるということは、おそらくmp4中のどこかのatomに格納されているのかもしれない。が、今回はそれは調べていない。調べました (2022/06/19 追記) izumisy.work

*1:どうやら頑張ればバイナリデータのパターンからフレームデータのチャンクを識別できたりするらしいのだが、余計に情報が少なく泥沼に入ってしまいそうな気がしたのでやっていない。ネットの情報を見ているとfaadというaac用のデコーダはraw aacをADTS付のaacにデコードできるらしい。おそらく、フレームデータを直接エスパーしているのだろう。

*2:こういうのもデマルチプレクサっていうのかな?

*3:こちらはstszの読み取りに対応していなかったので今回はIzumiSy/mp4としてフォークしたものを使った