この記事は、Gemini 3 に作業ログを読み込ませて生成したものです。内容の正確性については十分に注意を払っていますが、事実誤認や不適切な表現が含まれる可能性があります。ご了承ください。
Home Assistant(以下HA)の音声読み上げ(TTS)を、より人間らしく、感情豊かなものにしたい。
そんな思いから、ローカルLLM界隈で評価の高い音声合成エンジン 「Style-Bert-VITS2」 (Dockerイメージ: litagin02/Style-Bert-VITS2)を導入し、HAと連携させるまでの試行錯誤の記録です。
既存の統合がそのままでは使えず、長文読み上げの壁にもぶつかりましたが、最終的に 「HA側で専用のカスタムコンポーネントを自作する」 ことで、Dockerコンテナを改造することなく完璧な動作を実現しました。
目標構成
- TTSサーバー:
litagin02/Style-Bert-VITS2(Docker)- APIエンドポイント:
http://<YOUR_SBV2_IP>:5000/voice
- APIエンドポイント:
- クライアント: Home Assistant (OS/Container)
- 要件:
- ネット不要の完全ローカル動作。
- HAの標準TTSとして認識させる(オートメーションで使いやすくする)。
- 100文字を超える長文もエラーなく読み上げる。
直面した課題と解決のプロセス
1. 既存のVOICEVOX統合が動かない
Style-Bert-VITS2の実装の多くはVOICEVOX互換APIを持っているため、最初はHACSで公開されている ha-voicevox 統合にそのまま追加できないかと考えました。しかし、接続を試みるとエラーが発生しました。
- 原因: 今回使用した
litagin02版のコンテナは、ブラウザでの利用を想定した簡易API (/voice) がメインで動いており、本家VOICEVOX統合が要求する厳密なAPI仕様(/audio_queryでクエリ生成 →/synthesisで合成という2段階方式)と、エンドポイントの挙動が一部異なっていた(あるいは統合側が期待するレスポンスと噛み合わなかった)ようです。
2. GETリクエストの文字数制限(100文字の壁)
既存統合を諦め、簡易API (/voice) を直接叩くカスタムコンポーネントを自作することにしました。しかし、ここで新たな問題が発生します。
「こんにちは」のような短い挨拶は問題なく喋りますが、100文字を超えるような長文を送ると、リクエストが失敗してしまうのです。
- 推測される原因: 簡易APIは
GETリクエストでパラメータを受け取る仕様でした。一般的にGETリクエストにはURL長制限がありますが、今回はわずか100文字程度で失敗するため、サーバー(Style-Bert-VITS2コンテナ)側の仕様、あるいはPythonのrequestsライブラリとAPIサーバー間のエンコード処理において、何らかのボトルネックが存在するようです。 - 制約: サーバー側のコードを修正して
POST対応にすれば解決しそうですが、配布されているDockerイメージをそのまま運用したい(更新時のメンテナンスコストを下げたい)ため、サーバー側には手を加えない方針としました。
作業後の注釈: 具体的にはここで、リクエストの最大値を100文字に制限しているっぽい。configを上書きしてしまえば、100文字問題は解決するかもしれない。が、「BERTのAttentionメカニズムはシーケンス長の二乗に比例して計算量が増加する」らしいので、あまりにも長い文章を許可すると、それはそれで問題が生じるかもしれない。
3. 最終解決策:クライアント側分割アプローチ
サーバー側の制限を回避するため、HA側のコンポーネントで 「長文を句読点で分割し、短いリクエストとして連続送信し、返ってきた音声を結合する」 ロジックを実装することにしました。
実装手順(完全版)
以下の手順で、独自のカスタムコンポーネント sbv2_simple を導入します。
ステップ1: ディレクトリ作成
Home Assistantの /config ディレクトリ内に以下のフォルダ構成を作ります。
/config └ custom_components └ sbv2_simpleステップ2: マニフェストファイルの作成
/config/custom_components/sbv2_simple/manifest.json を作成します。
{ "domain": "sbv2_simple", "name": "Style-Bert-VITS2 Simple", "documentation": "https://github.com/litagin02/Style-Bert-VITS2", "dependencies": [], "codeowners": [], "requirements": ["requests"], "version": "1.0.0", "iot_class": "local_push"}ステップ3: プログラム本体の実装
/config/custom_components/sbv2_simple/tts.py を作成し、以下のコードを配置します。
このコードには以下の機能が含まれます:
- 自動分割: 句読点(。、!?)を基準に文章を分割。
- 安全性確保: 分割単位を 75文字 に制限し、100文字の壁を安全に回避。
- WAV結合: 受信した複数のWAVバイナリからヘッダを除去し、PCMデータを結合して正しいWAVヘッダを再計算・付与。
import loggingimport requestsimport ioimport structimport reimport voluptuous as vol
from homeassistant.components.tts import Provider, PLATFORM_SCHEMA, CONF_LANGfrom homeassistant.const import CONF_HOST, CONF_PORTimport homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
# --- 設定 ---# サーバーのIPアドレスとポートを設定(環境に合わせて変更してください)DEFAULT_HOST = "192.168.x.x"DEFAULT_PORT = 5000DEFAULT_LANG = "ja-JP"
# 1回のリクエストで送る最大文字数# 100文字を超えるとリクエストが失敗する現象を回避するため、安全マージンを見て75文字に設定MAX_CHARS_PER_REQUEST = 75
CONF_MODEL_ID = "model_id"CONF_STYLE = "style"CONF_SDP_RATIO = "sdp_ratio"CONF_NOISE = "noise"CONF_LENGTH = "length"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_MODEL_ID, default=0): cv.positive_int, vol.Optional(CONF_STYLE, default="Neutral"): cv.string, vol.Optional(CONF_SDP_RATIO, default=0.2): vol.Coerce(float), vol.Optional(CONF_NOISE, default=0.6): vol.Coerce(float), vol.Optional(CONF_LENGTH, default=1.0): vol.Coerce(float), vol.Optional(CONF_LANG, default=DEFAULT_LANG): cv.string,})
def get_engine(hass, config, discovery_info=None): return SBV2Provider(config)
class SBV2Provider(Provider): """Style-Bert-VITS2 Provider with Long Text Support"""
def __init__(self, config): self._host = config[CONF_HOST] self._port = config[CONF_PORT] self._model_id = config[CONF_MODEL_ID] self._style = config[CONF_STYLE] self._sdp_ratio = config[CONF_SDP_RATIO] self._noise = config[CONF_NOISE] self._length = config[CONF_LENGTH] self._lang = config[CONF_LANG] self.name = "Style-Bert-VITS2"
@property def default_language(self): return self._lang
@property def supported_languages(self): return [self._lang]
def get_tts_audio(self, message, language, options=None): """音声を生成する(長文対応版)"""
# 1. 文章を分割する chunks = self._split_text(message, MAX_CHARS_PER_REQUEST) wav_parts = []
# 2. 分割した文章ごとにサーバーへリクエスト for i, chunk in enumerate(chunks): _LOGGER.debug(f"Requesting chunk {i+1}/{len(chunks)}: {chunk}") wav_data = self._request_wav(chunk) if wav_data: wav_parts.append(wav_data) else: _LOGGER.error(f"Failed to generate audio for chunk: {chunk}") return None, None
if not wav_parts: return None, None
# 3. 複数のWAVデータを1つに結合する combined_wav = self._combine_wavs(wav_parts) return "wav", combined_wav
def _request_wav(self, text): """サーバーにリクエストを送る内部関数""" url = f"http://{self._host}:{self._port}/voice" params = { "text": text, "model_id": self._model_id, "style": self._style, "sdp_ratio": self._sdp_ratio, "noise": self._noise, "length": self._length, "language": "JP" } try: # タイムアウトを20秒に設定 response = requests.get(url, params=params, timeout=20) if response.status_code == 200: return response.content return None except Exception as e: _LOGGER.error(f"Connection Error: {e}") return None
def _split_text(self, text, max_len): """句読点などを考慮して文章を分割する""" parts = re.split(r'([。、!?\n]+)', text) chunks = [] current_chunk = ""
for part in parts: if len(current_chunk) + len(part) <= max_len: current_chunk += part else: if current_chunk: chunks.append(current_chunk)
if len(part) > max_len: for i in range(0, len(part), max_len): chunks.append(part[i:i+max_len]) current_chunk = "" else: current_chunk = part
if current_chunk: chunks.append(current_chunk)
return [c for c in chunks if c.strip()]
def _combine_wavs(self, wav_bytes_list): """ 複数のWAVバイナリを1つに結合する (単純結合ではなく、ヘッダを除去してPCMデータを繋ぎ、ヘッダを再計算する) """ if len(wav_bytes_list) == 1: return wav_bytes_list[0]
data_chunks = [] fmt_chunk = None
for wav_bytes in wav_bytes_list: try: data_marker_index = wav_bytes.find(b'data') if data_marker_index == -1: continue
data_size = int.from_bytes(wav_bytes[data_marker_index+4 : data_marker_index+8], 'little') raw_data = wav_bytes[data_marker_index+8 : data_marker_index+8+data_size]
data_chunks.append(raw_data)
if fmt_chunk is None: fmt_chunk = wav_bytes[:data_marker_index] except Exception: continue
if fmt_chunk is None or not data_chunks: return wav_bytes_list[0]
combined_data = b''.join(data_chunks) total_data_len = len(combined_data)
file_size = len(fmt_chunk) + 8 + total_data_len riff_size_bytes = (file_size - 8).to_bytes(4, 'little') new_header = fmt_chunk[:4] + riff_size_bytes + fmt_chunk[8:]
final_wav = new_header + b'data' + total_data_len.to_bytes(4, 'little') + combined_data return final_wavステップ4: 設定の有効化
configuration.yaml に以下を追記します。
※IPアドレスはご自身の環境に合わせて変更してください。
tts: - platform: sbv2_simple host: 192.168.x.x # サーバーのIPアドレス port: 5000 model_id: 0 style: "Neutral" # 必要に応じて "Happy", "Sad" などに変更可能ステップ5: 完了
Home Assistant を再起動します。 「設定」>「音声アシスタント」>「Text-to-speech」に Style-Bert-VITS2 が表示され、選択可能になります。
運用結果
- 音質: 非常に自然。Google等のTTSより温かみがある音声がローカルで生成できます。
- 長文: 300文字以上のニュース記事などを読ませても、適切な位置で(句読点ごとに)分割リクエストされるため、エラーにならず完走します。
コンテナを改造せずに、HA側の工夫だけで堅牢なシステムが構築できました。同様の構成で悩んでいる方の参考になれば幸いです。
