
Jarvis Learns Piper - Text-to-Speech Locally in the Homelab
Jarvis Learns Piper - Text-to-Speech Locally in the Homelab
Two voice assistant speakers, one in the kitchen, one in the bedroom, both respond to “Hey Jarvis”. They’re two Echo Dots from the 2nd generation, flashed with EchoMuse and now working as voice satellites for Home Assistant without Alexa. So far, however, all voice processing has run through Home Assistant Cloud by Nabu Casa: speech recognition (STT) was handled by the cloud, text-to-speech (TTS) as well, using the Austrian voice “Jonas”. In between sat Home Assistant’s built-in assistant, with no speech model at all.
That’s convenient, but it depends on internet and a subscription. My goal is to bring the pipeline into my own homelab step by step. We start with text-to-speech using Piper. Speech recognition comes later, because with Whisper and company I want to look at the model question in peace first.
Step 1: Cleaning Up Before the Overhaul
Before I changed anything, I wanted to know what Assist actually had access to. The inventory was sobering: of ten entities that were exposed to Assist, seven pointed to blinds that no longer existed. In total, Assist could control exactly three lights. I removed the dead exposures.

On top of that came a third speaker, an M5Stack Atom Echo with ESPHome, which hadn’t been reachable for weeks. It was supposed to be gone, and that’s where I stumbled: ESPHome devices can’t be deleted through the device registry. The way is through the device’s integration entry under ESPHome; then it disappears along with its entities.
I made a few decisions consciously:
- Heating and curtains remain without voice exposure.
- Bedroom: Instead of the large light group with effect and nightlight lights, only the main light is exposed.
- Aliases: The main lights now only respond to “living room” and “bedroom”.
I tested this with a simple query like “Is living room on?” That way you can see whether Assist finds the right entity without actually switching anything.
Exposure vs. Custom Voice Commands
Exposure in the Assist settings determines only what the built-in commands can see and control - sentences like “turn light on” or “close blinds”.
Automations with voice triggers work differently. Custom sentences like “Code Red” are checked by Home Assistant first, and the associated automation can switch anything it contains. It doesn’t need exposure for that.
This means: exposure is not a security mechanism. What can be triggered by voice can be triggered by anyone in earshot. So I think about each voice command and whether I’m comfortable with a visitor or the TV saying it too.
Step 2: Piper in the Homelab
My homelab server isn’t a powerhouse for AI workloads: Intel i5-1145G7 with 4 cores and 8 threads, 62 GB RAM, no dedicated GPU. About 30 Docker containers already run on it. For speech synthesis with Piper, that should be enough - whether it actually is will show in step 3.
Piper runs as a single container rhasspy/wyoming-piper via Docker Compose:
services:
piper:
image: rhasspy/wyoming-piper:latest
container_name: piper
command: --voice de_DE-thorsten-medium
ports:
- "10200:10200"
volumes:
- ./piper-data:/data
restart: unless-stopped
The voices land in the volume. The voice set with --voice is just the container’s default voice. Which voice actually speaks is decided by Home Assistant in the pipeline. If HA requests a different voice, the container loads it on first call.
I deliberately didn’t set up a reverse proxy or hostname. Wyoming is a simple TCP connection, not a web protocol; Home Assistant connects directly via IP and port. A name would just be an additional source of error via DNS, with no apparent benefit.
In Home Assistant, the integration was quick: add Wyoming integration, enter IP and port 10200, done. After that, there’s the entity tts.piper.
A Second Pipeline Instead of Overhaul
I didn’t touch the existing cloud pipeline; I copied it. The new “Local” pipeline continues to use the cloud for speech recognition and Piper for text-to-speech. STT and TTS are independent of each other within a pipeline, so the migration can be done in stages.

Which pipeline a speaker uses is determined per device via its “Assistant” selection. I first switched only one speaker to be able to compare directly, then switched the second one later.

