Closed6

TTSをリアルタイムに近づけるライブラリ「RealtimeTTS」を試す

kun432kun432

GitHubレポジトリ

https://github.com/KoljaB/RealtimeTTS

RealtimeTTS

リアルタイムアプリケーション向けの、使いやすく低遅延な音声合成ライブラリ

プロジェクトについて

RealtimeTTSは、リアルタイムアプリケーション向けに設計された最先端の音声合成(TTS)ライブラリです。このライブラリは、テキストストリームを素早く高品質な音声出力に変換し、遅延を最小限に抑えることを特長としています。

ヒント: Linguflexもぜひご覧ください。このプロジェクトはRealtimeTTSの派生元で、音声を使って環境を制御できる、最も高度なオープンソースアシスタントの1つです。

主な特徴

  • 低遅延
    • テキストから音声への変換をほぼ瞬時に実現
    • 大規模言語モデル(LLM)の出力と互換性あり
  • 高品質な音声
    • 明瞭で自然な音声を生成
  • 複数のTTSエンジンをサポート
    - OpenAI TTS、Elevenlabs、Azure Speech Services、Coqui TTS、gTTS、Edge TTS、Parler TTS、System TTSに対応
  • 多言語対応
  • 堅牢で信頼性の高い設計
    • フォールバックメカニズムにより連続稼働を実現
    • 障害発生時には代替エンジンに切り替え、重要で専門的なユースケースにおける安定した性能を保証

ヒント: 音声入力に対応したライブラリRealtimeSTTもぜひご覧ください。RealtimeTTSと組み合わせることで、大規模言語モデルを活用した強力なリアルタイム音声ラッパーを構築できます。

技術スタック

このライブラリは以下を使用しています:

  • 音声合成エンジン
    • OpenAIEngine 🌐:OpenAIのTTS(6種類の高品質ボイス対応)
    • CoquiEngine 🏠:高品質なローカルニューラルTTS
    • AzureEngine 🌐:月50万文字の無料使用枠付きMicrosoftのTTS
    • ElevenlabsEngine 🌐:多彩なオプションがある高品質音声
    • GTTSEngine 🌐:無料のGoogle Translate TTS(GPU不要)
    • EdgeEngine 🌐:Microsoft AzureのEdge無料TTSサービス
    • ParlerEngine 🏠:高性能GPU向けのローカルニューラルTTS
    • SystemEngine 🏠:システム組み込みTTSで迅速セットアップ可能

※🏠ローカル処理(インターネット不要) 🌐インターネット接続必須

  • 文境界検出
    • NLTK文トークナイザー:シンプルなTTSタスクに最適な英語用トークナイザー
    • Stanza文トークナイザー:多言語テキストや高精度が必要な場合に適したトークナイザー

RealtimeTTSは「業界標準」コンポーネントを採用し、高度な音声ソリューション開発のための信頼性の高い技術基盤を提供します。

要件の説明

  • Python バージョン
    • 必要バージョン: Python >= 3.9, < 3.13
    • 理由: このライブラリはGitHubにあるCoquiの「TTS」ライブラリに依存しており、Pythonのこのバージョン範囲が必要です。
  • PyAudio
    • 出力オーディオストリームを作成するために使用されます。
  • stream2sentence
    • 入力されるテキストストリームを文単位に分割するために使用されます。
  • pyttsx3
    • システム組み込みの音声合成エンジンを使用して、テキストを音声に変換します。
  • pydub
    • オーディオチャンクのフォーマット変換を行います。
  • azure-cognitiveservices-speech
    • Azureのテキスト音声合成エンジンを利用するために必要です。
  • elevenlabs
    • Elevenlabsの音声合成APIを利用するために必要です。
  • coqui-TTS
  • openai
    • OpenAIのテキスト音声合成APIと連携するために使用します。
  • gtts
    - Google Translateの音声合成機能を利用して、テキストを音声に変換します

訳注: それぞれのTTSエンジンごとに別の要件がある。


OpenAI Realtime APIやGemini Multimodal Live APIなど、リアルタイムな音声対話をするものがでてきているが、シンプルにTTSだけをリアルタイムに近づけるためのライブラリという感じ。

kun432kun432

インストール

