LoginSignup
33
30

More than 5 years have passed since last update.

音声読み上げ機能簡易サンプルと実装ポイントまとめ(※注記あり)

Last updated at Posted at 2015-12-27

音声読み上げ機能を試してみたかったので作成してみました

某所で行われたハッカソンの中で自分が思いついたアイデアとして、これからの時期はインフルエンザや風邪にかかってしまい、高熱や喉が痛い中で声に出してしゃべるのは辛いだろうなと思い立ったので、入力したテキスト文字列や登録をした定型文を音声読み上げしてくれる簡易的なサンプルを作成しましたので、簡単ではありますが共有できればと思います。

■ Github Sample Code:
定型文や入力テキストを音声読み上げをするサンプル ※今はシミュレーターOnly

こちらのサンプルはAppleWatch(Watch OS2)のWatchConnectivityのInteractive Messagingを利用してAppleWatch側でも定型文の音声読み上げができるようになっています。(ただしWatch側のボタンを押してからのタイムラグ&たまに動作しない場合がありますが...)

■ Screen Capture:
sample.jpg

このサンプルアプリのおおまかな機能とポイントになる部分は、

  • Realmを用いての定型文の登録・音声読み上げ履歴の保存
  • AVSpeechSynthesizerによる音声読み上げ機能
  • UIAlertControllerにテキストボックスをつける
  • WatchOS2でのWKInterfaceTableの表示方法
  • WatchConnectivityのInteractive Messagingを用いてのAppleWatch経由での定型文の音声読み上げ

の5つになるかと思います。今回は上記の部分に関しての解説を行っていきます。

■ 注意:

こちらのサンプルですが、XCode7でビルドした場合には、シミュレーターでは正常に音声が流れるのですが、実機では音声が流れませんでした...(こちらXCode7系のバグなのか、それとも自分の実装がおかしいかの切り分けが困難なので追って調査中です。)

FacebookのSwift開発者のグループにも質問させていただきましたところ下記のような質問自体は上がっているみたいなのですが、こちらの質問についてもまだ解決策がなく完全にResolvedの状態です。

・実際に出るエラーログ

2015-12-26 17:45:04.276 enryo_message[438:63495] |AXSpeechAssetDownloader|error| ASAssetQuery error fetching results (for com.apple.MobileAsset.MacinTalkVoiceAssets) Error Domain=ASError Code=21 "Unable to copy asset information" UserInfo={NSDescription=Unable to copy asset information}

・StackOverFlowでのQuestion

もし「同様の現象に現在出くわしている」ないしは「この現象に関しての解決策を知っている」方がいらっしゃれば当コメント欄等への記載をいただけますと嬉しく思いますm(_ _)m

1. Realmを用いての定型文の登録・音声読み上げ履歴の保存

Realm自体の導入に関しましては、下記にも手順&参考にした資料をまとめてありますので、その部分をご参考にしていただければと思います。

今回作成するテーブルに関しましては、

  • VoiceFormat.swift (定型文登録用)
  • VoiceHistory.swift (音声出力履歴登録用)

になります。

↓下記はVoiceFormat.swift (定型文登録用)のファイル

VoiceFormat.swift
import UIKit

//Realmクラスのインポート
import RealmSwift

class VoiceFormat: Object {

    //Realmクラスのインスタンス
    static let realm = try! Realm()

    //id
    dynamic private var id = 0

    //音声にしたいデータ
    dynamic var formatText = ""

    //PrimaryKeyの設定
    override static func primaryKey() -> String? {
        return "id"
    }

    //新規追加用のインスタンス生成メソッド
    static func create() -> VoiceFormat {
        let voice = VoiceFormat()
        voice.id = self.getLastId()
        return voice
    }

    //プライマリキーの作成メソッド
    static func getLastId() -> Int {
        if let voice = realm.objects(VoiceFormat).last {
            return voice.id + 1
        } else {
            return 1
        }
    }

    //インスタンス保存用メソッド
    func save() {
        try! VoiceFormat.realm.write {
            VoiceFormat.realm.add(self)
        }
    }

    //インスタンス削除用メソッド
    func delete() {
        try! VoiceFormat.realm.write {
            VoiceFormat.realm.delete(self)
        }
    }

    //登録順のデータの全件取得をする
    static func fetchAllVoiceFormatList() -> [VoiceFormat] {
        let voices = realm.objects(VoiceFormat).sorted("id", ascending: false)
        var voiceList: [VoiceFormat] = []
        for voice in voices {
            voiceList.append(voice)
        }
        return voiceList
    }

}

↓下記はVoiceHistory.swift (音声出力履歴登録用)のファイル

