24
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Life is Tech !Advent Calendar 2018

Day 14

[Swift4.2]蝶ネクタイ型変声期作ってみた

Last updated at Posted at 2018-12-14

#注意書き
某アニメのアイデアを参考にしていますが、、今回は残念ながらリアルタイム変換はしてません。マクドナルドのおもちゃ程度のものだと考えてください...
時間があったら、リアルタイム変換にも挑戦してみようかなと思います><

はじめに

はい、どうもこんにちは、最近毎日予定が詰まっているけど、クリスマスだけ予定が埋まりません。どうもTOSHです。

さて、ついにやってきましたこの時期が!そうです、年に一度のAdvent Calender!ずっと書きたいなと思っていましたが、実際に書くとなると何を書こうと悩むものなんです。
最近勉強している、Swiftにおける非同期処理とか、自作フレームワークの作り方とか、Testの書き方でも書こうかななんて考えてました。

###しかし、いや、それつまらなくね、ググればそんなの出てくるじゃん
と思ってしまいした。せっかくなので、技術を使って何か面白いことをやってみようと、思ったのです。

###そうだ、iPhoneでコナンの蝶ネクタイ型変声期でも作ってみるか
と思いました。うーん期限まで残り3日、時間がない笑!

環境

Xcode10.1
Swift 4.2
iOS12.1
iPhoneX(マイクを使用するので実機を用意しましょう、多分シミュレーターでもできるとは思うけど)

まずやること

  1. リアルタイムでの音声変換は音が混じってしまいそうなのでやめる(ここら辺は、入力はiPhoneで、出力はスピーカーとかにすればできなくもなさそうではあるけど)
  2. 音声の録音機能、再生機能(純正フレームワークのAVAudioPlayer使えばできそう)
  3. 録音した音声をボイスチェンジする必要がある(音声のデータの数値をいじればできるかな?)

と言う方針で進めていくことにしました。

設計について

基本的に、設計はMVCで作成しています。
ViewController.swiftには基本的にUIパーツの制御と、それに対する入出力を管理しています。
Audio.swiftで録音、ViewController.swiftから与えられた数値に対応してエフェクトをかけて再生。
のようにして、それぞれを管理させています。

作り方

1.マイクを使うのでinfo.plistPrivacy - Microphone Usage Descriptionを追加

スクリーンショット 2018-12-13 21.11.35.png
←これつけないとクラッシュするよ!

2.まずはボイスレコーダーを作る

音声を録音、再生する機能がメインなので、ボイスレコーダーを作ってみる。
これは結構記事がある。
と言っても、基本的には、大体の記事が全ての動作をViewControllerに書いてしまっているパターンなので、MVCで書こうと思うと少し変える必要がある。

普通にやろうとするとこんな感じ

viewController.swift
import UIKit
import AVFoundation

final class ViewController: UIViewController {
    
    var audioRecorder: AVAudioRecorder!
    var audioPlayer: AVAudioPlayer!
    //これらを使って、再生中、録音中かの判断をする
    var isRecording = false
    var isPlaying = false
    
    override func viewDidLoad() {
        super.viewDidLoad()

        //まずは録音できるようにAVAudioRecorderをセットアップします
        setUpAudioRecorder()
    }
    
    @IBAction func record(_ sender: Any) {
        //録音中でないならば、録音開始。録音中なら録音停止
        if !isRecording {
            isRecording = true
            audioRecorder.record()
        } else {
            isRecording = false
            audioRecorder.stop()
        }
    }
    
    @IBAction func play(_ sender: Any) {
        //再生中でないならば、再生開始。再生中なら再生停止
        if !isPlaying {
            isPlaying = true
            playSound()
        } else {
            isPlaying = false
            audioPlayer.stop()
        }
    }
}

extension ViewController: AVAudioRecorderDelegate {
    func setUpAudioRecorder() {
        let session = AVAudioSession.sharedInstance()
        
        do {
            try session.setCategory(.playAndRecord, mode: .default)
            try session.setActive(true)
            
            //ここで音源の設定をしている
            let settings = [
                AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
                AVSampleRateKey: 44100,
                AVNumberOfChannelsKey: 2,
                AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
            ]
            
           //保存先と設定をここで指定している
            audioRecorder = try AVAudioRecorder(url: getAudioFilrUrl(), settings: settings)
            audioRecorder.delegate = self
        } catch let error {
            print(error)
        }
    }
}

