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パケットあたりの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ビットレートはAVAudioConverter.bitRateで設定できました。
用途に応じたビットレートを設定します。
中間っぽい64kbpsをデフォルト値に設定しています。
詳しくは → https://www.rfc-editor.org/rfc/rfc6716#section-2.1.1エンコードする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
}
}
実装する上で注意したポイント
Frame Durationを2.5、5、10、20、40、60 ミリ秒から選びます。
エンコーダのInputのPCMのAVAudioFormatと、デコーダのOutputのPCMのAVAudioFormatは合致しなくてもデコードできました。
デコーダのOutputに設定したAVAudioFormatに変換されたPCMが作成されます。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もご覧頂けるととても嬉しいです!