A Pi voice assistant usually comes in four pieces: wake-word catches "Hey Assistant", VAD detects when someone is talking, KWS or streaming ASR converts the speech into a command, and something answers with a voice.
That last piece was usually the problem. Cloud TTS APIs work, but they need network, burn API quota, add 200 to 400 ms of latency, and send user text somewhere outside your box. For privacy-sensitive apps or offline-only devices, not an option.
LinnetTTS shipped on 2026-11-04. 41M params, on-device, Rust runtime, no PyTorch at inference. It runs comfortably on a Raspberry Pi 4 (Pi Zero 2 W also handles it, but on the edge). This tutorial shows how to pip install it and plug voice replies into your Pi voice assistant in one sitting.
If you built the Pi 4 voice pipeline from my earlier tutorial, this article closes the loop.
Adding offline TTS is not just a drop-in replacement for cloud TTS. It changes three things on a Pi:
With that frame, the install.
linnet-en-f1.npy (female, American) and linnet-en-m1.npy (male, American)
On licensing: LinnetTTS is an open-source on-device TTS runtime (Apache-2.0) with openly available model weights under a free license that permits commercial use; voice cloning and model training are licensed separately.
For this tutorial we use the built-in linnet-en-f1. Custom voice cloning is a separately licensed service from VoxRT and not part of the free tier.
pip install linnet-tts soundfile sounddevice
sudo apt install libportaudio2
sudo usermod -aG audio $USER
soundfile is for writing WAV files, sounddevice is for real-time playback through ALSA.
PyPI wheels available: manylinux 2.17 x86_64 and aarch64, macOS Apple silicon. Pi 4 on 64-bit Raspberry Pi OS is aarch64, so you get a wheel. On 32-bit Pi OS there is no wheel, so reflash to 64-bit or build from source.
Pi 4 with 2 GB or more RAM is recommended. The model resident is about 160 MB, plus Python, plus your voice-assistant stack. A 1 GB Pi 4 can work but is tight.
Important: the model files are not in the wheel. Download the weights release from GitHub releases tag weights-v1:
mkdir -p local/voices
wget -O local/linnet-en-35m.fp16.safetensors \
https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/linnet-en-35m.fp16.safetensors
wget -O local/tokenizer.model \
https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/tokenizer.model
wget -O local/voices/linnet-en-f1.npy \
https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/linnet-en-f1.npy
wget -O local/voices/linnet-en-m1.npy \
https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/linnet-en-m1.npy
Total download is about 85 MB.
Minimal batch synthesize to WAV:
import linnet_tts
import soundfile as sf
model = linnet_tts.Model(
"local/linnet-en-35m.fp16.safetensors",
"local/tokenizer.model",
)
voice = model.load_voice("local/voices/linnet-en-f1.npy")
audio = model.synthesize(
"Hello from your Raspberry Pi.",
voice,
temp=0.3,
cfg=2.0,
seed=0,
)
sf.write("hello.wav", audio, model.sample_rate)
Run on Pi 4: python synthesize.py produces hello.wav with the female voice reading "Hello from your Raspberry Pi." Short sentences complete quickly in batch mode.
Params:
temp=0.3 is the sampling temperature (lower means more deterministic output)cfg=2.0 is the classifier-free guidance scale (controls how closely output follows the voice prompt)seed=0 makes output reproducible (same text, same params, same seed produces bit-exact same audio)
Batch (model.synthesize) returns the full audio array when generation is done. Simpler code, one call, you own the array. Good for pre-generated phrases, voice samples saved to disk, or tests where latency does not matter.
Streaming (session.prime + chunk iteration) yields chunks every 80 ms so playback can start before the full sentence is generated. First audio arrives after the prime pass plus one step. On a Pi 4 that is usually tens of ms. Good for real-time assistant replies, live chatbot voices, or any UX where the user is waiting for the first sound.
For the Pi assistant integration below we use streaming, because the alternative (waiting for the whole reply to generate before playing it) adds perceivable delay to confirmation messages.
Batch mode is fine for pre-generated phrases. For a real-time assistant you want streaming: the user hears first audio within the first prime pass, not after the whole generation finishes.
Streaming API:
import linnet_tts
model = linnet_tts.Model(
"local/linnet-en-35m.fp16.safetensors",
"local/tokenizer.model",
)
voice = model.load_voice("local/voices/linnet-en-f1.npy")
session = model.session(voice, seed=0)
session.prime("Ready to help. What can I do?")
for chunk in session:
pass # feed chunk to your audio output (concrete sounddevice example below)
Playback on Pi 4 via sounddevice is the easy route:
import sounddevice as sd
import numpy as np
stream = sd.OutputStream(
samplerate=model.sample_rate,
channels=1,
dtype='float32',
)
stream.start()
session = model.session(voice, seed=0)
session.prime("Everything is ready on the Pi.")
for chunk in session:
stream.write(chunk.astype(np.float32))
stream.stop()
The first chunk arrives after the prime pass (voice latent load + text processing + context decode). On Pi 4 that is a few tens of ms. After prime, chunks stream every 80 ms of wall-clock time or faster (the model runs faster than real time on Pi 4).
If prime latency matters to you, the v0.2 API caches voice preparation inside load_voice, so prime itself only runs text rows. That optimization alone cuts cold-path first-audio by 2 to 3 times across our test configurations (Core i9 desktop, iPhone 13 Pro Max, iPhone 11, Snapdragon 662 Android).
If you built the pipeline from my "Pi 4 voice assistant" tutorial, this is a simple add-in.
Current flow:
mic → wake-word → VAD → KWS (14 commands) → trigger action
Add a TTS reply for confirmation or feedback:
import linnet_tts
import sounddevice as sd
import numpy as np
model = linnet_tts.Model(
"local/linnet-en-35m.fp16.safetensors",
"local/tokenizer.model",
)
voice = model.load_voice("local/voices/linnet-en-f1.npy")
stream = sd.OutputStream(
samplerate=model.sample_rate,
channels=1,
dtype='float32',
)
stream.start()
RESPONSES = {
"play_music": "Playing music now.",
"lights_on": "Lights on.",
"lights_off": "Lights off.",
"volume_up": "Volume up.",
"volume_down": "Volume down.",
}
def speak(text):
session = model.session(voice, seed=0)
session.prime(text)
for chunk in session:
stream.write(chunk)
def on_command(cmd):
if cmd in RESPONSES:
speak(RESPONSES[cmd])
Keep model and voice pre-loaded at startup. Re-initializing per command adds prime overhead to every reply.
Verified public numbers exist only for Pi Zero 2 W: 59 to 60 ms per step on 4 cores (75% RTF, real-time with margin), confirmed via an independent sustained 30-minute test. Pi 4 is architecturally higher in clock speed and cores, so we expect comfortable margin. A specific Pi 4 RTF measurement is still pending.
In practice: if Pi Zero 2 W can sustain LinnetTTS, Pi 4 can do it comfortably. For confirmation in your own deployment, measure prime time and step time on your target configuration, and decode vcgencmd get_throttled once a second if the workload is sustained and always on.
Thermal nuance: Pi Zero 2 W in a closed case without airflow can get close to the soft limit (80°C) under sustained TTS load. Pi 4 with a stock heatsink or active cooling is usually fine. On production deployments log get_throttled sticky bits (bits 16-19) to catch throttle events that fire between polling samples.
A few things that trip people on the first run:
Wheel install fails. Check your Python version (3.9+ required) and your Pi OS architecture (uname -m should be aarch64 on 64-bit Pi OS). 32-bit Pi OS does not get a wheel. Reflash the SD card to 64-bit, or pick the 64-bit image in Raspberry Pi Imager.
"File not found" when model. The paths in linnet_tts.Model(...) are relative to where you run the script, not where the script lives. Either cd to the directory with local/ in it before running, or use absolute paths.
Silent playback. If the streaming code runs but you hear nothing:
aplay -l lists audio devices. Make sure one is connected (HDMI, 3.5 mm jack, or USB audio).sudo raspi-config lets you pick the audio output.audio group (see install block above).sounddevice default device can be set explicitly via sd.default.device = <index> from sd.query_devices().
Audio stutters or drops. Usually CPU contention. Check if wake-word, VAD, and KWS are running at the same time. On Pi 4 this is typically fine, but if you see stutters, log vcgencmd get_throttled once a second to catch throttle events (bits 16-19 are sticky and catch events your per-second poll would miss).
Browser demo (the same LinnetTTS v0.2 with priming optimization): Hugging Face Space
Model card and weights download: huggingface.co/VoxRT/linnet-en-35m
GitHub repo (source, docs, streaming contract): github.com/VoxRT/LinnetTTS
If you have a Pi 4 deployment running LinnetTTS sustained, what is the cooling config? Open case with fan, sealed case, passive heatsink only? Real-world thermal data on these setups is still thin.
What was the breaking point in your Pi voice assistant before adding offline TTS: cost per cloud API call, latency, or privacy? Share in the comments.