The Google Home broadcast app for families.
One tap on a parent's phone β attention chime + spoken message on the Google/Nest speakers you choose, forced loud, interrupting whatever's playing. Call the kids to dinner, get someone out of their room, announce "we're leaving in five" β and actually be heard over headphones-off music and closed doors, where Google's built-in broadcast (fixed, polite volume) gets ignored. One-way by design. LAN-only, self-hosted: no cloud, no accounts, no data leaves the house.
| The app | Admin: speaker & volume | Admin: edit messages |
|---|---|---|
![]() |
![]() |
![]() |
[Parent phone PWA] --HTTP--> [FastAPI (Docker)] --pychromecast--> [Google/Nest speaker]
|
+-- serves pre-rendered audio over LAN HTTP
+-- presets.yaml (edited via /admin page)
Per tap: connect to the pinned speaker β stash its current volume β set the broadcast volume β cast the pre-rendered file β wait for playback β restore the stashed volume. Concurrent taps get "already playing" so two parents can't stomp each other's volume capture/restore. Whatever the kid was playing does not auto-resume β feature, not bug.
Docker (recommended, e.g. on a home server):
git clone https://github.com/dstahl11/holler.git && cd holler
cp presets.example.yaml presets.yaml
docker compose up -d --buildThe compose file uses host networking so the speaker can fetch audio straight
from your server's LAN IP. App: http://<server-ip>:8000.
Bare Python (dev / Mac):
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp presets.example.yaml presets.yaml
.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000Set HOLLER_DRY_RUN=1 to simulate casts while you play with the UI.
Open http://<server>:8000/admin (or the βοΈ gear in the app):
- Tap Scan network β every Cast device in the house shows up.
- Tap your kid's speaker to add it. Done.
Add as many speakers as you like β broadcasts play on every speaker that's switched on in the list (kid's room + hallway, two kids' rooms, β¦), each with its own volume capture/restore. Toggle them without removing.
Give each speaker a DHCP reservation in your router so its IP never moves.
(CLI alternative: python scripts/discover.py, then edit presets.yaml.)
Still in /admin: edit the message text on each card (the examples say "Sam"),
then Save changes β audio re-renders automatically. TTS engines:
- macOS
sayβ used automatically when running bare on a Mac. Not available inside Docker (Linux) β Piper takes over there. - Piper (self-hosted neural TTS) β bundled in the Docker image with the
en_US-lessac-mediumvoice, works out of the box. Audio rendered earlier on a Mac keeps working after a move to Docker β they're just WAV files; only future wording changes render with Piper. - Recorded parent voice β the one kids actually respond to. Record each
phrase on your phone, drop the files at
audio/raw/<preset-id>.m4a(any format), Save/re-render. Recordings always win over TTS.
Every rendered file is chime + phrase baked into one normalized WAV, ~3β8s.
Tap the π Test button (chime only) with the kid's door closed and their
own audio playing. Max loudness varies a lot by model β a Nest Mini caps much
lower than a Nest Audio or full Home. If it doesn't cut through: cast to a
speaker group, move a bigger speaker into the room, or add one in the hallway.
Volume slider lives in /admin (field-tested: 100% on a full Google Home
produced a formal complaint from the target; 70% is plenty).
Open the app URL in Safari (iOS) or Chrome (Android) β Share β Add to Home Screen. Opens full-screen with big buttons. That's the entire daily UX: open, tap, done.
Browsers only enable service workers over HTTPS, so over plain LAN HTTP this is a home-screen shortcut rather than a fully installed PWA β which is all it needs. Add Tailscale + HTTPS later and offline caching lights up by itself.
Everything lives in /admin β scan/pick speaker, add/reword/delete
messages (Save re-renders audio), broadcast volume, and two independent PINs:
- App PIN β gates the button grid, if the kids discover the URL and start counter-broadcasting.
- Admin PIN β gates this settings page.
Changes hot-reload; no restart. Saves write presets.yaml (previous version
kept as presets.yaml.bak), so hand-editing the YAML still works too.
GET /api/presetsβ button list for the PWAPOST /api/broadcast/{id}β do it (X-Pinheader if PIN set).200sent,401bad PIN,429already playing,502speaker unreachableGET /api/healthβ is the speaker reachableGET|PUT /api/admin/config,POST /api/admin/scan,POST /api/admin/renderβ the admin page's API (X-Admin-Pinheader if set)
| Key | Meaning |
|---|---|
devices[] |
name, host, optional uuid, enabled β broadcasts hit every enabled one |
broadcast.volume |
0.0β1.0 forced during broadcast |
broadcast.play_timeout |
Max seconds to wait before restoring volume |
broadcast.advertise_host |
LAN IP the speaker fetches audio from; auto-detected if empty |
security.pin / security.admin_pin |
App / admin PIN gates; "" disables |
tts.engine |
auto / say / piper β auto prefers audio/raw/ recordings |
presets[] |
id, label, emoji, tts (or file: for custom audio) |
- Cast, not Google's native Broadcast β native Broadcast plays at a fixed volume you can't override; casting lets us force loud. Loud won.
- One-way on purpose β the only "reply" path Google offers conflicts with forced volume, and we're optimizing for impossible to ignore.
- Pre-rendered audio, not TTS at trigger time β lower latency, predictable output, no runtime dependency.
- LAN-only β bind it to your home network and don't port-forward it. The attack surface is "people on your Wi-Fi," and there's a PIN for them.


