Siriでアプリを操作しよう

こんにちは!Voicyでモバイルアプリエンジニアをしている@horitamonです。
11月にも入って段々寒くなってきましたね!
早速僕はスノボシーズンにインしてきました。

滑っている最中はグローブをつけている上に寒くて指がかじかんでしまうので、スマホ操作がなかなか大変です。
そんなとき、音声でアプリが操作できたらなあ…と思ってしまうのです。

ということで今回は、アプリの音声アシスタント経由でユーザーがアプリを操作できるようにする方法について試してみた結果を記していこうと思います!

どちらのOSでも共通して「自然言語処理」が楽に実装できる

GoogleアシスタントやSiriといった音声アシスタントをつかって、音楽を再生したり、タイマーを設定したりしたことがある方もいるかと思います。
スマホ標準のアプリしか操作したことがない方がほどんどかと思われますが、実は我々一般的な開発者がつくるアプリもGoogleアシスタントやSiriからの操作を実現することができます。

こうした音声アシスタントを経由して音声操作する最大の利点は、自然言語からユーザーが実現したい操作を正確に汲み取るという処理を実装しなくていい点にあります。

人が「〇〇をしたい!」と話すときの言い方は無限にありますね。
例えばタイマーを使いたいとき。
「タイマーを3分」と機械にわかりやすく言う人もいれば、「3分測って!」とシンプルに言う人もいます。
しかしこのどちらでもやりたいことは「3分」の「タイマーを開始すること」という、共通の操作です。

こういった「言い方は違うけどやりたいことは一緒」であることを判定したり、言葉の中からパラメーターにあたる数値(今回であれば3分と言う時間)を抜き出したりと、人が話す言葉からプログラムの判定に必要な情報を抜き出す部分を、GoogleアシスタントやSiriがうまいこと担ってくれるのです。

実現できる操作には制限あり

上で説明した「人の言葉からやりたいことをうまいこと汲み取る」機能はとっても便利なのですが、プラットフォームから提供される特定の操作にしか対応していません。
例えば「メモを取る」「支払いを行う」「注文する」「メッセージを送る」といった、ある程度定型の操作でのみ音声操作が実装できるようになっています。
そのため完全にアプリ独自の機能(独自プロダクトのデバイスを操作するなど)を実装することはできません。

また一般的な操作であっても、プラットフォームとして提供していないものも存在します。
例えば音楽や動画といったメディアを再生する操作は、Siriでは提供されているものの、Googleアシスタントでは提供されていません。
これは音声プラットフォームを開発する立場としては困った話です……

どんな操作が実現できるかは、音声アシスタントからアプリが受け取れるリクエスト(intent)の種類を見ればわかります。

Googleアシスタントのintent一覧 developer.android.com

Siriのintent一覧 developer.apple.com

とはいえ一般的に思いつく範囲の操作ケースは用意されているので(特にGoogleアシスタントはかなり多い)、上手に選べば自由な音声操作を実現できるでしょう。

Siriをつかった音声操作の具体的な実装方法

ということで今回はSiriを使った音声操作の実装方法について具体的に見ていきます。

AndroidGoogleアシスタントを使った方法については、実は以前LT会で発表したことがあるのでそちらを見てみてください!

speakerdeck.com

今回の動作確認ではXcode 13.2.1を使用しました。

アプリにSiriの権限を追加する

まずアプリにSiriからアクセスする権限を追加していきます。
今回テキトーにHoritamonAppというサンプルアプリを作ったので、その起動時に権限リクエストの表示をするように実装してみます。

HoritamonAppのInfo.plistにPrivacy - Siri Usage Descriptionというキーを追加します。

+ボタンからドロップダウンで選択できるようになっていました。

Valueに設定した値は、実際に表示されるリクエスト表示の中に追加で表示されます。(後述)

次にViewControlerのviewDidLoadで、Siriの権限リクエストを行います。

import UIKit
import Intents

final class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        INPreferences.requestSiriAuthorization { status in
            // ...
        }
    }
    
}

そしてアプリを起動すると…?

無事表示されました。

アプリをSiriに対応させる

次に自分のアプリをSiriが認識してくれるように設定していきます。
Intents ExtensionというTargetを追加することで、Siriの挙動を実装できるようにしていきます。

まずアプリのTARGETSSigning & CapabilitiesからSiriを追加しましょう。

