Search code examples
iosaudiokit

AudioKit : AKNodeOutputPlot and AKMicrophone not working, potentially due to Lifecycle or MVVM architecture decisions


Early in my learning with AudioKit, and scaling in a larger app, I took the standard advice that AudioKit should be effectively be a global singleton. I managed to build a really sophisticated prototype and all was well in the world.

Once I started to scale up and get closer to an actual release. We decided to go MVVM for our architecture and try to not have a monstrous large AudioKit Singelton to handle every aspect of our audio needs in the app. In short, MVVM has been so incredibly elegant and has demonstrably cleaned up our code base.

In direct relation to our structure of AudioKit, it goes something like this:

AudioKit and AKMixer reside in a Singelton instance, and have public functions that allow the various viewmodels and our other Audio models to attach and detach the various nodes (AKPlayer, AKSampler, etc...). In the minimal testing I have done, I can confirm that this works as I tried it with my AKPlayer module and it works great.

I'm running into an issue where I cannot, for the life of me, get AKNodeOutputPlot and AKMicrophone to work with each other, despite the actual code implementation being identical to my working prototypes.

My concern is did I do the wrong thing thinking I could modularize AudioKit and the various nodes and components that need to connect to it, or does AKNodeOutputPlot have special requirements I am not aware of.

Here is the briefest snippets of Code I can provide without overwhelming the question:

AudioKit Singelton (called in AppDelegate):

import Foundation
import AudioKit

class AudioKitConfigurator
{
    static let shared: AudioKitConfigurator = AudioKitConfigurator()

    private let mainMixer: AKMixer = AKMixer()

    private init()
    {
        makeMainMixer()
        configureAudioKitSettings()
        startAudioEngine()
    }

    deinit
    {
        stopAudioEngine()
    }

    private func makeMainMixer()
    {
        AudioKit.output = mainMixer
    }

    func mainMixer(add node: AKNode)
    {
        mainMixer.connect(input: node)
    }

    func mainMixer(remove node: AKNode)
    {
        node.detach()
    }

    private func configureAudioKitSettings()
    {
        AKAudioFile.cleanTempDirectory()
        AKSettings.defaultToSpeaker = true
        AKSettings.playbackWhileMuted = true
        AKSettings.bufferLength = .medium

        do
        {
            try AKSettings.setSession(category: .playAndRecord, with: .allowBluetoothA2DP)
        }

        catch
        {
            AKLog("Could not set session category.")
        }

    }

    private func startAudioEngine()
    {
        do
        {
            try AudioKit.start()
        }
        catch
        {
            AKLog("Fatal Error: AudioKit did not start!")
        }
    }

    private func stopAudioEngine()
    {
        do
        {
            try AudioKit.stop()
        }
        catch
        {
            AKLog("Fatal Error: AudioKit did not stop!")
        }
    }
}

Microphone Component:

import Foundation
import AudioKit
import AudioKitUI

enum MicErrorsToThrow: String, Error
{
    case recordingTooShort          = "The recording was too short, just silently failing"
    case audioFileFailedToUnwrap    = "The Audio File failed to Unwrap from the recorder"
    case recorderError              = "The Recorder was unable to start recording."
    case recorderCantReset          = "In attempt to reset the recorder, it was unable to"
}

class Microphone
{
    private var mic:            AKMicrophone    = AKMicrophone()
    private var micMixer:       AKMixer         = AKMixer()
    private var micBooster:     AKBooster       = AKBooster()
    private var recorder:       AKNodeRecorder!
    private var recordingTimer: Timer

    init()
    {
        micMixer = AKMixer(mic)
        micBooster = AKBooster(micMixer)
        micBooster.gain = 0
        recorder = try? AKNodeRecorder(node: micMixer)

        //TODO: Need to finish the recording timer implementation, leaving blank for now
        recordingTimer = Timer(timeInterval: 120, repeats: false, block: { (timer) in

        })

        AudioKitConfigurator.shared.mainMixer(add: micBooster)
    }

