iOS
iOSDC
AVSpeechSynthesizer
PDFKit
iOSDC2018

全部iOSにしゃべらせちゃうLTを支えた技術

平成最後のiOSDC、その最後のセッションである最終日のLT、その最初の発表で全部iOSにしゃべらせちゃったひろんです。こんにちは。
当日のLTでは「会場を温めるのが自分の役目だ」と考え、ぼく自身が半分くらい発表のジャマをしていたので、みなさんの頭には何も入らなかったんではないかと思いますが、あれ(発表くん1号)を実現するために、色々と調べたことがあるので技術情報を共有したいと思います。

ソースコードはGitHubで公開しています

しゃべらせる + スライドをめくる

strategy.png

今回のLTを実現する中心的な部分がこちらです。
LTでも作戦について 話しました 話させましたが、PDF形式で用意したスライドをPDFViewに表示しておき、シナリオに沿ってAVSpeechSynthesizerにしゃべらせたり、PDFViewのページを切り替えたりしています。
このシナリオというのは単なるJSONファイルです。とにかくLTさえできれば問題ないので、スライドやシナリオの作成は別のツール(Keynote、VSCode)を使うことにしました。

シナリオのJSONファイルは次のような形式になっています。

{
  "language": "ja-JP",
  "rate": 0.5,
  "pitch": 1.0,
  "volume": 0.8,
  "postDelay": 0.5,
  "actions": [
    {
      "type": "changeSlidePage",
      "page": 0
    },
    {
      "type": "pause"
    },
    {
      "type": "speak",
      "text": "それでは発表します。"
    },
    {
      "type": "changeSlidePage",
      "page": "next"
    },
    {
      "type": "speak",
      "text": "まず、シーエフピーに応募したプロポーザルがこちらです。"
    },
    {
      "type": "speak",
      "pitch": 1.6,
      "postDelay": 0,
      "text": "いっけなーい。トークトーク。私ひろん。"
    }
  ],
  "presets" : [
    {
      "text" : "準備できましたぁ"
    }
  ]
}

トップにある languageratepitchvolumepreDelaypostDelay は、AVSpeechSynthesizerにしゃべらせるためのパラメータのデフォルト値で、 actions の配列に入っているのが実際のシナリオです。 presets は一時停止中(後述します)にシナリオと無関係に単発でしゃべらせるもので、とりあえず4つ登録できるようにしています。
これらを、Codableに準拠させたモデルにJSONDecoderを使ってデコードしています。

シナリオの個々の項目には「スライドのページを変える」、「話す」、「一時停止する」、「指定した時間待つ」の4種類があるのでそれぞれに分けて説明します。

スライドのページを変える

ページ番号で指定するか、「次のページ」、「前のページ」を指定してページを変えます。もともとページ番号指定しか作ってなかったのですが、実際にシナリオを作っていると途中で足りないスライドを挟みたくなりました。ページ番号で絶対指定していると挟んだスライド以降のすべてをずらす必要が出てくるので、これは面倒だと気づき、前後のページへの相対指定もできるようにしました。

なお、LT内でも触れましたが、ページを変えるのは簡単で、PDFViewの go(to:) を呼べばよく、ついでにこのタイミングでページの大きさに合わせて拡大率を調整しています(といってもちょうどいい拡大率が scaleFactorForSizeToFit で取得できるので、これを scaleFactor にセットするだけです。コードで書けばこうです)

if let pdfPage = slide.page(at: params.page) {
    slideView.go(to: pdfPage)
    slideView.scaleFactor = slideView.scaleFactorForSizeToFit
}

話す

話すのには、 languagerate などのパラメーターとともに、話す内容を text で指定させます。なお、AVSpeechSynthesizerでは読みをカスタマイズすることもできるようになっていますが、今回のLTではそこまでしなくてもよかったので対応しませんでした。読みのカスタマイズについては、 @shu223 さんが既にまとめてくださっています。 