VoiceHistory.swift
//
//  VoiceHistory.swift
//  enryo_message
//
//  Created by 酒井文也 on 2015/12/26.
//  Copyright © 2015年 just1factory. All rights reserved.
//

import UIKit

//Realmクラスのインポート
import RealmSwift

class VoiceHistory: Object {

    //Realmクラスのインスタンス
    static let realm = try! Realm()

    //id
    dynamic private var id = 0

    //音声で送信したデータ
    dynamic var formatText = ""

    //音声で送信したデータ
    dynamic var deviceType = 1

    //登録日
    dynamic var createDate = NSDate(timeIntervalSince1970: 0)

    //PrimaryKeyの設定
    override static func primaryKey() -> String? {
        return "id"
    }

    //新規追加用のインスタンス生成メソッド
    static func create() -> VoiceHistory {
        let voice = VoiceHistory()
        voice.id = self.getLastId()
        return voice
    }

    //プライマリキーの作成メソッド
    static func getLastId() -> Int {
        if let voice = realm.objects(VoiceHistory).last {
            return voice.id + 1
        } else {
            return 1
        }
    }

    //インスタンス保存用メソッド
    func save() {
        try! VoiceHistory.realm.write {
            VoiceHistory.realm.add(self)
        }
    }

}

Realmの導入は最初は若干戸惑う部分があるかもしれませんが、慣れてくるとその便利さがわかってくるかと思います。また充実したドキュメントやサポートは本当に嬉しい限りです。

2. AVSpeechSynthesizerによる音声読み上げ機能

音声の読み上げ機能を実現するためにはまず AVFoundation.framework をプロジェクトに追加してあげる必要があります。またテーブルビューの振舞い等に関しては前述で紹介したカロリー登録のサンプルの記事とほとんど同じなのでここでは割愛します。

テキストフィールドに入っている文字列を読み上げる処理に関しては、AVSpeechSynthesizerを利用して受け取ったテキストを読み上げるようにしたいため、下記のような実装にしてあります。

ViewController.swift
//※ AVSpeechSynthesizerDelegateを追加する

//AVSpeechSynthesizerのインスタンス変数
var talker:AVSpeechSynthesizer = AVSpeechSynthesizer()

--- (省略) ---

//音声再生アクション
@IBAction func playVoiceAction(sender: UIButton) {

    self.targetVoice = self.targetVoiceTextField.text!

    //異常処理時
    if self.targetVoice.isEmpty {

        //エラーのアラートを表示してOKを押すと戻る
        let errorAlert = UIAlertController(
            title: "エラー",
            message: "入力必須の項目に不備があります。",
            preferredStyle: UIAlertControllerStyle.Alert
        )
        errorAlert.addAction(
            UIAlertAction(
                title: "OK",
                style: UIAlertActionStyle.Default,
                handler: nil
            )
        )
        presentViewController(errorAlert, animated: true, completion: nil)

    //正常処理時
    } else {

        //Realmにデータを1件登録する
        let voiceHistoryObject = VoiceHistory.create()
        voiceHistoryObject.formatText = self.targetVoice
        voiceHistoryObject.deviceType = 1
        voiceHistoryObject.createDate = NSDate()

        //登録処理
        voiceHistoryObject.save()

        //話す内容をセット
        let utterance = AVSpeechUtterance(string: self.targetVoice)

        //言語を日本に設定
        /*
         * (2015.12.26) 下記のようなエラーが発生しており現在も未解決です。フォーラムでもまだ未解決っぽい。。。
         * シミュレーターでは問題なく動作します。
         *
         * 実装の参考:
         * http://dev.classmethod.jp/smartphone/iphone/swfit-avspeechsynthesizer/
         *
         * 2015-12-26 17:45:04.276 enryo_message[438:63495] |AXSpeechAssetDownloader|error| ASAssetQuery error 
         * fetching results (for com.apple.MobileAsset.MacinTalkVoiceAssets) Error Domain=ASError Code=21
         * "Unable to copy asset information" UserInfo={NSDescription=Unable to copy asset information}
         *
         */

        //読む言語(言語の選択だけで他の詳細な設定はできない)
        utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")
        //読む速さ
        utterance.rate = 0.55
        //音量
        utterance.volume = 1

        //実行されたらテキストフィールドは空にする
        self.targetVoiceTextField.text = ""

        //実行
        self.talker.speakUtterance(utterance)

        //登録されたアラートを表示してOKを押すと戻る
        let correctAlert = UIAlertController(
            title: "音声再生中...",
            message: "音声の再生と履歴が登録されました。",
            preferredStyle: UIAlertControllerStyle.Alert
        )
        correctAlert.addAction(
            UIAlertAction(
                title: "OK",
                style: UIAlertActionStyle.Default,
                handler: nil
            )
        )

        //戻る処理
        presentViewController(correctAlert, animated: true, completion: nil)    
    }

}

