Making Style-Bert-VITS2 Speak in Home Assistant, a Work Log
Table of Contents
This article was generated by feeding my work log to Gemini 3. I’ve taken care to keep it accurate, but it may contain factual errors or inappropriate wording. Thanks for your understanding.
I wanted Home Assistant’s (HA from here on) text-to-speech (TTS) to sound more human and more emotional.
So I set up “Style-Bert-VITS2”, a speech synthesis engine highly regarded in the local LLM scene (Docker image: litagin02/Style-Bert-VITS2), and hooked it up to HA. This is the record of that trial and error.
The existing integration didn’t work as-is, and I ran into a wall with long texts, but in the end I wrote a dedicated custom component on the HA side, which got everything working perfectly without modifying the Docker container at all.
Target Setup
- TTS server:
litagin02/Style-Bert-VITS2(Docker)- API endpoint:
http://<YOUR_SBV2_IP>:5000/voice
- API endpoint:
- Client: Home Assistant (OS/Container)
- Requirements:
- Fully local operation, no internet needed.
- Recognized as a standard HA TTS (so it’s easy to use in automations).
- Reads out long texts of more than 100 characters without errors.
Problems I Hit and How I Solved Them
1. The Existing VOICEVOX Integration Doesn’t Work
Most Style-Bert-VITS2 implementations have a VOICEVOX-compatible API, so at first I wondered whether I could just plug it into the ha-voicevox integration available through HACS. But when I tried to connect, I got an error.
- Cause: The
litagin02container I used mainly runs a simple API (/voice) meant for use from a browser. The behavior of its endpoints differs in places from the strict API spec the upstream VOICEVOX integration requires (a two-step flow: generate a query with/audio_query, then synthesize with/synthesis), or perhaps the responses just didn’t match what the integration expects.
2. The GET Request Length Limit (the 100-Character Wall)
I gave up on the existing integration and decided to write my own custom component that hits the simple API (/voice) directly. But then a new problem showed up.
A short greeting like “Hello” works fine, but as soon as I send a long text of more than 100 characters, the request fails.
- Suspected cause: The simple API takes its parameters through a
GETrequest. GET requests generally have a URL length limit, but failing at a mere 100 characters or so suggests a bottleneck somewhere, either in the server’s spec (the Style-Bert-VITS2 container) or in the encoding between Python’srequestslibrary and the API server. - Constraint: Modifying the server code to support
POSTwould probably fix it, but I want to run the distributed Docker image as-is (to keep maintenance costs down when updating), so I decided not to touch the server side.
Note added after the work: Specifically, it looks like the maximum request length is capped at 100 characters right here. Overriding the config might solve the 100-character problem. But apparently “BERT’s attention mechanism scales quadratically with sequence length,” so allowing overly long text might cause its own problems.
3. The Final Fix: Splitting on the Client Side
To work around the server-side limit, I decided to implement the following logic in the HA component: “split the long text at punctuation, send the pieces as short requests one after another, and join the returned audio.”
Implementation Steps (Full Version)
These steps install my own custom component, sbv2_simple.
Step 1: Create the Directory
Create the following folder structure inside Home Assistant’s /config directory.
/config └ custom_components └ sbv2_simpleStep 2: Create the Manifest File
Create /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"}Step 3: Implement the Program
Create /config/custom_components/sbv2_simple/tts.py and put the following code in it.
This code includes the following features:
- Automatic splitting: Splits text at punctuation (。、!?).
- Safety margin: Limits each chunk to 75 characters, safely staying under the 100-character wall.
- WAV merging: Strips the headers from the received WAV binaries, concatenates the PCM data, and recalculates and attaches a correct WAV header.
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__)
# --- Settings ---# Set the server's IP address and port (change to match your environment)DEFAULT_HOST = "192.168.x.x"DEFAULT_PORT = 5000DEFAULT_LANG = "ja-JP"
# Maximum number of characters sent in a single request# Requests fail above 100 characters, so this is set to 75 to leave a safety marginMAX_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): """Generate audio (long text support version)"""
# 1. Split the text chunks = self._split_text(message, MAX_CHARS_PER_REQUEST) wav_parts = []
# 2. Send a request to the server for each chunk 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. Combine the multiple WAV data into one combined_wav = self._combine_wavs(wav_parts) return "wav", combined_wav
def _request_wav(self, text): """Internal function that sends a request to the server""" 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: # Set the timeout to 20 seconds 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): """Split the text, taking punctuation into account""" 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): """ Combine multiple WAV binaries into one (not a simple concatenation: strip the headers, join the PCM data, and recalculate the header) """ 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_wavStep 4: Enable the Configuration
Add the following to configuration.yaml.
* Change the IP address to match your own environment.
tts: - platform: sbv2_simple host: 192.168.x.x # The server's IP address port: 5000 model_id: 0 style: "Neutral" # Change to "Happy", "Sad", etc. as neededStep 5: Done
Restart Home Assistant. Style-Bert-VITS2 will show up under “Settings” > “Voice assistants” > “Text-to-speech” and can be selected.
Results in Practice
- Sound quality: Very natural. It produces voices warmer than Google’s TTS and the like, locally.
- Long text: Even when I had it read a news article of 300+ characters, it gets split into requests at appropriate points (at each punctuation mark), so it runs through to the end without errors.
I was able to build a robust system purely through tweaks on the HA side, without modifying the container. I hope this helps anyone struggling with a similar setup.

More articles
- Guarding the Heart, or a Lightning Flash of Admonition• #HomelabAn unsettling short story written by Gemini 3 Pro Preview.
- Noelle Has Come to My Home• #HomelabBy combining Home Assistant, the Wyoming Protocol, Style-Bert-VITS2 and more, I turned my home server into a Noelle smart speaker.
- Notes on Turning a Home Server into a Smart Speaker• #HomelabI turned my home server into a smart speaker by combining Home Assistant, the Wyoming Protocol, a Jabra speakerphone, and VOICEVOX.
- Noelle Reporting for Duty! (Building the AI Agent Ineffa)• #HomelabA record of how the Clawdbot-based AI agent "Ineffa (Noelle)" overcame a series of troubles and finally went live.
Notes on games, radio and technology, translated from the Japanese blog.