extension ViewController: AVAudioPlayerDelegate {
    func playSound() {
        //保存しているものの場所をとってくる
        let url = getAudioFilrUrl()
        
        do {
            //音をとってきて、audioPlayerに入れる
            let sound = try AVAudioPlayer(contentsOf: url)
            audioPlayer = sound
            
            audioPlayer.delegate = self
            audioPlayer.prepareToPlay()
            //再生
            audioPlayer.play()
        } catch let error {
            print(error)
        }
    }
}

extension ViewController {
    //ここで保存先をとってくる
    func getAudioFilrUrl() -> URL {
        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let docsDirect = paths[0]
        let audioUrl = docsDirect.appendingPathComponent("recording.m4a")
        
        return audioUrl
    }
}

viewDidLoad内でRecorderのセットアップをし、ボタンが押された時に、録音中かどうかをboolで判定し、音声ファイルを保存。再生する際には、同様にして音声ファイルをとってくる。いたってシンプルな形。
これをMVCにしようと思ったら。Audioファイルを作成し、録音と再生のメソッドは、そっちに移しましょう。

##詰まったこと1
ここでまず一つ詰まったことがあります。最初の構造では、録音中、再生中の判断となるboolを自分で定義してました。しかし、調べてみると、そもそもAVAudioRecorderクラスはisRecordingを、AVAudioPlayerクラスはisPlayerをそれぞれboolでもっていて、自分自身で録音中か再生中か判定できるみたい。せっかくだから、これを使いたい。。。
と言うことで、使う方針に。AVAudioRecorderの方は問題なかったものの、AVAudioPlayerの方でやると、クラッシュ。
lldbを使い、po audio.audioPlayer.isPlayingを見てみると、中身はnil。そもそも、audio.audioPlayerそのものもnil
###あれ、インスタンスが生成されとらん。
どうやら、インスタンスを生成する前に参照してたみたい。
じゃあ、停止のタイミングでAVAudioPlayerのインスタンスを生成してと思ったら、またクラッシュ。
ここもbreak pointを使い、デバックしてみると、
###おぎゃ、音声止める前に音声ファイルをセットしてた。。。
こりゃダメだ、と言うことで、順番を再確認し、無事MVCに変化させられましたー。それが以下のコードになりまぁす。
MVCにするとこんな感じ。

viewController.swift
import UIKit
import AVFoundation

final class ViewController: UIViewController {

    //Audioクラスのインスタンスを作成
    var audio = Audio()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        audio.setUpAudioRecorder()
    }
    
    @IBAction func record(_ sender: Any) {
        if !audio.audioRecorder.isRecording {
            audio.audioRecorder.record()
        } else {
            audio.audioRecorder.stop()
            audio.setUpAudioPlayer()
        }
    }
    
    @IBAction func play(_ sender: Any) {
        if !audio.audioPlayer.isPlaying {
            audio.playSound()
        } else {
            audio.audioPlayer.stop()
            //これをすると、再生ファイルが最初に戻る
            audio.audioPlayer.currentTime = 0
        }
    }
}
Audio.swift
import UIKit
import AVFoundation

final class Audio {
    //ここの部分は基本的にさっきのviewControllerに書いてあるものを抜粋している
        
    var audioRecorder: AVAudioRecorder!
    var audioPlayer: AVAudioPlayer!
    
    init() {}
    
    func setUpAudioRecorder() {
        let session = AVAudioSession.sharedInstance()
        
        do {
            try session.setCategory(.playAndRecord, mode: .default)
            try session.setActive(true)
            
            let settings = [
                AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
                AVSampleRateKey: 44100,
                AVNumberOfChannelsKey: 2,
                AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
            ]
            
            audioRecorder = try AVAudioRecorder(url: getAudioFilrUrl(), settings: settings)
            audioRecorder.delegate = self as? AVAudioRecorderDelegate
        } catch let error {
            print(error)
        }
    }
    
