[iOS] AVAudioEngineでエフェクトをかけてみる

2022.02.10

こんにちは。きんくまです。

iOSで音をならしてみたかったです。それで、鳴らした後にエフェクトもかけてみました。

つくったもの

音量注意!!

機能

  • 音声ファイルの再生/停止
  • ディレイエフェクト
  • ディストーションエフェクト
  • 再生速度変更

ソースコード全部

最初に全部載せてしまいます

import UIKit
import AVFoundation

class ViewController: UIViewController {

    @IBOutlet weak var playStopButton: ToggleButton!
    @IBOutlet weak var delayButton: ToggleButton!
    @IBOutlet weak var distortionButton: ToggleButton!
    @IBOutlet weak var speedButon: ToggleButton!
    @IBOutlet weak var delaySlider: UISlider!
    @IBOutlet weak var speedSlider: UISlider!
    
    var audioFile: AVAudioFile?
    var audioEngine: AVAudioEngine?
    var audioPlayer: AVAudioPlayerNode?
    var needsFileScheduled = true
    var varispeedNode: AVAudioUnitVarispeed?
    var delayNode: AVAudioUnitDelay?
    var distortionNode: AVAudioUnitDistortion?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        playStopButton.setTitle("play", for: .normal)
        delaySlider.minimumValue = 0
        delaySlider.maximumValue = 1
        delaySlider.setValue(0.1, animated: false)
        speedSlider.minimumValue = 0.2
        speedSlider.maximumValue = 2
        speedSlider.setValue(0.5, animated: false)
        
        setupAudioSession()
        setupAudioFile()
        guard let format = audioFile?.processingFormat else { return }
        configureEngine(with: format)
    }
    
    func setupAudioSession() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback)
            try session.overrideOutputAudioPort(.speaker)
            try session.setActive(true, options: [])
        } catch let error {
            print("dbg session error \(error.localizedDescription)")
        }
    }
    
    func setupAudioFile() {
        do {
            guard let bundleUrl = Bundle.main.url(forResource: "bensound-jazzyfrenchy.mp3", withExtension: nil) else { return }
            let file = try AVAudioFile(forReading: bundleUrl)
            audioFile = file
        } catch let error {
            print("error \(error.localizedDescription)")
        }
    }
    
    func configureEngine(with format: AVAudioFormat) {
        let engine = AVAudioEngine()
        audioEngine = engine
        
        let player: AVAudioPlayerNode
        player = AVAudioPlayerNode()
        audioPlayer = player
        engine.attach(player)
        
        let varispeed = AVAudioUnitVarispeed()
        varispeed.rate = speedSlider.value
        engine.attach(varispeed)
        varispeedNode = varispeed

        let delay = AVAudioUnitDelay()
        delay.delayTime = TimeInterval(delaySlider.value)
        engine.attach(delay)
        delayNode = delay

        let dist = AVAudioUnitDistortion()
        let distPreset = AVAudioUnitDistortionPreset.drumsBufferBeats
        dist.loadFactoryPreset(distPreset)
        engine.attach(dist)
        distortionNode = dist
    }
    
    func scheduleAudioFile() {
        guard let engine = audioEngine,
              let file = audioFile,
              let player = audioPlayer else { return }
        engine.reset()
        
        let mainMixer = engine.mainMixerNode
        let format = file.processingFormat
        
        var effects: [AVAudioNode] = []
        if delayButton.isOn,
           let delay = delayNode {
            effects.append(delay)
        }
        if distortionButton.isOn,
           let distortion = distortionNode {
            effects.append(distortion)
        }
        if speedButon.isOn,
           let speed = varispeedNode {
            effects.append(speed)
        }

        if effects.count == 0 {
            engine.connect(player, to: mainMixer, format: format)
        } else {
            var currentNode: AVAudioNode = player
            effects.forEach { (newNode) in
                engine.connect(currentNode, to: newNode, format: format)
                currentNode = newNode
            }
            engine.connect(currentNode, to: mainMixer, format: format)
        }
        
        let output = engine.outputNode
        engine.connect(mainMixer, to: output, format: format)
        
        player.scheduleFile(file, at: nil) { [weak self] in
            self?.needsFileScheduled = true
        }
    }
    
    func playSound() {
        if needsFileScheduled {
            scheduleAudioFile()
        }
        if let running = audioEngine?.isRunning, !running {
            do {
                try audioEngine?.start()
            } catch let error {
                print("error \(error.localizedDescription)")
            }
        }
        audioPlayer?.play()
        playStopButton.setTitle("stop", for: .normal)
        playStopButton.setStateOn()
    }
    
    func stopSound() {
        audioPlayer?.stop()
        playStopButton.setTitle("play", for: .normal)
        playStopButton.setStateOff()
    }
    
    func playOrStop() {
        if let player = audioPlayer, player.isPlaying {
            stopSound()
        } else {
            playSound()
        }
    }

    @IBAction func didTapPlayButton() {
        playOrStop()
    }
    
    @IBAction func didTapDelayButton() {
        delayButton.toggleState()
        if let isPlaying = audioPlayer?.isPlaying, isPlaying {
            playSound()
        }
    }
    
    @IBAction func didTapDistortionButton() {
        distortionButton.toggleState()
        if let isPlaying = audioPlayer?.isPlaying, isPlaying {
            playSound()
        }
    }
    
    @IBAction func didTapSpeedButton() {
        speedButon.toggleState()
        if let isPlaying = audioPlayer?.isPlaying, isPlaying {
            playSound()
        }
    }
    
    @IBAction func didChangeDelaySlider() {
        delayNode?.delayTime = TimeInterval(delaySlider.value)
    }
    
    @IBAction func didChangeSpeedSlider() {
        varispeedNode?.rate = speedSlider.value
    }
}