今回のサンプルでは AVSpeechUtterance(string: "話す内容") のインスタンスを作成し、プロパティで言語・読む速さ・音量等を設定しています。
そして、AVSpeechSynthesizerのインスタンスの speakUtterance メソッドを実行して音声読み上げを実行します。また実際に開発をする際には下記のようなAVSpeechSynthesizerクラスのデリゲートメソッドを入れておくとよいでしょう。

ViewController.swift
//Debug: 音声読み上げ開始時のデリゲートメソッド
func speechSynthesizer(synthesizer: AVSpeechSynthesizer, didStartSpeechUtterance utterance: AVSpeechUtterance) {
    print("----- 開始 -----")
}

//Debug: 音声読み上げ最中のデリゲートメソッド
func speechSynthesizer(synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) {
    let word = (utterance.speechString as NSString).substringWithRange(characterRange)
    print("----- 内容: \(word) -----")
}

//Debug: 音声読み上げ終了時のデリゲートメソッド
func speechSynthesizer(synthesizer: AVSpeechSynthesizer, didFinishSpeechUtterance utterance: AVSpeechUtterance) {
    print("---- 終了 -----")
}

開発時にはどのように文字が解析されて読み上げられるかを見るために書いておくと良いかもしれませんね。

3. UIAlertControllerにテキストボックスをつける

iOS7以前にUIAlertViewがiOS8以降では使用できなくなりました。UIAlertView時代はできていたカスタマイズがUIAlertControllerではできない場合もあったりするので注意が必要です。

今回は登録用のテキストフィールドが一つあるだけですが、下記のような実装を行い「定型文」の登録を行うことができるようにしました。

ViewController.swift
//声のデータ追加アクション
@IBAction func addVoiceAction(sender: UIButton) {

    //登録されたアラートを表示してOKを押すと戻る
    let formAlert = UIAlertController(
        title: "音声内容追加",
        message: "音声のパターンを入力して下さい。",
        preferredStyle: UIAlertControllerStyle.Alert
    )
    formAlert.addAction(
        UIAlertAction(
            title: "OK",
            style: UIAlertActionStyle.Default,
            handler: {
                (action: UIAlertAction!) -> Void in

                    //UIAlertAction内にテキストフィールドを1個以上追加する際の書き方
                    let textFields:Array<UITextField>? = formAlert.textFields as Array<UITextField>?

                    //----- 喋ってほしい音声のパターンを追加(ここから) -----
                    if textFields != nil {

                        for textField:UITextField in textFields! {

                            //テキストがnilないしは空以外の場合は喋って欲しい言葉を登録
                            if textField.text != nil && textField.text != "" {

                                //値を格納してRealmに追加
                                self.addVoice = textField.text!
                                print(self.addVoice)

                                //Realmにデータを1件登録する
                                let voiceFormatObject = VoiceFormat.create()
                                voiceFormatObject.formatText = self.addVoice

                                //登録処理
                                voiceFormatObject.save()
                            }
                        }
                    }
                    //----- 喋ってほしい音声のパターンを追加(ここまで) -----

                    //テーブルビューのリロード
                    self.fetchAndReloadData()
                }
            )
        )

        //テキストフィールドの追加
        formAlert.addTextFieldWithConfigurationHandler({(text:UITextField!) -> Void in
        }
    )

    //戻る処理
    presentViewController(formAlert, animated: true, completion: nil)
}

今回はUIAlertController内のテキストフィールドから受け取った値を1件登録していますが、こちらを応用するとログイン用のポップアップ等も作成することができます。

4. WatchOS2でのWKInterfaceTableの表示方法

こちらはiPhoneでのテーブルビューの実装方法と大きく変わっています。
実装のおおまかな流れとしましては、

  • テーブルのセル専用のクラスを作成してその中にセルの部品を配置(その他関連付け含む)
  • テーブルビューの描画とタップ時のアクションをそれぞれ記述

という流れになります。

テーブルのセル専用のクラスを作成してその中にセルの部品を配置(その他関連付け含む)部分は下記のコードになります。

TableRowController.swift
import WatchKit

class TableRowController: NSObject {

    //セル内のラベルと関連づけをする
    //(注意1)「Custom Class」の部分をTableRowController.swiftにする
    //(注意2)Row Controllerの「Indentifier」をTheRowにする
    @IBOutlet var messageTableLabel: WKInterfaceLabel!    

}

