2056 文字
10 分
Home AssistantでStyle-Bert-VITS2を喋らせるまでの作業ログ

この記事は、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
  • クライアント: 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 を作成し、以下のコードを配置します。

このコードには以下の機能が含まれます:

  1. 自動分割: 句読点(。、!?)を基準に文章を分割。
  2. 安全性確保: 分割単位を 75文字 に制限し、100文字の壁を安全に回避。
  3. WAV結合: 受信した複数のWAVバイナリからヘッダを除去し、PCMデータを結合して正しいWAVヘッダを再計算・付与。
import logging
import requests
import io
import struct
import re
import voluptuous as vol
from homeassistant.components.tts import Provider, PLATFORM_SCHEMA, CONF_LANG
from homeassistant.const import CONF_HOST, CONF_PORT
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
# --- 設定 ---
# サーバーのIPアドレスとポートを設定(環境に合わせて変更してください)
DEFAULT_HOST = "192.168.x.x"
DEFAULT_PORT = 5000
DEFAULT_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側の工夫だけで堅牢なシステムが構築できました。同様の構成で悩んでいる方の参考になれば幸いです。

封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00