ローカルのMacで試す。

Python仮想環境を作成。自分はmiseを使うが、適宜。

mkdir realtimetts-work && cd realtimetts-work
mise use python@3.12
cat << 'EOS' >> .mise.toml

[env]
_.python.venv = { path = ".venv", create = true }
EOS
mise trust

パッケージインストール。extraの指定は必須で、使用するSTTに合わせて必要なものだけを追加することもできる。今回はallで。

pip install realtimetts[all]

Quick Startの例を日本語で。

sample.py
from RealtimeTTS import TextToAudioStream, GTTSEngine

engine = GTTSEngine()
stream = TextToAudioStream(engine)
stream.feed("こんにちは!今日はどんな一日にしたいですか?")
stream.play_async()

とりあえず普通に発話されると思う。

TextToAudioStreamでSTTエンジンからストリームを作成して、feed()でテキストを与えることで、TTSが行われる。

サンプルコードではSTTエンジンにSystemEngineが使用されていたが、自分の環境だと初回は実行できたのだけど、2回目以降はなぜか再生されなくなってしまったので、GoogleのTTSに変えた。

で、もう少しリアルタイム性を試したい、ということで、LLMと組み合わせてみる。

from RealtimeTTS import TextToAudioStream, OpenAIEngine
from openai import OpenAI
import os

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def write(prompt: str):
    for chunk in client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "あなたは親切な日本語のアシスタントです。ユーザと日本語で会話します。"},
            {"role": "user", "content" : prompt}
        ],
        temperature=0.0,
        stream=True
    ):
        if (text_chunk := chunk.choices[0].delta.content) is not None:
            print(text_chunk)  # 確認用
            yield text_chunk

engine = OpenAIEngine()
stream = TextToAudioStream(engine)

text_stream = write("走れメロスについて詳しく教えて")  # ある程度長めの文章を生成されるようなプロンプトがわかりやすい

stream.feed(text_stream)
stream.play_async()

OpenAIのLLMからの応答をストリーミングで受信しつつ、OpenAI TTSでTTS再生するというもの。

export OPENAI_API_KEY="XXXXX"
python sample.py

実行すると、ストリーミングのチャンクを順に処理している最中にTTSが再生開始されるのがわかると思う。つまり、LLMからの出力がすべて完了するのを待たずにTTSができるということになる。

日本語の場合、ある程度の文節で渡さないと、おそらくTTSがおかしなことになると思うのだけど、日本語でも大きな違和感を感じなかった(多少のTTSミスはあるが、元々TTSでも発話ミスは起きるので、個人的にはそのレベルと違いを感じなかった)。

ただ、何度かやってると、TTSが再生開始→しばらく発話→その後だんまり→発話再開、になることがあったりする。TTSにどういう単位で渡してるのかは別途確認してみたい。

上の例にある通りfeed()にイテレータでテキストを渡すとSTTしてくれるということは、シンプルに書くと以下でも良いということになる。

from RealtimeTTS import TextToAudioStream, GTTSEngine

engine = GTTSEngine()
stream = TextToAudioStream(engine)

# ref: wikipedia:オグリキャップ
# https://ja.wikipedia.org/wiki/%E3%82%AA%E3%82%B0%E3%83%AA%E3%82%AD%E3%83%A3%E3%83%83%E3%83%97
text = """
オグリキャップ(欧字名:Oguri Cap、1985年3月27日 - 2010年7月3日)は、日本の競走馬、種牡馬。

1987年5月に岐阜県の地方競馬・笠松競馬場でデビュー。8連勝、重賞5勝を含む12戦10勝を記録した後、1988年1月に中央競馬へ移籍し、重賞12勝(うちGI4勝)を記録した。1988年度のJRA賞最優秀4歳牡馬、1989年度のJRA賞特別賞、1990年度のJRA賞最優秀5歳以上牡馬および年度代表馬。1991年、JRA顕彰馬に選出。愛称は「オグリ」「芦毛の怪物」など多数。「スーパー・スター」と評された。

中央競馬時代はスーパークリーク、イナリワンの二頭とともに「平成三強」と総称され、自身と騎手である武豊の活躍を中心として起こった第二次競馬ブーム期において、第一次競馬ブームの立役者とされるハイセイコーに比肩するとも評される高い人気を得た。

競走馬引退後は北海道新冠町の優駿スタリオンステーションで種牡馬となったが、産駒から中央競馬の重賞優勝馬を出すことができず、2007年に種牡馬を引退。種牡馬引退後は同施設で功労馬として繋養されていたが、2010年7月3日に右後肢脛骨を骨折し、安楽死の処置が執られた。
"""
text_stream = iter(text)