次に、テーブルビューの描画とタップ時のアクションをそれぞれ記述部分は下記のコードになります。

InterfaceController.swift
//--- (↓Table生成部分抜粋) ---
//テーブルのカウント数
self.messageDetailTable.setNumberOfRows(self.resultList.count, withRowType: "TheRow")

//テーブルビューの描画実行
if self.messageDetailTable.numberOfRows > 0 {

    for index in 0..<self.messageDetailTable.numberOfRows {

        //ラベルにテキストを設定する
        let row = self.messageDetailTable.rowControllerAtIndex(index) as! TableRowController
        let displayMessage: String = (self.resultList.objectAtIndex(index) as? String)!
        print("テーブルビュー用のメッセージ:\(displayMessage)")
        row.messageTableLabel.setText(displayMessage)
    }
}

//--- (↓Tableタップ時の処理抜粋) ---
override func table(table: WKInterfaceTable, didSelectRowAtIndex rowIndex: Int) {
    if table == self.messageDetailTable {

        //選択された行のメッセージを抜き出す
        let sendMessage: String = (self.resultList[rowIndex] as? String)!

        --- (省略) ---    

    }
}

余談になりますがいつものTableViewの実装とはかなり異なるので私自身も「あれ、どうするんだっけな??」となってしまいます。

5. WatchConnectivityのInteractive Messagingを用いてのAppleWatch経由での定型文の音声読み上げ

定型文のデータはiPhone側のデータベース(Realm)にあります。このデータをWatch側に表示させるためにWatchOS2から新しく出たWatchConnectivityクラスを利用します。

WatchConnectivityクラスを利用したAppleWatchとiPhone間の双方向のやり取りには、

  • Background Transfer
  • Interactive Messageing

の2つの方法があるのですが、今回はInteractive Messageingの sendMessage メソッドを利用して双方向のやり取りを実現します。

処理その1. 下準備

まずはAppDelegate.swiftとExtensionDelegate.swiftに準備をします。

AppDelegate.swiftにWatchConnectivityの有効化とWatch側から受け取ったメッセージに関する処理を記述します。

AppDelegate.swift
//WatchConnectivityのimportとWCSessionDelegateの追加

//WatchConnectivityに関する処理を追記する
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {

    // ----- WatchConnectivityが有効か否かのチェック(ここから) -----
    if (WCSession.isSupported()) {
        let session = WCSession.defaultSession()
        session.delegate = self
        session.activateSession()

        if session.paired != true {
            print("Apple Watch is not paired")
        }

        if session.watchAppInstalled != true {
            print("WatchKit app is not installed")
        }

    } else {
        print("WatchConnectivity is not supported on this device")
    }
    // ----- WatchConnectivityが有効か否かのチェック(ここまで) -----

    // Override point for customization after application launch.
    return true
}

--- (省略) ---

//Interactive Messagingの処理を追加する
func session(session: WCSession, didReceiveMessage message: [String : AnyObject], replyHandler: ([String : AnyObject]) -> Void) {

    //コールバック用のDictionaryデータ
    var replyValues = Dictionary<String, AnyObject>()

    //最初の画面のViewControllerのインスタンス
    let viewController = self.window!.rootViewController as! ViewController

    //Watch側から送られてきた値(AnyObjectなのでStringにダウンキャスト)
    let operation: String = message["command"] as! String

    //Watch側が呼ばれた際にテーブルビューに描画する
    if operation == "init" {

        //データを取得して結果を返す
        let resultDataList: NSMutableArray = viewController.getAllVoiceFormat()
        replyValues["result"] = resultDataList
        replyHandler(replyValues)

    //Watch側のテーブルより選択されたデータを再生する
    } else {

        //一番最新の履歴データを表示する
        viewController.receiveMessageAndSaveForWatch(operation)
        replyValues["result"] = "App側に送られました!" + "(メッセージ:" + operation + ")"
        replyHandler(replyValues)
    }        
}

ExtensionDelegate.swiftにもWatchConnectivityの有効化に関する処理を記述します。

ExtensionDelegate.swift
//WatchConnectivityのimportとWCSessionDelegateの追加

func applicationDidFinishLaunching() {
    // Perform any final initialization of your application.

    //WatchConnectivityが有効か否かのチェック
    if (WCSession.isSupported()) {
        let session = WCSession.defaultSession()
        session.delegate = self
        session.activateSession()
    }
}

これにて双方向のやりとりに関する下準備が完了しました。

