Now in REALITY Tech #58 iOSでOpusにAVAudioConverterを使ってエンコード/デコードする

REALITYのiOSのエンジニアのあおやまです。
今週の「Now in REALITY Tech」では、iOSでAVAudioConverterを使ってOpusにエンコード、デコードする方法を紹介します。

REALITYでは配信やビデオチャットの機能にOpusを利用しています。

iOS11からOpusがNativeサポートされました。
REALITYでは互換性の問題から、Opusの導入当時にOpus-iOSを独自ビルドして利用するようにし、そのままとなっていました。
REALITYがiOS10のサポートを終了してから幾多の歳月が過ぎてしまいましたが、今回は外部のライブラリを使わずにAVFoundationのAVAudioConverterを使って、Opusにエンコード、デコードできるかを検証しました。
AVAudioConverterを使うことで、C言語のライブラリを気にせずSwiftのみで実装できる事や、依存ライブラリを減らせるなどのメリットがあると考えています。

エンコーダの実装

作成したエンコーダがこちらです。

/// PCMからOpusのDataに変換します
public class OpusEncoder {
    private let framesPerPacket: AVAudioFrameCount
    private let converter: AVAudioConverter
    /// Opusのエンコーダを作成します
    /// - Parameters:
    ///   - inputFormat: 入力されるPCMのAudioFormat
    ///   - frameDurationSec: 変換するPCMのフレーム時間
    ///     2.5、5、10、20、40、60 ミリ秒に対応しています
    ///     詳しくは → https://www.rfc-editor.org/rfc/rfc6716#section-2.1.4
    ///   - bitRate: ビットレート
    ///     詳しくは → https://www.rfc-editor.org/rfc/rfc6716#section-2.1.1
    public init(inputFormat: AVAudioFormat, frameDurationSec: Double = 0.02, bitRate: Int = 64000) {
        self.framesPerPacket = AVAudioFrameCount(inputFormat.sampleRate * frameDurationSec)
        var outputDescription = AudioStreamBasicDescription(mSampleRate: inputFormat.sampleRate,
                                                            mFormatID: kAudioFormatOpus,
                                                            mFormatFlags: 0,
                                                            mBytesPerPacket: 0,
                                                            mFramesPerPacket: framesPerPacket,
                                                            mBytesPerFrame: 0,
                                                            mChannelsPerFrame: inputFormat.channelCount,
                                                            mBitsPerChannel: 0,
                                                            mReserved: 0)
        let outputFormat = AVAudioFormat(streamDescription: &outputDescription)!
        self.converter = AVAudioConverter(from: inputFormat, to: outputFormat)!
        converter.bitRate = bitRate
    }
    /// PCMからOpusのDataに変換します
    /// - Parameters:
    ///   - pcm: 変換するPCM
    ///     frameLengthの時間の長さが指定したframeDurationSecと等しい必要があります。
    ///     例えば、48kHzのPCMでframeDurationSecが20msの場合は、frameLengthが960である必要があります。
    /// - Returns: 変換されたOpusのData
    public func encode(from pcm: AVAudioPCMBuffer) throws -> Data {
        if pcm.frameLength != framesPerPacket { throw NSError(domain: "Frames must be \(framesPerPacket)", code: -1) }
        let compressed = AVAudioCompressedBuffer(format: converter.outputFormat, packetCapacity: 1, maximumPacketSize: 1024)
        var error: NSError?
        converter.convert(to: compressed, error: &error) { (_: AVAudioPacketCount,
                                                            outStatus: UnsafeMutablePointer<AVAudioConverterInputStatus>) in
            outStatus.pointee = .haveData
            return pcm
        }
        if let error { throw error }
        let data = Data(bytes: compressed.data, count: Int(compressed.byteLength))
        return data
    }
}

実装する上で注意したポイント

  1.  1パケットあたりのFrame Durationを2.5、5、10、20、40、60 ミリ秒から選びます。
    例えば、Floatの48kHzのPCMでFrame Durationが20ミリ秒の場合、1パケットあたりのフレーム長は960です。
    OpusがサポートしていないFrame Durationが設定された場合、AVAudioConverterの作成に失敗します。
    Opusのオススメは20ミリ秒のようです。
    詳しくは → https://www.rfc-editor.org/rfc/rfc6716#section-2.1.4

  2. ビットレートはAVAudioConverter.bitRateで設定できました。
    用途に応じたビットレートを設定します。
    中間っぽい64kbpsをデフォルト値に設定しています。
    詳しくは → https://www.rfc-editor.org/rfc/rfc6716#section-2.1.1

  3. エンコードするPCMは、フレーム長が1パケットごとのフレーム長と合致するものだけを受け入れます。
    AVAudioConverter.convert(to:error:withInputFrom:)の仕様上は、エンコードするPCMのフレーム長は1パケットごとのフレーム長より大きくても小さくても問題無いのですが、inputBlockが複数回実行されたり、作成されたAVAudioCompressedBufferに複数のパケットが含まれたりして、処理が複雑化してしまいます。

