Runner in the High

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

なぜElmは0.19のままか、変化すること/しないこと

discourse.elm-lang.org

つい先日、数か月ぶりにElmのupdate話がでてきた。

Elmは0.19からほとんどメジャーバージョンアップしていない。最後のリリースは約9か月前にもなる。

この事実だけを知ると「Elmはもう終わったのか」「Evan*1は開発のモチベーションを失ったのか」と思われることがある。実際そういう話はネットでチラホラ見かける。確かに、フロントエンド開発言語のAltJSとして近しいTypeScriptやFlutterと比較すると、あまりにも機能追加され無さすぎるようにも見える。究極的には「何と比較するか?」という話だとは思うが、たしかにフロントエンド界隈的な観点ではElmは亀の歩みなのは間違いない。

変化するのはいいことだ...

なんとなく肌で感じる人も多い事実として、世の中には"最先端を目指して変化するのはいいことだ"という暗黙的な統一見解が存在している。

プロダクト*2開発における変化というのは大抵の場合「新しく機能を追加する」という行為とイコールであり、バグ修正や保守関連開発のみをやっているプロダクトは「歩みを止めた」ものと捉えられる。従って、プロダクトへの新機能追加や長期に渡るロードマップの公開は"衆目を集めるパフォーマンスでもある"ということがまず前提として理解できなければここから先の話へは進めない。

パフォーマンス... これにはいろんな意味合いがある。たとえば、利用者が求める機能をガンガン取り込むことで「ユーザー主導のコミュニティである」とアピールすれば、OSSコントリビューションの経歴を作りたい開発者が集められるだろう。関数型プログラミングの文法を機能として取り入れれば、関数型プログラミングに興味のある開発者も集められるかもしれない。開発コミュニティのコアメンバーがロードマップと共に「我々はまだ歩みを止めるつもりはない」という宣言をすれば、企業から出資を得られるかもしれない。

また、スタートアップやヴェンチャーは人的リソースを確保するために変化の多い最先端な「開発者にとって人気のあるツール」を使うことで魅力をアピールし、ときにはスポンサードする。開発者もまた、目新しく最先端なツールを使う環境にいることで自らのキャリア価値向上をさせたい(そして高い給与を得たい)ので、目新しくエッジなツールを積極的に学び、そしてときには自らのアピールのためにコントリビュートする。

プログラミング言語フレームワークにおけるエコシステムはこのような形で発展する。一般的にはこういうものがいわゆる"民主的なOSS"だと言える。

変化しないElm

ではElmはどうか。おそらくElmはそのようなエコシステムを期待していない。

まず第一にElmは他と比較してまったくコミュニティが民主的ではない。ロードマップも特に決まったものを公開はしないというポリシーだし、コアメンバー(とは言ってもほぼEvan)以外が新機能を提案して追加したりすることは基本的にはありえない。*3

この思想の根本はどこにあるのか。少なくともコミュニティ主導によるOSS開発に対する解釈の違いがEvanの中にあるのは間違いない。以下のカンファレンストークでそのような思想に至った背景が垣間見える。

www.youtube.com

まず第一に「コミュニティからの要望」に対する動きがそもそも異なっている。

たとえば「カスタムオペレータを自前で実装できる文法が欲しい」という話がコミュニティから挙げられた際に、機能の実装するかどうかの判断は求める人間の数やユースケースによって考慮されるのではなく、既存の機能の応用やコミュニティ内の開発者同士によるサポートで解決が出来ないか促される。ほとんどこの時点で"ほしい機能"の議論は終わる。事実、Elm Discourseで観測される要望の大部分は、このような「既存でも問題ないがあると嬉しい」ようなものでしかない。とはいえ、一般的なOSSであれば、こういうときはRFCが立てられ賛同者の数やユースケースなどのインパクトを考慮した上で機能追加の議論がGithub Issueで始まったりするが、Elmではそのようなものはほとんど受け入れられない。

このようにElmがコミュニティ主導の機能変更に対して大手を振って対応しない理由は大きく3つの理由があると言える。

