A Uniform Voice: AI Text-to-Speech for Camera Audio Messages
Your camera talks. It announces “Wi-Fi connected,” warns you when it reboots, and tells you motion has been detected. But whose voice does it use? Up until now, that depended on how you got the files. Some builds shipped with audio recorded by a developer on a headset microphone in a noisy hotel room. Some used an online TTS service from 2019 that nobody can remember signing up for. Some had nothing at all — the speaker sat silent because recording a dozen phrases and getting them all to sound the same was more effort than anyone wanted to spend.
You record six phrases on Monday, you come back Wednesday to record three more, and the second batch sounds like a different person — different mic distance, different room, different energy level. Online services solve the consistency problem but introduce their own: rate limits, API keys, quota pages, output that changes when the provider updates their model. You want something you can run offline, repeatably, and get the same result every time.
Thingino now ships a TTS pipeline based on Kokoro-82M, a compact 82 million-parameter neural model that runs on CPU and produces studio-quality speech. One script, one setup step, and your camera speaks in a voice that will not change from one build to the next.
What you get
After a one-time setup, generating audio is a single command:
# First time only: download model + voices, create a Python venv
./scripts/tts/setup.sh
# Generate one phrase
./scripts/tts/run.sh "Motion detected at the front door"
That produces output/motion_detected_at_the_front_door.wav — 24 kHz,
16-bit mono, ready to drop into a camera overlay or package.
The default voice is af_heart, a female American English voice that
sounds warm and natural. You get five voices out of the box and can
download more from HuggingFace in one line. Switch to British English with
a config flag. Adjust speed if you want announcements faster or more
deliberate. Output MP3 instead of WAV if storage is tight.
How it works
The chain is short and offline:
Text -> espeak-ng (phonemizer) -> Kokoro-82M (neural TTS) -> WAV
Text comes in, espeak-ng converts it to phonemes (the same way a
dictionary tells you how to pronounce “schedule”), and the Kokoro model —
an 82M-parameter StyleTTS2 transformer — turns those phonemes into audio
waveforms. Everything runs on CPU. No GPU needed. No network calls. The
model weights and voice files are downloaded once during setup.sh and
stored in scripts/tts/models/.
Generation speed depends on the phrase length and your CPU, but a typical short announcement (“Wi-Fi connection failed”) takes under a second. The model produces 2–5 seconds of audio per second of processing — faster than real time.
By default, output files are auto-named from the input text: spaces become
underscores, everything is lowercased, and punctuation is stripped. You can
override with -o if you want a specific filename. All generated files
land in scripts/tts/output/, which is gitignored so they stay local to
your tree.
Standard announcements
The thingino-sounds package ships pre-generated clips for the common
camera lifecycle events: Wi-Fi connected and failed, configuration portal
up / down / mode, motion detection activated and deactivated, video and
timelapse recording started / stopped, WireGuard VPN up and down, and a
power-cycle-requested prompt. The files live in
package/thingino-sounds/files/ and are installed to
/usr/share/sounds/ at build time.
Every file in the package shares the same voice, speed, and sample rate.
The default format is Opus; G.711 mu-law (.ulaw) is also available.
Configuration
Everything is pinned in scripts/tts/config.yaml:
voice: af_heart # female American English
speed: 1.0 # playback speed (0.5 = slower, 1.5 = faster)
sample_rate: 24000 # Kokoro native sample rate
format: wav # output format: wav or mp3
british: false # true for British English phonemes
Change voice to any downloaded voice pack. The five bundled voices are
all female American English. To add male voices or British voices,
download them from HuggingFace and regenerate:
# List every voice available
./scripts/tts/run.sh --list-voices
# Download a British male voice
.venv/bin/python -c "
from huggingface_hub import hf_hub_download
hf_hub_download('hexgrad/Kokoro-82M', 'voices/bm_george.pt', local_dir='./models')
"
# Switch and regenerate
sed -i 's/voice: af_heart/voice: bm_george/' ./scripts/tts/config.yaml
./scripts/tts/run.sh "Wi-Fi connected"
Set format: mp3 to trade a slight quality loss for smaller files.
British English
Flip the british flag and the phonemizer switches to en-gb pronunciation
rules. Same voice pack, different rendering — “garden” and “schedule”
come out sounding local:
sed -i 's/british: false/british: true/' ./scripts/tts/config.yaml
./scripts/tts/run.sh "Motion detected in the garden"
Romanized foreign words
Kokoro-82M is English-only, but you can feed it romanized foreign words and it will produce a reasonable approximation with an English accent. The output is not native TTS, but it is consistent — good enough for a device that mostly speaks English and needs one or two localized phrases:
./scripts/tts/run.sh "Kamera ga kidou shimashita"
Scenarios
Doorbell camera with a chime
You flash Thingino onto a Wyze Doorbell and wire it to the existing mechanical chime. When someone presses the button, the camera plays a spoken alert through its speaker while also triggering the physical chime. The default messages do not cover this, so you generate your own:
./scripts/tts/run.sh "Someone is at the front door"
./scripts/tts/run.sh "Package delivered to the porch"
Convert to your target format, drop into
user/common/overlay/usr/share/sounds/, and play them from the script
that handles the doorbell button GPIO event. Every doorbell in the house
now announces the event in the same voice that says “Wi-Fi connected.”
Multi-camera property with zone announcements
You have four cameras covering different zones — driveway, backyard, side gate, and garage. When motion fires, you want the announcement to name the zone so you know where to look without checking the app:
./scripts/tts/run.sh "Motion detected in the driveway"
./scripts/tts/run.sh "Motion detected in the backyard"
./scripts/tts/run.sh "Motion detected at the side gate"
./scripts/tts/run.sh "Motion detected in the garage"
All four use the same voice. The consistency means your brain stops processing who is talking and registers where the event is.
Factory reset and maintenance warnings
If you build firmware for less technical users — family members, clients — a clear spoken warning before a destructive action is worth the few kilobytes it costs in flash:
./scripts/tts/run.sh "Factory reset in progress, do not power off"
./scripts/tts/run.sh "Update complete, camera will reboot now"
Batch generation
If you have a long list of phrases, write them one per line in a text file and run:
./scripts/tts/batch.sh phrases.txt
Or pipe them straight from your script:
echo -e "Starting up\nShutting down\nEntering sleep mode" | ./scripts/tts/batch.sh
Each line produces a separate file in output/. The batch mode uses the
same config settings as single generation, so every file matches.
Converting to target formats
The TTS pipeline produces 24 kHz WAV. Firmware sounds use 16 kHz mono
Opus (raptor / prudynt) or G.711 mu-law (timps without the Opus plugin).
Use the bundled scripts/convert_audio.sh to convert for your target
streamer:
# Convert to Opus (for raptor or prudynt)
scripts/convert_audio.sh -f opus scripts/tts/output/*.wav
# Convert to G.711 mu-law (for timps without TIMPS_PLAY_OPUS)
scripts/convert_audio.sh -f pcm scripts/tts/output/*.wav
# Then rename .pcm -> .ulaw (G.711 mu-law is s16le PCM)
convert_audio.sh resamples to 16 kHz mono and can normalize loudness
with -n. For a quick single-file check, scripts/pcmtool.sh converts
and previews in one step.
Integrating into a build
Drop the converted files into your user overlay:
user/common/overlay/usr/share/sounds/doorbell_4.opus
user/common/overlay/usr/share/sounds/motion_driveway.ulaw
Files in user/common/ apply to every camera you build. For
camera-specific sounds, use user/<camera>/overlay/ instead.
Pick the format that matches your streamer:
| Streamer | Format | Notes |
|---|---|---|
| Raptor | .opus |
rac decodes Opus natively |
| Prudynt | .opus |
Opus decode built in |
| timps (full audio) | .opus |
With TIMPS_PLAY_OPUS enabled |
| timps (minimal audio) | .ulaw |
G.711 built-in, no extra libs |
The thingino-sounds package installs to /usr/share/sounds/. Your
custom files land alongside the built-in ones — the play command
(whether raptor’s /usr/bin/play or timps’ /usr/sbin/play) finds them
there with no extra wiring.
How they get triggered depends on your setup. Typical hooks include the
MQTT event subscriber (S91mqttsub), a custom init script that watches
for GPIO events, or the streamer’s own audio output pipeline. If you
maintain multiple camera configurations, generate once with the desired
voice and copy the same files to each overlay — all your cameras will
speak in the same voice.
What you learned
- Deterministic output is the killer feature. Voice, speed, and sample
rate are pinned in
config.yaml. Generate today, regenerate next year — identical result. No model drift, no API-side changes. - Voice packs are weight files. Pin them in config, download them once, and your camera’s voice is as versionable as any other dependency.
- One command, no network, no GPU.
./scripts/tts/run.sh "text"produces a WAV on CPU in under a second for short phrases. No API keys, no quota, no calls leaving your machine. - Consistency across cameras is the real win. A multi-camera property where every device speaks in the same voice is a unified experience, not a patchwork of whatever recordings happened to ship.
- Custom messages land alongside the built-ins. Convert with
scripts/convert_audio.sh, drop intouser/common/overlay/usr/share/sounds/, and theplaycommand finds them like any other system sound.