Big league music for big league moments.
BatterBox is a walk-up song player for youth baseball, hosted on a Raspberry Pi in the dugout. Tap a player's face on the touchscreen, their walk-up song blasts over the PA. Long-press for the home-run clip. Walter Walkup — headphones, cap, mustache — is the mascot, and he takes his job seriously.
- Kiosk grid — 1024×600 touchscreen layout, one big tile per player, photo + name + number. Tap = walk-up clip, long-press (600ms) = home-run clip. Instant switch, STOP in under 200ms.
- O/D/H mode switch — Offense (batting grid), Defense (pitcher walk-out songs), or Hype (crowd stingers like "Charge!" — not tied to any player, tap to fire).
- Multiple teams — switch the active team per game; each team has its own batting order. Touch drag-and-drop reorder in the admin screen.
- Clip workflow — import from YouTube or upload mp3/m4a, trim on a waveform editor, add fades and volume boost, loudness-normalized to 192k MP3.
- Physical buttons — wire real buttons to the Pi's GPIO pins for STOP / next batter / volume, or use the on-screen/keyboard mock buttons.
- Works offline at the field — phones on the dugout Wi-Fi hotspot can queue songs; no internet needed once clips are imported.
| Kiosk grid (1024×600 touchscreen) | Admin (teams, roster, drag-to-reorder) |
|---|---|
![]() |
![]() |
| Clip editor (waveform trim) | Phone layout |
|---|---|
![]() |
![]() |
Requires Docker Desktop. That's all.
docker compose up --buildOpen http://localhost:8080/?player=1 — the grid loads seeded with two demo teams (Sandlot Sluggers and Dugout Demons). Audio plays through your PC's browser (the ?player=1 marks this tab as the speaker; other tabs/phones are silent controllers); GPIO is mocked.
Mock GPIO keyboard shortcuts (same code path as real GPIO):
| Key | Action |
|---|---|
| Space | Stop |
| ↑ / ↓ | Volume ±5 |
| N | Next batter |
Debug buttons for the same actions appear on screen when mock GPIO is on.
Do this at home, on Wi-Fi — YouTube imports need internet, and the field won't have any.
- Open http://localhost:8080/admin.html — create your team, add players (names + jersey numbers), snap photos or upload them.
- For each player, paste a YouTube URL (or upload an mp3/m4a) and pick walk-up or home-run.
- BatterBox downloads the audio in the background and suggests the loudest 30-second window.
- Open the trim editor: drag the start/end handles on the waveform, preview, set fade in/out and volume boost if a track is quieter than the rest.
- Save. The clip is sliced, faded, loudness-normalized, and live on the grid.
- Set the batting order by dragging tiles in admin.
At the field, everything plays from local disk. No internet, no problem.
Target: Raspberry Pi 4 (any RAM), 64-bit Raspberry Pi OS Bookworm — the one supported platform. Wi-Fi tip: use the 5GHz band when a Bluetooth speaker is paired — Bluetooth shares 2.4GHz airtime and can stutter the audio.
-
Install Docker:
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # log out and back in after this
-
Get the image onto the Pi. The image is arm64; an image built on your x86 PC won't run on the Pi. Two ways:
Build on the PC, push, pull on the Pi (fastest for the Pi):
# on the PC, once: docker buildx create --use docker buildx build --platform linux/arm64 -t <dockerhub-user>/batterbox:latest --push . # on the Pi: git clone <your-repo-url> batterbox && cd batterbox # point compose at your pushed image: docker tag <dockerhub-user>/batterbox:latest batterbox-app docker compose -f docker-compose.yml -f docker-compose.pi.yml up -d
Or build on the Pi itself (works fine on a Pi 4, just slow — go warm up the infield):
git clone <your-repo-url> batterbox && cd batterbox docker compose -f docker-compose.yml -f docker-compose.pi.yml up -d --build
The app comes up on port 80 (and 8080) and restarts on reboot automatically (
unless-stopped). -
Kiosk display (full-screen touchscreen UI on the Pi's display):
No KDE, Gnome, or any desktop environment is needed — and none is used. The "window system" is one of two tiny options; both just put Chromium's kiosk window on the HDMI touchscreen. There is no Qt app and nothing to write in Qt — Chromium is the UI runtime, pointed at
http://localhost:8080.Option A — Raspberry Pi OS Lite + cage (recommended, lightest). cage is a Wayland kiosk compositor: it is the entire "window manager", runs exactly one app full-screen, and is made for appliances like this. Touch works out of the box via libinput.
sudo apt update && sudo apt install -y cage chromium # autologin to a console on boot: sudo raspi-config # System Options → Boot / Auto Login → Console Autologin # launch the kiosk when the console session starts: echo 'if [ -z "$WAYLAND_DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then exec cage -- /home/pi/batterbox/kiosk/start-kiosk.sh; fi' >> ~/.bash_profile
On boot: Pi logs itself in on tty1 → cage starts →
start-kiosk.shwaits for the container and execs Chromium full-screen. Boot-to-music on a Pi 4 is well under a minute.Option B — Raspberry Pi OS with desktop (what you'd use anyway for other things): keep the stock Wayland/X session and autostart the kiosk into it:
mkdir -p ~/.config/autostart cp kiosk/batterbox-kiosk.desktop ~/.config/autostart/ # edit ~/.config/autostart/batterbox-kiosk.desktop if the repo isn't at /home/pi/batterbox
Either way the kiosk script waits for the app to answer on port 8080, then opens Chromium full-screen. Audio plays through the browser out the 3.5mm jack / HDMI / USB DAC — plug your speaker/PA into the Pi.
On-screen keyboard (no physical keyboard attached): install
squeekboardon the Pi (sudo apt install squeekboard). The kiosk launcher starts it automatically when present — it pops up whenever a text field (Wi-Fi SSID/password, player names, YouTube URLs) gains focus and hides after. Without it, text fields are unreachable on the touchscreen. Lockout recovery even without an OSK: the Wi-Fi Start Hotspot button needs no typing (it uses the stored SSID/password) — kiosk → ADMIN → Start Hotspot → joinBatterBoxfrom a phone → fix Wi-Fi from the phone's browser. -
Networking at the field — two ways to get phones and the Pi on the same network:
Option A — Pi joins an existing Wi-Fi network (client mode). Either your iPhone's Personal Hotspot (gives the Pi internet as a side effect — YouTube imports and the yt-dlp auto-update keep working at the field) or any home/field Wi-Fi. Easiest: Admin → Wi-Fi Hotspot section → put the network's SSID + password in the box → Use Wi-Fi (blank password = open network). From the Pi console instead:
sudo nmcli device wifi connect "Your iPhone Name" password "your-hotspot-password"
The Pi remembers the network and rejoins automatically. Side effect: the Pi has real internet through the phone — YouTube imports and the yt-dlp auto-update keep working at the field.
Option B — Pi runs its own hotspot (fully offline). No internet anywhere; good when there's no cell signal — or when the phone plan charges for tethering. Everything is done from the admin UI: open Admin → Wi-Fi Hotspot, set the SSID and password (defaults:
BatterBox/bigleague1), and tap Start Hotspot. One credentials box, two directions: Start Hotspot broadcasts it, Use Wi-Fi joins it. The Pi leaves its current Wi-Fi and broadcasts the new network — join it from your phone and reopen http://batterbox.local (or http://10.42.0.1). Stop Hotspot turns it off; the Pi rejoins any remembered network on its own. You can also save the SSID/password ahead of time with Save Settings (works on the PC at home, where there's no Wi-Fi radio to control).Fallback if the admin UI can't reach the Pi at all, run once on the Pi itself:
sudo nmcli device wifi hotspot ifname wlan0 con-name batterbox ssid BatterBox password "bigleague1" band bg
-
Find the Pi by name, not IP (mDNS/Bonjour). Raspberry Pi OS ships with Avahi, which broadcasts the Pi's hostname as
<hostname>.local— no app-side work needed. Set the hostname once:sudo hostnamectl set-hostname batterbox # then rebootWith the Pi compose override the app answers on port 80, so from any phone on the same network:
http://batterbox.localThat's it — no port, no IP. (iPhone, Android, Mac, and Windows all resolve
.local; a barehttp://batterboxoften works too, but.localis the guaranteed form. Bookmark it on the dugout phones.) Optional:sudo cp kiosk/avahi/batterbox.service /etc/avahi/services/to make BatterBox show up in Bonjour service browsers as well.If a phone can't resolve the name, fall back to the Pi's IP:
ip addr show wlan0on the Pi (typicallyhttp://10.42.0.1in option B). The grid and admin pages work fine on a phone screen.
Default is AUDIO_BACKEND=browser: the kiosk Chromium on the Pi plays the sound. Zero Docker audio config needed.
With the browser backend, only player-role clients make sound — the kiosk launcher opens /?player=1, and the kiosk top bar has a speaker toggle any device can use to opt itself in. Every page starts silent, so phones and forgotten admin tabs no longer echo the song over each other. Opting in is deliberately open rather than exclusive: a spectator can turn sound on for their own phone (handy with a Bluetooth earpiece) and nobody can mute or hijack anyone else's device. That also means "one speaker" is up to you, not enforced — if two devices near each other are both on, you'll hear both. Turning sound on partway through a song joins that song where it already is, and turning it off silences only that device — the song keeps playing everywhere else.
If you want the Pi headless (no browser, phones only), set AUDIO_BACKEND=server in docker-compose.pi.yml — mpv inside the container plays directly to ALSA via the mapped /dev/snd. You may need AUDIO_OUTPUT (e.g. plughw:1,0 for a USB dongle) if auto picks the wrong device.
The kiosk Chromium plays audio to the Pi's OS default output — so a Bluetooth speaker just needs to be paired to the Pi itself.
Primary flow (speaker/phone initiates): tap the BT button on the kiosk top bar. It starts flashing blue — the Pi is discoverable for 120 seconds (tap again to stop early). During that window, select the Pi (its hostname, e.g. raspberrypi) from the speaker's or phone's Bluetooth menu. When a device is connected the dot turns solid blue and the button tooltip shows its name. Pairing auto-accepts — no PIN.
Some speakers can't browse for devices (they expect to be discovered). For those, pair once from the Pi OS desktop (or bluetoothctl scan on → pair <MAC> on the host), then reconnect later with POST /api/bluetooth/connect and the speaker's MAC.
On PC dev there's no BlueZ in the container by design — the BT button shows "Bluetooth unavailable" instead of breaking anything.
An optional physical LED mirrors the flashing state: wire an LED (with ~330Ω resistor) to GPIO 26 (BCM) — it blinks ~2Hz while pairing is active. Disable by setting gpio_bt_led_pin to 0.
docker-compose.pi.yml already mounts the host's BlueZ D-Bus socket; the Pi's stock bluetooth service does the rest.
All env vars, settable in docker-compose.yml / .env / shell:
| Var | Default | What it does |
|---|---|---|
PORT |
8080 |
HTTP port inside the container (compose maps host ports onto it; the Pi override adds 80:8080 so phones browse with no port) |
DATA_DIR |
/data |
SQLite DB + clips/photos/sources (mounted to ./data on the host) |
MOCK_GPIO |
true |
true = keyboard/on-screen mock buttons; false = real GPIO (Pi) |
AUDIO_BACKEND |
browser |
browser = clients play audio; server = mpv in-container to ALSA |
AUDIO_OUTPUT |
auto |
ALSA device hint for server playback (e.g. plughw:1,0) |
YTDLP_AUTO_UPDATE |
true |
true = upgrade yt-dlp to latest at container start when online |
Playback settings (default snippet length, master volume) are also editable live in admin.
Physical buttons call the same playback API as the touchscreen — one code path, no surprises. Wire momentary push buttons between GPIO pins and GND (uses internal pull-ups, active-low). Suggested defaults, BCM numbering:
| Button | BCM pin | Physical pin |
|---|---|---|
| Stop | GPIO 17 | 11 |
| Next batter | GPIO 23 | 16 |
| Volume up | GPIO 27 | 13 |
| Volume down | GPIO 22 | 15 |
| BT pairing LED (optional, via ~330Ω resistor) | GPIO 26 | 37 |
/dev/gpiomem is mapped into the container by docker-compose.pi.yml — no privileged container needed. Keep wires away from the audio cable; GPIO noise on a cheap speaker wire sounds like a swarm of bees.
Designed for a Hammond 1456KH3BKBU sloped aluminum console (254 × 211 × 76 mm, 15° slope): 10.1″ touchscreen in the top panel, jumbo arcade buttons (STOP / VOL+ / VOL− / NEXT) along the operator edge wired to GPIO, Pi 4 + USB DAC or BT speaker inside. Artist rendering (generated by tools/render-enclosure.py):
-
Physical buttons do nothing on the Pi — check
docker compose logs appfor the[gpio]lines from the startup self-check. You want[gpio] pin factory: LGPIOFactory. The ERROR lines mean lgpio didn't install (rebuild the image) or/dev/gpiochip0isn't mapped (use docker-compose.pi.yml). Silent mock-GPIO fallback on real hardware is exactly what this check exists to catch. -
"No audio output device found" warning — the app couldn't find a sound device. With
browserbackend this is just informational (browsers play their own audio); withserverbackend, check that/dev/sndis mapped (Pi compose file), the speaker is plugged in before container start, and try settingAUDIO_OUTPUTexplicitly (aplay -lon the Pi lists devices). -
YouTube imports fail — YouTube changes its internals regularly; yt-dlp answers with frequent releases. Two layers of defense:
- At every container start with internet access, BatterBox auto-upgrades yt-dlp to the latest release (check
docker compose logs appfor the[entrypoint] yt-dlpline). Offline (the field), it silently keeps the baked-in version. Disable withYTDLP_AUTO_UPDATE=false. - The pin in
requirements.txtis the known-good fallback baked into the image — what you tested at home is what runs at the field. If imports break even after the auto-update, bump the pin and rebuild:
pip index versions yt-dlp # or check https://github.com/yt-dlp/yt-dlp/releases # edit requirements.txt, then: docker compose up --build
- At every container start with internet access, BatterBox auto-upgrades yt-dlp to the latest release (check
-
Nothing downloads at the field — expected. YouTube import needs internet. Import at home; the field run is fully offline.
-
Tapping tiles is silent everywhere — no device holds the audio player role. Tap the speaker toggle on the kiosk top bar (turns green), open the kiosk as
/?player=1, or useAUDIO_BACKEND=server. (The song still ends on time and the tile stops pulsing either way — the server tracks the clip's length itself.) -
Chromium shows a "restore pages?" bubble — the kiosk script already passes
--disable-session-crashed-bubbleand--incognito; if you see it anyway you killed power mid-write. It's harmless; tap through once. -
Container logs —
docker compose logs -f app.
app/— FastAPI backend (routers, clip pipeline, playback, GPIO)static/— no-build JS SPA (kiosk grid, admin, trim editor, Walter Walkup)data/— runtime data (gitignored)docs/API.md— binding backend↔frontend API contractkiosk/— Pi kiosk launcher + autostart desktop entry