まずひとつめは、機能に対する根源的なモチベーションの解釈。新機能を追加したいというモチベーションがどのような課題解決に根差すものかをEvanは慎重に見ている。特にプログラミング言語における機能は「追加するのは簡単だが削除するのは難しい」ものが多くあり、機能がさらに増えるたびに別機能との整合性や、後方互換性などの考慮事項も芋づる式に増えていく。ある程度未来を見越したメンテナンスコストをかけてまでも追加したい機能というのは「あればいい」ではなく「なければ使えない」のレベルまで昇華しきったものに限定される。*4

次に、機能の責任に対する解釈。上の話ともつながる部分はあるが、仮にコミュニティ主導で機能が提案され追加されたとして、その機能が結果的にどういうインパクトをプログラミング言語の利用者に与えたか、そしてその機能に対してどういうアクションをとるか(さらに機能拡充するのか、削除するのか、それとも放置するのか)という長期的な責任の所在がコミュニティ主導な世界では維持されにくい性質があるとEvanは示唆している。新機能を作ることには熱心だが、一度作られた機能の生涯には誰も熱意を示さないというのは非常によくある話であり、業務でプロダクト開発をしている現場ですら起こりえる。となると、単なる内発的動機でOSS活動をしている人間の集団においては、誰もが煌びやかな新機能追加などの「オイシイとこどり」だけをしたい*5のは間違いない。とはいえ、OSSとして無償でコントリビュートしてくれている人間に長期的な責任の所在を求めることは当然理にかなった話ではない。

そして最後に、おそらくEvan自身のプログラミング言語に対する解釈もある。EvanはElmを教育用言語の位置づけで開発したこともあり、言語のシンプルさに対して大きな優先度を与えていることが推測できる。事実、0.16から現時点で最新の0.19までには少なくない言語仕様の変更があり、そのどれもが機能を減らすものだった。つまり今のElmはこれまでのElmの歴史の中で最も機能が少ないバージョンであり、自分自身も確かにこれ以上減らせる機能はないのではないかとすら思う。関数型言語という立て付けでありながら型クラスもモナドもない。ある程度は幽霊型のようなそれっぽい実装はできるが、それが限界というレベルのシンプルさが今のElmになっている。

トレーダーの間では「株価は下がるときよりも上がるときのほうが怖い」とよく言われるが、これはプログラミング言語についても同じことが言える。株価もプログラミング言語の複雑さも下限はあっても上限はない。コントロールしなければいくらでも言語自体の複雑さから周辺のツールチェインまでいくらでも複雑になるし、ほとんどの場合機能を増やす側は良かれと思って機能を追加しているので誰かが止めなければいくらでも機能は増え続ける。どれとは言わないがそういうプログラミング言語はたくさんある。教育用言語として始まったElmにとって機能拡充とそれに伴う複雑性の増加が直行する方向性である*6ことは間違いない。

思想の違い

超ざっくり要約するならば、TypeScriptやFlutterなどのイケイケなやつがシリコンバレーであり、Elmはアーミッシュの村のような立ち位置にある。派手さやダイナミックな変化はないが、慎重でサステイナブルではある。変化はしないが死ぬつもりはない。

とはいえ、Elmの理想とする思想が持続するか/スケールするかと言われると、結局は理想の話でしかない。現実世界では利益を出せとプレッシャーをかけられている組織のほうが必死であり、結果的には大きな影響力を持つ。資本が注入されていればいるほど絡む人間や組織も増えるし、それに伴って利益を得たいというモチベーションが増幅される。金でドライブされたモチベーションによってマーケティングにもより一層力がかかる。金の流れとコミュニティの勢いは比例する。シリコンバレーアーミッシュなら、シリコンバレーのほうが圧倒的にみんな必死でありそれに引き寄せられて金と人が集まってくるのは間違いない。これをどう捉えるかは、個々人の解釈次第だ。

