耳慣らしのために英文テキストを読む音声ファイルを作成したい

このエントリは2021/10/19現在の情報に基づいています。将来の機能追加や変更に伴い、記載内容からの乖離が発生する可能性があります。

いつもとは違う人から、以下のような問い合わせをもらった。

問い合わせ

今英語の勉強をしていて、耳ならしのためにある程度まとまった英文を音声ファイルにしたいのだが、何かいい方法はないか?できれば、各国の訛りが出せるとうれしい。

個人的には、「それ、たぶん六本木あたりに行ったらなんとかなるで(知らんけど)」とか、「それ、Google先生にお願いしたらええねん(知らんけど)」と思うのだが、TOEICやTOEFL、IELTSなどのテスト対策として用意されているListening教材だけじゃなくて、新聞などの文章を読ませたい、ということらしい。そしてさらに、自分の音声を聞かせて、それが正しく英語として認識されるのか試せればよいなぁ、とも言っていたが、それはおまけ、とのこと。

前者はファイルはテキスト形式で、これをBlob storageあたりに放り込むと、うまい具合に出力されたらいいなー、ということのよう。イメージはこんな感じ。

デザインコンセプト

お手盛り感は拭えないが、ひとまずこちら。

  • ファイルの受け渡し
    • 一定期間を過ぎればアーカイブできてお財布にも優しい点を考慮してBlob storageを使う
  • 音声変換
    • AzureのText-to-Speechサービスを使う
  • 処理実行・制御
    • 大量のファイルが到着しても処理できるように、スケール可能なAzure Functionsを使う
    • 国ごとの訛りを反映したファイルを生成できるよう、ロケールを指定できるようにしておく

ドキュメントはこちら。

テキスト読み上げのドキュメント / Text-to-speech documentation
https://docs.microsoft.com/azure/cognitive-services/speech-service/index-text-to-speech

Function app実装の言語は当然Javaである。Javaバージョンは11、OSはLinux、SKUはConsumptionを使うが、これらについては完全にお好みである。

実装

音声サービス

テキストをしゃべらせるためのサービスがCognitive Serviceとして用意されている。今回はSpeech to Text(文字起こし)ではなく、Text to Speech(テキスト読み上げ)なので、音声合成サービスを使う。ニューラル テキスト読み上げだと、自然っぽい読み上げになるので、耳慣らしにはちょうどよいかもしれない。

テキスト読み上げのドキュメント / Text-to-speech documentation
https://docs.microsoft.com/azure/cognitive-services/speech-service/index-text-to-speech

インスタンスを作成し、キー(Subscription Key)とエンドポイント、そして場所を取得しておく。これらは音声データの属性を取得したり、音声合成サービスを呼び出したりする際に必要。

続いて、音声リストを取得する。これは、各国語で音声合成するにあたって、どの音声を使って合成するかを指定するために使う。詳細は以下のドキュメントを参照。

Text to Speech REST API
https://docs.microsoft.com/azure/cognitive-services/speech-service/rest-text-to-speech

以下ではちょっとだけ手順を記載しておく。

認証トークンを取得するなら、上記の手順で取得してエンドポイントに対して以下のようにアクセスする。なお、音声リストを取得するだけならこの操作は不要。

POST /sts/v1.0/issueToken HTTP/1.1
Ocp-Apim-Subscription-Key: <Subscription Key>
Host: <region>.api.cognitive.microsoft.com
Content-type: application/x-www-form-urlencoded
Content-Length: 0

【注意】JDK 11のHTTP ClientではContent-Lengthを指定できないためにトークン取得に失敗するので、別のHTTP Clientを使うことを推奨する(JDK-8213696にある通り、JDK 12以後であればシステムプロパティ jdk.httpclient.allowRestrictedHeaders を指定すれば回避できるが、FunctionsがサポートしているのはJDK 11までなので、回避策がない。。)。

[JDK-8213696] Make restricted headers in HTTP Client configurable and remove Date by default
https://bugs.openjdk.java.net/browse/JDK-8213696

音声リストは以下の呼び出しで取得する。Subscription Keyを使うのであれば、Authorizationヘッダーは不要。

GET /cognitiveservices/voices/list HTTP/1.1

Authorization: Bearer <token>
Host: <region>.tts.speech.microsoft.com
Ocp-Apim-Subscription-Key: <Subscription Key>

結果はJSON形式(配列)で取得できる。以下はPostmanで取得した例。

Function appが稼働するタイミングで都度取得してもよいが、そうそう頻繁に変更されることもないので、このJSONをファイルから読み出すようにしておいてもよい。

