iOS 音声合成SDK（3種類）を聴き比べてみた

# はじめに

iOS音声合成SDKは、すでに色々なアプリで活用されていると思います。
私は残念ながら今まで触ったことはありませんでした。今回、ある事情で[ゆめみ](https://www.yumemi.co.jp)のiOSエンジニア数名で、部活動的に3種類の音声合成SDKを試してみましたので、紹介します。


# 対象としたSDK

- [Apple AVFoundation: Speech Synthesis](https://developer.apple.com/documentation/avfoundation/speech_synthesis)
- [Amazon Polly](https://aws.amazon.com/jp/polly/)
- [Google Cloud Platform: TextToSpeechAPI](https://cloud.google.com/text-to-speech)

他にも候補はあったのですが、メンバーのスケジュールの都合上このあたりが精一杯でした🙇

# 気になるお値段

費用は気になるところだと思います。
下記のように、Apple純正は無料かつネット接続が必須ではないために、軽く使ってみる分にはお手頃です。

料金は各Webページを見ていただけると分かりますが、もう少し細かく料金設定されていますので、参考に留めて頂ければと思います。

もう一つの気になる合成の品質につきましては、後で述べたいと思います。

|SDK（料金ページへのリンク）|料金|1万文字あたりの料金|インターネット接続|
----------|--------|--------|--------
|Speech Synthesis|無料|無料|不要
|[Amazon Polly](https://aws.amazon.com/jp/polly/pricing/)|$4/100 万文字 (標準音声)|4.56円|必要
|[GCP TextToSpeechAPI](https://cloud.google.com/text-to-speech/pricing)|$4/100 万文字 (標準音声)|4.56円|必要

※ 1$ = 114円換算

# 実装について

実装は、各メンバーがそれぞれ担当しました。

|SDK | Twitterアカウント|
----------|--------|
|Speech Synthesis|[@milanista_2nd](https://twitter.com/milanista_2nd)|
|Amazon Polly | [@beowulf_tech](https://twitter.com/beowulf_tech) （私）|
|GCP TextToSpeechAPI | [@potechi_if](https://twitter.com/potechi_if) |

下記の参考ソースコードでは、play()関数まわりに絞って転記してみました。

## Speech Synthesis

[Appleのドキュメント](https://developer.apple.com/documentation/avfoundation/speech_synthesis)は非常にそっけないです。

>Convert text to spoken audio.

>Overview
The Speech Synthesis framework manages voice and speech synthesis, and requires two primary tasks:
Create an AVSpeechUtterance instance that contains the text to speak. Optionally, configure speech parameters, such as voice and rate, for each utterance.

### ソースコード

今回の実装の中では一番シンプルに記述できました。
再生用のコードを別に記述しなくても良いのが特徴でしょうか。

```swift

import Combine
import AVFoundation

final class AVSpeechSynthesizerUsecase: NSObject {

    // 音声再生中か
    let isSpeeching = PassthroughSubject<Bool, Never>()

    private let synthesizer = AVSpeechSynthesizer()

    override init() {
        super.init()
        self.synthesizer.delegate = self
    }

    // 日本語の声を抽出
    static func voices() -> [AVSpeechSynthesisVoice] {
        return AVSpeechSynthesisVoice.speechVoices().filter { $0.language == "ja-JP" }.sorted { voice1, voice2 in
            voice1.name < voice2.name
        }
    }

    func play(text: String, speechRate: Float, pitchMultiplier: Float, voiceType: Int, speechVolume: Float) {
        isSpeeching.send(true)
        let utterance = AVSpeechUtterance(string: text)
        utterance.rate = speechRate
        utterance.pitchMultiplier = pitchMultiplier

        let voice = voice(for: voiceType)
        if let voice = AVSpeechSynthesisVoice(identifier: voice.identifier) {
            utterance.voice = voice
        } else {
            utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")
        }
        utterance.volume = speechVolume
        synthesizer.speak(utterance)
    }
    
    private func voice(for voiceType: Int) -> AVSpeechSynthesisVoice {
        let voices = AVSpeechSynthesizerUsecase.voices()
        let index: Int = voiceType >= voices.count ? voices.count - 1 : voiceType
        return voices[index]
    }
}

```

```swift

extension AVSpeechSynthesizerUsecase: AVSpeechSynthesizerDelegate {
    /// 音声再生が終了した際に呼ばれるdelegateメソッド
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
        isSpeeching.send(false)
    }
    
    func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
        isSpeeching.send(false)
    }
}

```

## Amazon Polly


GCPも同様ですが、Amazon PollyはAWSとの接続が必要なためiOSアプリだけでは動作しません。
AWSの設定はそれだけで結構なボリュームになるので今回の記事では割愛させていただきます。


公式の解説は[こちら](https://docs.aws.amazon.com/ja_jp/polly/latest/dg/examples-ios.html)です。

また、AWS公式のサンプルソースコードは[こちら](https://github.com/awslabs/aws-sdk-ios-samples)です。

以下、[公式のページ](https://docs.aws.amazon.com/ja_jp/polly/latest/dg/what-is.html)から引用します。

- 高品質— Amazon Polly は、新しいニューラル TTS とクラス最高の標準 TTS テクノロジーの両方を提供し、高い発音精度 (略語、頭字語の展開、日付/時刻の変換、同形異義語の読み分けを含む) で優れた自然音声を合成します。
 
- 低レイテンシー— Amazon Polly は応答が早いため、ダイアログシステムなどの低レイテンシーなユースケースにおいても選択肢になります。
 
- 言語と音声の大規模なポートフォリオをSupport— Amazon Polly は、多数のボイス言語をサポートしています。ほとんどの言語で男性と女性のボイスを選択できます。ニューラル TTS は現在、3 つの英国英語音声と 8 つの米国英語音声をサポートしています。さらに多くのニューラル音声が提供されるにつれて、この数は増え続ける予定です。米国英語の音声 Matthew と Joanna では、プロのニュースアンカーのようなニューラルニュースキャスターの話し方も使用できます。
 
- コスト効率が良い— Amazon Polly は従量課金制であり、セットアップコストはかかりません。小規模で開始し、アプリケーションが大きくなるにつれてスケールアップできます。
 
- クラウドベースソリューション— デバイス上の TTS ソリューションは、膨大なコンピューティングリソース、特に CPU パワー、RAM、ディスク容量を必要とします。そのため、開発コストが高くなり、またタブレットやスマートフォンなどのデバイスの電力消費も高くなります。これに対して、TTS 変換は AWS クラウド は、ローカルリソース要件を大幅に削減します。これにより、すべての利用可能な言語とボイスを可能な限りの最高品質でサポートできます。さらに、音声が改良されるとすぐにすべてのエンドユーザーが使用できるようになり、デバイスで追加更新する必要がありません。


### ソースコード

AWSMobileClientとAWSPollyをCocoapodsで組み込みます。

```
platform :ios, '15.0'
use_frameworks!

target 'App' do
  pod 'AWSMobileClient'
  pod 'AWSPolly'
end

```


```swift

import AVFoundation
import AWSPolly
import Combine

final class AmazonPollyUsecase: NSObject, TextToSpeechProtocol {
    static let defaultVoiceId = AWSPollyVoiceId.mizuki

    private(set) var selectedVoiceId: AWSPollyVoiceId = AmazonPollyUsecase.defaultVoiceId
    private var player: AVAudioPlayer?
    private let queue = DispatchQueue(label: "AmazonPollyUsecase")

    let isSpeeching = PassthroughSubject<Bool, Never>()

    func play(text: String, speechRate: Float, pitchMultiplier: Float, voiceType: Int, speechVolume: Float) {
        queue.async {
            let voice = AmazonPollyUsecase.voices()
                .enumerated()
                .first(where: { $0.offset == voiceType })
                .map { $0.element }

            self.selectedVoiceId = voice?.voiceId ?? AmazonPollyUsecase.defaultVoiceId

            self.generateSpeech(from: text) { url, _, error in
                guard let url = url else {
                    // エラー
                    return
                }

                let audioSession = AVAudioSession.sharedInstance()
                do {
                    try audioSession.setCategory(.playback, mode: .default, options: [])
                } catch {
                    // エラー
                }

                // AVAudioPlayerで再生
                self.player = try? AVAudioPlayer(contentsOf: url)
                if let player = self.player {
                    player.prepareToPlay()
                    player.delegate = self
                    player.enableRate = true
                    player.rate = speechRate
                    player.volume = speechVolume
                    player.play()

                    self.isSpeeching.send(true)
                } else {
                    // エラー
                }
            }
        }
    }

    // Amazon Pollyに接続して、音声合成結果のファイルをダウンロードします。
    private func generateSpeech(from text: String, completion: @escaping ((URL?, URLResponse?, Error?) -> Void)) {
        let request = AWSPollySynthesizeSpeechURLBuilderRequest()

        request.text = text
        request.outputFormat = AWSPollyOutputFormat.mp3
        request.voiceId = selectedVoiceId

        let task = AWSPollySynthesizeSpeechURLBuilder.default().getPreSignedURL(request)

        // Amazon Pollyによって音声合成を行う
        task.continueOnSuccessWith { (awsTask: AWSTask<NSURL>) -> Any? in
            guard let url = awsTask.result as URL? else { return nil }

            // 音声合成されたファイルをダウンロードする
            let config: URLSessionConfiguration = URLSessionConfiguration.default
            let session: URLSession = URLSession(configuration: config)

            let request: URLRequest = URLRequest(url: url)
            let task: URLSessionDownloadTask = session.downloadTask(with: request) { url, response, error in
                completion(url, response, error)
            }

            task.resume()

            return nil
        }
    }
}

```

Pollyの日本語の声をリストアップします。

```swift

    struct PollyVoice: Identifiable {
        var id = UUID()
        let voiceName: String
        let voiceId: AWSPollyVoiceId

        static var dummy: PollyVoice {
            PollyVoice(voiceName: "mizuki", voiceId: AWSPollyVoiceId(rawValue: 0)!)
        }
    }

    static func voices() -> [PollyVoice] {
        var sortedVoices: [PollyVoice]?
        let task = AWSPolly.default().describeVoices(AWSPollyDescribeVoicesInput())

        task.continueOnSuccessWith { (awsTask: AWSTask<AWSPollyDescribeVoicesOutput>) -> Any? in
            let data = (awsTask.result! as AWSPollyDescribeVoicesOutput).voices

            sortedVoices = data!
                .filter { $0.languageName == "Japanese" }
                .map { PollyVoice(voiceName: $0.name ?? "", voiceId: $0.identifier) }
            return nil
        }
        .waitUntilFinished()

        guard let sortedVoices = sortedVoices else {
            assertionFailure("音声が見つかりません")
            return [.dummy]
        }

        return sortedVoices
    }

```

音声再生の終了処理を行います。

```swift

extension AmazonPollyUsecase: AVAudioPlayerDelegate {
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        // 完了処理 ...
        self.isSpeeching.send(false)
    }
    
    func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
        // エラー処理...
        self.isSpeeching.send(false)
    }
}

```

## GCP TextToSpeechAPI

**標準音声**

- 一般的な音声テクノロジーの 1 つであるパラメータ テキスト読み上げでは通常、ボコーダと呼ばれる信号処理アルゴリズムを介して出力を渡すことによって音声データを生成している。
- Text-to-Speech の標準音声も、このテクノロジーのバリエーションを使用している

**WaveNet 音声**

- Text-to-Speech による音声の作成方法は、合成音声技術が音声の機械モデルを作成する方法によって異なる
- WaveNetはGoogle アシスタント、Google 検索、Google 翻訳の音声生成に使用されているモデル
- WaveNet 音声を使うと他のテキスト読み上げシステムよりも自然な音声(より人間らしく、音節、音素、単語の強調や抑揚がある音声)が合成される。他の合成音声よりも暖かみがあり、人間のそれに似ていると感じられます。


```swift

final class GCPTextToSpeechUsecase: NSObject, TextToSpeechProtocol {
    private struct Const {
        static let apiURL: URL! = URL(string: "https://texttospeech.googleapis.com/v1/text:synthesize")
        static let apiKey = "**************" // GCPで取得
    }
    private var player: AVAudioPlayer?
    let isSpeeching = PassthroughSubject<Bool, Never>()

    func play(text: String, speechRate: Float, pitchMultiplier: Float, voiceType: Int, speechVolume: Float) {
        Task(priority: .background) { [weak self] in
            guard let self = self, let requestParams = self.requestParams(text, voiceType, pitchMultiplier) else { return }
            let headers = ["X-Goog-Api-Key": Const.apiKey, "Content-Type": "application/json; charset=utf-8"]

            // リクエストを送信
            let response = await self.postRequest(postData: requestParams, headers: headers)

            // レスポンスからbase64エンコードされた音声合成データを取得
            guard let audioContent = response["audioContent"] as? String else {
                assertionFailure("無効なレスポンス")
                return
            }

            // デコード(base64String → Data)
            guard let audioData = Data(base64Encoded: audioContent) else {
                return
            }

            self.player = try? AVAudioPlayer(data: audioData)

            // 以下 Amazon Pollyの再生処理とほぼ同じ ...
        }
    }

    private let requestParams: ((String, Int, Float) -> Data?) = { text, voiceType, pitch in
        guard let voiceType = GCPVoiceType(rawValue: voiceType)?.identifier else { return nil }
        let voiceParams: [String: Any] = [
            "languageCode": "ja-JP",
            "name": voiceType,
        ]

        let requestParams: [String: Any] = [
            "input": [
                "text": text,
            ],
            "voice": voiceParams,
            "audioConfig": [
                "pitch": pitch,
                "audioEncoding": "LINEAR16",
            ],
        ]

        return try? JSONSerialization.data(withJSONObject: requestParams)
    }

    // GCPのAPI音声合成データを取得すためのリクエストを送信します
    private func postRequest(postData: Data, headers: [String: String]) async -> [String: AnyObject] {
        var dict: [String: AnyObject] = [:]

        var request = URLRequest(url: Const.apiURL)
        request.httpMethod = "POST"
        request.httpBody = postData

        for header in headers {
            request.addValue(header.value, forHTTPHeaderField: header.key)
        }

        let result = try? await URLSession.shared.data(for: request)

        if let data = result?.0, let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: AnyObject] {
            dict = json
        }
        return dict
    }
}

```

# 聴き比べ

音声ファイルをQiitaにアップロードすることが難しそうだったので、品位の５段階評価を記載します。

5: 違和感がない読み上げ
4: 違和感があるが理解できる
3: 意味が分かりにくい
2: 意味が分からない
1: 読み上げの間違いが多く、意味が通じない

## case 1

```:読み上げテキスト
我々は宇宙人だ
```

|SDK | 声 | 読み間違い|品位（５段階)|
----------|--------|--------|--------|
|Speech Synthesis|Kyoko|なし|4
|Amazon Polly| Takumi|なし|5
|GCP TextToSpeechAPI |waveNetFemale2 |なし|5

Speech Synthesisは、シミュレータでは、「宇宙人」を「うちゅうひと」と読みます。

## case 2

```:読み上げテキスト
 Macintoshは、Appleの創業者の一人、スティーブ・ジョブズの陣頭指揮のもとに開発された。
ジョブズの思想や夢、感性が設計思想に盛り込まれ、直感的で視覚的な操作インタフェース、画面に表示される文字フォントの細やかさや美しさ、画面と印刷物に表示される図像の精度（特にWYSIWYGの実現）、筺体の美しさなどが重視されている
```


|SDK | 声 | 読み間違い|品位（５段階)|
----------|--------|--------|--------|
|Speech Synthesis|Kyoko|なし|3
|Amazon Polly| Takumi|なし|5
|GCP TextToSpeechAPI |waveNetFemale2 |なし|5

Speech Synthesisは、実機でも「筺体」を「きょうからだ」と読んでいます。発話も辿々しいので、厳しく３！

# おわり

音声合成の品位の評価はむずかしいので、余裕があったら定量的な評価に挑んでみたいと思います。

ソースコードは公開準備中です・・・