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

> Source: <https://dev.to/voxrtio/offline-voice-replies-on-a-35-raspberry-pi-linnettts-integration-in-python-4la6>
> Published: 2026-11-12 15:12:07+00:00

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](https://dev.to/voxrtio/building-a-fully-offline-voice-assistant-on-raspberry-pi-4-3d1m), 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.

```
# 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.
```

`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`](https://github.com/VoxRT/LinnetTTS/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
```

Total download is about 85 MB.

Minimal batch synthesize to WAV:

``` python
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)
```

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:

``` python
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)
```

**Playback on Pi 4** via `sounddevice` is the easy route:

``` python
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()
```

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](https://dev.to/voxrtio/building-a-fully-offline-voice-assistant-on-raspberry-pi-4-3d1m), 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:

``` python
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
```

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 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.`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](https://huggingface.co/spaces/VoxRT/linnet-tts)

Model card and weights download: [huggingface.co/VoxRT/linnet-en-35m](https://huggingface.co/VoxRT/linnet-en-35m)

GitHub repo (source, docs, streaming contract): [github.com/VoxRT/LinnetTTS](https://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.