stream.feed(text_stream)
stream.play_async()

再生させてみるとわかるけど、年や月などは英語で発話されたりする。やはり内部でどういう区切りでTTSに渡しているのか、とか、言語の設定はどうするのか、とか、日本語で使うには色々確認は必要そう。

kun432kun432

loggingを有効にしてみた。

from RealtimeTTS import TextToAudioStream, GTTSEngine
import logging

# logging設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

engine = GTTSEngine()
stream = TextToAudioStream(engine, level=logging.INFO)  # loggingを有効化

text = """
オグリキャップ(欧字名:Oguri Cap、1985年3月27日 - 2010年7月3日)は、日本の競走馬、種牡馬。

1987年5月に岐阜県の地方競馬・笠松競馬場でデビュー。8連勝、重賞5勝を含む12戦10勝を記録した後、1988年1月に中央競馬へ移籍し、重賞12勝(うちGI4勝)を記録した。1988年度のJRA賞最優秀4歳牡馬、1989年度のJRA賞特別賞、1990年度のJRA賞最優秀5歳以上牡馬および年度代表馬。1991年、JRA顕彰馬に選出。愛称は「オグリ」「芦毛の怪物」など多数。「スーパー・スター」と評された。

中央競馬時代はスーパークリーク、イナリワンの二頭とともに「平成三強」と総称され、自身と騎手である武豊の活躍を中心として起こった第二次競馬ブーム期において、第一次競馬ブームの立役者とされるハイセイコーに比肩するとも評される高い人気を得た。

競走馬引退後は北海道新冠町の優駿スタリオンステーションで種牡馬となったが、産駒から中央競馬の重賞優勝馬を出すことができず、2007年に種牡馬を引退。種牡馬引退後は同施設で功労馬として繋養されていたが、2010年7月3日に右後肢脛骨を骨折し、安楽死の処置が執られた。
"""
text_stream = iter(text)

stream.feed(text_stream)
stream.play_async()
出力
INFO:root:Initializing tokenizer nltk for language en
INFO:root:Initializing NLTK Tokenizer
INFO:root:loaded engine gtts
INFO:root:stream start
INFO:root:Time to split sentences: 0.010688066482543945
INFO:root:Time to split sentences: 1.621246337890625e-05
INFO:root:Time to split sentences: 5.0067901611328125e-06
INFO:root:Time to split sentences: 3.0994415283203125e-06
(snip)

なるほど、何も指定しなければ英語かつNLTKがトークナイザーになる。ドキュメントを見る限り、多言語の場合はstanzaを使うのが良いみたい。あと、言語の指定もできそうだし、TTSに渡しているテキストをログに取れそう。

ということでこんな感じに。

from RealtimeTTS import TextToAudioStream, GTTSEngine
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

engine = GTTSEngine()
stream = TextToAudioStream(engine, level=logging.INFO, language="ja", tokenizer="stanza")  # 言語・トークナイザーの設定

text = """
オグリキャップ(欧字名:Oguri Cap、1985年3月27日 - 2010年7月3日)は、日本の競走馬、種牡馬。

1987年5月に岐阜県の地方競馬・笠松競馬場でデビュー。8連勝、重賞5勝を含む12戦10勝を記録した後、1988年1月に中央競馬へ移籍し、重賞12勝(うちGI4勝)を記録した。1988年度のJRA賞最優秀4歳牡馬、1989年度のJRA賞特別賞、1990年度のJRA賞最優秀5歳以上牡馬および年度代表馬。1991年、JRA顕彰馬に選出。愛称は「オグリ」「芦毛の怪物」など多数。「スーパー・スター」と評された。

中央競馬時代はスーパークリーク、イナリワンの二頭とともに「平成三強」と総称され、自身と騎手である武豊の活躍を中心として起こった第二次競馬ブーム期において、第一次競馬ブームの立役者とされるハイセイコーに比肩するとも評される高い人気を得た。

競走馬引退後は北海道新冠町の優駿スタリオンステーションで種牡馬となったが、産駒から中央競馬の重賞優勝馬を出すことができず、2007年に種牡馬を引退。種牡馬引退後は同施設で功労馬として繋養されていたが、2010年7月3日に右後肢脛骨を骨折し、安楽死の処置が執られた。
"""
text_stream = iter(text)

