寝落ち用の朗読アプリを作って、審査に出して、やれやれと思いながら布団の中で自分のアプリを再生してみたら、目が冴えました。声が、機械なんです。
開発中はずっと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と分担した話も別で書いています)