Closed8

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 については詳しくまだわかっていないが、詳しく知りたいタイミングで以下あたりをチェックしようと思っている。

    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
    }

AVAudioRecorderrecord, 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 として取得する方法」から。

さっきの例で見たように 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 では実現できないのかもしれない。

https://developer.apple.com/documentation/avfoundation

また、Speech Recognition という形でリアルタイムに音声を認識し、それをテキストに起こすという方法についての記事は見つかった。(やりたいこととは違っていそうだが)
https://swift-ios.keicode.com/ios/speechrecognition-live.php

AVFoundation では今回実現したいことは実現できなくて、CoreAudio などを利用するしか無いのだろうか

アイカワアイカワ

いくつか追加で調べていたが、AVAudioEngine を利用するという方法がありそうだった。

https://zenn.dev/kalupas226/scraps/19bdb48a83d601 にメモを書いたが、AVAudioEngine を利用すれば、installTapAVAudioPCMBuffer にアクセスすることができるため、それを良い感じに処理すればいけそうなのではないかという予想。

上記のスクラップの元の記事では、audio file を input として音声処理を行っていたが、この input を iOS device のマイクにして、その input からの audio data を良い感じに加工していければ良さそう。
↑ のスクラップで AVAudioEngine をどのように扱えるかという話についてはなんとなく理解できているが、AVAudioEngine そのものについてしっかり理解できていないため、まずは AVAudioEngine のドキュメントを見てみる。

https://developer.apple.com/documentation/avfaudio/audio_engine

https://developer.apple.com/documentation/avfaudio/avaudioengine

ドキュメントによると AVAudioEngine は audio nodes の graph を管理したり、音声の再生をコントロールしたり、real-time rendering の制約を設定する object らしい。

ドキュメントから情報を読み取ってみようとしたが、思ったよりキャッチアップに手こずりそうなので、ちょっと調べたら見つかった WWDC の 3 つのビデオを見てみることにした。

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
            }
        }
    }
    // ...
}

ちゃんと変換できているのかはまだわかっていない。

試しに AVAudioPCMBufferData に変換しようとして、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 とは何なのかをしっかり理解していないので、いくつか記事などを漁ってみる。

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 が得られた。

このスクラップは2023/02/11にクローズされました