stream.feed(text_stream)
stream.play_async(log_synthesized_text=True)  # TTS時のテキストをログに出力

ログを見ると設定が有効になっていることがわかる。なお、以下には含まれていないが、初回はstanzaのモデルダウンロードが行われるので、ちょっと時間がかかる。

出力
INFO:root:Initializing tokenizer stanza for language ja
INFO:root:Initializing Stanza Tokenizer
Downloading https://raw.githubusercontent.com/stanfordnlp/stanza-resources/main/resources_1.9.0.json: 392kB [00:00, 145MB/s]
INFO:stanza:Downloaded file to /Users/kun432/stanza_resources/resources.json
INFO:stanza:Downloading default packages for language: ja (Japanese) ...
INFO:stanza:File exists: /Users/kun432/stanza_resources/ja/default.zip
INFO:stanza:Finished downloading models and saved to /Users/kun432/stanza_resources
INFO:stanza:Loading these models for language: ja (Japanese):
===============================
| Processor    | Package      |
-------------------------------
| tokenize     | gsd          |
| pos          | gsd_charlm   |
| lemma        | gsd_nocharlm |
| constituency | alt_charlm   |
| depparse     | gsd_charlm   |
| ner          | gsd          |
===============================
INFO:stanza:Using device: cpu
INFO:stanza:Loading: tokenize
(snip)
INFO:stanza:Done loading processors!
INFO:root:loaded engine gtts
(snip)

でTTS中のログ。これを見る限り、ある程度の文節で渡しているように思えるので、年月が英語で発話されていたのはどっちかというとTTS側の問題なのではないかという気がする。

