iOS で音声をリアルタイムアップロードする方法を学ぶ
[GOAL] iOS で音声を逐次入力してそれを data(ulaw) としてリアルタイムアップロードする方法を見つける
Apple の Working with Audio から見てみる。
この中に Capturing Stereo Audio from Buit-In Microphones という、マイクから audio をキャプチャするというそれっぽいサンプルコードがあるのを見つけた。
見てみると AVAudioRecorder
を使って、音声を特定の URL に保存し、それを再生できるようにするという形のアプリっぽい。
このアプリの主要な class として AudioController
が存在していて、この class の initializer では以下のような処理が呼ばれている。
override init() {
super.init()
setupRecorder()
setupAudioSession()
enableBuiltInMic()
}
それぞれの処理を簡単に見てみると、
func setupRecorder() {
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
let fileURL = tempDir.appendingPathComponent("recording.wav")
do {
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatLinearPCM),
AVLinearPCMIsNonInterleaved: false,
AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: isStereoSupported ? 2 : 1,
AVLinearPCMBitDepthKey: 16
]
recorder = try AVAudioRecorder(url: fileURL, settings: settings)
} catch {
fatalError("Unable to create audio recorder: \(error.localizedDescription)")
}
recorder.delegate = self
recorder.isMeteringEnabled = true
recorder.prepareToRecord()
}
setupRecorder
では、録音した音声ファイルの保存先である fileURL
と音声の設定が埋め込まれた settings
dictionary を AVAudioRecorder
に渡して、recorder
として利用できるようにしている。
リアルタイムで音声を入力する際に AVAudioRecorder
を利用するのであれば、参考になりそう。
func setupAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, options: [.defaultToSpeaker, .allowBluetooth])
try session.setActive(true)
} catch {
fatalError("Failed to configure and activate session.")
}
}
setupAudioSession
では、AVAudioSession
に対して諸々の設定を行なっている。
AVAudioSession
については詳しくまだわかっていないが、詳しく知りたいタイミングで以下あたりをチェックしようと思っている。
- https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/Introduction/Introduction.html
- https://developer.apple.com/documentation/avfaudio/avaudiosession
private func enableBuiltInMic() {
// Get the shared audio session.
let session = AVAudioSession.sharedInstance()
// Find the built-in microphone input.
guard let availableInputs = session.availableInputs,
let builtInMicInput = availableInputs.first(where: { $0.portType == .builtInMic }) else {
print("The device must have a built-in microphone.")
return
}
// Make the built-in microphone input the preferred input.
do {
try session.setPreferredInput(builtInMicInput)
} catch {
print("Unable to set the built-in mic as the preferred input.")
}
}
enableBuiltInMic
では、AVAudioSession
を用いて利用可能な input を探って、それを setPreferredInput
でセットしているようだ。
音声入力を行う際はマイクを利用することになるはずなので、この処理も参考になりそう。
アプリの「レコードボタン」「レコード停止ボタン」が押された時の処理は、それぞれ以下のようになっている。
@discardableResult
func record() -> Bool {
let started = recorder.record()
state = .recording
return started
}
// Stops recording and calls the completion callback when the recording finishes.
func stopRecording() {
recorder.stop()
state = .stopped
}
AVAudioRecorder
の record
, stop
という function を呼び出しているようだ。
また AVAudioRecorderDelegate
の delegate method も一つ実装されており、それは以下のようになっている。
extension AudioController: AVAudioRecorderDelegate {
// AVAudioRecorderDelegate method.
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
let destURL = FileManager.default.urlInDocumentsDirectory(named: "recording.wav")
try? FileManager.default.removeItem(at: destURL)
try? FileManager.default.copyItem(at: recorder.url, to: destURL)
recorder.prepareToRecord()
audioURL = destURL
}
}
名前の通り、おそらく AVAudioRecorder
の record が成功した時にこの処理が呼ばれ、結果的に特定の URL にそのファイルを保存しているようだ。
録音した音声の再生・停止部分の処理は以下のようになっている。
func play() {
guard let url = audioURL else { print("No recording to play"); return }
player = try? AVAudioPlayer(contentsOf: url)
player?.isMeteringEnabled = true
player?.delegate = self
player?.play()
state = .playing
}
func stopPlayback() {
player?.stop()
state = .stopped
}
シンプルだが、保存していた url を取得して、AVAudioPlayer
によってそのファイルを再生しているようだ。
基本的な AVAudioSession
/ AVAudioRecorder
/ AVAudioPlayer
の利用方法としては参考になりそうだった。
また、音声入力にマイクを利用するための方法についても参考になりそう。
ただ、この方法だと「録音 → それを再生」ということは実現できるものの、今回実現したい「リアルタイムで音声を入力し、その音声を API などを経由してアップロードする」ということは達成できなさそうな気がする。
やりたいのは録音ではなく、音声を逐次入力してそれを data(ulaw) としてリアルタイムアップロードするということ。
- マイクからの音声をリアルタイムに data として取得する方法
- その data を ulaw 形式に変換する方法
- ulaw 形式の data をリアルタイムでアップロードする方法
- これは Uploading Streams of Data でいけそう?
あたりを一つずつ潰していく必要がある。
まずは「マイクからの音声をリアルタイムに data として取得する方法」から。
さっきの例で見たように AVAudioRecorder
は音声を録音する力はあるが、録音を停止するまでその音声 data を取得することはできないように見えた。
調べてみると、Record and send/stream sound from iOS device to a server continuously というそれっぽい Stack Overflow の質問を見つけた。
しかし、この解決策として提示されている実装方法は以下のようになっていて、
-(void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag
{
NSLog(@"stoped");
if (!stoped) {
NSData *data = [NSData dataWithContentsOfURL:recorder.url];
[self sendAudioToServer:data];
[recorder record];
NSLog(@"stoped sent and restarted");
}
}
- (IBAction)startRec:(id)sender {
if (!audioRecorder.recording)
{
sendButton.enabled = YES;
stopButton.enabled = YES;
[audioRecorder record];
}
}
- (IBAction)sendToServer:(id)sender {
stoped = NO;
[audioRecorder stop];
}
- (IBAction)stop:(id)sender {
stopButton.enabled = NO;
sendButton.enabled = NO;
recordButton.enabled = YES;
stoped = YES;
if (audioRecorder.recording)
{
[audioRecorder stop];
}
}
どうやら録音が停止されて成功した時に呼ばれる audioRecorderDidFinishRecording
のタイミングで restart することで、継続的な録音を可能にしているという解決策になっているようだった。
これだと録音している間に音声を送ることができず、おそらくラグが発生してしまうため「録音しながら音声を送る」は達成できなさそうに思える。(試していないのでわからないが)
また、Stack Overflow のスレッドでは、sendToServer
の処理を別スレッドで行うようにすれば良いとかそういう話もされているが、別々のスレッドで録音してそれを送れたとしても期待するような挙動にはならなさそうなので、微妙な感じがする。
AVFoundation のドキュメントを軽く見てみたが、期待しているような動作をするような API はパッと見つからず、AVFoundation では実現できないのかもしれない。
また、Speech Recognition という形でリアルタイムに音声を認識し、それをテキストに起こすという方法についての記事は見つかった。(やりたいこととは違っていそうだが)
AVFoundation では今回実現したいことは実現できなくて、CoreAudio などを利用するしか無いのだろうか
いくつか追加で調べていたが、AVAudioEngine
を利用するという方法がありそうだった。
https://zenn.dev/kalupas226/scraps/19bdb48a83d601 にメモを書いたが、AVAudioEngine
を利用すれば、installTap
で AVAudioPCMBuffer
にアクセスすることができるため、それを良い感じに処理すればいけそうなのではないかという予想。
上記のスクラップの元の記事では、audio file を input として音声処理を行っていたが、この input を iOS device のマイクにして、その input からの audio data を良い感じに加工していければ良さそう。
↑ のスクラップで AVAudioEngine
をどのように扱えるかという話についてはなんとなく理解できているが、AVAudioEngine
そのものについてしっかり理解できていないため、まずは AVAudioEngine
のドキュメントを見てみる。
ドキュメントによると AVAudioEngine は audio nodes の graph を管理したり、音声の再生をコントロールしたり、real-time rendering の制約を設定する object らしい。
ドキュメントから情報を読み取ってみようとしたが、思ったよりキャッチアップに手こずりそうなので、ちょっと調べたら見つかった WWDC の 3 つのビデオを見てみることにした。
- WWDC 2014: What's New in Core Audio
- WWDC 2014: AVAudioEngine in Practice
- WWDC 2015: What's New in Core Audio
- WWDC 2019: What's New in AVAudioEngine
AVAudioEngine は 2014 年に AVFoundation に追加された。
AVAudioEngine が導入されるまでは Core Audio などの機能を利用して、難しいプログラミングをする必要があったが、AVAudioEngine はパワフルで機能が豊富な API set を提供することによって、複雑なタスクでもできるだけシンプルに実装できるようにしたり、real-time audio についてもシンプルに実装できるようにするために導入されたらしい。
AVAudioEngine は主に以下のように構成されている。
- Engine (AVAudioEngine)
- Node (AVAudioNode)
- Output node (AVAudioOutputNode)
- Mixer node (AVAudioMixerNode)
- Player node (AVAudioPlayerNode)
メモするのをやめて聞き流していたら答えを見つけた気がする。
具体的には「AVAudioEngine in Practice」のセッションの 24 分くらいの部分で「Capturing Input」について話されている部分に答えがあった。
AVAudioEngine では PlayerNode, MixerNode, OutputNode という順番で connection を貼っていくことで音声処理を実現できるが、PlayerNode に指定するものとして AVAudioInputNode を指定すると、どうやら iOS device のマイクの入力がそれとして扱われるらしい。
しかも、AVAudioInputNode は単体で利用することができるっぽく、AVAudioInputNode に対して tap すれば、マイクからの入力を AVAudioBuffer という形で扱えるようになるので、あとはその AVAudioBuffer をよしなにアプリ側でハンドリングすることができるっぽい。
これにより、おそらく「リアルタイムでマイクから入力を受け取り、音声情報である AVAudioBuffer を利用することができる」ようになるはずなので、あとはその AVAudioBuffer をよしなに変換してあげれば良さそうな気がしてきた
普通に音声系の WWDC の動画は勉強になることが多く、面白そうだったので後で見るとして、一旦「AVAudioEngine を利用してマイクからの音声を AVAudioBuffer として受け取る」最小実装を作ってみることにする。
struct ContentView: View {
let manager = AudioManager()
var body: some View {
VStack(spacing: 8) {
Button {
manager.engineStart()
} label: {
Text("Start engine!")
}
Button {
manager.engineStop()
} label: {
Text("Stop engine!")
}
}
.padding()
}
}
final class AudioManager {
let engine = AVAudioEngine()
init() {
configureAVAudioSession()
configureEngine()
}
private func configureAVAudioSession() {
try! AVAudioSession.sharedInstance()
.setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth)
}
private func configureEngine() {
let input = engine.inputNode
let output = engine.outputNode
let format = engine.inputNode.inputFormat(forBus: 0)
engine.connect(
input,
to: output,
format: format
)
engine.inputNode.volume = 1.0
}
func engineStart() {
try! engine.start()
let format = engine.inputNode.outputFormat(forBus: 0)
engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
print(buffer)
}
}
func engineStop() {
engine.stop()
engine.inputNode.removeTap(onBus: 0)
}
}
かなり雑な感じと理解ではあるが、一応 ↑ のコードで bluetooth スピーカーから自分が話した声がリアルタイムで聞き取れて、しかも installTap
経由で AVAudioPCMBuffer
も取得することができているようだった。(AVAudioSession 周りの設定がかなり適当なので後でチェックする)
今 installTap
経由で取得できている AVAudioPCMBuffer
の format を print してみると、以下のようになっている。
<AVAudioFormat 0x283fa20d0: 1 ch, 48000 Hz, Float32>
これを ulaw・8000 Hz の buffer に変換し (これはサーバー側の仕様)、さらに data に変換することでサーバーに送信できるようにしたい。
変換ということで AVAudioConverter
について調べていたが、convert(to:error:withInputFrom:)
の方は codecs や sample rates の変換も伴える変換ができて、convert(to:from:)
の方は codecs や sample rates を伴わないシンプルな変換が行えるっぽい。
一旦強引っぽい実装ではあるが、以下のようにして変換処理を行えたような気がする。
final class AudioManager {
let engine = AVAudioEngine()
var convertedStreamBasicDescription = AudioStreamBasicDescription(
mSampleRate: 8000,
mFormatID: kAudioFormatULaw,
mFormatFlags: 0,
mBytesPerPacket: 1,
mFramesPerPacket: 1,
mBytesPerFrame: 1,
mChannelsPerFrame: 1,
mBitsPerChannel: 8,
mReserved: 0
)
var convertedAudioFormat: AVAudioFormat {
AVAudioFormat(streamDescription: &convertedStreamBasicDescription)!
}
// ...
func engineStart() {
try! engine.start()
let format = engine.inputNode.outputFormat(forBus: 0)
engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
guard let self else { return }
let convertedBuffer = AVAudioPCMBuffer(pcmFormat: self.convertedAudioFormat, frameCapacity: buffer.frameCapacity)!
let converter = AVAudioConverter(from: format, to: self.convertedAudioFormat)
var error: NSError? = nil
converter?.convert(to: convertedBuffer, error: &error) { _, inputStatus in
inputStatus.pointee = .haveData
return buffer
}
}
}
// ...
}
ちゃんと変換できているのかはまだわかっていない。
試しに AVAudioPCMBuffer
→ Data
に変換しようとして、https://stackoverflow.com/questions/28048568/convert-avaudiopcmbuffer-to-nsdata-and-back に記載されている以下の function を実行してみた。
extension AVAudioPCMBuffer {
func data() -> Data {
let channelCount = 1 // given PCMBuffer channel count is 1
let channels = UnsafeBufferPointer(start: self.floatChannelData, count: channelCount)
let ch0Data = NSData(bytes: channels[0], length:Int(self.frameCapacity * self.format.streamDescription.pointee.mBytesPerFrame))
return ch0Data as Data
}
}
convertedBuffer.data()
を実行してみると channels
の行でクラッシュしてしまった。
floatChannelData
が nil のためクラッシュしているようで、変換がうまくいっていないのかもしれない。
error
を print してみても nil ではあるので、convert には成功しているものの、期待するような結果になっていないという感じっぽい。 また、元の buffer である
buffer.data()を実行してみると
Optional(9600 bytes)` となるため、やはり convert がうまくいっていないらしい。
関係ないが、クラッシュを回避するために先ほどのロジックを少し書き換えた。
extension AVAudioPCMBuffer {
func data() -> Data? {
guard let channels = self.floatChannelData else { return nil }
let ch0Data = NSData(bytes: channels[0], length:Int(self.frameCapacity * self.format.streamDescription.pointee.mBytesPerFrame))
return ch0Data as Data
}
}
引き続き検証していく。
そもそも AVAudioPCMBuffer
とは何なのかをしっかり理解していないので、いくつか記事などを漁ってみる。
- https://developer.apple.com/documentation/avfaudio/avaudiopcmbuffer
- https://note.com/shu223/n/nc65fbc626a3b
AVAudioConverter の sample rate を変換することについての Technotes もあった。(まさかのついこの間 publish されたっぽいやつだった)
AVAudioConverter について詳細に解説されていそうなので見てみる。
... 見てみたが、今知っている以上の知識は得られなかった。
AVFoundation のさまざまな基礎知識が欠けてしまっているため (というか一からキャッチアップしているので何も知らない)、何がわからないのかがわからない状態になっていると思い、WWDC 2014: What's New in Core Audio を飛ばし飛ばしスライドだけ見ていたら、後半の方で AVFoundation のさまざまなクラスについて説明されていることに気づけた。
また、WWDC 2015: What's New in Core Audio の方では 24 分あたりからなんと AVAudioConverter について説明されていることに気づけた。
音声処理周り WWDC でかなり解説されていることが多そうで、ちゃんと一から見なければいけないという気持ちになった。
ということで、少しずつ動画からキャッチアップしてみることにする。
ulaw に変換した後データが存在しないと思っていたが、floatChannelData
ではなく audioBufferList.pointee.mBuffers.mData
にアクセスすると data が得られた。