AndroidでFFmpegを使って音声ファイルを解析・変換する


はじめに

FFmpegと聞くと、動画や音声を解析・変換することができたりと機能豊富で非常に難しそうなイメージですよね。ですが、音声だけに絞ってみれば意外と簡単(?)に使いこなすことができるんじゃないかと思います。

今回は音声解析・変換の基礎的な使い方をAndroidで実践してみたいと思います!
(サンプルコードはこちら

FFmpegのインストール

AndroidのFFmpegライブラリはいくつかありますが一番開発が活発なMobileFFmpegを使ってみます。
MobileFFmpegはAndroidの他にiOS, tvOSで利用でき、メインバージョンとLTSバージョンがあります。
(今回ご紹介するコマンドはどちらのバージョンでも使えます)

app/build.gradle
// NOTE: フル機能が使えるがサポートされるAPIレベルが24以上
implementation 'com.arthenica:mobile-ffmpeg-full:4.2.2'
// or
// LTS版はAPIレベル16以上で使えるが機能制限版
implementation 'com.arthenica:mobile-ffmpeg-full:4.2.2.LTS'

FFmpegを使ってみる

使い方は非常に簡単。単純に実行させたいだけならこれだけでOKです。

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // FFmpegのバージョンを確認する
        FFmpeg.execute("-version")
        val rc = FFmpeg.getLastReturnCode()
        val output = FFmpeg.getLastCommandOutput()

        when (rc) {
            FFmpeg.RETURN_CODE_SUCCESS -> {
                Log.i("FFmpeg", "Success!")
            }
            FFmpeg.RETURN_CODE_CANCEL -> {
                Log.e("FFmpeg", output)
            }
            else -> {
                Log.e("FFmpeg", output)
            }
        }
    }

では今度は本題の音声ファイルを解析してみます。
FFmpegのvolumedetectフィルターを使って解析してみます。

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        button.setOnClickListener {
            // NOTE: filesDir.pathの場所に音声ファイルを配置
            val filename = filesDir.path + File.separator + "inputFile.wav"

            FFmpeg.execute("-i $filename -af volumedetect -f null NULL")
            val rc = FFmpeg.getLastReturnCode()
            val output = FFmpeg.getLastCommandOutput()

            when (rc) {
                FFmpeg.RETURN_CODE_SUCCESS -> {
                    text.text = "FFmpeg Success!\n $output"
                }
                FFmpeg.RETURN_CODE_CANCEL -> {
                    text.text = "FFmpeg Cancel\n $output"
                }
                else -> {
                    text.text = "FFmpeg Error\n $output"
                }
            }
        }
    }

これを実行してみると...

何やら色々と出力されました。
この中で自分なりに重要だと思っているのが下記3項目です。

項目 概要
time 音声ファイルの長さ。フォーマットは00:00:00.00
mean_volume 平均音量。音声ファイル全体の平均音量をdBで表す。最大は0.0dB
max_volume 最大音量。音声ファイル全体の中で最大音量をdBで表す。最大は0.0dB

項目の抽出は下記のような感じでやります。

音声ファイル解析結果の抽出
            val DELIMITER_TIME = "time="
            val DELIMITER_SPACE = " "
            val DELIMITER_MEAN_VOLUME = "mean_volume: "
            val DELIMITER_MAX_VOLUME = "max_volume: "

            // NOTE: time抽出
            var resultTime = ""
            if (Regex(DELIMITER_TIME).containsMatchIn(output)) {
                val times = output.split(DELIMITER_TIME)
                // NOTE: timeが2つあるが2つ目が音声ファイルの長さを表す。
                resultTime = if (times.size == 3) {
                    times[2].split(DELIMITER_SPACE)[0]
                } else {
                    times[1].split(DELIMITER_SPACE)[0]
                }
            }

            // NOTE: mean_volume抽出
            var meanVolume: Double? = null 
            if (Regex(DELIMITER_MEAN_VOLUME).containsMatchIn(output)) {
                meanVolume = try {
                    output.split(DELIMITER_MEAN_VOLUME)[1].split(DELIMITER_SPACE)[0].toDouble()
                } catch (e: NumberFormatException) {
                    null
                }
            }

            // NOTE: max_volume抽出
            var maxVolume: Double? = null 
            if (Regex(DELIMITER_MAX_VOLUME).containsMatchIn(output)) {
                maxVolume = try {
                    output.split(DELIMITER_MAX_VOLUME)[1].split(DELIMITER_SPACE)[0].toDouble()
                } catch (e: NumberFormatException) {
                    null
                }
            }

