この記事は NewsPicks Advent Calendar 2019 の13日目の記事です。
はじめに
いらっしゃいませ、NewsPicksでエンジニアをしているkohemonです。
突然ですが僕のワンダフルライフという映画をご存知でしょうか?
最近では続編の僕のワンダフル・ジャーニーも有名でした。
犬と飼い主の絆の物語なのですが、これがまあ泣ける映画で試写会では**満足度100%**という驚異的な数値を叩き出しました。
劇中では犬の健気な飼い主を愛する気持ちがセリフとなり、伝わってきます。
犬好きな自分はよりこう思うわけです、犬飼いたいと。犬と話したいと。
しかし、私が住む築20年のアパートはそんなことを許してくれるはずがありません。だったらARで犬を表示して、さらに会話をできるようにしましょう。
要件
ARで犬を表示する
→ARKitでいけそう
音声入力で犬に話しかける
→SFSpeechRecognizerでいけそう
AIで返事をする
→chat系のAPIがあればできそう
その返事を読み上げる
→AVSpeechSynthesizerでいけそう
あれ、これ1日でいける!?
ということで早速実装していきます。
ARで犬を表示する
まず3Dデータを準備します。.obj
か.dae
のファイルをXcodeで.scn
に変えてあげる必要があります。
Free 3D
こちらのサイトにフリーのいい感じの3Dファイルがたくさんあるので好きなものを選びましょう。今回はもちろん犬です。
準備ができたらNew File
からSceneKit Catalog
を選択します。
この中にさきほどダウンロードしてきた.obj
ファイルたちを入れてあげます。
そうしたらobj
にカーソルを合わせてEditor
からConvert to SceneKit file format
を選択して.scn
ファイルに変換しましょう。
すると.scn
ファイルが作られます。中身をみていくと
とんでもない向きになってますね。可愛いうちのわんちゃんを地面にめり込ませるわけにはいかないので向きを変更しましょう。
3Dのオブジェクトを選択してNode Inspector
のtransform
で向きを調整します。
うん、可愛い。名前付けたい
ニンテンドッグスとかにはまっちゃう私としてはたまりません。
早速このわんちゃんをARで表示していきます。
ARを表示するためのARSCNView
をviewにおき、シーンのセットとデバッグ情報を表示できるようにします。
@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を設定します。今回は水平な面にわんちゃんをおきたいので水平を検知する設定をいれます。
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = .horizontal
sceneView.session.run(configuration)
}
これで水平な面が検知されたらdelegateで受け取ることができるのでそこでわんちゃんを置いていきましょう。
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)
})
}
}
かんわぃぃぃぃ
ペット禁止の我が家についにわんちゃんが現れました。
可愛すぎてさわれないのが惜しいですがこのままやっていきましょう。
次はこのわんちゃんに話しかけられるようにします。
音声入力で犬に話しかけたい
次に音声認識で声から文字列を取得していきます。
SFSpeechRecognizerを使用します。
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()
}
これで音声入力で文字列を取得できるようになります。
次はここで取得したテキストをChatのAPIに投げていきましょう。
AIで返事をする
今回はユーザーローカルさんのSupportChatbotのAPIを使っていきましょう。
個人利用の場合は申請フォームから申し込むだけですぐにAPIのKeyが発行され、それと元に叩くだけで利用できます。
早速サンプルで投げてみましょう。
https://chatbot-api.userlocal.jp/api/chat?message=こんにちは&key=APIKey
レスポンスがこちら
{
"status": "success",
"result": "なーにー?"
}
めちゃくちゃ簡単。今回はこのmessageに音声認識で取得した文字列をセットして投げていきます。
struct ChatStruct: Codable {
var status: String
var result: String
}
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を叩くようにして、結果を出力してみましょう。
@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を使用して文字列を読み上げてもらいます。
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.")
}
}
これで一通りの機能は完成したので早速試してみましょう。
声が機械的すぎてあれだけどちゃんと慰めてくれてますね。Qiitaに動画載せられなかったので音声は伝わらないのですが。。
結果
これで僕にも友達ができました。僕のワンダフルライフはここから始まります。
gitにもあげたので全体のソースはそこで確認してください。
明日は弊社のAndroidの神こと@kozmatsの記事です、お楽しみに!!
参考
ARKitで簡単ARやってみた
【ARKit】今日からはじめる AR プログラミング Part.1「現実空間をトラッキングする」
Swiftでリアルタイム音声認識するための最小コード
【Speech Framework】音声認識してテキストを入力する
[Swift] AVSpeechSynthesizerで読み上げ機能を使ってみる
上記とてもわかりやすくて参考にしました、ありがとうございますm(_ _)m