LoginSignup
5

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-20

はじめに

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

対象としたSDK

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

気になるお値段

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

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

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

SDK(料金ページへのリンク) 料金 1万文字あたりの料金 インターネット接続
Speech Synthesis 無料 無料 不要
Amazon Polly $4/100 万文字 (標準音声) 4.56円 必要
GCP TextToSpeechAPI $4/100 万文字 (標準音声) 4.56円 必要

※ 1$ = 114円換算

実装について

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

SDK Twitterアカウント
Speech Synthesis @milanista_2nd
Amazon Polly @beowulf_tech (私)
GCP TextToSpeechAPI @potechi_if

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

Speech Synthesis

Appleのドキュメントは非常にそっけないです。

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.

ソースコード

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


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]
    }
}


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の設定はそれだけで結構なボリュームになるので今回の記事では割愛させていただきます。

公式の解説はこちらです。

また、AWS公式のサンプルソースコードはこちらです。

以下、公式のページから引用します。

  • 高品質— 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


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の日本語の声をリストアップします。


    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
    }

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


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 音声を使うと他のテキスト読み上げシステムよりも自然な音声(より人間らしく、音節、音素、単語の強調や抑揚がある音声)が合成される。他の合成音声よりも暖かみがあり、人間のそれに似ていると感じられます。

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段階評価を記載します。

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

case 1

読み上げテキスト
我々は宇宙人だ
SDK 読み間違い 品位(5段階)
Speech Synthesis Kyoko なし 4
Amazon Polly Takumi なし 5
GCP TextToSpeechAPI waveNetFemale2 なし 5

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

case 2

読み上げテキスト
 Macintoshは、Appleの創業者の一人、スティーブ・ジョブズの陣頭指揮のもとに開発された。
ジョブズの思想や夢、感性が設計思想に盛り込まれ、直感的で視覚的な操作インタフェース、画面に表示される文字フォントの細やかさや美しさ、画面と印刷物に表示される図像の精度(特にWYSIWYGの実現)、筺体の美しさなどが重視されている
SDK 読み間違い 品位(5段階)
Speech Synthesis Kyoko なし 3
Amazon Polly Takumi なし 5
GCP TextToSpeechAPI waveNetFemale2 なし 5

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

おわり

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

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

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
5