Flutter 音声合成 (flutter_azure_tts)

音質について

前回の「flutter 音声合成」では、flutter_ttsを使って、端末での音声合成をしてみました。しかしながら、どうも声の質が気に入りません。現時点で、iPhoneで使える音声は、「声1」と「声2」の2種類のみです。どちらも一世代前の合成音声です。最新の音声合成を使う場合には、クラウド版を使う必要がありそうです。

どんな音声が利用可能か、クラウド合成音声を比べてみました。日本の音声合成ソフトの専門会社もいくか見つかりました。確かに、高品質でいろんな声が選べるので使ってみたいのですが、ちょっと高くて、気軽には手が出ません。商用アプリを作る時には検討したいと思っています。

今回は、Google, AWS, Azureの3種類で比べてみました。どれを選択するかは、個人の好みですが、私はAzureの声が一番良いと思いました。

今回はAzureを採用しますが、この分野も日進月歩です。来月にはもっと良いものがリリースされているかもしれません。

ちなみに、MicrosoftAzureの合成音声は、こちらのサイトから聞くことができます。

音声合成クラウド版のコスト

クラウドサービスを利用する際には、ランニングコストを見ておかなければなりません。Microsoft Azureの場合、以下のようになっていました。

1 か月あたり 0.5 million 文字まで無料

リアルタイムの合成: 1M あたり $16 文字
長い音声の作成: 1,000,000 文字あたり $100

少し微妙な表現ですが、1ヶ月 50万文字までは無料。その後100万文字毎に$16のようです。個人で使う分には、ほぼ無料の範囲で使えるかなと思いました。文字数のカウント方法については、以下の通りなので、注意が必要です。

テキスト読み上げ機能を使用している場合、句読点を含めて、文字が音声に変換されるごとに課金されます。 SSML ドキュメント自体は課金対象外ですが、テキストが音声に変換される方法を調整するために使用される省略可能な要素 (音素やピッチなど) は、課金対象の文字としてカウントされます。 課金対象の一覧を次に示します。

・要求の SSML 本文でテキスト読み上げ機能に渡されたテキスト

<speak> と <voice> タグを除く、SSML 形式の要求本文のテキスト フィールド内のすべてのアークアップ

・文字、句読点、スペース、タブ、マークアップ、すべての空白文字

・Unicode で定義されているすべてのコード ポイント

https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/text-to-speech#pricing-note

ちなみに、GoogleもAWSもニューラルネットの音声合成はほぼ同じ価格でした。

Azure 音声合成のパッケージ

flutter_azure_ttsパッケージの利用

https://pub.devで、Azureを使ったttsのパッケージを調べると、flutter_azure_ttsが見つかりました。

いつものように、パッケージをインストールします。

flutter pub add flutter_azure_tts

クラウドサービスの登録

クラウドサービスを利用するために、アカウントとキーが必要です。

事前にMicerosoft Azureでアカウントを作り、音声サービスのリソースを作成します。そして、リソースの概要欄で「キーの管理」を選択し、キー1(SUBSCRIPTION KEY)と、場所/地域(REGION)をメモしておきます。

flutter_azure_ttsを使った音声合成の実装

まず、取得したSUBSCRIPTION KEYとREGIONを使って、AzureTtsを初期化します。

AzureTts.init(
      subscriptionKey: "YOUR SUBSCRIPTION KEY",
      region: "YOUR REGION",
      withLogs: true); // enable logs

次に、声の選択をします。以下のコードでは、英語のNeural voiceを選択しています。

// Get available voices
  final voicesResponse = await AzureTts.getAvailableVoices() as VoicesSuccess;
  
  //Pick a Neural voice
  final voice = voicesResponse.voices.where((element) =>
          element.voiceType == "Neural" && element.locale.startsWith("en-"))
      .toList(growable: false)[0];

ここでは、日本語の音声を使いますので、"en-"を"ja-"に変更します。

実際に音声合成をするコードは以下の通りです。

final text = "Microsoft Speech Service Text-to-Speech API";

TtsParams params = TtsParams(
    voice: voice,
    audioFormat: AudioOutputFormat.audio16khz32kBitrateMonoMp3,
    rate: 1.5, // optional prosody rate (default is 1.0)
    text: text);
  
  final ttsResponse = await AzureTts.getTts(params) as AudioSuccess;

必要なパラメータをparamsに設定して、AzureTts.getTts(params)で、クラウドにリクエストを投げます。