デコーダの実装

作成したデコーダがこちらです。

/// OpusのDataからAVAudioPCMBufferに変換します
public class OpusDecoder {
    private let framesPerPacket: AVAudioFrameCount
    private let converter: AVAudioConverter
    /// Opusのデコーダを作成します
    /// - Parameters:
    ///   - outputFormat: 出力されるPCMのAudioFormat
    ///   - frameDuration: 変換するPCMのフレーム時間、OpusEncoderのframeDurationと同じ
    public init(outputFormat: AVAudioFormat, frameDurationSec: Double = 0.02) {
        self.framesPerPacket = AVAudioFrameCount(outputFormat.sampleRate * frameDurationSec)
        var inputDescription = AudioStreamBasicDescription(mSampleRate: outputFormat.sampleRate,
                                                           mFormatID: kAudioFormatOpus,
                                                           mFormatFlags: 0,
                                                           mBytesPerPacket: 0,
                                                           mFramesPerPacket: framesPerPacket,
                                                           mBytesPerFrame: 0,
                                                           mChannelsPerFrame: outputFormat.channelCount,
                                                           mBitsPerChannel: 0,
                                                           mReserved: 0)
        let inputFormat = AVAudioFormat(streamDescription: &inputDescription)!
        self.converter = AVAudioConverter(from: inputFormat, to: outputFormat)!
    }
    /// OpusのDataからAVAudioPCMBufferに変換します
    /// - Parameter data: 変換するOpusのData
    /// - Returns: 変換されたPCM
    public func decode(from data: Data) throws -> AVAudioPCMBuffer {
        let compressed = AVAudioCompressedBuffer(format: converter.inputFormat, packetCapacity: 1, maximumPacketSize: data.count)
        _ = data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
            memcpy(compressed.data, ptr.baseAddress!, data.count)
        }
        compressed.packetDescriptions?.pointee = AudioStreamPacketDescription(mStartOffset: 0,
                                                                              mVariableFramesInPacket: 0,
                                                                              mDataByteSize: UInt32(data.count))
        compressed.packetCount = 1
        compressed.byteLength = UInt32(data.count)
        let pcm = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: framesPerPacket)!
        var error: NSError?
        converter.convert(to: pcm, error: &error) { (_: AVAudioPacketCount, outStatus:
                                                        UnsafeMutablePointer<AVAudioConverterInputStatus>) in
            outStatus.pointee = .haveData
            return compressed
        }
        if let error { throw error }
        return pcm
    }
}

実装する上で注意したポイント

  1.  Frame Durationを2.5、5、10、20、40、60 ミリ秒から選びます。

  2. エンコーダのInputのPCMのAVAudioFormatと、デコーダのOutputのPCMのAVAudioFormatは合致しなくてもデコードできました。
    デコーダのOutputに設定したAVAudioFormatに変換されたPCMが作成されます。

  3. AVAudioCompressedBufferはエンコーダが作成するBufferと同一になるように諸々設定しています。

互換性の検証

Opus-iOSと互換性があるかを確認しました。
Opus-iOSでエンコード → AVAudioConverterでデコード、
AVAudioConverterでエンコード → Opus-iOSでデコード、
その両方で楽曲が聞こえる事を確認しました。

また、処理時間はAVAudioConverterとOpus-iOSで大きな差は見られませんでした。(両方ともマイクロ秒オーダー)

まとめ

AVAudioPCMBufferを使って、Opusにエンコード、デコードできるかを検証しました。
たしかに、C言語のライブラリを気にせずSwiftのみで実装できる事や、依存ライブラリを減らせるなどのメリットがありそうです。

既存のOpus-iOSともきちんと互換性があったため、スムーズにAVAudioConverterへ移行できそうだと感じました。

REALITYでは、一緒に古い依存ライブラリを刷新しながら、REALITYを作ってくれる仲間を大絶賛募集中です!

もし、REALITYのiOSエンジニアのお仕事に興味を持って頂けたら、こちらのnoteもご覧頂けるととても嬉しいです!