いずれのAPIもOcp-Apim-Subscription-Keyを使ってSubscription Keyを指定しているので、このAPIはAzure API Managementでホストされていることがわかる。

これで音声サービスに関連する構成はおしまい。SDKはFunction appで使うので後ほど。

Blob storage

今回はFunctionsを構成した時点で作成されるStorageにBlobコンテナーを作成し、そこにファイルを突っ込むようにする。もちろん、別のStorage accountにBlobコンテナーを作成してもよい。

Function app

ArcheTypeを使ってプロジェクトを生成しておく。以下はPortalから得られるMavenコマンドの例。

mvn archetype:generate \
-DarchetypeGroupId=com.microsoft.azure \
-DarchetypeArtifactId=azure-functions-archetype \
-DappName=text2speech-java \
-DappRegion=japaneast \
-DresourceGroup=text2speech \
-DgroupId=com.{functionAppName}.group \
-DartifactId={functionAppName}-functions \
-Dpackage=com.{functionAppName} \
-DinteractiveMode=false

依存関係

生成されたpom.xmlに、音声サービスのSDKならびにJSON操作用の依存関係を追加する必要がある。音声サービスのSDKはPublic Mavenリポジトリには存在しないので、リポジトリを明示的に指定しなければならない。詳細は以下のURLを参照。

クイック スタート:開発環境を設定する / Quickstart: Setup development environment
https://docs.microsoft.com/azure/cognitive-services/speech-service/quickstarts/setup-platform?pivots=programming-language-java&tabs=dotnet%2Cwindows%2Cjre%2Cbrowser

2021/10/19現在、リポジトリは以下。

<repositories>
    <repository>
        <id>maven-cognitiveservices-speech</id>
        <name>Microsoft Cognitive Services Speech Maven Repository</name>
        <url>https://csspeechstorage.blob.core.windows.net/maven/</url>
    </repository>
</repositories>

依存関係は以下を追加する。2021/10/19現在の最新は1.18.0のよう。

<dependency>
    <groupId>com.microsoft.cognitiveservices.speech</groupId>
    <artifactId>client-sdk</artifactId>
    <version>1.18.0</version>
</dependency>

今回はJSON処理にorg.json.jsonを使ったので、2021/10/19現在最新の20210307を使っている。

<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20210307</version>
</dependency>

トリガーとバインド

Function appではBlobトリガーとBlob出力バインドを構成する。HTTPトリガーでPOSTによるリクエストを投げ込んでから、Blob storageに入っているBlobを処理してもいいのだが、シンプルにしたいので、今回はBlobトリガーを使う。Blob出力バインドは戻り値として構成することもできるが、今回はパラメータとして構成する。

Azure Functions の Azure Blob Storage トリガー / Azure Blob storage trigger for Azure Functions
https://docs.microsoft.com/azure/azure-functions/functions-bindings-storage-blob-trigger?tabs=java
Azure Functions における Azure Blob Storage の出力バインド / Azure Blob storage output binding for Azure Functions
https://docs.microsoft.com/azure/azure-functions/functions-bindings-storage-blob-output?tabs=java

...
    @FunctionName("Text2Speech-Java")
    @StorageAccount("AzureWebJobsStorage")
    public void run(
        @BlobTrigger(
            name = "source",
            path = "in-files/{inName}_{gender}_{locale}.txt",
            dataType = "binary")
            byte[] source,
        @BindingName("inName") String sourceFileName,
        @BindingName("gender") String gender,
        @BindingName("locale") String locale,
        @BlobOutput(
            name = "target",
            dataType = "binary",
            path = "out-files/{inName}_{gender}_{locale}.mp3")
            OutputBinding<byte[]> target,
        final ExecutionContext context
    ) {
...

入力ファイルの命名ルールから必要な情報を取得するために、@BindingName で以下を取得・設定している。

  • inName(ファイル名)
  • gender(音声話者の性別)
  • locale(ロケール)

設定情報の取得

音声サービスのSubscription Keyと地域は、アプリケーションの設定(ローカル開発時はlocal.settings.json)に構成し、環境変数として取得できるようにしている。もちろん、Key Vault参照にしてもかまわない。Key Vault参照は以下。

App Service と Azure Functions の Key Vault 参照を使用する / Use Key Vault references for App Service and Azure Functions
https://docs.microsoft.com/azure/app-service/app-service-key-vault-references

過去に以下のようなエントリも書いていた。。

SpeechConfigの設定

テキスト読み上げでは、ロケール、音声名、音声出力フォーマットなどを指定できる。今回は、Blobトリガーで取得したロケール、話者の性別を使って音声名を探索して設定している。音声出力フォーマットはmp3。

SpeechConfig speechConfig = SpeechConfig.fromSubscription(speechKey, speechRegion);
speechConfig.setSpeechSynthesisLanguage(locale);
speechConfig.setSpeechSynthesisVoiceName(voiceName);
speechConfig.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Audio16Khz128KBitRateMonoMp3);