A note about the voice: there’s no Austrian Piper voice. I ended up with “thorsten”, but that’s a matter of taste. You can conveniently test-listen to the voices in the pipeline settings.
Step 3: Measuring Instead of Guesswork
After the A/B comparison, I had a feeling, but no numbers. And the first obvious way to get numbers turned out to be a dead end.
Three Measurement Traps
Pipeline logs are misleading. In the debug views of the pipeline, HA shows only 2-7 ms for TTS. But it only creates a link to the audio output there. The actual synthesis happens only when the speaker fetches this link. For TTS comparisons, the logs are therefore useless.
Caching works despite cache: false. In my tests, HA still delivered identical sentences from the cache in about 0.05 s. The solution: make each run unique by adding a different number of spaces at the end of the sentence. Same language, different cache key.
Voice switching costs load time. When switching between two Piper voices, Piper reloads the model each time. I therefore measured per voice in separate blocks with a warm-up round. In operation, this means: set one voice and stick with it.
I measured via Home Assistant’s REST API. A small Python script requests voice output via /api/tts_get_url and then downloads the WAV file completely. The time is measured from request to finished file. That’s what the speaker actually experiences. The core, shortened:
def run(entity, lang, voice, text):
t0 = time.perf_counter()
# 1. HA only creates a link to the voice output
path = json.load(req("POST", "/api/tts_get_url", {
"engine_id": entity, "message": text, "language": lang,
"options": {"voice": voice},
"cache": False, # is ignored by HA, see uniq()
}))["path"]
# 2. The fetch triggers the actual synthesis
data = req("GET", path).read()
return time.perf_counter() - t0
pad = 0
def uniq(s):
# same language, but new text for the cache
global pad
pad += 1
return s + " " * pad
I tested five sentences from short to long, typical responses from everyday use. Each sentence ran three passes per voice; the table shows the median.
| Sentence | Cloud “Jonas” | Piper thorsten-high | Piper thorsten-medium |
|---|---|---|---|
| “Light is on.” | 0.18 s | 0.59 s | 0.18 s |
| “It is 3 PM.” | 0.20 s | 0.75 s | 0.18 s |
| Timer remaining (1 sentence) | 0.28 s | 2.11 s | 0.42 s |
| PV power + storage (long sentence) | 0.34 s | 4.15 s | 0.68 s |
| Car battery (2 sentences) | 0.53 s | 4.28 s | 0.90 s |
Time from request to finished audio file, median of 3 runs. After a container restart, the first response takes about 2 s because Piper loads the model. After that it’s back to 0.16-0.21 s.
The table is unambiguous:
- thorsten-high is too slow on a CPU without a GPU. For longer sentences, it’s over 4 seconds, with individual outliers reaching up to 10.8 s. For a voice response, that’s not an option.
- thorsten-medium is level with the cloud for short responses and stays under a second even for the longest sentence. I can barely hear the quality difference from “high”.
- The cloud is still measurably fastest, especially for long sentences. That’s important to acknowledge.
Result
Both speakers now use the “Local” pipeline with Piper “thorsten-medium”, and it’s set as the default pipeline. The old cloud pipeline remains as a fallback. If Piper ever hiccups, I can switch back per speaker with a click.
Text-to-speech hasn’t gotten faster this way. The benefit is elsewhere: responses are generated in your own network, independent of internet connection and subscription, at practically the same response time. In everyday life, I notice no difference between “Light is on” from the cloud and “Light is on” from Piper, except that Jonas sounds Austrian and Thorsten doesn’t.
The setup still isn’t completely local: speech recognition continues to run via the cloud.
What I Learned
- Clean up first, then overhaul. Seven of ten exposures pointed to nothing. Without the inventory, I’d be looking for bugs later that have nothing to do with Piper.
- Exposure is not access control. It controls built-in commands, not custom voice automations.
- HA logs don’t measure what you think. For TTS, they only show link creation. If you want to compare, you have to fetch the finished file and stop the time yourself.
- Caching and model switching skew measurements. Unique texts and measurement blocks per voice are mandatory.
- On a CPU without a GPU, “medium” is the right choice. “high” sounds minimally better but costs many times more time.
- Stages work. Because STT and TTS are separate in the pipeline, the switch could be done without risk and with a fallback.
Conclusion and Outlook
Text-to-speech now runs locally, and without any noticeable downside in everyday use. The effort was manageable: one container, one integration, one copied pipeline. The most time went to cleanup and proper measurement, and that time was well spent.
Next, I want to try getting speech recognition local too. The question is which STT model on a CPU without a GPU will reliably understand short German commands with an Austrian accent. I’ll look at various Whisper sizes, German-optimized variants, and alternatives - again with measurements instead of guesswork.