処理その2. 定型文一覧をWatchに表示

  1. WatchからiPhoneへメッセージを送る
  2. iPhoneで登録されている定型文を取得しreplyに定型文データを入れてWatchへ返す
  3. Watchで定型文一覧を受け取ってTableに表示

下記がsendMessageを用いた処理になります。

InterfaceController.swift

//初期メッセージ表示
self.messageResultLabel.setText("既存データを取得中...")

//Interactive Messagingでデータを取得
if (WCSession.defaultSession().reachable) {
    let message = ["command" : "init"]
    WCSession.defaultSession().sendMessage(message,

        replyHandler: { (reply) -> Void in
            dispatch_async(dispatch_get_main_queue(), {

                //成功時のメッセージ
                self.messageResultLabel.setText("読み上げる言葉を選択")

                //中のデータを可変配列に変換
                self.resultList = reply["result"] as! NSMutableArray

                //テーブルのカウント数
                self.messageDetailTable.setNumberOfRows(self.resultList.count, withRowType: "TheRow")

                //テーブルビューの描画実行
                if self.messageDetailTable.numberOfRows > 0 {

                    for index in 0..<self.messageDetailTable.numberOfRows {

                         //ラベルにテキストを設定する
                         let row = self.messageDetailTable.rowControllerAtIndex(index) as! TableRowController
                         let displayMessage: String = (self.resultList.objectAtIndex(index) as? String)!
                         print("テーブルビュー用のメッセージ:\(displayMessage)")
                         row.messageTableLabel.setText(displayMessage)
                    }
                }

            })
        },

        errorHandler: { (error) -> Void in
            dispatch_async(dispatch_get_main_queue(), {

                //失敗時のメッセージ
                self.messageResultLabel.setText("データ取得失敗")
            })
        }
    )           
}

処理その3. 選択された音声を出力する

  1. WatchのTableで表示されている定型文をタップ
  2. 定型文を読み上げる

下記がsendMessageを用いた処理になります。

InterfaceController.swift
//選択された行のメッセージを抜き出す
let sendMessage: String = (self.resultList[rowIndex] as? String)!

//送信メッセージをメンバ変数へ格納
self.targetSendMessage = sendMessage

if (WCSession.defaultSession().reachable) {
    let message = ["command" : self.targetSendMessage]
    WCSession.defaultSession().sendMessage(message,

        replyHandler: { (reply) -> Void in
            dispatch_async(dispatch_get_main_queue(), {

                //App側からのメッセージ
                print(reply["result"])

            })
        },

        errorHandler: { (error) -> Void in
            dispatch_async(dispatch_get_main_queue(), {

                //失敗時のメッセージ
                print("送信失敗しました")
            })

         }
    )
}

Interactive Messagingに関する参考資料に関しては以前が私がまとめた資料で恐縮ではございますが、今回よりもよりシンプルで簡単な実装例やコードの解説を掲載しましたので、ご参考にしていただければ幸いです。

WatchOS1の時代からわずか半年程度でがらりと使用が変わってしまったWatchOSですが、その分便利な機能やAPIも徐々に使用可能になっていますので、今後の動向には是非とも期待したいものです。

JFYI:今回のサンプルを作成するにあたっての参考資料

今回はWatchOS関連の部分とAVSpeechSynthesizerの部分がポイントになるのでその部分を中心に参考資料をピックアップしました。

1. WatchOS2のWatchConnectivityに関するチュートリアル

こちらはすべて英語で書かれていますが、具体的なコード例が掲載されていますので、コードを追っかけて写経するだけでもすごく参考になりました。

2. WKInterfaceTableの実装に関して

よく使うiPhoneアプリのUTableViewの実装とはかなり勝手が異なるので、公式ドキュメントやその他実装例に関しては1度目を通しておくと良いかもしれません。

3. AVSpeechSynthesizerの実装に関して

こちらはiOS開発ではもはや大御所?というべきクラスメソッドさんのブログになります。丁寧な解説で本当にいつもお世話になっておりますm(_ _)m

もう少しAVFoundationやAVSpeechSynthesizer / AVSpeechSynthesisVoice関連に関してさらに詳しくお調べするとより理解が深まると思います。

4. コードはObjective-Cですが参考になったリンク

上記はObjective-Cでのコード例になりますが、おおまかな仕様の参考や実装する際の参考にしました。

追記とその他

2016.01.03:

  • このサンプルを改めて実機検証してみたところ、一応問題なく動きました。(もしかするとiPhoneの設定でうまく動くのかもと思ったり...でもWatch側が実機まだうまく動かないので引き続き調査中です)

このサンプルについて

  • GithubへのPull Requestならびに要望や改善に関する提案も受け付けていますのでお気軽にどうぞ!
33
30
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
33
30