こっちは本編に関係ないのですが、上のソースに入っているので載せます

class ToggleButton: UIButton {
    
    var isOn: Bool = false
    
    override func awakeFromNib() {
        super.awakeFromNib()
        if isOn {
            setStateOn()
        } else {
            setStateOff()
        }
    }
    
    func setStateOn() {
        isOn = true
        // 298ED6
        let highlight = UIColor(hue: 205.0 / 360.0, saturation: 0.81, brightness: 0.84, alpha: 1)
        layer.cornerRadius = 10
        layer.borderWidth = 1
        layer.borderColor = highlight.cgColor
        backgroundColor = highlight
        setTitleColor(.white, for: .normal)
    }
    
    func setStateOff() {
        isOn = false
        layer.cornerRadius = 10
        layer.borderWidth = 1
        layer.borderColor = UIColor.gray.cgColor
        backgroundColor = .white
        setTitleColor(.black, for: .normal)

    }
    
    func toggleState() {
        if isOn {
            setStateOff()
        } else {
            setStateOn()
        }
    }
}

AVAudioEngineって?

AVFoundationは、音声と描画を管理します。
音声処理はCやC++を使いそうですが、swiftから操作できるのがポイントなのかと。

AVFoundation

AVFoundationの中で音声処理ができるのがAVAudioEngineです。
AVAudioEngine

AVAudioNodeと呼ばれるノードを入力から出力までつなげてあげれば音がなります。

入力(ファイルや動的生成された波形データなど)-> AVAudioPlayerNode -> エフェクトNode(効果) -> ミキサーNode(音量調整) -> 出力(スピーカーやヘッドフォン)

一番簡単な構成だと以下になります。

AVAudioPlayerNode -> AVAudioEngineのmainMixerNode(AVAudioMixerNode)

初期化処理

    func setupAudioSession() {
        do {
            let session = AVAudioSession.sharedInstance()
            try session.setCategory(.playback)
            try session.overrideOutputAudioPort(.speaker)
            try session.setActive(true, options: [])
        } catch let error {
            print("dbg session error \(error.localizedDescription)")
        }
    }

    func setupAudioFile() {
        do {
            guard let bundleUrl = Bundle.main.url(forResource: "bensound-jazzyfrenchy.mp3", withExtension: nil) else { return }
            let file = try AVAudioFile(forReading: bundleUrl)
            audioFile = file
        } catch let error {
            print("error \(error.localizedDescription)")
        }
    }