ちなみに今回サンプルに使った音声ファイルはtime=00:00:03.16mean_volume=-41.8 dBmax_volume=-17.9 dBでした。

これらの項目は色々使い道あると思いますし(音声ファイルの長さが短い場合はエラーにする、音量小さい場合はエラーにするなどのバリデーション)、後述する音声ファイルのノーマライズにも使えます。

音声ファイルのノーマライズ

ノーマライズとは「音量レベルの正規化」です。
と言われても分かりづらいと思うのでもう少しわかりやすく言うと、音量を最大まで上げる処理のことです。そのために必要な項目が前述のvolumedetectフィルターで取得したmax_volumeで、このピーク時の音量「max_volume」を丁度0dB1になるように設定します。この時、0dBを超えてしまうとデジタルクリップ(音割れ)が発生するため超えないように注意してください。
また、音声ファイルはデジタルデータなので小さい音をそのまま大きくすれば音量は大きくなりますが音質は劣化します。なので高音質を求める方には向きません。

ノーマライズ
            maxVolume?.let {
                FFmpeg.execute("-y -i $inputFile -af volume=${-it} $outputFile")
                val rcNormalize = FFmpeg.getLastReturnCode()
                val outputNormalize = FFmpeg.getLastCommandOutput()

                fFmpegResult.errorReason = when (rcNormalize) {
                    RETURN_CODE_SUCCESS -> { null }
                    else -> { FFmpegErrorReason.FFMPEG_NORMALIZE }
                }
            }

音声ファイルのノイズ除去

マイクの良し悪しによりますがマイクで音声を録音すると生活音などノイズを拾ってしまいます。ノイズキャンセル機能を持ったマイクや、指向性マイクなど使えばある程度抑えることはできると思いますがソフトウェア的に後からノイズを除去することもできます。

superequalizerフィルター

音声を18の周波数帯に分けてゲイン(シグナルの強さ)を調整することが出来ます。たとえば人の声は100Hz ~ 2000Hz(ソプラノ歌手は2000Hzまで出せるらしい)なのでそれ以外をノイズとして抑えるようゲインを設定します。

FFmpeg.execute("-y -i $inputFile -af superequalizer=1b=0:2b=0:3b=1:4b=1:5b=1:6b=1:7b=1:8b=1:9b=1:10b=1:11b=1:12b=0:13b=0:14b=0:15b=0:16b=0:17b=0:18b=0 $outputFile")

highpass, lowpassフィルター

音声の周波数を上限、下限を設定することで除外することが出来ます。除外する音声の上限、下限値が決まっている場合はこちらのほうが楽です。

FFmpeg.execute("-y -i $inputFile -af \"highpass=f=100, lowpass=f=2000\" $outputFile")

anlmdnフィルター

Non-local Meansアルゴリズムを用いたフィルターです。基本的にはドキュメントの通りで、個人的にはノイズ除去強度は0.01くらいがバランスよくておすすめです。

FFmpeg.execute("-y -i $inputFile -af anlmdn=s=0.01 $outputFile")

最後に

FFmpegには他にも様々なフィルターが用意されています。これでさらに動画に関する編集もできるのでほんとに多機能ですね~。いずれは動画周りもやってみたいです。

参考サイト

ニコラボ - 18の周波数帯に分けてゲインを調整する
FFmpeg Filters Documentation
ヘッドホンアンプの最大音量が0 dB? オーディオに関する素朴な疑問あれこれ


  1. 自分みたいな素人だと最大音量が0dBってどういうこと?って思ってしまいますが、今回のdBが意味するところは例えば100dBの音量が出るスピーカーがあるとすると、そこからの減算値のことを指します。0dBであれば減算すること無くそのまま100dBでスピーカーから音が流れるということです。