    deinit {
//      removeComponent()
    }

    public func removeComponent()
    {
        AudioKitConfigurator.shared.mainMixer(remove: micBooster)
    }

    public func reset() throws
    {
        if recorder.isRecording
        {
            recorder.stop()
        }
        do
        {
            try recorder.reset()
        }
        catch
        {
            AKLog("Recorder can't reset!")
            throw MicErrorsToThrow.recorderCantReset
        }
    }

    public func setHeadphoneMonitoring()
    {
        // microphone will be monitored while recording
        // only if headphones are plugged
        if AKSettings.headPhonesPlugged {
            micBooster.gain = 1
        }
    }

    /// Start recording from mic, call this function when using in conjunction with a AKNodeOutputPlot so that it can display the waveform in realtime while recording
    ///
    /// - Parameter waveformPlot: AKNodeOutputPlot view object which displays waveform from recording
    /// - Throws: Only error to throw is from recorder property can't start recording, something wrong with microphone. Enum is MicErrorsToThrow.recorderError
    public func record(waveformPlot: AKNodeOutputPlot) throws
    {
        waveformPlot.node = mic
        do
        {
            try recorder.record()
//          self.recordingTimer.fire()
        }
        catch
        {
            print("Error recording!")
            throw MicErrorsToThrow.recorderError
        }
    }

    /// Stop the recorder, and get the recording as an AKAudioFile, necessary to call if you are using AKNodeOutputPlot
    ///
    /// - Parameter waveformPlot: AKNodeOutputPlot view object which displays waveform from recording
    /// - Returns: AKAudioFile
    /// - Throws: Two possible errors, recording was too short (right now is 0.0, but should probably be like 0.5 secs), or could not retrieve audio file from recorder, MicErrorsToThrow.audioFileFailedToUnwrap, MicErrorsToThrow.recordingTooShort
    public func stopRecording(waveformPlot: AKNodeOutputPlot) throws -> AKAudioFile
    {
        waveformPlot.pause()
        waveformPlot.node = nil

        recordingTimer.invalidate()
        if let tape = recorder.audioFile
        {
            if tape.duration > 0.0
            {
                recorder.stop()
                AKLog("Printing tape: CountOfFloatChannelData:\(tape.floatChannelData?.first?.count) | maxLevel:\(tape.maxLevel)")
                return tape
            }
            else
            {
                //TODO: This should be more gentle than an NSError, it's just that they managed to tap the buttona and tap again to record nothing, honestly duration should probbaly be like 0.5, or 1.0 even. But let's return some sort of "safe" error that doesn't require UI
                throw MicErrorsToThrow.recordingTooShort
            }
        }
        else
        {
            //TODO: need to return error here, could not recover audioFile from recorder
            AKLog("Can't retrieve or unwrap audioFile from recorder!")
            throw MicErrorsToThrow.audioFileFailedToUnwrap
        }
    }
}

Now, in my VC, the AKNodeOutputPlot is a view on Storybard and hooked up via IBOutlet. It renders on screen, it's stylized per my liking and it's definitely connected and working. Also in the VC/VM is an instance property of my Microphone component. My thinking was that upon recording, we would pass the nodeOutput object to the ViewModel, which then would call the record(waveformPlot: AKNodeOutputPlot) function of Microphone, which then would waveformPlot.node = mic be sufficient to hook them up. Sadly this is not the case.

View:

class ComposerVC: UIViewController, Storyboarded
{
    var coordinator: MainCoordinator?
    let viewModel: ComposerViewModel = ComposerViewModel()

    @IBOutlet weak var recordButton: RecordButton!
    @IBOutlet weak var waveformPlot: AKNodeOutputPlot! // Here is our waveformPlot object, again confirmed rendering and styled

    // MARK:- VC Lifecycle Methods
    override func viewDidLoad()
    {
        super.viewDidLoad()

        setupNavigationBar()
        setupConductorButton()
        setupRecordButton()
    }

