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 into user/common/overlay/usr/share/sounds/, and the play command finds them like any other system sound.