出力
INFO:root:stream start
INFO:root:-- ["オグリキャップ(欧字名: "], buffered 0.000000s
⚡ synthesizing → 'オグリキャップ(欧字名:'
INFO:root:Time to split sentences: 0.2747187614440918
INFO:root:Time to split sentences: 0.14331793785095215
INFO:root:Audio stream start, latency to first chunk: 0.96s
INFO:root:Time to split sentences: 0.16537976264953613
INFO:root:Time to split sentences: 0.15061092376708984
INFO:root:Time to split sentences: 0.1358039379119873
INFO:root:Time to split sentences: 0.13613080978393555
INFO:root:Time to split sentences: 0.1513841152191162
INFO:root:Time to split sentences: 0.15319108963012695
INFO:root:Time to split sentences: 0.15931296348571777
INFO:root:Time to split sentences: 0.15390396118164062
INFO:root:Time to split sentences: 0.16581177711486816
INFO:root:Time to split sentences: 0.17098116874694824
INFO:root:Time to split sentences: 0.16903281211853027
INFO:root:Time to split sentences: 0.1782231330871582
INFO:root:Time to split sentences: 0.19078874588012695
INFO:root:Time to split sentences: 0.20578598976135254
INFO:root:Time to split sentences: 0.1924741268157959
INFO:root:Time to split sentences: 0.1882319450378418
INFO:root:Time to split sentences: 0.24238824844360352
INFO:root:Time to split sentences: 0.26035189628601074
INFO:root:Time to split sentences: 0.23694992065429688
INFO:root:Time to split sentences: 0.2584073543548584
INFO:root:Time to split sentences: 0.21183371543884277
INFO:root:Time to split sentences: 0.204941987991333
INFO:root:Time to split sentences: 0.21967411041259766
INFO:root:Time to split sentences: 0.23357701301574707
INFO:root:Time to split sentences: 0.2772181034088135
INFO:root:Time to split sentences: 0.27651000022888184
INFO:root:Time to split sentences: 0.27794694900512695
INFO:root:Time to split sentences: 0.2927689552307129
INFO:root:Time to split sentences: 0.3012700080871582
INFO:root:Time to split sentences: 0.3173561096191406
INFO:root:Time to split sentences: 0.34099388122558594
INFO:root:Time to split sentences: 0.34736204147338867
INFO:root:Time to split sentences: 0.3882260322570801
INFO:root:Time to split sentences: 0.3885068893432617
INFO:root:Time to split sentences: 0.4132242202758789
INFO:root:Time to split sentences: 0.42574596405029297
INFO:root:-- ["Oguri Cap、1985年3月27日 - 2010年7月3日)は、日本の競走馬、種牡馬。 "], buffered -2.742857s
⚡ synthesizing → 'Oguri Cap、1985年3月27日 - 2010年7月3日)は、日本の競走馬、種牡馬。'
INFO:root:Time to split sentences: 0.17043089866638184
INFO:root:Time to split sentences: 0.15935301780700684
INFO:root:Time to split sentences: 0.18973612785339355
INFO:root:Time to split sentences: 0.18868112564086914
INFO:root:Time to split sentences: 0.15686893463134766
INFO:root:-- ["1987年5月に岐阜県の地方競馬・笠松競馬場でデビュー。 "], buffered -15.333878s
⚡ synthesizing → '1987年5月に岐阜県の地方競馬・笠松競馬場でデビュー。'
INFO:root:Time to split sentences: 0.16449904441833496
INFO:root:Time to split sentences: 0.1653611660003662
INFO:root:Time to split sentences: 0.2032308578491211
INFO:root:Time to split sentences: 0.16022515296936035
INFO:root:Time to split sentences: 0.16955280303955078
INFO:root:Time to split sentences: 0.1859760284423828
INFO:root:Time to split sentences: 0.1826009750366211
INFO:root:Time to split sentences: 0.1911029815673828
INFO:root:Time to split sentences: 0.19673776626586914
INFO:root:Time to split sentences: 0.2009727954864502
INFO:root:Time to split sentences: 0.19942712783813477
INFO:root:Time to split sentences: 0.21091604232788086
INFO:root:Time to split sentences: 0.23510503768920898
INFO:root:Time to split sentences: 0.22292494773864746

OpenAI TTSに変えてみたら、年月は正しく発話された。ただ別の箇所で発話ミスしたり、イントネーションおかしかったりとか、このへんはモデルごとに変わってくるよね。あとレイテンシーも気になった。

あとplay_async/playでも言語やトークナイザーは設定できるみたいなのだけど、こちらだとtokenize_sentencesで自分でトークナイザー関数を書いて処理させることもできるみたい。

ロギング有効にしつつ色々試行錯誤して確認しよう。

kun432kun432

まとめ

冒頭にも書いている通り、OpenAI Realtime APIやGemini Multimodal Live APIなど、リアルタイムな音声対話をするものがでてきているが、WebSocketやWebRTCなどやや実装が複雑、かつ、それぞれのベンダーでしか利用できない、ということもある。

LLMはストリーミングを提供してくれているので、TTSでもストリーミング処理を実装すれば、少なくともレスポンスの発話部分のレイテンシーは短縮できる。この部分を含めて、入出力すべてを実装している例としては以下の記事がある。

https://engineers.ntt.com/entry/202411-streaming-dialogue/entry

https://developers.cyberagent.co.jp/blog/archives/44592/

「RealtimeTTS」はTTSの部分をラップしてくれて、STTについては「RealtimeSTT」を使えば、同じことができるようになるのではないかなと思うので、あわせて試してみたい。レポジトリには、「RealtimeTTS」「RealtimeSTT」をLLMと組み合わせた例なども用意されているようなので、参考になりそう。

あと手動で実装することにも意味はあると思うので、上の記事の内容も実際に書いてみたいと思う。

kun432kun432

ただ、何度かやってると、TTSが再生開始→しばらく発話→その後だんまり→発話再開、になることがあったりする。TTSにどういう単位で渡してるのかは別途確認してみたい。

高速化できないかなと思って、パラメータを少し調べてみた。play / play_asyncに付与できるもの。

