Python controller for Raspberry Pi (Argon One V5) that synchronizes:
- 2 audio tracks routed to separate Zoom L6 outputs
- MIDI file playback driving a Processing visual sketch
- SSD1306 OLED display (built into Argon case)
- USB keyboard control (4 arrow keys + ESC)
USB Keyboard (←→↑↓ ESC)
│
controller.py
├── TrackManager — scans ~/rig/set-*/song-*/
├── Player — sounddevice audio + mido MIDI
├── Display — SSD1306 OLED via I2C
└── Keyboard — evdev arrow keys
│ │ │
Processing sketch Zoom L6 Argon One DAC
(HDMI visuals) Out 1-2: FOH mix front 3.5mm jack
Out 3-4: click track (FOH mirror)
~/rig/
├── set-01/
│ ├── song-01/
│ │ ├── My Song.wav # FOH mix → L6 outputs 1-2 (no keyword)
│ │ ├── My Song_drumless.wav # drumless mix (optional, contains 'drumless')
│ │ ├── My Song_metronome.wav # click track → L6 outputs 3-4 (contains 'metronome')
│ │ ├── My Song.mid # MIDI for Processing (.mid or .midi)
│ │ └── info.txt # optional metadata (see below)
│ └── song-02/...
└── set-02/...
Filenames are matched by keyword — the exact names don't matter:
- FOH mix: any
.wavwith nometronomeordrumlessin the name - Drumless mix:
.wavcontainingdrumless(notmetronome) — optional per song - Click track:
.wavcontainingmetronome - MIDI: any
.midor.midifile (one per folder) — optional; song plays audio-only without it
info.txt format (all fields optional):
title: My Song Name
bpm: 120
platform: SMD
timing: 11/4
title— display name shown on OLED ticker (defaults to folder name)bpm— injected as a MIDI tempo event if the MIDI file has noneplatform/timing— shown on OLED ticker alongside title and BPM- Legacy: a
bpm.txtfile containing just the BPM number is also accepted as a fallback
Shown at every boot for set and mode selection:
| Key | Action |
|---|---|
↑ / ↓ |
Navigate sets (slides in from top/bottom, wraps) |
← |
Confirm — drumless mode (drum icon with ✕, left) |
→ |
Confirm — full mix mode (drum icon, right) |
Once confirmed, the rig loads all tracks from the selected set.
| Key | Action |
|---|---|
← |
Stop and go to previous track |
→ |
Stop and go to next track |
↓ |
Play (starts audio + MIDI if present) |
↑ |
Pause / Resume |
ESC |
Exit (graceful shutdown) |
↑ + ← + → |
Exit combo (hold all three simultaneously) |
If the combo is not completed, each key fires its normal action on release.
- Ticker (top): large track number on the left; scrolling
title bpm platformon the right - Countdown (middle): remaining time (
M:SS left) orPAUSED - Set clock (bottom half): elapsed time since the first track started, rendered in large pixel font
- NO MIDI warning: if a song has no MIDI file,
NO MIDIreplaces the ticker and clock for 30 seconds after playback starts, then the display returns to normal
sudo raspi-config
# Interface Options → I2C → EnableOr add to /boot/firmware/config.txt:
dtparam=i2c_arm=on
Verify the OLED is detected at 0x3C:
i2cdetect -y 1The rig uses sounddevice which routes through PipeWire. Verify it's running:
systemctl --user status pipewire pipewire-pulse wireplumberIf not running:
systemctl --user enable --now pipewire pipewire-pulse wireplumberPipeWire must be alive for the user session before controller.py starts — the labwc autostart (section 8) handles this.
Zoom L6 presents as a multi-channel USB audio device. Connect via USB; no extra drivers needed on Pi OS.
AUDIO_DEVICE = None auto-detects by searching device names for zoom, l6, or l-6 with ≥4 output channels. No configuration needed in the normal case.
To pin a specific device:
python3 -c "import sounddevice as sd; print(sd.query_devices())"Look for L6: USB Audio or Zoom L-6, then set the index in controller.py:
AUDIO_DEVICE = 2 # pin to a specific device indexIf the index is stale after a reconnect (PaErrorCode -9998), use None instead.
Zoom L6 must be in 4-ch (multi-track) mode, not stereo. On the device:
- Menu → USB → Mode → Multi Track
ALSA config for consistent device naming — add to /etc/udev/rules.d/99-zoom.rules:
SUBSYSTEM=="sound", ATTRS{idVendor}=="1686", ATTRS{idProduct}=="0045", ATTR{id}="ZoomL6"
Then sudo udevadm control --reload && sudo udevadm trigger. Now the device always appears as hw:ZoomL6 for ALSA-level tools (this does not affect sounddevice auto-detection).
Argon One DAC — the Argon One V5 exposes a USB audio device (front 3.5mm jack). The controller auto-detects it and mirrors the FOH mix there as a stereo headphone feed. No configuration needed; if the DAC isn't found, it's skipped silently.
The controller creates a virtual ALSA MIDI port (RigMIDI) and bridges it to VirMIDI, which Processing can open as a raw MIDI device.
Load the module:
sudo modprobe snd_virmidi midi_devs=1Make it persistent — add to /etc/modules:
snd_virmidi
Verify it loaded:
aconnect -l
# Should show: "Virtual Raw MIDI 0-0" or similarIf VirMIDI doesn't appear, run:
sudo bash ~/rig/patch_midi.shpatch_midi.sh does three things (one-time only):
- Loads
snd_virmidiand makes it persistent in/etc/modules - Patches
MidiHandler.javato open the VirMIDI device - Recompiles and hot-patches the class into
sticker_spinner.jar
Stops all three Argon OLED service names (argononed, argone-oled, argonone-led) on startup and restarts only the ones that were active on exit.
Grant passwordless systemctl access (only needed if running as a non-root user — see section 8):
sudo bash ~/rig/setup_sudoers.shThis writes /etc/sudoers.d/rig-argon covering all three service names.
I2C address: the Argon One OLED is at 0x3C on I2C bus 1 (the default).
The controller normally runs as root via the autostart (see section 8), so no group changes are needed for production use.
If you want to run as a non-root user, add yourself to the required groups:
sudo usermod -a -G input,i2c nmlstyl
# log out and back in for group membership to take effectevdev needs the input group; I2C needs the i2c group. Without root or these groups, keyboard grab and OLED will fail.
On startup controller.py kills wf-panel-pi; on exit it relaunches it under lwrespawn to restore cleanly.
Do not kill the panel in autostart. An earlier hack added these lines to ~/.config/labwc/autostart:
# BAD — causes panel to glitch / shell to break after controller exits
sleep 1 && pkill -f "lwrespawn.*wf-panel-pi" && pkill wf-panel-pi &Killing lwrespawn externally causes the panel to oscillate between hide/show until the shell stops responding. The controller manages the panel lifecycle — autostart just needs to launch the controller.
Launch from ~/.config/labwc/autostart — the controller needs the desktop session (Processing needs a display, taskbar management needs labwc).
The autostart waits for PipeWire then launches via the venv's Python (so all deps are available to root):
bash -c 'until systemctl --user is-active pipewire > /dev/null 2>&1; do sleep 0.5; done; sudo /home/nmlstyl/rig/venv/bin/python /home/nmlstyl/rig/controller.py' &Do NOT enable performance-rig.service — it fires before the desktop exists, fails, then retries and collides with the autostart. Keep it disabled:
sudo systemctl disable performance-rig.serviceTo check it's not running twice:
pgrep -a python | grep controllerTwo ways to install. Pick one and use it consistently.
Installs packages into the system Python so sudo python3 can find them directly.
sudo pip install sounddevice soundfile numpy mido python-rtmidi evdev \
adafruit-circuitpython-ssd1306 pillow \
--break-system-packages
--break-system-packagesis required on Pi OS 12 (Bookworm) and later — it acknowledges you're intentionally installing outside a venv.
Autostart line:
bash -c 'until systemctl --user is-active pipewire > /dev/null 2>&1; do sleep 0.5; done; sudo python3 /home/nmlstyl/rig/controller.py' &Manual run:
sudo python3 ~/rig/controller.pyKeeps all rig packages separate from the system Python. sudo is pointed at the venv's binary directly — no activation needed.
bash ~/rig/install_multichannel.sh # creates ~/rig/venv and installs all depsOr manually:
cd ~/rig
python3 -m venv venv
source venv/bin/activate
pip install sounddevice soundfile numpy mido python-rtmidi evdev \
adafruit-circuitpython-ssd1306 pillowAutostart line:
bash -c 'until systemctl --user is-active pipewire > /dev/null 2>&1; do sleep 0.5; done; sudo /home/nmlstyl/rig/venv/bin/python /home/nmlstyl/rig/controller.py' &Manual run:
sudo ~/rig/venv/bin/python ~/rig/controller.pyIf you switch from system-wide to venv (or vice versa), update the autostart line in ~/.config/labwc/autostart to match. The two approaches don't conflict — they just use different Python binaries.
To check which Python the rig is currently using:
pgrep -a python | grep controller
# e.g. "sudo python3 ..." → system-wide
# e.g. "sudo /home/nmlstyl/rig/venv/bin/python ..." → venvEdit constants at the top of controller.py:
MUSIC_ROOT = Path("/home/nmlstyl/rig") # set-*/song-* root
PROCESSING_SKETCH = Path("/home/nmlstyl/sketchbook/sticker_spinner/linux-aarch64/sticker_spinner")
VIRTUAL_MIDI_PORT = "RigMIDI"
AUDIO_DEVICE = None # auto-detect Zoom L6 by name (or set to a device index)
KEYBOARD_NAME = None # target keyboard substring (None = any arrow-key keyboard)
W, H = 128, 64 # OLED dimensionsIf multiple keyboards are attached (e.g. a USB hub plus a built-in), set KEYBOARD_NAME to a substring of the target device name:
KEYBOARD_NAME = "USB Keyboard" # matches any device whose name contains this stringFalls back to any keyboard with arrow keys if the named device isn't found.
The rig requires a USB keypad with standard HID arrow keys and no host-side driver. Not all 4-key arrow pads meet this requirement.
BTXETUEL Mini 4-Key (SayoDevice platform)
- Plug and play — Up/Left/Down/Right stored in firmware, zero configuration required
- Recognized by Linux as a standard HID keyboard (
hid-generic/usbhid), USB VID0x8089 - Web configurator at sayodevice.com works on Linux via Chrome (WebHID API); config is saved on-device
- Documented working on Raspberry Pi with Python evdev
LINKEET KEY4T / KEY9T (driver-dependent model)
- Requires a Windows-only
keyboard_driver.exeto function — no Linux or Mac driver - Ships with no default keymap in firmware: keys produce zero HID reports without the driver running
- Will enumerate on USB and create
/dev/input/eventXnodes but send no events — the rig's silent-rebind loop will fire every 8 seconds indefinitely - Diagnosable:
sudo python3 -c "from evdev import InputDevice; import select,time; d=InputDevice('/dev/input/event6'); [print(e) for _ in [select.select([d.fd],[],[],8)] for e in d.read()]"— if nothing prints after pressing keys, the firmware has no keymap
- "Plug and play, no software needed" — must be in the listing
- "Linux" listed in compatibility — not just Windows/Mac
- Web-based configurator (not a
.exedownload) — means config lives on-device - Avoid keypads where the product page links to a
.exedriver download with no Linux alternative
The rig boots from an NVMe SSD in the Argon One V5 PCIe slot (BOOT_ORDER=0xf146, with the SD card kept as fallback).
For any NVMe drive in the Argon One V5, both lines must be present in /boot/firmware/config.txt:
dtparam=pciex1
dtparam=nvme
Without dtparam=pciex1 the PCIe slot stays disabled and the drive will not enumerate — the bootloader may even hang at the Raspberry Pi logo while attempting to read it. Add this line first before suspecting a faulty drive.
Optionally pin the link to Gen3 for higher throughput (the Pi 5 defaults to Gen2):
dtparam=pciex1_gen=3
Silicon Power 128GB NVMe M.2 PCIe Gen3x4 2280 (model SP128GBP34A60M28)
- Boots cleanly from cold power-on with the
config.txtentries above - Stable as the live root filesystem
No keyboard detected
sudo usermod -a -G input $USER # then log out/in
evtest # list and test input devicesKeyboard grabbed but no events — silent-rebind loops forever The device has no keymap in firmware and requires a host-side driver (see LINKEET KEY4T under Keyboard Hardware). Replace it with a plug-and-play HID keyboard.
Keyboard grab fails at startup The display server may be holding an exclusive grab on the keyboard. The rig will automatically attempt a USB unbind/rebind to force re-enumeration and break the grab, then reconnect.
OLED not working
sudo raspi-config # Interface Options → I2C → Enable
i2cdetect -y 1 # should show 0x3CInvalid number of channels / PaErrorCode -9998
USB enumeration changed after a reconnect. Check the current index:
python3 -c "import sounddevice as sd; print(sd.query_devices())"Set AUDIO_DEVICE = None to use auto-detect instead.
ModuleNotFoundError: sounddevice or soundfile
source ~/rig/venv/bin/activate
pip install sounddevice soundfile --break-system-packagesMIDI port creation fails
sudo apt install python3-rtmidi alsa-utils
pip install python-rtmidi mido --break-system-packagesVirMIDI not found
sudo modprobe snd_virmidi
sudo bash ~/rig/patch_midi.sh
aconnect -l # verify Virtual Raw MIDI appearsProcessing sketch doesn't launch
chmod +x ~/sketchbook/sticker_spinner/linux-aarch64/sticker_spinner
# test manually:
DISPLAY=:0 ~/sketchbook/sticker_spinner/linux-aarch64/sticker_spinnerSample rate mismatch Zoom L6 runs at 48kHz. Convert files if needed:
sox input.wav -r 48000 output.wav
soxi your_file.wav | grep "Sample Rate"Audio clicks or dropouts
Increase blocksize in _audio_loop:
stream = sd.OutputStream(..., blocksize=2048, ...)Audio and MIDI out of sync
The MIDI thread is delayed by blocksize/samplerate (≈21ms) to compensate for the audio stream's internal buffer. If still drifting, adjust the delay passed to _midi_loop in Player.play():
self._midi_t = threading.Thread(target=self._midi_loop, args=(midi, start, 0.030), ...)
# increase the last value if MIDI leads audioTracks not found
tree ~/rig/ | head -30
# Each song-XX folder needs title.wav and metronome.wav (.mid is optional)Intermittent boot to terminal instead of GUI Caused by a race between Plymouth (boot splash) and lightdm's VT switch. Fix: remove Plymouth entirely.
sudo apt purge plymouthTo diagnose future boot failures, enable persistent journald logs:
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journaldThen after a bad boot: sudo journalctl -b -1 -u lightdm