このようなElmの根本的にある思想はある意味ではナイーブで繊細でもあり、別の見方をすれば現代資本主義に対するアンチテーゼですらある。レッセフェールな市場のメカニズムが中心となって生まれる技術の発展や進歩は大部分で世の中の我々の生活を良くした。しかし一方で、利益重視なカネ駆動モチベーションが個別最適的で意図しない方向に集団を突き動かしているという話はIT界隈でいくらでも見聞きできるだろう。

こういう話をもっと知りたければ以下の本がおもしろかった。もしかしたら、ソフトウェア・エンジニアとしての桃源郷*7シリコンバレーにあるかどうか分かるかもしれない。

*1:Elmの作者

*2:ここではElmなどのプログラミング言語フレームワークなどを包含してプロダクトと表現することにする

*3:この件でかつて「ElmはEvanの独裁制だ」というようは批判の記事がredditで盛り上がったことがある。見方によってはたしかにそうだ。

*4:なおElmにはポートと呼ばれるFFI相当の機能があるので、JavaScriptでできることはElmでもできてしまう。「〇〇ができる機能が欲しい」という要望に対して「ポートじゃできないのか?」という問いにMust-haveで返せる事例は少ない。これを誠実な対応と捉えるか、不誠実な対応と捉えるかは利用者次第だ。

*5:既存機能の改修って新機能開発よりもキラキラ感が少ない割に、過去の歴史を遡ったりや技術的負債に手を入れないといけなかったり色んな調整が必要だったりでおいしさが少ない感があるのはめっちゃわかる。

*6:機能の少なさだけではなくエラーメッセージの分かりやすさを重視する変更が何回か過去にリリースされたり、コミュニティおいてビギナーに対するポリシーやインクルージョンを重要視する姿勢もこの辺にかかってくる。

*7:この解釈も各々にゆだねる

docker-composeを使う際のprofiles, networks, volumes

go-cleanarchitectureにdocker-composeを導入した過程で学んだprofiles, volumes, networksの話。

https://github.com/IzumiSy/go-cleanarchitecture/blob/master/docker-compose.ymlgithub.com

profiles

ざっくり言うと、サービスをグループ化して部分的に起動できる。

r7kamura.com

そもそも、開発中のアプリケーションという"コロコロと中身の変化するもの"をDockerイメージにするのはあまりいいやり方ではない。少しでもコードが変更されるたびにdocker buildが必要で効率が悪い。なので、開発環境としてdocker-composeを使う場合にはミドルウェアだけをservicesとしてまとめて起動したほうがいい。

ただ、いかんともしがたい理由で開発中のアプリケーションもdocker-composeのサービスとして含めたいこともある。つまり、ローカルでアプリケーションを起動したいこともあればdocker-composeのイメージとしてアプリケーションを起動したいこともある、という場合にはprofilesが非常に便利。毎回、利用するprofileを指定する手間は若干あるが、逆に言えばそれだけでいい。

volumes

DockerでMySQLなどに対してボリュームを使いたい場合にはvolumesセクションで driver: local なボリュームをあらかじめ作っておくといい。

# 以下で作ったボリュームは "mysqlvolume:/var/lib/mysql" みたいな感じで使える。

volumes:
  mysqlvolume:
    driver: local
  redisvolume:
    driver: local

ネットに転がっている記事では "./tmp/data/db:/var/lib/mysql" のような記述でパス指定したボリュームをサービスに対して与えているものがあるが、この指定では ./tmp ディレクトリのownerがrootになってしまう。すると、全サービスをdocker-composeで再起動したときに permission denied なエラーがでて困る。

この権限周りとボリュームの話はよくあるエラーのようで、docker-composeを実行するときにユーザーを明示的に指定するなどの解決策があるらしい。が、最も簡単な解決策は明示的にホスト側のパスを指定せずボリュームを作ることだと思う。

元ネタとなる回答は以下のstackoverflowにある。

stackoverflow.com

networks

docker-composeはデフォルトでそれっぽいネットワーク名をつけてくれるが、明示的にネットワーク名を指定すると便利なこともある。

# 以下で作ったネットワークは各サービスで app-network として指定すれば使える

networks:
  default:
    external:
      name: bridge
  app-network:
    name: go-cleanarchitecture-network
    driver: bridge