これらのパラメーターでAVSpeechUtteranceを作って、AVSpeechSynthesizerに渡すと話してくれます。(実際のコード

let utterance = AVSpeechUtterance(string: params.text)
utterance.voice = AVSpeechSynthesisVoice(language: params.language)
utterance.pitchMultiplier = params.pitch
utterance.rate = params.rate
utterance.volume = params.volume
utterance.preUtteranceDelay = params.preDelay

askToSpeakCompletion = completion
speechSynthesizer.speak(utterance)

話し終わると、delegateに通知がやってくるので、そこで次のシナリオアクションに移します。(実際のコード

public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
    if let completion = askToSpeakCompletion {
        askToSpeakCompletion = nil
        completion()
    }
}

実際には、通知の中から次のアクションを実行していくのはちょっとためらって、通知を抜けてから実行するようにしています

一時停止する

画面をタップするまでシナリオを停止するアクションです。タイトルのスライドを表示した状態で実際の発表を始めるタイミングを待つのと、最後のスライドをいつまでも表示するために使っています。さらに、一時停止している間に、プリセットとして登録した単発のしゃべりができるようにして、「準備できましたぁ」を言わせています。
なぜ、一時停止している間だけなのかというと、そもそもしゃべっている途中に同時にしゃべらせることはできないし、間に割り込ませるのはシナリオ進行の状態管理が複雑になるからという、一種の手抜きです。目的が果たせれればそれでよかったので。それでも、後から継ぎ足しで作ったので、状態管理はちょっと複雑になっています。

指定した時間待つ

ちょっとした「溜め」を作るのと、笑いが起こるだろうなあと思われるところで少し待ったり、最後に言い残したことをぼくが話すのを待つのに使っています。実際にはマイクがなくて言い残したことは話せませんでしたが(めちゃくちゃおいしかった :joy:)。
しかし、予想外のところで笑いが起こったり、思った以上に笑いが長かったり、拍手があったりと、みなさんの反応と次のセリフが被ってしまって聞き取りづらい点がありましたね。ごめんなさい。今後の課題です(いや、今後はこんな発表しないだろ…)。

時間を待つのは、単純にDispatchQueueの asyncAfter実現しています。DispatchWorkItemを使っているのは途中で発表を終了させるときにキャンセルするため。

let workItem = DispatchWorkItem { [weak self] in
    guard let me = self else { return }
    me.waitingWorkItem = nil
    me.performNextAction()
}
waitingWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: workItem)

ドキュメントベースのアプリ

ゲームを除けば、現在の一般的なiOSアプリはWeb上のサービスを使いやすくした専用のフロントエンドが多いと思いますが、PagesやKeynoteのように、いわゆる「書類」を編集するアプリというものも存在します。雑に言えば、パソコンによくあるようなアプリです。iPhoneよりもiPadに向いてるアプリかもしれません。

実はiCloudが登場したiOS 5から、このようなアプリ作りを支援するAPIとしてUIDocumentクラスが存在していました。独自形式のドキュメントをUIDocumentクラスを継承したクラスで表すようにすれば、最低限、 contents(forType:) をオーバーライドしてドキュメントの書き出し処理を実装し、 load(fromContents:ofType:) をオーバーライドして読み込み処理を実装するだけで保存タイミングやiCloud同期の面倒を見てくれるというスグレモノです。

さらに、iOS 8からは、Document Provider Extensionと、それを呼び出すUIDocumentPickerViewControllerによって、ドキュメントの保存先をiCloudだけでなく、DropboxやGoogleドライブのような他のストレージサービスにすることができるようになりました。

このストレージサービスを直接ユーザーが見える形にしてくれたのが、iOS 11で登場した「ファイル」アプリで、その「ファイル」アプリのようなドキュメント管理UIをアプリに提供してくれるのがUIDocumentBrowserViewControllerです。

今回の「発表くん1号」では、Keynoteっぽいアプリにして、複数の発表資料を扱えるようにしたかったので、UIDocumentBrowserViewControllerとUIDocumentを利用しています。Xcodeでプロジェクトを作成するときに、「Document Based App」テンプレートで作ればそうなります。

DocumentBased.png

といっても、最低限の実装しかしていません。例えば、iCloud同期でコンフリクトが発生したときの挙動などを全く実装していません。どうなるんだろ :stuck_out_tongue_winking_eye:

なお、その独自ドキュメントの形式ですが、NSFileWrapperを使うとファイルパッケージが簡単に利用できます。ファイルパッケージは、実体はディレクトリだけど単一ファイルとして扱われるものです。身近なところでは、.xcodeprojファイルがファイルパッケージですね。
「発表くん1号」は、スライドのPDFと、シナリオのJSONファイルを格納すればいいだけなので、ファイルパッケージで実装し始めたのですが、これはDropboxやGoogleドライブといったサードパーティのストレージと相性が悪いことがわかりました。アップロードに失敗します。まあ、実体がフォルダなのにファイルとして扱うっていう点に無理がありますよね。
ということで、最終的にはファイルパッケージをやめて、PDFとJSONを格納したzipファイルになりました。

外部ディスプレイ対応

本物のKeynoteって、発表中、プロジェクターからはスライドを写して、手元のMacにはカンペを表示することができますね。
「発表くん1号」は、勝手にしゃべってくれるのでカンペの必要はないのですが、iOSアプリはそのままではミラーリングにしかなりません。
ミラーリングでは、外部ディスプレイ(プロジェクター)の標準解像度で表示できません。特にiPadは4:3、会場のプロジェクターは16:9というのもあって、外部ディスプレイ対応を行いました。

