DEV Community

Cover image for Offline voice replies on a $35 Raspberry Pi: LinnetTTS integration in Python
VoxRT
VoxRT

Posted on

Offline voice replies on a $35 Raspberry Pi: LinnetTTS integration in Python

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.

Why offline TTS changes the pipeline

Adding offline TTS is not just a drop-in replacement for cloud TTS. It changes three things on a Pi:

  • No more per-request cost. Your assistant can speak as often as the UX calls for, without racking up a cloud bill or hitting rate limits.
  • No network dependency for replies. Wake-word is already always-on local. Adding local TTS means the assistant can confirm commands while Wi-Fi is down or the user is in a basement with no cell signal.
  • CPU budget changes. On-device TTS runs on the same CPU as wake-word, VAD, and KWS. On a Pi 4 that is generally fine. On a Pi Zero 2 W, running all four components plus TTS at once needs careful testing because they all compete for the same four cores.

With that frame, the install.

What ships at no extra cost

  • Two built-in voices: linnet-en-f1.npy (female, American) and linnet-en-m1.npy (male, American)
  • 41M parameters (31.5M flow LM + 9.9M Vocos-style decoder)
  • 83 MB fp16 weights on disk, about 160 MB resident
  • Platforms: Linux x86_64 and aarch64, macOS Apple silicon, Android AAR, iOS SwiftPM. Pi 4 running 64-bit Pi OS is aarch64, so you are fine
  • Streaming in 80 ms chunks (first chunk is 50 ms), real-time on phone-class CPU

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.

Install and download

# Python packages:
pip install linnet-tts soundfile sounddevice

# System dependency for sounddevice on Pi OS:
sudo apt install libportaudio2

# Make sure your user is in the audio group (prevents silent playback failure):
sudo usermod -aG audio $USER
# Log out and back in for the group change to take effect.
Enter fullscreen mode Exit fullscreen mode

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

# Weights (~83 MB):
wget -O local/linnet-en-35m.fp16.safetensors \
  https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/linnet-en-35m.fp16.safetensors

# Tokenizer:
wget -O local/tokenizer.model \
  https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/tokenizer.model

# Female voice:
wget -O local/voices/linnet-en-f1.npy \
  https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/linnet-en-f1.npy

# Optional male voice:
wget -O local/voices/linnet-en-m1.npy \
  https://github.com/VoxRT/LinnetTTS/releases/download/weights-v1/linnet-en-m1.npy
Enter fullscreen mode Exit fullscreen mode

Total download is about 85 MB.

First working example

Minimal batch synthesize to WAV:

import linnet_tts
import soundfile as sf

# Load model + tokenizer:
model = linnet_tts.Model(
    "local/linnet-en-35m.fp16.safetensors",
    "local/tokenizer.model",
)

# Load voice (prepared for the model):
voice = model.load_voice("local/voices/linnet-en-f1.npy")

# Synthesize a full sentence to float32 array at 24 kHz:
audio = model.synthesize(
    "Hello from your Raspberry Pi.",
    voice,
    temp=0.3,
    cfg=2.0,
    seed=0,
)

# Save:
sf.write("hello.wav", audio, model.sample_rate)
Enter fullscreen mode Exit fullscreen mode

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)

When batch vs streaming

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.

Streaming example

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")

# Create a streaming session with voice + seed:
session = model.session(voice, seed=0)

# Prime with text (text-only pass thanks to load_voice):
session.prime("Ready to help. What can I do?")

# Iterate chunks as they arrive:
for chunk in session:
    # chunk = numpy float32 at 24 kHz
    # first chunk: 50 ms (1200 samples)
    # middle chunks: 80 ms each (1920 samples)
    # last chunk: 30 ms of zeros (720 samples)
    pass  # feed chunk to your audio output (concrete sounddevice example below)
Enter fullscreen mode Exit fullscreen mode

Playback on Pi 4 via sounddevice is the easy route:

import sounddevice as sd
import numpy as np

# Start audio output stream once:
stream = sd.OutputStream(
    samplerate=model.sample_rate,
    channels=1,
    dtype='float32',
)
stream.start()

# Streaming synthesize + play:
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()
Enter fullscreen mode Exit fullscreen mode

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).

Plugging TTS into a Pi voice assistant

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
Enter fullscreen mode Exit fullscreen mode

Add a TTS reply for confirmation or feedback:

import linnet_tts
import sounddevice as sd
import numpy as np

# Setup once at startup:
model = linnet_tts.Model(
    "local/linnet-en-35m.fp16.safetensors",
    "local/tokenizer.model",
)
voice = model.load_voice("local/voices/linnet-en-f1.npy")

# Audio output stream (keep alive for the lifetime of the assistant):
stream = sd.OutputStream(
    samplerate=model.sample_rate,
    channels=1,
    dtype='float32',
)
stream.start()

# Response phrases per command:
RESPONSES = {
    "play_music": "Playing music now.",
    "lights_on": "Lights on.",
    "lights_off": "Lights off.",
    "volume_up": "Volume up.",
    "volume_down": "Volume down.",
    # ... for your 14 commands
}

def speak(text):
    session = model.session(voice, seed=0)
    session.prime(text)
    for chunk in session:
        stream.write(chunk)

# In your KWS handler:
def on_command(cmd):
    if cmd in RESPONSES:
        speak(RESPONSES[cmd])
    # ... actually do the command
Enter fullscreen mode Exit fullscreen mode

Keep model and voice pre-loaded at startup. Re-initializing per command adds prime overhead to every reply.

What "comfortable" means on Pi 4

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.

Troubleshooting on Pi 4

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 loading 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.
  • Your user has to be in the 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).

Try it + links

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.

Your turn

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.

Top comments (0)