setupAudioSessionは処理をやらなくても音自体はなるのですが、再生と停止を繰り返したときの1回目の音量がおかしなことになりました。
なので必要なのかと。

setupAudioFileでmp3のファイルからAVAudioEngineで扱える形式に変えています。
内部では難しいこと(ファイル形式ごとのデコード処理をしてるとか)をしてそうですが、開発者的には数行ですんでいます。

EngineとNode初期化

    func configureEngine(with format: AVAudioFormat) {
        let engine = AVAudioEngine()
        audioEngine = engine
        
        let player: AVAudioPlayerNode
        player = AVAudioPlayerNode()
        audioPlayer = player
        engine.attach(player)
        
        let varispeed = AVAudioUnitVarispeed()
        varispeed.rate = speedSlider.value
        engine.attach(varispeed)
        varispeedNode = varispeed

        let delay = AVAudioUnitDelay()
        delay.delayTime = TimeInterval(delaySlider.value)
        engine.attach(delay)
        delayNode = delay

        let dist = AVAudioUnitDistortion()
        let distPreset = AVAudioUnitDistortionPreset.drumsBufferBeats
        dist.loadFactoryPreset(distPreset)
        engine.attach(dist)
        distortionNode = dist
    }

Engineと必要なNodeをインスタンス化しています。AVAudioPlayerNodeはわざわざ変数制限をしています。
これは以前にバグがあったのをネットの記事で見たので、それの回避処理に合わせてやっています。最近だと必要ないのかも

        let player: AVAudioPlayerNode
        player = AVAudioPlayerNode()

        //本当だったらこれでもよさそうだけど、そうしていない
        let player = AVAudioPlayerNode()

Nodeをつなぐ

scheduleAudioFileの中

        if effects.count == 0 {
            engine.connect(player, to: mainMixer, format: format)
        } else {
            var currentNode: AVAudioNode = player
            effects.forEach { (newNode) in
                engine.connect(currentNode, to: newNode, format: format)
                currentNode = newNode
            }
            engine.connect(currentNode, to: mainMixer, format: format)
        }
        
        let output = engine.outputNode
        engine.connect(mainMixer, to: output, format: format)

UIでEffectボタンのスイッチがOnになっていたら、Nodeを入力と出力の間にはさむ処理をしています。

Playerにfileをセット

        player.scheduleFile(file, at: nil) { [weak self] in
            self?.needsFileScheduled = true
        }

再生

    func playSound() {
        if needsFileScheduled {
            scheduleAudioFile()
        }
        if let running = audioEngine?.isRunning, !running {
            do {
                try audioEngine?.start()
            } catch let error {
                print("error \(error.localizedDescription)")
            }
        }
        audioPlayer?.play()
    }

Engineは一度startすれば、Onにしっぱなしで問題ないと思うので、条件分岐で1回だけするようにしています。
最後にplayerをplayすれば再生開始です。

エフェクトのスライダー

    @IBAction func didChangeDelaySlider() {
        delayNode?.delayTime = TimeInterval(delaySlider.value)
    }

Nodeの設定をUIのSliderの値を使って調整しています。これは、リアルタイムに変更しても適用されました。

感想

面白かったです

参考リンク

AVAudioEngine

Swift AVAudioEngine の基本

Building a Signal Generator

AVAudioEngine Tutorial for iOS: Getting Started

[iOS 8] AVFoundationのAVAudioPlayerNodeで音楽ファイルを再生してみる

iOSアプリ開発 入門 (4) - AVAudioEngine

Introducing the AVAudioEngine Sound Engine on iOS

楽曲素材
ROYALTY FREE MUSIC by BENSOUND