そんなに難しいものではありません。UIScreenクラスの screens が返す配列の要素が2つあれば、2つ目が外部ディスプレイのスクリーンです。
新しくUIWindowオブジェクトを作って、その screen プロパティに、2つ目のスクリーンを入れることで、ミラーリングではない別のものを表示することができます。「発表くん1号」は発表の再生時にだけ、外部ディスプレイのスクリーンを使うようにしています

if UIScreen.screens.count > 1 {
    let secondScreen = UIScreen.screens[1]
    let externalWindow = UIWindow(frame: secondScreen.bounds)
    externalWindow.screen = secondScreen
    externalWindow.rootViewController = ...
}

あと、外部ディスプレイが接続されたときと、切断されたときに、それぞれ .UIScreenDidConnect.UIScreenDidDisconnect の通知がNotificationCenterにやってくるので、そこでも対応が必要です。「発表くん1号」は単純に発表を中止するようにしています。手抜きともいう。

その他

発表中にスクリーンがロックされるのを防ぐ

発表してもらっている途中でスクリーンロックがかかると困るので、発表の再生中はスクリーンロックされないようにしています。

UIApplication.shared.isIdleTimerDisabled = true

発表の再生を抜けるときに false に戻しています。

外部ディスプレイの特別対応をしない設定

外部ディスプレイに対応したものの、実際に会場のプロジェクターに接続するのは現地に行ってからとなるので、もしそこで表示が乱れるとか何も映らなくなるようなことがあると危険です。ということで、現地で接続確認したときにうまくいかなければ、外部ディスプレイの特別対応をやめて、iOSのミラーリングに任せようと思っていました。

最近は、ライセンス情報の表示にしか使われていないような気がしますが、Settings.bundleに設定UIを定義しておけば、「設定」アプリに設定を表示してくれるようになります。その設定はUserDefaultsに反映されます。

Setting.jpg

UserDefaultsのこの設定値を見て、外部ディスプレイの対応をするかどうかを判断させるようにしていましたが、幸い、このスイッチを変更する必要はありませんでした :slight_smile:

OS標準のエッジスワイプを一度だけキャンセル

外部ディスプレイを使わないときは、再生状態を終わるために、画面下部からエッジスワイプさせて操作パネルを出すようにしています。
しかし、画面下部からのスワイプはOSのコントロールセンター表示に使われますね。iOS 11からは、UIViewControllerの preferredScreenEdgesDeferringSystemGestures() をオーバーライドすれば、その方向からのスワイプジェスチャーでOS標準の動作を一度だけキャンセルさせられます。

public override func preferredScreenEdgesDeferringSystemGestures() -> UIRectEdge {
    return .bottom
}

例えば画面下部からのエッジスワイプなら、一度目はペロっとした引き出しつまみ?が表示され、そのつまみをさらにスワイプすると標準のコントロールセンターが出てきます。

とまあ、物知りげに書いてますが、ぼくは一度書いたコードをすぐに忘れてしまうので、 @henteko さんの発表のときに、これを使えば問題解決できるかも!と気づいたのに、それがどういうやり方をするものだったか思い出せず、答えられませんでした(@kishikawakatsumi さんが答えてくれてたのでよかった)。ここに書いたので、今度からはここを見れば思い出せる…はず。

おまけ

参考にした資料へのリンクを ここ (https://gist.github.com/hironytic/eb4174f4a44f04dddc7e066a3bae7cb7) にまとめてあります。

また、上記リンク集の最後に、スライド(SpeakerDeckへのリンク)とシナリオ(gistへのリンク)があります。
その一つ手前に「発表くん1号」を公開しているGitHubリポジトリへのリンクもあります。

つまり、SpeakerDeckからPDFを、gistからjsonをダウンロードしておいて、GitHubリポジトリをチェックアウトしてRunすれば、手元でもしゃべらせることができるわけですね。アプリ起動後のその手順を簡単に説明しておきます。

まず、新規の書類を作成します。
step1.png

編集画面に変わるので、右から二番目の矢印が入ってくる方向のインポートアイコンをタップします。
step2.png

まずはスライドをインポート(ダウンロードしたPDFファイルをどこかのストレージに入れておいてそれを選択)、続いて、インポートアイコンを再びタップし、シナリオをインポート(ダウンロードしたJSONファイルを以下略)

step3.png

いかにも再生しそうな右向き三角アイコンをタップすると再生が始まります。一時停止状態で始まっているので、一度画面をタップしてあげると話し始めます。
step4.png

再生を終了するときは、画面の下から上方向へエッジスワイプをしてください。こんな感じの操作パネルが現れるので、左上の四角アイコンをタップして再生の終了です。
step5.png