soranoba
soranoba Author of soranoba.net
programming

AVAudioEngineでエコキャン実装 (with ManualRendering)

iOS13からAVAudioEngineでVoiceProcessingIOを用いたエコーキャンセルがsetVoiceProcessingEnabled(_:)によって簡単にできるようになりました。
とはいえ、iOS13以前のバージョンでもエコーキャンセルをできるようにしたいので、以前のバージョンでも対応する形で実装してみました。

Manual Rendering

実装の方針としては、AVAudioEngineのManualRenderingModeを使用し、AudioUnitから取得した音声をAVAudioEngineに流し込むこみ、スピーカーへの出力はAVAudioEngineが担う形にします。

実装のイメージ


AudioUnitの初期化

AudioComponentDescription.componentSubTypekAudioUnitSubType_VoiceProcessingIOを指定してAudioUnitの初期化を行います。
この際に出力は使用しないので入力のみのkAudioOutputUnitProperty_EnableIOを有効にします。

// @see AudioUnitElement
enum {
    OUT_BUS = 0,
    IN_BUS = 1
};

- (BOOL)setupAudioUnit {
    OSStatus error = noErr;
    AudioComponent component = AudioComponentFindNext(NULL, &self->_audioComponentDescription);
    if (component == NULL) {
        return NO;
    }

    error = AudioComponentInstanceNew(component, &self->_audioUnit);
    if (error != noErr) {
        goto fail;
    }

    // BUS1からの出力
    error = AudioUnitSetProperty(self.audioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, IN_BUS,
                                &self->_audioStreamBasicDescription, sizeof(self->_audioStreamBasicDescription));
    if (error != noErr) {
        goto fail;
    }

    // BUS0への入力
    error = AudioUnitSetProperty(self.audioUnit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, OUT_BUS,
                                &self->_audioStreamBasicDescription, sizeof(self->_audioStreamBasicDescription));
    if (error != noErr) {
        goto fail;
    }

    // 入力だけ使用する
    UInt32 enabled = 1;
    error = AudioUnitSetProperty(self.audioUnit, kAudioOutputUnitProperty_EnableIO, kAudioUnitScope_Input, IN_BUS, &enabled, sizeof(enabled));
    if (error != noErr) {
        goto fail;
    }

    // AVAudioEngine#enableManualRenderingMode:format:maximumFrameCount:error: で使用する
    UInt32 framePerSliceSize = sizeof(self->_maximumFramesPerSlice);
    error = AudioUnitGetProperty(self->_audioUnit, kAudioUnitProperty_MaximumFramesPerSlice, kAudioUnitScope_Global, OUT_BUS, &self->_maximumFramesPerSlice, &framePerSliceSize);
    if (error != noErr) {
        goto fail;
    }

    AURenderCallbackStruct renderCallback;
    renderCallback.inputProc = AudioRecorderRenderCallback;
    renderCallback.inputProcRefCon = (void*)self->_recorderContext;
    error = AudioUnitSetProperty(self.audioUnit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Output, OUT_BUS, &renderCallback, sizeof(renderCallback));
    if (error != noErr) {
        goto fail;
    }

    AURenderCallbackStruct inputCallback;
    inputCallback.inputProc = AudioRecorderInputCallback;
    inputCallback.inputProcRefCon = (void*)self->_recorderContext;
    error = AudioUnitSetProperty(self.audioUnit, kAudioOutputUnitProperty_SetInputCallback, kAudioUnitScope_Input, IN_BUS, &inputCallback, sizeof(inputCallback));
    if (error != noErr) {
        goto fail;
    }

    error = AudioUnitInitialize(self.audioUnit);
    if (error != noErr) {
        goto fail;
    }
fail:
    AudioComponentInstanceDispose(self.audioUnit);
    self.audioUnit = NULL;
    return NO;
}

Manual Renderingの流れ

AudioUnitの初期化ができたら、これを用いてManualRenderingを実行していきます。
ManualRenderingは自分でレンダリング関数を実行する必要があり、これをAudioUnitのループのタイミングで実行します。

AudioUnitのループと流れ


ここで少しややこしいのは、renderingBlockの引数にバッファはなく、AVAudioInputNode#setManualRenderingInputPCMFormat:inputBlock:によって取得されるという点です。
そのため、予め取得先のバッファを返すようにしておき、そのバッファにAudioUnitRenderで書き込みを行うように実装する必要があります。