    func setUpAudioPlayer() {
        let url = getAudioFilrUrl()
        
        do {
            let sound = try AVAudioPlayer(contentsOf: url)
            audioPlayer = sound
            audioPlayer.delegate = self as? AVAudioPlayerDelegate
            audioPlayer.prepareToPlay()
        } catch let error {
            print(error)
        }
    }
    
    func playSound() {
        audioPlayer.play()
    }
    
    private func getAudioFilrUrl() -> URL {
        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let docsDirect = paths[0]
        let audioUrl = docsDirect.appendingPathComponent("recording.m4a")
        
        return audioUrl
    }
}

3.次に、UIをセットします

今回は時間がなかったので、とりま最低要件だけを抑えて、こんな形に!
とりあえず今回は、UISwitchUISliderを主に使用しました。
ちょっと、ボタンが流石にいけてなさ過ぎるから、変えたかったよね。。。
IMG_7127.PNG

##4.残るは、音声にエフェクトをかけるだけ!
UISliderのvalueをfloatでとってきて、UISWitchもboolでとってきて、それをパラメーターとして再生する際にエフェクトに加えればいいだろう。
###と思っていた自分がいた...

詰まったこと2

通常の、音楽を録音して、再生するだけだから、録音するときはAVAudioRecord使って、再生するときはAVAudioPlayerでいいだろうと思ってたところ。
###あれ、できない...
うーん、どうやら、AVAudioPlayerは、普通に音楽を再生するだけのクラスみたいだな。
どうやら、エフェクトを加えるためには、AVAudioEngineクラスを使わないといけないみたい。
そんで、さらに、AVAudioEngineクラスに、AVPlayerNodeをアタッチしてと...
意外とややこしいのね...

と言うことで、急遽、AVAudioEngineAVPlayerNodeをアタッチする方針に転換。
###しかし、ここからが闇。圧倒的に調べてもヒットしにくい
とりま、英語でSwift voice changerとかググって、それっぽいものが出てきたら、試してみると言った感じで実際に実装してみる。

とりま、AVAudioEngineを調べてみる。
A group of connected audio node objects used to generate and process audio signals and perform audio input and output.
らしいっす.
audio nodeってのをたくさんつけることで、エフェクトつけることができるみたい。
あ、あれoutputだけじゃなくて、inputもできるの?
もしかしたら、これ録音もこっちでやったほうがよかったかも、とか思いつつ、一旦今回は出力部分を実装。
時間があったら、入力部分にも挑戦してみる。

そもそも、AVAudioEngineには、
・AVAudioUnitEffect
・AVAudioUnitTimeEffect
の二つのエフェクトが存在しているようで、AVAudioEffectだと、リアルタイムで音声変換ができるようで、
 AVAudioUnitTimeEffectだとリアルタイム変換ができないみたいです。
というのも、後者では、再生速度等も変更できるためリアルタイム変換に向いていないみたい。
細かい使い方の説明は省きます。それは、ここのページからみてくださーい
AVAUdioEngineの使い方
実際の実装のイメージはこんな感じ

Audio.swift
import UIKit
import AVFoundation

final class Audio {
    
    var audioRecorder: AVAudioRecorder!
    var audioEngine: AVAudioEngine!
    var audioFile: AVAudioFile!
    var audioPlayerNode: AVAudioPlayerNode!
    var audioUnitTimePitch: AVAudioUnitTimePitch!
    
    init() {}
    
    func setUpAudioRecorder() {
        let session = AVAudioSession.sharedInstance()
        
        do {
            try session.setCategory(.playAndRecord, mode: .default)
            try session.setActive(true)
            
            let settings = [
                AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
                AVSampleRateKey: 44100,
                AVNumberOfChannelsKey: 2,
                AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
            ]
            
            audioRecorder = try AVAudioRecorder(url: getAudioFilrUrl(), settings: settings)
            audioRecorder.delegate = self as? AVAudioRecorderDelegate
        } catch let error {
            print(error)
        }
    }
    