たとえば自分のアプリケーションではマイグレーションにflywayを使っていて、ローカルにインストールせずDockerイメージとして起動するようにしている。この場合docker-composeで起動しているサービスに対してflywayのイメージが繋ぎにいくことになるわけだが、起動しているDockerコンテナ間で接続を行う際にはネットワーク名を指定しないといけない。

他にも、APIサーバに対するインテグレーションテストでdreddを利用していて、こちらも同様にDockerイメージとして利用している。

test/integration:
  docker run --net=go-cleanarchitecture-network --rm -it -v "$$(pwd):/app" -w /app apiaryio/dredd dredd \
      api-description.apib http://app:8080 --hookfiles=./dredd_hook.js

db/migrate:
  docker run --net=go-cleanarchitecture-network --rm -v "$$(pwd)/schemas/sql:/flyway/sql" -v "$$(pwd)/config:/flyway/config" \
      flyway/flyway -configFiles=/flyway/config/flyway.conf -locations=filesystem:/flyway/sql migrate

db/clean:
  docker run --net=go-cleanarchitecture-network --rm -v "$$(pwd)/schemas/sql:/flyway/sql" -v "$$(pwd)/config:/flyway/config" \
      flyway/flyway -configFiles=/flyway/config/flyway.conf -locations=filesystem:/flyway/sql clean

docker-composeでグルーピングされたサービスとしては扱いたくないが局所的にテストやマイグレーションなどで使用する別のDocker-basedなツールがある、というケースであればネットワーク指定はあったほうが便利だと思われる。docker-composeで自動で付与されるネットワーク名を予想してもいいが、自分で指定したほうが確実。

CloudBuildのキャッシュに便利なgcs-cachingというDockerイメージを作った

github.com

CloudBuildではこんな感じで使える。

steps:
  # キャッシュの保存
  - name: izumisy/gcs-caching:latest
    args: ["store", "./caching-directory", "gs://your-own-build-cache", "./cachekey-file"]
  # キャッシュの取り出し
  - name: izumisy/gcs-caching:latest
    args: ["restore", ".", "gs://default-build-cache", "./cachekey-file"]

公式のドキュメントだと「キャッシュの読み書きには gcr.io/cloud-builders/gsutil を使え」みたく書いてあるが、リアルなユースケースではディレクトリを固めてキャッシュキーを計算して、みたいな処理もある。

そういうことをしようと思うとgsutilだけだとプリミティブ過ぎて微妙なので作ってみた。

m4a(mp4)におけるFPS計算

FPSググると動画の話ばっかり出てくるが、デコーダを扱うには音声でも秒間フレームサイズが知りたくなることがある。

「秒間」というくらいなので、まずはm4a(mp4)における再生時間データを計算する方法が必要。以下の式がそれにあたる。DurationもTimescaleもどちらもmdhdというBoxに格納されていて、計算結果は秒数で出る。

再生時間 = トラックの長さ(Duration) / トラックのタイムスケール(Timescale)

あとは上で計算されたデータでstszに格納されているフレームの総数を割るだけでいい。

FPS = フレーム数(stsz) / 再生時間

ここまで計算できれば「時間あたりmdat上のバイナリサイズがどれくらいか」なども出せるようになるので、たとえば「m4aファイルから時間指定のオフセットで90秒分だけwavデコードする」などの処理も実装できるようになる。

GCSのReader実装を拡張してio.Seekerを実装する

という実装のコード片をGithubのissueコメントから拾ったので自分で完成版を作ってみた。

内部的にNewRangeReaderを何度も呼び出しているのでストレージに対するReadの量は増える可能性があるのでそれだけ注意したい。

もしもシーク位置が比較的局所的な仕様ならばsunfish-shogi/bufseekioという便利なものがあるので、これでラップしてやるとReadトラフィックが増えるみたいな懸念はある程度払しょくできるかもしれない。

package storage

import (
    "context"
    "errors"
    "io"

    "cloud.google.com/go/storage"
)

var _ io.ReadSeekCloser = &SeekableReader{}