テキスト読み上げ

今回、Blob storageに書き出したいので、AudioOutputStreamで受け取るようにした。先頭行で、Blob storageに格納されたテキストファイルの内容をtextに格納し、その内容を音声サービスに渡している。このあたりはクイックスタートのコードとあまり変わらない。

音声合成の結果はresultに格納されているので、これを使って詳細を確認している。また、音声データのバイト列は result.getAudioData() で取得できるので、これをBlob出力バインドの引数に渡している。

String text = new String(source, Charset.defaultCharset());

try (PullAudioOutputStream stream = AudioOutputStream.createPullStream();
     AudioConfig streamConfig = AudioConfig.fromStreamOutput(stream);
     SpeechSynthesizer synthesizer = new SpeechSynthesizer(speechConfig, streamConfig))
{
    // 結果の確認
    SpeechSynthesisResult result = synthesizer.SpeakTextAsync(text).get();
    if (result.getReason() == ResultReason.SynthesizingAudioCompleted)
    {
        context.getLogger().info("Speech synthesized for text [" + text + "], and the audio was written to output stream.");
    }
    else if (result.getReason() == ResultReason.Canceled)
    {
        SpeechSynthesisCancellationDetails cancellation = SpeechSynthesisCancellationDetails.fromResult(result);
        context.getLogger().info("CANCELED: Reason=" + cancellation.getReason());

        if (cancellation.getReason() == CancellationReason.Error)
        {
            context.getLogger().info("CANCELED: ErrorCode=" + cancellation.getErrorCode());
            context.getLogger().info("CANCELED: ErrorDetails=" + cancellation.getErrorDetails());
            context.getLogger().info("CANCELED: Did you update the subscription info?");
        }
    }
    // 結果を取得し、バイト列に書き出す
    target.setValue(result.getAudioData());
}
catch (ExecutionException | InterruptedException executionException)
{
    executionException.printStackTrace();
    context.getLogger().severe(executionException.getLocalizedMessage());
}

これでおしまい。ビルドしてデプロイまで実施しておく。

テスト

ローカルでテストしてもよいが、いきなりクラウドで実施してももちろんよい。

今回使った文章はBBCニュースの以下の記事。

Facebook to hire 10,000 in EU to work on metaverse
https://www.bbc.com/news/world-europe-58949867

記事をテキストファイルとして保存して、前述のファイル命名規則に従って名前をつけておく。中身は全く同じテキストファイルである。生成した音声ファイルを聞いてみると、話者がそもそも違うので比較しづらいのはあるが、インド英語はいかにも感がある。ナイジェリア英語は聞いたことがないので、こんな感じなのかもしれない。アメリカとイギリスは、もっちゃりしているかキレがあるかでなんとなく違うなぁ、というのがわかる。

入力ファイル名話者の性別ロケール出力ファイル
BBCworldnews-europe-58949867_female_en-GB.txtfemaleen-GBbbcworldnews-europe-58949867_female_en-gb.mp3
BBCworldnews-europe-58949867_female_en-IN.txtfemaleen-INbbcworldnews-europe-58949867_female_en-in.mp3
BBCworldnews-europe-58949867_female_en-NG.txtfemaleen-NGbbcworldnews-europe-58949867_female_en-ng.mp3
BBCworldnews-europe-58949867_female_en-US.txtfemaleen-USbbcworldnews-europe-58949867_female_en-us.mp3
BBCworldnews-europe-58949867_male_en-GB.txtmaleen-GBbbcworldnews-europe-58949867_male_en-gb.mp3
BCworldnews-europe-58949867_male_en-IN.txtmaleen-INbbcworldnews-europe-58949867_male_en-in.mp3
BBCworldnews-europe-58949867_male_en-NG.txtmaleen-NGbbcworldnews-europe-58949867_male_en-ng.mp3
BBCworld-europe-58949867_male_en-US.txtmaleen-USbbcworldnews-europe-58949867_male_en-us.mp3

まとめ

問い合わせ主にこの例を紹介したところ、「そんなに難しくなさそうで、かつコストもそんなにかからなさそうだね」と理解してくれた。

Resources

GitHub repository
https://github.com/anishi1222/text2speech

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中