これだけでは、どうやって音声を出力するのかがよくわかりません。Exampleのページにあるコードの最後を見ると、ttsResponse.audioにオーディオデータが格納されていることがわかります。

音声を出力するには、別途audioPlayersパッケージが必要な様です。

flutter pub add audioplayers

また、バイトデータをそのままaudioPlayersで出力することができない様です。そこで、一旦テンポラリファイル作成して、Byteデータを書き込み、そのファイルをaudioPlayersに渡すこととしました。

ファイルを操作する必要ができましたので、追加でpath_providerパッケージをインストールします。

flutter pub add path_provider

path_providerは、テンポラリディレクトリを取得するために使います。

また、File操作のためにdart:ioもimportしておきます。 結果として、以下の3つのimport文を追加しました。

import 'dart:io' as io;
import 'package:path_provider/path_provider.dart';
import 'package:audioplayers/audioplayers.dart';

おうむ返し クラウド版

音声認識と、音声合成関連のStateクラスだけ抜き出すと、以下のような感じになります。今後のことを考えて、認識したユーザの言葉を "lastWords"、 ボットに発話させる言葉を"botWords"としています。

おうむ返しでは、botWords = lastWords です。

class _MyHomePageState extends State<MyHomePage> {
  String lastWords = "";
  String botWords = "";
  stt.SpeechToText speech = stt.SpeechToText();

  AudioPlayer audioPlugin = AudioPlayer();

  Voice selectedVoice = Voice();
  io.Directory directory = io.Directory('');
  io.File file = io.File('');

  _MyHomePageState() {
    AzureTts.init(
        subscriptionKey: "**************",   // YOUR KEY
        region: "*******",                   // YOUR REGION
        withLogs: true); // enable logs
    getVoices();
  }

  void getVoices() async {
    final voicesResponse = await AzureTts.getAvailableVoices() as VoicesSuccess;
    selectedVoice = voicesResponse.voices
        .where((element) =>
            element.voiceType == "Neural" && element.locale.startsWith("ja-"))
        .toList(growable: false)[0];
  }

  Future<void> synthesizeText(String text) async {
    try {
      TtsParams params = TtsParams(
          voice: selectedVoice,
          audioFormat: AudioOutputFormat.audio16khz32kBitrateMonoMp3,
          rate: 1.0,
          text: text);
      AudioSuccess ttsResponse = await AzureTts.getTts(params) as AudioSuccess;

      directory = await getTemporaryDirectory();
      file = await io.File('${directory.path}/temp.mp3');
      await file.writeAsBytes(ttsResponse.audio);
      await audioPlugin.play(file.path, isLocal: true);
    } catch (e) {
      setState(() {
        botWords = 'Azureに接続できませんでした';
      });
    }
  }

  Future<void> _speak() async {
    lastWords = "";
    bool available = await speech.initialize(
        onError: errorListener, onStatus: statusListener);
    if (available) {
      speech.listen(onResult: resultListener);
      setState(() {
      });
    } 
  }

  Future<void> _stop() async {
    await speech.stop();
    setState(() {
    });
    if (lastWords != "") {
      botWords = lastWords   // 最終的にはここでユーザ入力に対して応答を生成する。
      await synthesizeText(botWords);
    } else {
      await synthesizeText('ちょっと聞き取りにくかったです。');
    }
  }

  void resultListener(SpeechRecognitionResult result) {
    < 省略 >
  }

  void errorListener(SpeechRecognitionError error) {
    < 省略 >
  }

  void statusListener(String status) {
    < 省略 >
  }
以下、省略

蛇足

最後にちょっと蛇足です。

もう少し声が可愛くなるようにピッチを変えたいのですが、TtsParamsにはピッチに関するパラメータが含まれていませんでした。

パッケージコードの以下の部分に無理やり埋め込んでしまうことで、少し声を高くすることができます。

(flutterのinstall dir)/.pub-cache/hosted/pub.dartlang.org/flutter_azure_tts-0.1.5/lib/src/ssml/ssml.dart

  String get buildSsml {
    return "<speak version='1.0' "
        "xmlns='http://www.w3.org/2001/10/synthesis' "
        "xml:lang='${voice.locale}'>"
        "<voice xml:lang='${voice.locale}' "
        "xml:gender='${voice.gender}' "
        "name='${voice.shortName}'>"
        "<prosody rate='$speed' pitch='20%'>"   //  pitch='20%' を追記
        "$text"
        "<\/prosody><\/voice><\/speak>";
  }
}