    func playSound(speed: Float, pitch: Float, echo: Bool, reverb: Bool) {
        audioEngine = AVAudioEngine()
        
        let url = getAudioFilrUrl()
        
        do {
            //ファイルを指定
            audioFile = try AVAudioFile(forReading: url)
            
            //nodeを作成し、audioEngineにアタッチ
            audioPlayerNode = AVAudioPlayerNode()
            audioEngine.attach(audioPlayerNode)
            
            //これを使うと、スピードや音の高さを変更可能。ただし、リアルタイムで変更してくれない
            audioUnitTimePitch = AVAudioUnitTimePitch()
            audioUnitTimePitch.rate = speed
            audioUnitTimePitch.pitch = pitch
            //これもアタッチ
            audioEngine.attach(audioUnitTimePitch)
            
            //echoをかける
            let echoNode = AVAudioUnitDistortion()
            echoNode.loadFactoryPreset(.multiEcho1)
            audioEngine.attach(echoNode)
            
            //ここは先ほどと同様。エフェクトをかけている
            let reverbNode = AVAudioUnitReverb()
            reverbNode.loadFactoryPreset(.cathedral)
            reverbNode.wetDryMix = 50
            audioEngine.attach(reverbNode)
            
            //渡されたパラメーターによってどのエフェクトをかけるかを指定している
            if echo && reverb {
                connectAudioNodes(audioPlayerNode, audioUnitTimePitch, echoNode, reverbNode, audioEngine.outputNode)
            } else if echo {
                connectAudioNodes(audioPlayerNode, audioUnitTimePitch, echoNode, audioEngine.outputNode)
            } else if reverb {
                connectAudioNodes(audioPlayerNode, audioUnitTimePitch, reverbNode, audioEngine.outputNode)
            } else {
                connectAudioNodes(audioPlayerNode, audioUnitTimePitch, audioEngine.outputNode)
            }
            
            audioPlayerNode.stop()
            audioPlayerNode.scheduleFile(audioFile, at: nil)
            
            //これで再生
            try audioEngine.start()
            audioPlayerNode.play()
        } catch let error {
            print(error)
        }
    }
    
    private func connectAudioNodes(_ nodes: AVAudioNode...) {
        for x in 0..<nodes.count - 1 {
            audioEngine.connect(nodes[x], to: nodes[x+1], format: audioFile.processingFormat)
        }
    }
    
    private func getAudioFilrUrl() -> URL {
        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let docsDirect = paths[0]
        let audioUrl = docsDirect.appendingPathComponent("recording.m4a")
        
        return audioUrl
    }
}

細かい説明は無しにしてざっくりいうと、音声ファイルを取ってきて、ViewControllerからきたパラメータによって、エフェクトを加えて音源をセットしているという感じで見てみてください。

これで先ほどのviewControllerに変更を加えると、うまく動くようになります!
そこらへんの部分は、GitHubのソースコードの方を見てもらえると助かります。

##5.完成
それでやってみると、
変わった!、声が変化した!!!
ちょっと音声なので、あげられないけど笑
GitHubからcloneして実際に使ってみてください!

#意外に大変だったこと
ボイスチェンジャーという性質上、どうしても音を使います。
んっ?これって図書館とかカフェじゃデバックできないじゃん...
と言うことで、デバックするのは夜家に帰ってきたときのみ、まあ多分実装できているだろうと言う感じで日中は開発をしていました。
意外とデバックできないことって辛いのね。

#GitHub
ここのレポジトリにプロジェクトがあるので何かの参考にしてください。
ちょっと、時間なくてAutoLayoutかけてないっす笑
extension使ったりして自分で色加えたり、割ときちんとコードも書いてるのでよかったら参考にしてください
https://github.com/tosh7/Voice-ChangingBowtie

さて、明日は関西メンターのジャスティンがやってくれます!一度とあるインターンであったことがあるんですが、めちゃくちゃできるスーパーメンターでした〜!
でわっ!バァ〜い!

#参考にしたサイト
https://qiita.com/lovee/items/8bdf7b58d96d683d31b9
https://iosrevisited.blogspot.com/2017/11/voice-recorder-swift-4.html
http://asmz.hatenablog.jp/entry/2015/10/17/avaudioengine-avaudiouniteffect-tester-app
https://qiita.com/rinatooon/items/2233c23a0e8070d265ea

24
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?