Your phone plays music over Bluetooth; the Pi reads the playback position and scrolls time-synced, karaoke-style lyrics on a bar display in the car — no app on the phone, nothing to tap while driving.
Step-by-step setup for the car lyric display on a Raspberry Pi 5 running the 64-bit Lite (console-only, no desktop) image. Tested end-to-end on a Pi 5 1GB; any Pi 5 (1/2/4/8 GB) works — the app is light and never decodes audio. No source-code changes are required; the code is identical to the Pi Zero 2W build.
📱 Just want to use the display once it's running? See the First-Time Setup Guide — a touchscreen walkthrough of every on-screen menu (fonts, background, Bluetooth, Wi-Fi, clock format) plus day-to-day use. This README covers the one-time Pi install below.
⚠️ Use the Lite image, not "with desktop." The desktop image auto-starts its own Wayland compositor (labwcvialightdm), which grabs the HDMI output and fightscage. You'll get an endlessSwapchain … failed testand a black screen.cagemust be the only compositor. (Already on the desktop image? See Display gotchas → #1 below to switch to console without reflashing.)
These instructions assume the Linux username fuwenxu and the install path
~/carlyrics. If your username differs, read If your username isn't
fuwenxu just below — the one place it truly matters is the service
ExecStart path in Part E.
The Python code needs no edits — it resolves
config.json,cache/, andrejections.jsonrelative to the script itself, so it runs from any path. Only deployment paths change. Substitutingpi5as an example:
Where Change Required? systemd unit ExecStart(Part E)/home/pi5/carlyrics/Lyrics_Display.pyYes — a wrong/placeholder path is the #1 reason the service fails to start carlyric-claude.sudoers(Part D-3)leading fuwenxu→pi5Only if you use password-less restarts clone path, ssh/scphostyour username + home dir Yes (it's just where you log in / clone) Fix the repo files in one pass (from the repo root, after Part C):
grep -rl fuwenxu . | xargs sed -i 's/fuwenxu/pi5/g'The systemd unit lives in
/etc/systemd/system/(outside the repo), so set its path by hand in Part E — do not leave any<pi-user>placeholder in it.
| Item | What to do |
|---|---|
| HDMI | Plug the bar display into HDMI0 — the micro-HDMI port nearest the USB-C power connector. Needs a micro-HDMI cable/adapter. |
| Power | Use a 5V / 5A (25W) USB-C supply. An under-sized car charger causes low-voltage throttling on the Pi 5 — size the car power accordingly. |
| Cooling | Fit the official active cooler or a fan case. The Pi 5 runs hot and throttles in a warm car under sustained load. |
| RAM | 1GB is enough (tested). More RAM gives no benefit for this app. |
| Audio | No 3.5mm jack, and irrelevant here — the Pi is a Bluetooth A2DP sink and never decodes audio (SDL_AUDIODRIVER=dummy). |
| Bluetooth | Onboard BT works the same as the Zero; pairing / bt-agent / AVRCP flow is unchanged. |
| RTC (optional) | The Pi 5 has an RTC header. A coin cell keeps the clock correct before Wi-Fi/NTP — nice for the idle-clock display. |
OS: Raspberry Pi OS (64-bit) — the Lite image. 64-bit is required on the
Pi 5 (it uses the vc4-kms-v3d graphics stack cage needs).
-
Install Raspberry Pi Imager (https://www.raspberrypi.com/software/) and insert the microSD card.
-
In Imager:
- Device: Raspberry Pi 5
- OS: Raspberry Pi OS (other) → Raspberry Pi OS Lite (64-bit)
- Storage: your microSD card
-
Click Next → Edit Settings and set:
- Hostname:
carlyric(socarlyric.localresolves on the network) - Username:
fuwenxu+ a password (keepfuwenxuto match the paths) - Wi-Fi: your SSID, password, and country
- Services tab: enable SSH (password authentication)
- Hostname:
-
Write the card, then insert it into the Pi 5.
-
Connect the display to HDMI0, apply the 5A USB-C power, and boot.
-
After ~1 minute, SSH in from your computer:
ssh fuwenxu@carlyric.local
If
.localdoesn't resolve, use the Pi's IP from your router.
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y python3-dbus-next python3-pygame fonts-noto-cjk \
cage seatd python3-requests bluez bluez-tools git uhubctlcage is the single-app Wayland kiosk compositor; seatd gives it a seat
without a full desktop; fonts-noto-cjk provides Chinese glyphs. uhubctl
lets the app power-cycle the USB 5G modem's port when connectivity drops (see
Auto-recovery below) — optional, but recommended for in-car use.
Enable seatd at boot (on Lite you must do this yourself, or cage fails with a
libseat/seat error):
sudo systemctl enable --now seatdInstall git first, then clone:
sudo apt install git -y
git clone https://github.com/xiabo-lab/lyrics.git ~/carlyrics
cd ~/carlyrics1. Bluetooth auto-pairing agent (lets you pair phones from the touchscreen):
sudo cp ~/carlyrics/bt-agent.service /etc/systemd/system/
sudo systemctl enable --now bt-agent2. Stop the stray cursor (the phone's AVRCP channel looks like a mouse):
sudo cp ~/carlyrics/99-carlyric-ignore-avrcp-pointer.rules /etc/udev/rules.d/
sudo udevadm control --reload && sudo udevadm trigger3. (Optional) password-less service restarts:
sudo cp ~/carlyrics/carlyric-claude.sudoers /etc/sudoers.d/carlyric
sudo chmod 440 /etc/sudoers.d/carlyricThe GUI needs a graphical seat (cage), so it can't run over plain SSH — it runs from systemd. Create the unit:
sudo nano /etc/systemd/system/carlyric.servicePaste this exactly. The paths below are for user fuwenxu. If your username
is different, replace fuwenxu in the ExecStart line — never leave a
<pi-user> placeholder there, or the service loops with
can't open file '/home/<pi-user>/carlyrics/Lyrics_Display.py'.
[Unit]
Description=Car Lyrics Display (cage + pygame scroller)
After=systemd-user-sessions.service getty@tty1.service bluetooth.service
Wants=bluetooth.service
Conflicts=getty@tty1.service
[Service]
User=root
PAMName=login
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
StandardError=journal
Environment=XDG_RUNTIME_DIR=/tmp
ExecStart=/usr/bin/cage -s -- /usr/bin/python3 -u /home/fuwenxu/carlyrics/Lyrics_Display.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetWhy the
tty1binding matters (Pi 5). At boot the kernel framebuffer console owns the HDMI output (DRM master). A plain background service can't take it, socagestarts but never presents — you'd seeCould not make device fd drm master: Device or resource busyand endlessSwapchain … failed test.Conflicts=getty@tty1.service+TTYPath=/dev/tty1+StandardInput=tty+PAMName=loginmakecagethe active VT session ontty1, so the kernel hands it DRM master cleanly. Don't add anyWLR_*env vars — not needed here.
Restart=on-failure+ theEsckey. With a keyboard attached,Escexits the kiosk cleanly and it stays stopped (a real crash still auto-recovers). Manage the Pi over SSH — VT switching (Ctrl+Alt+F2) is blocked under cage on this board. Do not addOnSuccess=getty@tty1.service: it also fires during everysystemctl restart, collides with thetty1hand-off, and breaks "Update Firmware" / manual restarts.
Save (Ctrl+O, Enter, Ctrl+X), then enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now carlyric.service
journalctl -t cage -f # watch it start (see note below)A healthy start logs [config] … and [display] <W> x <H>, and the lyrics
appear on the HDMI display. Press Ctrl+C to stop watching the log (the service
keeps running).
Why
python3 -u? systemd hands the app a journal socket, not a terminal, and Python block-buffersprint()to anything that isn't a terminal. Without-u, the[config]/[display]lines above sit in a memory buffer for hours instead of reaching the journal — and are lost outright if the process crashes, which is exactly when you want them.-uturns buffering off so[config],[track],[sync]and[lyrics]appear live.Why
-t cageand not-u carlyric.service? The app runs as a child ofcage, so its output carries thecagesyslog identifier — and because this unit usesPAMName=login, that child is placed in asession-N.scoperather than incarlyric.service. Sojournalctl -u carlyric.serviceshows only cage's own EGL/xwayland errors, not the app's lines. Filtering by identifier always works:journalctl -t cage -f | grep -E '\[(config|display|track|sync|seek|lyrics|status)\]'
Sanity-check the path if it's looping:
grep ExecStart /etc/systemd/system/carlyric.service # must show YOUR real home dirOnly needed for in-car / outdoor use. The Pi fetches lyrics for new songs over the internet (songs you've already confirmed are cached and play offline). At home it's on the Wi-Fi you set in Part A; in the car it needs a mobile internet source. Set this up once, parked, and NetworkManager auto-connects whenever that network is in range — nothing to do per drive.
- Using a dedicated mobile hotspot (e.g. the TCL LINKPORT IK511 5G / T-Mobile)? Add its network to the Pi once (below) and you're set — it powers up with the car and the Pi rejoins automatically. Skip this part on future drives.
- Using your phone's hotspot? Add the phone's hotspot the same way; the Pi then joins it whenever you turn Personal Hotspot on.
Add the network from your computer over SSH while the Pi is still on your home Wi-Fi:
nano ~/carlyrics/wifi.sh # set SSID and PASSWORD to the hotspot's
~/carlyrics/wifi.sh # asks for the sudo password; joins and saves itNetworkManager remembers every network and auto-picks whichever known one is in range, so this is a one-time step per hotspot.
iPhone hotspot tip: if the Pi won't join, toggle Settings → Personal Hotspot → Maximize Compatibility on the phone and re-run
wifi.sh.
Mobile data in the car is flaky — a 5G hotspot or USB modem can silently stop passing traffic. The app watches for this: if two songs in a row get no lyrics and a live reachability check confirms the internet is actually down, it recovers automatically:
- On a USB 5G modem (e.g. the IK511 plugged in as a USB network adapter):
it power-cycles just that modem's USB port with
uhubctl, falling back to a driver re-enumeration ifuhubctlisn't installed. Only the modem's own port is touched — never the touchscreen or anything else on the bus. - On Wi-Fi (home Wi-Fi or a Wi-Fi hotspot): it reboots the Pi, which re-kicks NetworkManager and the radio.
Two genuinely-unfindable songs on a working connection never trigger it — the
reachability check gates on the internet actually being down. Turn it off with
"auto_recover": false in config.json.
- On the Pi screen, long-press 10 s → Settings → Bluetooth → Pair New Phone.
- On your phone, pair with
carlyricjust like a Bluetooth speaker. - Play a song — lyrics should scroll in sync. Tune offsets from the on-screen
menu or
config.json(see Test/README_Pi_Zero_2W.md for every key).
✅ That completes the required setup (Parts A–G). If the lyrics scroll in sync, you're done. Everything below is optional or for troubleshooting only — skip it unless something isn't working or you want to migrate settings.
The current lyric line fills with colour in time with the song — the sung
part in karaoke_color (default white), the rest in current_color. Where the
matched lyric comes from Kugou (KRC) or QQ Music (QRC) the fill tracks
the exact word (or CJK character) being sung; otherwise it glides evenly across
the line. On by default (karaoke_sync) and it replaces the progress bar. QRC
decoding uses the bundled qqcrypto.py. Full details + the karaoke_* config
keys are in the Pi Zero 2W guide.
The local console looks broken — that's expected. Two things happen once
cagereleases the screen:
- Undervoltage messages may scroll by, e.g.
hwmon hwmon3: Undervoltage detected!. These were happening all along —cagejust hid the console. They mean the Pi 5 isn't getting a full 5V/5A; fix the supply/cable (a 27W USB-C PD adapter + a 5A-rated cable). Check withvcgencmd get_throttled(0x0= fine,0x10000= undervoltage occurred since boot). Don't suppress the warning.- The keyboard does nothing. The unit binds
tty1(Conflicts=getty@tty1.service), so withcagestopped there is no login shell ontty1, and VT switching (Ctrl+Alt+F2) is blocked — by design you manage this Pi over SSH. Recover withssh fuwenxu@carlyric.localthensudo systemctl restart carlyric.service(brings the display back), orsudo systemctl start getty@tty1for a local login prompt. No other computer? Power-cycle — it boots straight back into the display.
Almost every "black screen / Swapchain … failed test" comes down to something
other than cage owning the HDMI output. In order of how often they bite:
If you flashed the "with desktop" image, labwc (started by lightdm) holds
DRM master and fights cage. Symptom: nonstop Swapchain for output 'HDMI-A-1' failed test, black screen. Switch to console — no reflash needed:
systemctl get-default # if 'graphical.target', the desktop is on
sudo systemctl set-default multi-user.target
sudo systemctl enable --now seatd # the desktop used to start this for you
sudo rebootConfirm nothing else holds the GPU:
sudo fuser -v /dev/dri/card* # should list only cage (+seatd), no labwc/XorgEven with no desktop, the framebuffer console owns tty1 at boot: Could not make device fd drm master: Device or resource busy. The Part E unit fixes this via
the tty1 binding. If you wrote the unit without those lines, a manual run works
but boot doesn't — add them.
The app logs [display] <W> x <H> (SDL video driver: …).
- Correct:
[display] 1920 x 440 (SDL video driver: x11)— SDL runs through cage's Xwayland. Leave SDL on its default; do not setSDL_VIDEODRIVER=wayland(on this trixie/SDL build the native Wayland driver reports "did not add any displays" or hangs inset_mode()). [display] 1 x 1— you're hitting gotcha #1 (a second compositor is up). Fix the desktop conflict, not SDL.
A healthy boot still prints these — they are not the problem:
[EGL] … eglQueryDeviceStringEXT … EGL_BAD_PARAMETER— a query V3D doesn't support. This is the warning you'll see on every boot; safe to ignore.xkbcomp … Unsupported maximum keycode 708, clipping— keymap warning.xwayland/xwm.c … Failed to get window property/xcb error … ConfigureWindow— cosmetic Xwayland startup noise.
WLR_DRM_NO_ATOMIC/WLR_DRM_NO_MODIFIERS/WLR_NO_HARDWARE_CURSORS— only silence the swapchain error; the output still never reaches the app.WLR_RENDERER=vulkan— fails withCould not match drm and vulkan device.WLR_RENDERER=pixman/SDL_VIDEODRIVER=kmsdrm— both fail to start here.- Forcing an HDMI mode in
cmdline.txt— the bar panel's native mode is 1920×440; forcing another mode gives a black screen. Let KMS pick the EDID mode; remove anyvideo=…you added.
To bring existing tuning and confirmed lyrics instead of starting blank, run from your computer with the other Pi powered on:
# pull from the old Pi
scp fuwenxu@<old-ip>:~/carlyrics/config.json ./
scp -r fuwenxu@<old-ip>:~/carlyrics/cache ./
scp fuwenxu@<old-ip>:~/carlyrics/rejections.json ./ # if it exists
scp fuwenxu@<old-ip>:~/carlyrics/aliases.json ./ # saved name corrections, if any
# push to the Pi 5
scp ./config.json fuwenxu@carlyric.local:~/carlyrics/
scp -r ./cache fuwenxu@carlyric.local:~/carlyrics/
scp ./rejections.json fuwenxu@carlyric.local:~/carlyrics/
scp ./aliases.json fuwenxu@carlyric.local:~/carlyrics/Then restart the service on the Pi 5:
sudo systemctl restart carlyric.serviceTwo Pis on one network: if both are powered at once they'll both want the hostname
carlyric. mDNS disambiguates (e.g.carlyric-2.local), but it's cleaner to keep only one powered during setup.
From the screen: long-press → Software Version → Update Firmware, or manually:
cd ~/carlyrics && git pull && sudo systemctl restart carlyric.serviceYour config.json, cache/, rejections.json, and aliases.json (saved name
corrections) are preserved across updates.