fast_sentence_fragment

  • 概要:
    • 最初の文をフラグメント(部分)ごとに合成し、出力までの時間を短縮する
    • デフォルト値: True
  • メリット:
    • ユーザーが最初の音声をすばやく聞けるようにする。
    • テキストが長い場合に効果的。
  • 注意点:
    • 2文目以降は通常の合成プロセスに戻るため、長いテキスト全体ではリアルタイム性が落ちる場合がある。

fast_sentence_fragment_allsentences

  • 概要:
    • fast_sentence_fragment の動作を全ての文に適用、つまり、最初の1文だけでなく、2文目以降もフラグメント単位で音声を生成する。
    • デフォルト値: False
  • メリット:
    • 長文でも各文がすばやく出力されるため、リアルタイム性をさらに向上。
  • 注意点:
    • フラグメント単位での処理は音声の自然さが若干損なわれる可能性がある(特に文全体の流れを考慮した音声合成において)。

fast_sentence_fragment_allsentences_multiple

  • 概要:
    • 各文に対して複数のフラグメントを同時に生成・再生する。これにより、文単位ではなくフラグメント単位でリアルタイム性が向上。
    • デフォルト値: False
  • メリット:
    • 特に長い文の場合に、リアルタイムの流暢な出力が可能になる。
    • より細かい粒度で音声生成を制御。
  • 注意点:
    • フラグメント生成が頻繁に行われるため、エンジンに負荷がかかる場合がある
    • 短い文ではあまり恩恵がない場合がある。

とりあえず以下を試してみたけど、結構早くなったように思える。ただ多少発話の流れが変になる場合はたしかにあった。

    stream.play_async(
        fast_sentence_fragment=True,
        fast_sentence_fragment_allsentences=True,
        fast_sentence_fragment_allsentences_multiple=True,
    )

buffer_threshold_seconds

  • 概要:
    • バッファリングのしきい値を秒単位で指定。この値は、音声再生の滑らかさと連続性に影響する。
    • デフォルト値: 0.0
    • 動作の仕組み
      • 新しい文を合成する前に、システムはバッファ内の残り音声データが buffer_threshold_seconds で指定された時間より長いかを確認する。
      • 指定された時間よりバッファが長い場合、新しい文の合成を行う
      • 低遅延を重視する場合: 小さな値(例: 0.1)を設定し、即時の応答を優先
      • 音声再生の滑らかさを重視する場合: 大きな値(例: 1.0 以上)を設定し、途切れのない音声再生を確保。
  • メリット
    • バッファ内に十分な音声データを用意することで、音声再生中の無音や途切れを防ぐことができる。
  • 注意点:
    • 値を大きく設定すると、バッファが多くなるため音声再生が滑らかになるが、初回の音声出力までに遅延が発生する可能性がある。
    • 値を小さく設定すると、音声合成エンジンが迅速に反応するが、再生中に音声が途切れるリスクが高まる。

以下でさらに早くなったというか、しばらく発話→その後だんまりがだいぶ気にならなくなった気がする。

    stream.play_async(
        fast_sentence_fragment=True,
        fast_sentence_fragment_allsentences=True,
        fast_sentence_fragment_allsentences_multiple=True,
        buffer_threshold_seconds=0.5,
    )

ユーザ側は入力、LLM側はTTSで対話できるようにしてみた、結構悪くない気がする。

from RealtimeTTS import TextToAudioStream, OpenAIEngine
from openai import OpenAI
import os

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def write(prompt: str):
    for chunk in client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "あなたは親切な日本語のアシスタントです。ユーザと日本語で会話します。"},
            {"role": "user", "content" : prompt}
        ],
        temperature=0.0,
        stream=True
    ):
        if (text_chunk := chunk.choices[0].delta.content) is not None:
            print(text_chunk)
            yield text_chunk

engine = OpenAIEngine()
stream = TextToAudioStream(engine)

while True:
    user_input = input("User: ")
    if user_input.lower() == 'quit':
        print("チャットを終了します。さようなら。")
        break

    text_stream = write(user_input)

    stream.feed(text_stream)
    stream.play_async(
        fast_sentence_fragment=True,
        fast_sentence_fragment_allsentences=True,
        fast_sentence_fragment_allsentences_multiple=True,
        buffer_threshold_seconds=0.5,
    )
このスクラップは2024/12/25にクローズされました