static OSStatus AudioRecorderInputCallback(void* _Nonnull inRefCon, AudioUnitRenderActionFlags* _Nonnull ioActionFlags,
                                           const AudioTimeStamp* _Nonnull inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames, AudioBufferList* _Nullable ioData) {
    assert(ioData == NULL);

    AudioRecorderContext* context = (AudioRecorderContext*)inRefCon;
    OSStatus error = noErr;

    AudioBufferList* inputAudioBufferList = context->pInputAudioBufferList;
    inputAudioBufferList->mNumberBuffers = 1;
    // sizeofの部分が設定依存なので注意
    inputAudioBufferList->mBuffers[0].mDataByteSize = inNumberFrames * sizeof(UInt16) * context->pStreamBasicDescription->mChannelsPerFrame;
    inputAudioBufferList->mBuffers[0].mNumberChannels = context->pStreamBasicDescription->mChannelsPerFrame;

    error = AudioUnitRender(context->audioUnit, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, inputAudioBufferList);
    if (error != noErr) {
        return error;
    }

    return noErr;
}

- (BOOL)connectTo:(AVAudioEngine* _Nonnull)engine {
    NSParameterAssert(engine != nil);

    AudioRecorderContext* context = self->_recorderContext;
    if (![engine enableManualRenderingMode:AVAudioEngineManualRenderingModeRealtime format:self.processingFormat maximumFrameCount:self.maximumFramesPerSlice error:NULL]) {
        return NO;
    }

    BOOL success = [engine.inputNode setManualRenderingInputPCMFormat:self.processingFormat inputBlock:^const AudioBufferList * _Nullable(AVAudioFrameCount inNumberOfFrames) {
        // AudioUnitRenderで読み込む先のbufferを指定する
        return context->pInputAudioBufferList;
    }];
    if (!success) {
        return NO;
    }
    self.recorderContext->renderingBlock = (__bridge void*)engine.manualRenderingBlock;
    return YES;
}

static OSStatus AudioRecorderRenderCallback(void* _Nonnull inRefCon, AudioUnitRenderActionFlags* _Nonnull ioActionFlags,
                                            const AudioTimeStamp* _Nonnull inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames, AudioBufferList* _Nullable ioData) {
    assert(ioData != NULL);

    AudioRecorderContext* context = (AudioRecorderContext*)inRefCon;
    OSStatus error = noErr;

    AVAudioEngineManualRenderingBlock renderingBlock = (__bridge AVAudioEngineManualRenderingBlock)(context->renderingBlock);
    AVAudioEngineManualRenderingStatus renderingStatus = renderingBlock(inNumberFrames, ioData, &error);

    if (renderingStatus != AVAudioEngineManualRenderingStatusSuccess) {
        return error;
    }

    return noErr;
}

エコーキャンセルについて

これでiOS13以前にも対応した、AVAudioEngineのエコキャン実装ができます。

@constant		kAudioUnitSubType_VoiceProcessingIO
 - Available on OS X and with iOS 3.0 or greater
   This audio unit can do input as well as output. Bus 0 is used for the output
   side, bus 1 is used to get audio input (thus, on the iPhone, it works in a
   very similar way to the Remote I/O). This audio unit does signal processing on
   the incoming audio (taking out any of the audio that is played from the device
   at a given time from the incoming audio).

kAudioUnitSubType_VoiceProcessingIOはデバイスからの音声出力を使用して処理を行うようなので、必ずしも同じAudioUnitインスタンスから音を出す必要はありません。
上記の実装もAVAudioEngineに移譲するので別のAudioUnitインスタンスで音声が出力されます。

Trouble shooting

error -10868

2019-12-22 16:26:57.868008+0900 AudioPlayer[50276:2769503] [avae] AVAEInternal.h:109 [AVAudioEngineGraph.mm:1389:Initialize: (err = AUGraphParser::InitializeActiveNodesInOutputChain(ThisGraph, kOutputChainOptimizedTraversal, *GetOutputNode(), isOutputChainActive)): error -10868

このようなエラーが出た場合はkAudioUnitErr_FormatNotSupportedなので、formatの指定が足りていません。
AVAudioEngine.connect(_:to:format:)でformatを正しく設定すると解消されます。
formatの指定がなくても動く時は動くのですが、指定した方が無難です。正しく指定しないとこれまたエラーになるので難しいのですが。

mPtrState == kPtrsInvalid is false

2019-12-22 17:37:24.649149+0900 AudioPlayer[50304:2785651] AUBuffer.h:61:GetBufferList: EXCEPTION (-1) [mPtrState == kPtrsInvalid is false]: “”

このようなログが大量に吐かれた場合はkAudioUnitProperty_SetRenderCallbackにcallbackを指定していない時なので、なんらかのcallbackを指定すると解消されるようです。

音が断続的になる

次の記事に書きました

参考文献

  • twilio/video-quickstart-ios
    • 数少ないマニュアルレンダリング実装が公開されているので大変参考にしました

(Updated: )

comments powered by Disqus