Real-time audio spectrum visualizer for Raspberry Pi Zero 2W + Adafruit OLED Bonnet.
| Component | Details |
|---|---|
| Pi | Raspberry Pi Zero 2W, 64-bit Raspberry Pi OS Lite (aarch64) |
| Display | Adafruit OLED Bonnet 4567 — 128×32px, SSD1305, I2C |
| I2C bus | /dev/i2c-1, address 0x3C, 400kHz fast-mode |
| Audio signal chain | micro-HDMI → HDMI audio extractor → S/PDIF → digital receiver |
MPD (playback)
├── ALSA → hw:vc4hdmi,0 → HDMI → extractor → S/PDIF → receiver
└── FIFO output plugin → /tmp/mpd.fifo (raw PCM, 44100/16bit/stereo)
│
CAVA
(reads PCM FIFO, outputs spectrum bars at 30fps)
│
/tmp/cava.fifo (16 × u16 LE per frame)
│
crust (this binary)
(reads bar values, scales, renders rectangles)
│
I2C @ 400kHz
│
SSD1305 OLED (128×32)
mpd0.24.4: audio playback daemonmpc: MPD command-line clientcava0.10.4: audio spectrum analyzeri2c-tools: fori2cdetect(diagnostics)
Cross-compile on macOS for aarch64 Linux using cargo-zigbuild:
# prerequisites (one-time)
rustup target add aarch64-unknown-linux-gnu
brew install zig
cargo install cargo-zigbuild
# build
cargo zigbuild --release
# deploy
scp target/aarch64-unknown-linux-gnu/release/crust <user>@<pi>:~/The default target is set in .cargo/config.toml, so plain cargo zigbuild --release builds for the Pi.
See SETUP.md for first-time Pi setup: dependencies, config files, systemd services, and deployment steps.
scripts/mpc-aliases.sh wraps common mpc commands with short names. Source it from ~/.bashrc on the Pi:
source ~/crust/scripts/mpc-aliases.shms # status: current track, play/pause state, progress, volume
mc # current track name only
mp # play/pause toggle
mn # next track
mb # back — cdprev: restarts current track if past ~3s, else goes to previous
mx # stopmvo 80 # set volume to 80%
mup # volume +5%
mdn # volume -5%mq # show current queue
mclear # clear the queue
mshuffle # shuffle the queue in placemfind and madd both accept an optional tag as the first argument (artist, album, title, etc.). Without a tag they search all fields.
mfind "ok computer" # search all fields, print matches
mfind artist "radiohead" # search by artist tag
madd "ok computer" # add all matches to current queue
madd album "ok computer" # add by album tag
mplay "in rainbows" # clear queue, add matches, start playing
mplay artist "radiohead" # same, filtered by artist tag
mls # list library root
mls "Radiohead" # list contents of a directorymwatch uses mpc idleloop to listen for MPD player events and react without polling.
mwatch # print track name to terminal on each change
mwatch title & # update terminal title bar in the backgroundThe background variant is useful in an active SSH session: run it once and your terminal's title bar tracks the current song for the rest of the session. It exits automatically when the SSH session ends. To stop it early: kill %1 (or check jobs for the job number).
Each frame from /tmp/cava.fifo is exactly NUM_BARS * 2 bytes: one little-endian u16 per bar, in frequency order (low → high). Values range from 0 to 65535.
frame = [ lo0, hi0, lo1, hi1, ..., lo15, hi15 ] (32 bytes total)
The SSD1305 has 132 column drivers for a 128px panel; hardware columns 0–3 are off-screen on the left. The custom DisplaySize128x32Ssd1305 type in src/display.rs sets DRIVER_COLS=132 and OFFSETX=4, which tells the ssd1306 driver to start column addressing at hardware column 4. Software column 0 maps to the first visible pixel, software column 127 maps to the last, and all 16 bars fit exactly with no clipping.
const NUM_BARS: usize = 16; // bars CAVA is configured to output
const BAR_WIDTH: u32 = 7; // pixels wide per bar
const BAR_STEP: u32 = 8; // pixels from one bar's left edge to the next
// = BAR_WIDTH (7) + 1px gap; 16 × 8 = 128px
const DISPLAY_HEIGHT: u32 = 32; // pixel rows available
Layout (software x positions, bar index 0–15):
bar 0: x = 0, pixels 0–6 (7px, flush with left edge)
bar 1: x = 8, pixels 8–14 (7px)
bar 2: x = 16, pixels 16–22 (7px)
...
bar 15: x = 120, pixels 120–126 (7px, flush with right edge)
CAVA outputs u16 values (0–65535). These are scaled linearly to pixel height (0–32):
let raw = u16::from_le_bytes([buf[i * 2], buf[i * 2 + 1]]);
let height = (raw as u32 * DISPLAY_HEIGHT) / 65535;Bars grow from the bottom of the display. The top-left corner of each bar rectangle is computed as:
let x = (i as u32 * BAR_STEP) as i32; // left edge of bar i
let y = (DISPLAY_HEIGHT - height) as i32; // top edge (higher bar → smaller y)A bar with height = 0 is skipped entirely (nothing drawn). A bar with height = 32 fills the full column from y=0 to y=31.
Each iteration:
read_exact— blocks until a full 32-byte frame arrives from the FIFO- Clear the framebuffer by drawing a black rectangle over the full display
- For each bar: draw a white filled rectangle at
(x, y)with size(BAR_WIDTH, height) flush()— push the framebuffer to the display over I2C
The loop rate is naturally paced by CAVA's output (30fps target). No explicit sleep or timer is needed.