type SeekableReader struct {
    object   *storage.ObjectHandle
    ctx      context.Context
    reader   *storage.Reader
    offset   int64 // initial offset
    fileSize int64 // if this is known and set, it enables io.SeekEnd
}

func NewSeekableReader(ctx context.Context, object *storage.ObjectHandle) (*SeekableReader, error) {
    metaReader, err := object.NewRangeReader(ctx, 0, 0)
    if err != nil {
        return nil, err
    }
    if metaReader.Attrs.Size <= 0 {
        return nil, errors.New("fileSize should not be zero or negative value")
    }
    fileSize := metaReader.Attrs.Size

    return &SeekableReader{
        object:   object,
        ctx:      ctx,
        reader:   nil,
        offset:   0,
        fileSize: fileSize,
    }, nil
}

func (r *SeekableReader) Read(p []byte) (int, error) {
    var err error

    if r.reader == nil {
        r.reader, err = r.object.NewRangeReader(r.ctx, r.offset, int64(len(p)))
        if err != nil {
            return 0, err
        }
    }

    n, err := r.reader.Read(p)
    if err != nil {
        return 0, err
    }

    return n, nil
}

func (r *SeekableReader) Seek(offset int64, whence int) (int64, error) {
    var newOffset int64

    switch whence {
    case io.SeekStart:
        newOffset = offset
    case io.SeekCurrent:
        newOffset = r.offset + offset
    case io.SeekEnd:
        newOffset = r.fileSize - offset
    }

    r.Close()
    r.reader = nil
    r.offset = newOffset
    return r.offset, nil
}

func (r *SeekableReader) Close() error {
    if r.reader != nil {
        return r.reader.Close()
    }
    return nil
}

JMOOCの「クラウドサービス・分散システム」の講座がいい感じ

内容的には自分が過去記事で紹介した「データ指向アプリケーションデザイン」の内容を、もう少しとっつきやすくかみ砕いたような雰囲気。大学での講義を収録したっぽいものなので、ある程度初めての人でも分かりやすいかもしれない。

lms.gacco.org

この講座の中で触れられているトピックは

  • 分散コミット(2層コミット, 3層コミット)
  • 定足数(クオラム, ビザンチン合意)
  • 論理クロック
  • データ複製の一貫性(強整合性, 因果整合性, 弱整合性, 結果整合性, ...)
  • IaC(Infrastructure as Code)

などが中心になっている。

定足数や論理クロックのあたりは、実際にアプリケーション開発をやっていて必ず登場する概念ではないが、クラウドプラットフォームから提供されるデータベース製品の仕様だったり、オーケストレータの挙動を理解するためにはある程度知っている方がいいのかなと思う。

一貫性回りの話は、DynamoDBだったりSpannerだったりいわゆるNoSQL周りを使うならばもはや必須の知識と言える。自分の場合は、社会人になってからプロダクト開発の都合上GoogleのDatastoreを触るようになった関係で先輩から整合性に関して細かく手ほどきを受けたけれど、こういう話を学生の頃から学べるのはちょっとうらやましい。

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としてフォークしたものを使った

fdk-aacでM4A→WAV変換サンプル実装のビルド方法

izumisy.work

上記の記事のビルドをやっている前提

# libavformatは必須らしい
$ sudo apt install libavformat-dev

# fdk-aac配下でサンプルがあるブランチに切り替え
$ cd fdk-aac
$ git checkout decoder-example

# m4a-dec.cをビルド
# (https://github.com/mstorsjo/fdk-aac/issues/66#issuecomment-304263604から拾ったやつ)
$ gcc m4a-dec.c -o m4a-dec wavreader.c wavwriter.c \
    -I./libAACdec/include -I./libAACenc/include \
    -I./libSBRdec/include -I./libSBRenc/include \
    -I./libMpegTPDec/include -I./libMpegTPEnc/include \
    -I./libFDK/include  -I./libSYS/include ./.libs/libfdk-aac.a -I./libPCMutils/include \
    -lm -Wdeprecated-declarations -lavcodec -lavformat -lavutil

あとは m4a-dec というファイルが実行できればok