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.
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. X_OFFSET=2 distributes the resulting 3px deficit across
both edge bars: bar 0 loses 2px (renders 5px, flush with left panel edge), bar 15
loses 1px (renders 6px). All 14 inner bars are 7px wide with 1px gaps.
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
const X_OFFSET: i32 = 2; // splits SSD1305 3px deficit across edge bars
const DISPLAY_HEIGHT: u32 = 32; // pixel rows available
Layout (software x positions, bar index 0–15):
bar 0: x = 2, panel pixels 0–4 (5px visible — 2px off-screen left)
bar 1: x = 10, panel pixels 6–12 (7px)
bar 2: x = 18, panel pixels 14–20 (7px)
...
bar 15: x = 122, panel pixels 118–123 (6px — 1px clipped at software 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.