追加した後の画面。「+ Capability」 から追加できます。

続いてSiriの諸々の処理を実装していくTargetを追加します。

File → New → Target からIntents Extensionを選び、

ProductNameを入れます。
HoritamonAppというアプリ名なので、そのままExtentionをつけてHoritamonAppExtentionとしています。
デフォルトでチェックが入っているInclude UI Extensionは今回使用しないので、チエックを外しました。

有効化するか聞かれるので、もちろん承諾。

するとHoritamonAppExtentionというTargetができました。
デフォルトでは「メッセージ送信」機能が実装されていますが、今回はサンプルコードをわかりやすくするために「メモ作成」機能に変更していきます。

HoritamonAppExtentionディレクトリ内のInfo.plistのNSExtension > NSExtensionAttributes > IntentsSupportedを展開し、すでに設定されている項目を削除した上でINCreateNoteIntentを追加します。

ここまででHoritamonAppExtentionの設定は完了しました。

Siriの挙動を実装する

AppExtentionの中身の実装に移ります。

先に書いた通りデフォルトではメッセージ機能の実装になっているので、メモ作成機能に書き換えていきます。
さっそくですが、書き換えたコード全体がこちらです。

import Intents

class IntentHandler: INExtension {

    override func handler(for intent: INIntent) -> Any {
        
        return self
    }
}

extension IntentHandler: INCreateNoteIntentHandling {

    func resolveTitle(for intent: INCreateNoteIntent, with completion: @escaping (INSpeakableStringResolutionResult) -> Void) {
        print(intent.title)
        if let title = intent.title {
            // タイトルがあれば成功とする
            completion(INSpeakableStringResolutionResult.success(with: title))
        } else {
            // 不足していたら質問を返す
            completion(INSpeakableStringResolutionResult.needsValue())
        }
    }
    
    func resolveContent(for intent: INCreateNoteIntent, with completion: @escaping (INNoteContentResolutionResult) -> Void) {
        print(intent.content)
        if let content = intent.content {
            // メモの内容があれば成功とする
            completion(INNoteContentResolutionResult.success(with: content))
        } else {
            // 不足していたら質問を返す
            completion(INNoteContentResolutionResult.needsValue())
        }
    }

    // intentの中身を取り出す
    func handle(intent: INCreateNoteIntent, completion: @escaping (INCreateNoteIntentResponse) -> Void) {
        let response = INCreateNoteIntentResponse(code: INCreateNoteIntentResponseCode.success, userActivity: nil)
        print(intent.title)
        print(intent.content)
        // 本来であればここでデータ保存を行うが、動作を確認するだけなので今回はintentの中身を表示する
        response.createdNote = INNote(title: intent.title!, contents: [intent.content!], groupName: nil, createdDateComponents: nil, modifiedDateComponents: nil, identifier: nil)
        completion(response)
    }

}
resolve

Siriに話しかけた内容が十分であるか判定し、問題があれば聞き返す処理を実装しています。
今回はシンプルに、タイトルや内容が無かった場合に聞き返すようになっています。
このメソッド自体はoptionalなので、実装しなければすぐに次のhandleに進みます。

今回はメモ作成機能のためタイトルと内容の精査のみですが、他のIntentであればそれぞれ必要な要素のresolveメソッドが用意されています。

handle

resolveがすべてsuccessに進んだ場合にhandleにたどり着きます。
ここでアプリにデータを保存したり、機能を起動したりする処理を書いていきます。
今回はアプリが受け取れる部分までの実装としたため、Noteを表示する処理のみ実装しました。

困ったこと

「HoritamonApp」というアプリ名はどうやっても認識してくれませんでした…笑
プロダクトを新たに作り始めるときはわかりやすい名前にしたほうがいいですね…
これはGoogleアシスタントの開発を試した時にも起きた問題でした。

アプリのInfo.plistのBundle display nameからアプリ名を変えるとSiriの認識名も変更できるので、動作確認で困ったら変えてみてください。
既にプロダクトが走っているときには…解決策を探してみましょう…
(ちなみに「ボイシー」は本当に音声認識してくれません…大体「おいしい」になる…)

おわりに

AndroidGoogleアシスタントにつづいてSiriKitも実装してみましたが、なかなか手軽に実装できそうです!
もうちょっと研究して、そのうちVoicyも音声操作が実装できたらなあ…なんて考えています。