    func setupWaveformPlot() {
        waveformPlot.plotType = .rolling
        waveformPlot.gain = 1.0
        waveformPlot.shouldFill = true
    }

    override func viewDidAppear(_ animated: Bool)
    {
        super.viewDidAppear(animated)

        setupWaveformPlot()

        self.didDismissComposerDetailToRootController()
    }

    // Upon touching the Record Button, it in turn will talk to ViewModel which will then call Microphone module to record and hookup waveformPlot.node = mic
    @IBAction func tappedRecordView(_ sender: Any)
    {
        self.recordButton.recording.toggle()
        self.recordButton.animateToggle()
        self.viewModel.tappedRecord(waveformPlot: waveformPlot)
        { (waveformViewModel, error) in
            if let waveformViewModel = waveformViewModel
            {
                self.segueToEditWaveForm()
                self.performSegue(withIdentifier: "composerToEditWaveForm", sender: waveformViewModel)
                //self.performSegue(withIdentifier: "composerToDetailSegue", sender: self)
            }
        }
    }

ViewModel:

import Foundation
import AudioKit
import AudioKitUI

class ComposerViewModel: ViewModelProtocol
{

//MARK:- Instance Variables
var recordingState: RecordingState

var mic:            Microphone                      = Microphone()

init()
{
    self.recordingState = .readyToRecord
}


func resetViewModel()
{
    self.resetRecorder()
}

func resetRecorder()
{
    do
    {
        try mic.reset()
    }
    catch let error as MicErrorsToThrow
    {
        switch error {
        case .audioFileFailedToUnwrap:
            print(error)
        case .recorderCantReset:
            print(error)
        case .recorderError:
            print(error)
        case .recordingTooShort:
            print(error)
        }
    }
    catch {
        print("Secondary catch in start recording?!")
    }
    recordingState = .readyToRecord
}

func tappedRecord(waveformPlot: AKNodeOutputPlot, completion: ((EditWaveFormViewModel?, Error?) -> ())? = nil)
{
    switch recordingState
    {
    case .readyToRecord:
        self.startRecording(waveformPlot: waveformPlot)

    case .recording:
        self.stopRecording(waveformPlot: waveformPlot, completion: completion)

    case .finishedRecording: break
    }
}

func startRecording(waveformPlot: AKNodeOutputPlot)
{

    recordingState = .recording
    mic.setHeadphoneMonitoring()
    do
    {
        try mic.record(waveformPlot: waveformPlot)
    }

    catch let error as MicErrorsToThrow
    {
        switch error {
        case .audioFileFailedToUnwrap:
            print(error)
        case .recorderCantReset:
            print(error)
        case .recorderError:
            print(error)
        case .recordingTooShort:
            print(error)
        }
    }
    catch {
        print("Secondary catch in start recording?!")
    }
}

I'm happy to provide more code but I just don't want to overwhelm anyway with their time. The logic seems sound, I just feel I'm missing something obvious and or a complete misunderstanding of AudioKit + AKNodeOutputPlot + AKMicrohone.

Any ideas are so welcome, thank you!


Solution

  • EDIT AudioKit 4.6 fixed all the issues! Highly encourage MVVM/Modularization of AudioKit for your projects!

    ====

    So after alot of experiments. I have come to a few conclusions:

    1. In a separate project, I brought over my AudioKitConfigurator and Microphone classes, initialized them, hooked them to a AKNodeOutputPlot and it worked flawlessly.

    2. In my very large project, no matter what I do, I cannot get the same classes to work at all.

    For now, I am reverting back to an old build, slowly adding components until it breaks again, and will update the architecture one by one, as this problem is too complex and might be interacting with some other libraries. I have also downgraded from AudioKit 4.5.6, to AudioKit 4.5.3.

    This is not a solution, but the only one that is workable right now. The good news is, it is entirely possible to format AudioKit to work with an MVVM architecture.