0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

寝落ちアプリの朗読が「いかにも機械音声」だったので、Azure Neural TTSで作り直した

0
Posted at

寝落ち用の朗読アプリを作って、審査に出して、やれやれと思いながら布団の中で自分のアプリを再生してみたら、目が冴えました。声が、機械なんです。

開発中はずっとmacOSの say コマンドで音声を作っていました。ターミナルで一発で出せるし無料だし、動作確認には文句なし。ただ「目を閉じて、眠るために聴く」という状況だと、声の硬さがやけに引っかかる。眠りに落ちる手前で、ふっと意識が戻ってしまう。これ、寝落ちアプリとしては致命傷でした。

というわけで音声を全部作り直しました。最終的にAzureのNeural TTSに乗り換えて、まあ聴いていられる声にはなった、という話です。

say の調整では、たぶん勝てない

最初は「say のまま速度とか声を変えればいけるのでは」と粘りました。声をKyokoにしたり速度を落としたり。多少マシにはなるんですが、根っこの「合成してます」という質感は消えません。

理由は単純で、エンジンが古いからだと思います。Appleの say の日本語音声は、ニューラルとはいえ最新のクラウドTTSと比べると抑揚が一本調子。あと地味に効いていたのが音質で、元の音声はモノラルの22kHz・28kbpsでした。声の素性以前に、この低ビットレートの「電話っぽさ」が機械感を底上げしていた気がします。

このへんで「say の枠内でいくら頑張っても無理だな」と見切りをつけて、クラウドTTSを探し始めました。

結局Azureにしたのは、無料枠が太かったから

候補はElevenLabsとOpenAIとAzure。正直、人間らしさだけならElevenLabsが頭ひとつ抜けてる印象でした。でも今回Azureにしたのは、F0という無料枠が月50万文字までニューラル音声を無料で使えるから。このアプリは全10作品あわせても8万字くらいなので、まるごと無料枠に収まります。

寝落ちアプリの朗読で、月額を払い続けるのも違うなと。一度作ってしまえば配布物として焼き込むだけなので、ランニングコストはゼロにしたかった。日本語の自然さも十分だったので、そこで決めました。

声が一気に化けたのはSSMLの3行

乗り換えて一番効いたのはここでした。Azureはリクエストの中身をSSMLで書けるので、抑揚をいじれます。やったのは、速度を落とす、ピッチを少し下げる、句読点に無音を挟む、それだけ。

def build_ssml(text, voice, rate="-12%", pitch="-2%"):
    body = text.replace("", '。<break time="350ms"/>')
    body = body.replace("", '、<break time="120ms"/>')
    return (
        '<speak version="1.0" xml:lang="ja-JP">'
        f'<voice name="{voice}">'
        f'<prosody rate="{rate}" pitch="{pitch}">{body}</prosody>'
        f'</voice></speak>'
    )

rate="-12%"pitch="-2%"、句点で350ms、読点で120ms。この数字に深い根拠はなくて、何回か生成しては聴いて、しっくりくるところで止めただけです。でもこれだけで、淡々とした読み上げが「読み聞かせ」っぽくなった。寝かしつけの声って、声質そのものより間とテンポなんだなと、ここで腑に落ちました。

出力も48kHzに上げました。22kHzから倍以上なので、声がそのまま出てくる感じになります。

ここから半日溶かした:10分で切られる

サンプルの一節がいい感じだったので、勢いで一番長い作品を丸ごと生成したら、途中でぶつ切りになって出てきました。何度やっても同じところで切れる。

調べたら、AzureのREST APIは1リクエストで約10分の音声まででした。超えると、ちょうど10分ぶん(48kHz/96kbpsで7.2MB)だけ返してきて、残りは無言で捨てられる。エラーですらなく途中まで返ってくるので、最初は自分のコードのバグを疑って無駄に時間を使いました。

直し方は素直で、テキストを区切って何回かに分けて投げて、ffmpeg でくっつける。

list_file.write_text("".join(f"file '{p}'\n" for p in part_paths))
subprocess.run([
    "ffmpeg", "-y", "-f", "concat", "-safe", "0",
    "-i", str(list_file), "-c", "copy", str(out_path)
], check=True)

で、「3000字くらいなら10分以内だろ」と分割して投げたら、また切られた。これがしばらく分からなくて。

原因は、さっき入れたSSMLの無音でした。句点ごとに350ms、段落ごとにもっと長い間が積み上がるので、文字数から想像する尺より実際の音声がだいぶ長くなる。間を入れた自分が、自分の首を絞めていたわけです。結局チャンクは1400字くらいまで小さくして、やっと安定しました。

あと地味に効いた2つ

無料枠は文字数とは別にレート制限がそこそこ厳しくて、チャンクを連投すると 429 Quota Exceeded が返ってきます。これは待てばいいだけなので、バックオフ入りでリトライするようにしました。長い作品だと1チャンクのために100秒待たされたりもしましたが、放置で完走します。

if e.code == 429 and attempt < 5:
    time.sleep(20 * (attempt + 1))  # 20s, 40s, 60s...
    continue

もう一つ、青空文庫のテキストを使っている人向けの罠。say 向けの前処理だと句読点に [[slnc 400]] みたいな無音コマンドを差し込むことがあるんですが、これをAzureに渡すと文字として読み上げます。「スランク よんひゃく」と。間はSSMLの <break> で入れるので、テキスト側のこいつは消しておかないといけません。最初これに気づかず、出来上がった音声で素で吹き出しました。

落語だけは、男性の声にした

これは作りながら気づいたことで、全部を同じ女性の声で通したら、落語だけ何か違う。当たり前といえば当たり前で、落語はもともと男性の話芸なんですよね。なので落語だけ男性の声(ja-JP-NaokiNeural)、ほかは女性の声(ja-JP-NanamiNeural)に振り分けました。パイプラインに声の名前を渡すだけなので、手間はほぼゼロ。

ちなみに「どの声がいいか」は、候補を何種類か同じ一節で生成して、ひたすら聴き比べて決めました。ここはコードでもパラメータでもどうにもならない、完全に好みの世界。AIに音声を量産させて人間が耳で選ぶという、妙に正しい分業になっていました。


そんなこんなで、機械音声だった朗読が、ちゃんと聴いていられる声になりました。say のままリリースしてたらと思うとぞっとします。読み上げの質感で悩んでる人がいたら、SSMLの間と、長文の10分制限あたりは先に知っておくと半日得します。

(これはApp Storeリリース記の続きです。署名でハマった話やリリース作業をAIと分担した話も別で書いています)

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?