go-mp3: Pure Go な MP3 デコーダー

  • 14
    いいね
  • 0
    コメント

tl;dr (要約)

Go の MP3 デコーダー実装がなかったので、既存の C のものを移植し、 go-mp3 というライブラリを作りました。

背景

Pure Go な実装の MP3 デコーダーを探したのですが、自分の探す限りでは見つかりませんでした。 tcolgate/mp3 が最も近かろうと思われましたが、これはどうやらコンテナをデコードするのみのようです。

ないものはしょうがないので、自分で実装することにしました。フルスクラッチで実装するモチベーションはなかったため、既存の別言語の物を転用することにしました。今回は PDMP3 という Public Domain なデコーダーを採用しました。 PDMP3 は C で書かれていますが、これを手で Go に移植したわけです。できあがったものが go-mp3 です。

移植

移植にあたっては cgo を使って、まず PDMP3 素の状態で Go から叩けることを確認しました。最初はこの MP3 デコーダーは自作ゲームライブラリ Ebiten の 1 ライブラリとして実装していたのですが、デコーダーを使ったオーディオのサンプルがそのまま動くことを確認しました。最初のコミット の時点で、 C ファイルがほぼそのまま使われています。

実際に動くのを確認した上で、関数 1 個 1 個を C から Go に移植していきました (変更履歴)。どのコミットをとっても概ね動くようになっています。

たまに実装をミスって、高音が変な音になっていたりLR が逆になってしまったりを直す必要がありました。本来はユニットテストがあるのが望ましいのですが、今回は自分の耳を頼りにテストしました。 LR 逆転はしばらく気づかなかったので危なかったのですね。実によろしくない。今思えばオリジナルの状態の出力を正として自動テストができましたね。

あとは、オリジナル実装はグローバル変数を多用しており、デコード状態を同時に 1 個しか持てませんでした。可読性も低かったので、これを適宜構造体のメンバに移動したりなどしました。例えば v_vec というグローバル変数がデコード状態に使われていたのを、 frame 構造体に移しました

他細かい話ですと、一部再生できなかった MP3 があったのを修正しました。すでに最初のコミットの時点で反映済みで、 is_header という関数がそれです。 MP3 はフレームという最小単位がたくさん並んで構成されます。デコーダーは、先頭のビット列を見て特定のビットの並びならばフレームの開始とみなします。オリジナル実装ではヘッダの解析が仕様どおり過ぎたせいか、「その特定のビットの並びだが実はフレームの開始ではない」 MP3 ファイルをうまく解析できませんでした。解析の際、追加で別のビットフィールドをみて、そのフィールドとしてあり得ない値が来るのであれば、フレーム開始でないと判断するようにしました。果たしてこれで正解なのか正直良くわかりません。というか MP3 は確実なフレーム開始判定方法がないように思われるのですがどうなんでしょう。

とりあえず動けば良いというノリで移植したため、コードが雑然としています。時間があればいずれ整理したいと考えています。

そもそもなぜ MP3 デコーダーが必要だったのか

ゲーム開発のために、デスクトップとモダンブラウザ両方で動く音楽フォーマットのデコーダーが欲しかったからです。

最初は Ogg/Vorbis メインでいこうと思っていました。 Ogg/Vorbis はライセンスフリーですし、 Go のデコーダー実装もあってよかったのですが、 Safari や Edge が対応していません。 同じくライセンスフリーな Opus も似たような状況で、 Safari が対応していません

MP3 や AAC といったフォーマットなら大体のブラウザが対応しています。 AAC は特許およびライセンス料支払い義務が存在しますが、 MP3 は今年になって特許の殆どが期限切れとなり、ライセンス料を支払う必要がなくなりました

おまけ: io.Copy による MP3 プレイヤー

過去に Oto というマルチプラットフォームなサウンド再生ライブラリを作りました。 Oto は io.WriterPlayer を提供し、 PCM バイト列を書き込むことで音が再生できます。

go-mp3 が io.Reader、 Oto が io.Writer を提供するので、 io.Copy でつなぐことによって MP3 を再生することが出来ました。

https://github.com/hajimehoshi/go-mp3/blob/master/example/main.go (要 cgo)

package main

import (
    "io"
    "log"
    "os"

    "github.com/hajimehoshi/oto"

    "github.com/hajimehoshi/go-mp3"
)

func run() error {
    f, err := os.Open("classic.mp3")
    if err != nil {
        return err
    }
    defer f.Close()
    d, err := mp3.Decode(f)
    if err != nil {
        return err
    }
    defer d.Close()
    p, err := oto.NewPlayer(d.SampleRate(), 2, 2, 65536)
    if err != nil {
        return err
    }
    defer p.Close()
    if _, err := io.Copy(p, d); err != nil {
        return err
    }
    return nil
}

func main() {
    if err := run(); err != nil {
        log.Fatal(err)
    }
}

これで go run main.go とすると音がなります。楽しい!