僕のワンダフルライフ　〜ARのAI犬が友達〜

この記事は [NewsPicks Advent Calendar 2019](https://qiita.com/advent-calendar/2019/newspicks) の13日目の記事です。

## はじめに

いらっしゃいませ、NewsPicksでエンジニアをしている[kohemon](https://github.com/kohemon)です。

突然ですが僕のワンダフルライフという映画をご存知でしょうか？
最近では続編の僕のワンダフル・ジャーニーも有名でした。
犬と飼い主の絆の物語なのですが、これがまあ泣ける映画で試写会では**満足度100%**という驚異的な数値を叩き出しました。
劇中では犬の健気な飼い主を愛する気持ちがセリフとなり、伝わってきます。

犬好きな自分はよりこう思うわけです、**犬飼いたい**と。**犬と話したい**と。
しかし、私が住む築20年のアパートはそんなことを許してくれるはずがありません。だったらARで犬を表示して、さらに会話をできるようにしましょう。

## 要件

ARで犬を表示する
→[ARKit](https://developer.apple.com/jp/augmented-reality/arkit/)でいけそう

音声入力で犬に話しかける
→[SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer)でいけそう

AIで返事をする
→chat系のAPIがあればできそう

その返事を読み上げる
→[AVSpeechSynthesizer](https://developer.apple.com/documentation/avfoundation/avspeechsynthesizer)でいけそう

あれ、これ１日でいける！？

ということで早速実装していきます。

## ARで犬を表示する

まず3Dデータを準備します。`.obj`か`.dae`のファイルをXcodeで`.scn`に変えてあげる必要があります。

[Free 3D](https://free3d.com/)
こちらのサイトにフリーのいい感じの3Dファイルがたくさんあるので好きなものを選びましょう。今回はもちろん犬です。

準備ができたら`New File`から`SceneKit Catalog`を選択します。
<img width="729" alt="スクリーンショット 2019-12-12 10.43.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/a54e9bbe-0634-60ef-d40a-5e21e5e17a66.png">

この中にさきほどダウンロードしてきた`.obj`ファイルたちを入れてあげます。
<img width="221" alt="スクリーンショット 2019-12-12 10.43.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/689dfa1c-46b6-4a87-dbe3-ada26941f8c2.png">

そうしたら`obj`にカーソルを合わせて`Editor`から`Convert to SceneKit file format`を選択して`.scn`ファイルに変換しましょう。
<img width="285" alt="スクリーンショット 2019-12-12 10.47.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/482cc81a-4afc-ebec-b82e-b4341d16fa98.png">

すると`.scn`ファイルが作られます。中身をみていくと
<img width="1151" alt="スクリーンショット 2019-12-12 10.48.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/7733aa9d-4dbb-4c9a-9598-1a77efca5b9d.png">
とんでもない向きになってますね。可愛いうちのわんちゃんを地面にめり込ませるわけにはいかないので向きを変更しましょう。

3Dのオブジェクトを選択して`Node Inspector`の`transform`で向きを調整します。
<img width="1410" alt="スクリーンショット 2019-12-12 10.50.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/4e610b19-8039-2a42-da2a-5a85efb2df58.png">

**うん、可愛い。名前付けたい**
<img width="270" alt="スクリーンショット 2019-12-12 10.51.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/40dd00b6-bfed-1bfd-2ff3-2354e43ad063.png">
ニンテンドッグスとかにはまっちゃう私としてはたまりません。
早速このわんちゃんをARで表示していきます。

ARを表示するための`ARSCNView`をviewにおき、シーンのセットとデバッグ情報を表示できるようにします。

```ViewController.swift

    @IBOutlet weak var sceneView: ARSCNView!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let scene = SCNScene()
        sceneView.scene = scene
        sceneView.delegate = self
        sceneView.showsStatistics = true
        sceneView.debugOptions = ARSCNDebugOptions.showFeaturePoints
    }
```
次にviewが表示されたらconfigurationを設定します。今回は水平な面にわんちゃんをおきたいので水平を検知する設定をいれます。

```ViewController.swift
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = .horizontal
        sceneView.session.run(configuration)
    }
```

これで水平な面が検知されたらdelegateで受け取ることができるのでそこでわんちゃんを置いていきましょう。

```ViewController.swift
extension ViewController: ARSCNViewDelegate {
    func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor else {
            fatalError()
        }
        guard let scene = SCNScene(named: "dog.scn", inDirectory: "art.scnassets/dog") else {
            fatalError()
        }
        guard let catNode = scene.rootNode.childNode(withName: "Dog", recursively: true) else {
            fatalError()
        }
        
        let magnification = 0.005
        catNode.scale = SCNVector3(magnification, magnification, magnification)
        catNode.position = SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)

        DispatchQueue.main.async(execute: {
            node.addChildNode(catNode)
        })

    }
}

```

![IMG_0340.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/a08c7694-af04-1675-66fc-47b2951b4a3d.jpeg)



**かんわぃぃぃぃ**
ペット禁止の我が家についにわんちゃんが現れました。
可愛すぎてさわれないのが惜しいですがこのままやっていきましょう。
次はこのわんちゃんに話しかけられるようにします。

## 音声入力で犬に話しかけたい
次に音声認識で声から文字列を取得していきます。
[SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer)を使用します。

```ViewController.swift
    private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ja-JP"))!
    private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
    private var recognitionTask: SFSpeechRecognitionTask?
    private let audioEngine = AVAudioEngine()

    override func viewDidLoad() {
        super.viewDidLoad()
                
        speechRecognizer.delegate = self
    }

    @IBAction func tapeedRecording(_ sender: UILongPressGestureRecognizer) {
        switch sender.state{
        case .began:
            try! start()
            recordingLabel.text = "認識中..."
            isRecording = true
        case .ended:
            recordImageView.isUserInteractionEnabled = false
            audioEngine.stop()
            recognitionRequest?.endAudio()
            audioEngine.inputNode.removeTap(onBus: 0)
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.recordImageView.isUserInteractionEnabled = true
            }
        default:
            break
        }
    }

    private func start() throws {
        guard !isRecording else {
            return
        }
        if let recognitionTask = recognitionTask {
            recognitionTask.cancel()
            self.recognitionTask = nil
        }

        let audioSession = AVAudioSession.sharedInstance()
        try audioSession.setCategory(.record, mode: .measurement, options: [])
        try audioSession.setActive(true, options: .notifyOthersOnDeactivation)

        let recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
        self.recognitionRequest = recognitionRequest
        recognitionRequest.shouldReportPartialResults = true
        recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] (result, error) in
            guard let `self` = self else {
                return
            }
            guard self.isRecording else {
                return
            }
            var isFinal = false
            if let result = result {
                isFinal = result.isFinal
                self.recordingLabel.text = result.bestTranscription.formattedString
            }

            if error != nil || isFinal {
                self.audioEngine.stop()
                self.audioEngine.inputNode.removeTap(onBus: 0)
                self.recognitionRequest = nil
                self.recognitionTask = nil
            }
        }

        let recordingFormat = audioEngine.inputNode.outputFormat(forBus: 0)
        audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in
            self.recognitionRequest?.append(buffer)
        }

        audioEngine.prepare()
        try? audioEngine.start()
    }
```

これで音声入力で文字列を取得できるようになります。

![IMG_0349.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/752c82e0-65fc-228b-35ae-672c5ead5aa4.jpeg)

![IMG_0350.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/b0f20883-51e2-5bbf-de2e-cc8b2a244e8c.jpeg)




次はここで取得したテキストをChatのAPIに投げていきましょう。

## AIで返事をする

今回はユーザーローカルさんの[SupportChatbot](https://ai.userlocal.jp/document/free/top/)のAPIを使っていきましょう。
個人利用の場合は申請フォームから申し込むだけですぐにAPIのKeyが発行され、それと元に叩くだけで利用できます。
早速サンプルで投げてみましょう。

```
https://chatbot-api.userlocal.jp/api/chat?message=こんにちは&key=APIKey
```

レスポンスがこちら

```
{
    "status": "success",
    "result": "なーにー？"
}
```

めちゃくちゃ簡単。今回はこのmessageに音声認識で取得した文字列をセットして投げていきます。

```ChatStruct.swift
struct ChatStruct: Codable {
    var status: String
    var result: String
}
```

```ChatApi.swift
class ChatApi {
    static func getMessage(message: String, completion: @escaping (ChatStruct) -> Swift.Void) {
        let url =  URL(string: "https://chatbot-api.userlocal.jp/api/chat?message=\(message)&key=your_api_key".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)
        let task = URLSession.shared.dataTask(with: url!) { data, response, error in
            guard let jsonData = data else {
                return
            }
            do {
                let chat = try JSONDecoder().decode(ChatStruct.self, from: jsonData)
                completion(chat)
            } catch {
                print(error.localizedDescription)
            }
        }
        task.resume()
    }
}

```

こんな感じでStructとModelを作成して音声認識が終わったらAPIを叩くようにして、結果を出力してみましょう。

```ViewController.swift
    @IBAction func tapeedRecording(_ sender: UILongPressGestureRecognizer) {
        switch sender.state{
        case .began:
            try! start()
            recordingLabel.text = "認識中..."
            isRecording = true
        case .ended:
            recordImageView.isUserInteractionEnabled = false
            audioEngine.stop()
            recognitionRequest?.endAudio()
            audioEngine.inputNode.removeTap(onBus: 0)
            loadingView.isHidden = false
            // 追加
            ChatApi.getMessage(message: inputMessage) { [unowned self] (chat) in
                self.chat = chat
                DispatchQueue.main.async {
                    self.loadingView.isHidden = true
                    self.recordingLabel.text = nil
                    // 出力してみる
                    print("result:", chat.result)
                }
                self.isRecording = false
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.recordImageView.isUserInteractionEnabled = true
            }
        default:
            break
        }
    }
```

`こんにちは`と話しかけた結果がこちら

```
result: すごーっ
```

絶妙に会話にはなっていないですがまあいいでしょう。最後にこの結果を読み上げてもらいましょう。

## その返事を読み上げる

[AVSpeechSynthesizer](https://developer.apple.com/documentation/avfoundation/avspeechsynthesizer)を使用して文字列を読み上げてもらいます。

```ViewController.swift
    private let speechSynthesizer = AVSpeechSynthesizer()

    private func speak(message: String) {
        defer {
            disableAVSession()
        }
        do {
            try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker)
            try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
        } catch {
            print("audioSession properties weren't set because of an error.")
        }
        let utterance = AVSpeechUtterance(string: message)
        utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")
        utterance.pitchMultiplier = 1
        self.speechSynthesizer.speak(utterance)
    }
    
    private func disableAVSession() {
        do {
            try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        } catch {
            print("audioSession properties weren't disable.")
        }
    }
```

これで一通りの機能は完成したので早速試してみましょう。

![hachi.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97817/7b48f4ee-0d2d-f2f9-e4a8-c7dd52737c3e.gif)

声が機械的すぎてあれだけどちゃんと慰めてくれてますね。Qiitaに動画載せられなかったので音声は伝わらないのですが。。

## 結果
これで僕にも友達ができました。僕のワンダフルライフはここから始まります。
gitにもあげたので全体のソースはそこで確認してください。

https://github.com/kohei1218/LoveMinus

明日は弊社のAndroidの神こと[@kozmats](https://qiita.com/kozmats)の記事です、お楽しみに！！


## 参考
[ARKitで簡単ARやってみた](https://qiita.com/pontake-dev/items/daf4a27cccc222f9ae9d)
[【ARKit】今日からはじめる AR プログラミング Part.1「現実空間をトラッキングする」](https://qiita.com/chino_tweet/items/cb14518f3c91d3f66c04)
[Swiftでリアルタイム音声認識するための最小コード](https://qiita.com/mishimay/items/71304f0aa2a313ad93ac)
[【Speech Framework】音声認識してテキストを入力する](https://qiita.com/chino_tweet/items/027c432cfb983f95679a)
[[Swift] AVSpeechSynthesizerで読み上げ機能を使ってみる](https://dev.classmethod.jp/smartphone/iphone/swfit-avspeechsynthesizer/)

上記とてもわかりやすくて参考にしました、ありがとうございますm(_ _)m