A production-grade, headless network audio player built on Raspberry Pi 4. NAP provides stable, deterministic multi-source audio switching between MPD, AirPlay, Plexamp, and Bluetooth β controlled via a REST API, WebSocket-powered Web UI, physical buttons, rotary encoder, and IR remote.
Solutions like Volumio and Moode Audio are excellent general-purpose players, but they manage audio services in-process, which means source conflicts, unpredictable ALSA state, and hard-to-debug audio dropout on switching.
NAP takes a fundamentally different approach:
systemd is the service orchestrator. Python is the control layer. ALSA is never shared.
| Volumio / Moode | NAP | |
|---|---|---|
| Service switching | In-process start/stop | systemctl isolate (kernel-level) |
| ALSA access | Shared / dmix | Exclusive lock per source |
| State machine | Implicit | Explicit IDLE β SOURCE with rollback |
| Source conflict prevention | Best-effort | Guaranteed by Conflicts= in unit files |
| OTA updates | Via image | git pull + rollback |
- Four audio sources β MPD (FLAC/web radio), AirPlay (shairport-sync), Plexamp Headless, Bluetooth A2DP Sink
- Deterministic switching β
systemctl isolatetransitions with a two-phase commit and automatic rollback on failure - Global ALSA lock β
flock(2)on/var/run/audio.lockprevents any two sources from touching the DAC simultaneously - FastAPI backend β REST API + live WebSocket events; OpenAPI docs at
/docs - Single-file Web UI β source selection, playback controls, volume, config, OTA trigger, live log panel; zero JS framework dependencies
- 16Γ2 LCD UI β I2C HD44780 display with double-buffer anti-flicker rendering and rotary encoder navigation
- Hardware controls β rotary encoder, dedicated power button (short press = toggle, long press = shutdown), action buttons, IR remote (LIRC/evdev)
- OTA updates β
git pull-based update with dependency refresh, import verification, and automatic rollback on failure; triggered manually (API or Web UI) or on a configurable cron schedule - Structured logging β JSON log entries, in-memory ring buffer, queryable via API
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Web UI / LCD UI / Hardware Input β presentation
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β FastAPI (REST + WebSocket) β API layer
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β AudioController (state machine) β control layer
β StateManager (event bus) β
β ConfigManager (JSON + env vars) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β audio_lock.py (flock on /var/run/audio.lock) β safety layer
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β systemd targets + services (mpd, shairport-sync, β¦) β OS layer
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each audio source is a dedicated systemd target. Switching is performed with systemctl isolate, which atomically stops all conflicting units before starting the requested one.
audio-mpd.target Wants=mpd.service
audio-airplay.target Wants=shairport-sync.service
audio-plexamp.target Wants=plexamp.service
audio-bluetooth.target Wants=bluetooth-audio.service
Every target declares Conflicts= against the other three, so it is physically impossible for two audio services to run simultaneously β even if the Python layer fails.
Before any systemctl isolate call, AudioController acquires an exclusive flock(2) lock on /var/run/audio.lock. This single serialisation point ensures:
- No two switch requests race at the kernel level
- The lock file records the current holder (readable by monitoring tools)
- Timeout (default 8 s) with a
SwitchTimeoutexception if the lock is not acquired
AudioController transitions between five states:
IDLE ββ MPD
ββ AIRPLAY
ββ PLEXAMP
ββ BLUETOOTH
Every transition: acquire lock β isolate β verify active β commit state.
On any failure: rollback to previous target β release lock β raise exception.
NAP/
βββ backend/
β βββ app/
β β βββ audio_controller.py # State machine; only module that calls systemctl
β β βββ state_manager.py # Wraps AudioController + WebSocket event bus
β β βββ config_manager.py # Pydantic-Settings: JSON file + NAP_* env vars
β β βββ ota_updater.py # Git-based OTA: fetch, pull, verify, rollback
β β βββ lcd_ui.py # I2C 16Γ2 LCD double-buffer renderer
β β βββ hardware_input.py # GPIO encoder, buttons, IR receiver (evdev)
β β βββ main.py # FastAPI app factory + lifespan
β β βββ api/
β β β βββ routes.py # REST endpoints (/health, /source, /playback, β¦)
β β β βββ websocket.py # /ws WebSocket with keepalive
β β β βββ ota.py # OTA endpoints (/ota/update, /ota/version, β¦)
β β β βββ playback.py # mpc / amixer dispatch per source
β β β βββ schemas.py # Pydantic request/response models
β β βββ utils/
β β βββ audio_lock.py # flock(2) context manager
β β βββ logger.py # JSON formatter + in-memory ring buffer
β βββ requirements.txt
βββ systemd/
β βββ audio-mpd.target # systemd audio source targets (AllowIsolate=yes)
β βββ audio-airplay.target
β βββ audio-plexamp.target
β βββ audio-bluetooth.target
β βββ mpd.service
β βββ shairport-sync.service
β βββ plexamp.service
β βββ bluetooth-audio.service
βββ config/
β βββ asound.conf # System-wide ALSA: buffer geometry, named PCMs
β βββ mpd.conf # MPD: soxr resampler, ALSA output, buffer tuning
β βββ shairport-sync.conf # AirPlay receiver: soxr clock-lock, ALSA output
β βββ 90-nap-defaults.conf # Kernel-level ALSA defaults (alsa.conf.d)
β βββ wiring.conf # GPIO pin assignments (INI reference)
β βββ wiring_diagram.txt # ASCII hardware wiring diagram
βββ scripts/
β βββ install.sh # Production installer (idempotent, 14 steps)
βββ tests/
β βββ test_audio_controller.py # 14 unit tests (no root, no hardware required)
βββ web/
β βββ index.html # Single-file Web UI (HTML + CSS + JS)
βββ docs/
β βββ INSTALL.md # Complete software installation guide
β βββ HARDWARE.md # Hardware assembly, GPIO wiring, testing
βββ LICENSE
βββ SPEC.md # Product specification
| Component | Details |
|---|---|
| Raspberry Pi 4 Model B | Any RAM variant; Raspberry Pi OS Bookworm recommended |
| DAC | USB DAC or I2S HAT (e.g. HiFiBerry DAC+, Allo Boss). The onboard 3.5 mm jack is not recommended for quality audio. |
| 16Γ2 LCD | HD44780 with PCF8574 I2C backpack (address 0x27 or 0x3F) |
| Rotary encoder | KY-040, Alps EC11, or any 2-bit Gray-code encoder with push switch |
| Push buttons | 3β4 momentary NO buttons (power, play/pause, next, previous) |
| IR receiver | TSOP4838, VS1838B, or equivalent 38 kHz demodulator |
| Passive components | 10 kΞ© + 100 nF (encoder RC filter), 1 kΞ© (IR protection), 4.7 kΞ© (I2C pull-ups, usually on LCD backpack) |
| MicroSD / SSD | 16 GB minimum; Class 10 / A1 or USB SSD for reliability |
| Power supply | Official Raspberry Pi 4 USB-C PSU (5.1 V / 3 A) |
Full assembly instructions, circuit diagrams, and troubleshooting are in docs/HARDWARE.md. Machine-readable pin assignments are in config/wiring.conf and ASCII diagrams in config/wiring_diagram.txt.
GPIO Summary (BCM numbering):
| BCM | Physical | Function | Pull |
|---|---|---|---|
| 2 | 3 | LCD SDA (I2C) | 4.7 kΞ© (on board) |
| 3 | 5 | LCD SCL (I2C) | 4.7 kΞ© (on board) |
| 17 | 11 | Encoder A | PUD_UP + RC filter |
| 18 | 12 | Encoder B | PUD_UP + RC filter |
| 27 | 13 | Encoder button | PUD_UP |
| 22 | 15 | Power button | PUD_UP |
| 23 | 16 | Play/Pause button | PUD_UP |
| 24 | 18 | Next button | PUD_UP |
| 25 | 22 | Previous button | PUD_UP |
| 16 | 36 | IR receiver data | kernel (gpio-ir) |
Note: GPIO18 is also PCM_CLK (I2S). If you use an I2S DAC HAT, move
encoder_bto GPIO20 or GPIO23 and updatePinConfig.encoder_binhardware_input.py.
For a complete step-by-step guide including OS flashing, interface setup, and Plexamp authentication, see docs/INSTALL.md.
- Raspberry Pi 4 running Raspberry Pi OS Bookworm (64-bit recommended)
- Internet connection for package downloads
- SSH access or keyboard/monitor
git clone https://github.com/pernastefano/NAP.git
cd NAP
sudo bash scripts/install.shThe installer is fully idempotent β safe to run multiple times. It performs 14 steps:
- System packages (
apt-get: mpd, shairport-sync, bluealsa, avahi, Python 3, I2C tools, β¦) - Service accounts (
nap,mpd,shairport-sync,plexamp) - Directory structure (
/opt/nap,/etc/nap,/var/log/nap,/var/lib/nap) - Application code sync to
/opt/nap - Python virtual environment at
/opt/nap/venvwith all dependencies 5b. Plexamp Headless β Node.js (LTS) + binary download fromplexamp.plex.tv - Default configuration at
/etc/nap/config.json(never overwrites existing) - systemd unit installation (4 targets + 4 audio services +
nap-backend.service) - Minimal
sudoersrule (onlysystemctl isolate+systemctl restart nap-backend) - udev rules (I2C, GPIO, IR device symlink)
/var/run/audio.lockcreation and permissions- Log rotation (
/etc/logrotate.d/nap) - ALSA configuration (
/etc/asound.conf,/etc/mpd.conf,/etc/shairport-sync.conf) - Enable and start
nap-backend.service - I2C / SPI interface enablement via
raspi-config
sudo bash scripts/install.sh --no-apt # Skip apt (packages already installed)
sudo bash scripts/install.sh --no-services # Install files only; do not start services
sudo bash scripts/install.sh --dev # Skip RPi-specific hardware packagescd /opt/nap
python3 -m venv venv
source venv/bin/activate
pip install -r backend/requirements.txt
pip install RPLCD smbus2 RPi.GPIO evdevThe configuration file lives at /etc/nap/config.json. All fields are also overridable via environment variables prefixed NAP_ (e.g. NAP_API_PORT=9000).
{
"default_source": "idle",
"lock_timeout": 8.0,
"systemd_verify_timeout": 10.0,
"lcd_enabled": true,
"lcd_backlight_timeout": 30,
"ota_enabled": true,
"ota_github_repo": "pernastefano/NAP",
"ota_schedule_cron": "0 3 * * *",
"api_host": "0.0.0.0",
"api_port": 8000,
"log_level": "INFO",
"log_max_lines": 500
}Changes take effect after a service restart (sudo systemctl restart nap-backend) or via the PATCH /api/v1/config endpoint.
-
Find your Pi's IP address:
hostname -I # or, from another machine: ping raspberrypi.local -
Open in your browser:
http://<pi-ip-address>:8000/
The Web UI provides:
- Source grid β one-click switching between MPD, AirPlay, Plexamp, Bluetooth, Idle
- Playback controls β play, pause, stop, next, previous, volume slider
- Config panel β edit all settings live
- OTA panel β trigger an update and watch the progress
- Log viewer β filterable live log panel
The interactive API documentation is available at http://<pi-ip-address>:8000/docs.
All endpoints are prefixed /api/v1.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Service health and current source |
GET |
/source |
Current audio source |
POST |
/source |
Switch source ({"source": "mpd"}) |
POST |
/playback |
Playback action (play, pause, stop, next, previous, set_volume) |
GET |
/config |
Current configuration |
PATCH |
/config |
Update configuration fields |
GET |
/logs |
Recent log entries (filterable by level) |
POST |
/ota/update |
Trigger OTA update |
GET |
/ota/version |
Current application version |
GET |
/ota/history |
Last 50 OTA update records |
WS |
/ws |
WebSocket: live state_changed and ping events |
NAP updates itself directly from this repository.
Via Web UI: Click the Update button in the OTA panel.
Via API:
curl -X POST http://<pi-ip>:8000/api/v1/ota/updateVia command line (on the Pi):
sudo -u nap bash -c 'cd /opt/nap && git pull && \
venv/bin/pip install -q -r backend/requirements.txt && \
systemctl restart nap-backend'Set ota_schedule_cron in /etc/nap/config.json to a standard 5-field cron expression:
"ota_schedule_cron": "0 3 * * *"This schedules a nightly update at 03:00. Disable automatic updates by setting "ota_enabled": false.
git fetch originβ check for new commits without touching the working tree- Compare HEAD to
origin/<branch>β if identical, stop (nothing to do) - Stash any local uncommitted changes
git pull --ff-only origin <branch>β fast-forward only; force-pushes are rejectedpip install -r backend/requirements.txtβ refresh dependencies- Spawn a fresh Python process to verify the application imports cleanly
- Write a
VERSIONfile with the new commit SHA systemctl restart nap-backendβ 1.5 s delayed so the API response is sent first
Rollback: If any step from 4 onwards fails, NAP automatically runs git reset --hard <previous-commit> and re-installs the previous dependencies. The update history (last 50 entries) is recorded in ota_history.json.
No hardware or root access required.
cd /opt/nap # or your development clone
python3 -m pytest tests/ -vtests/test_audio_controller.py::test_initial_state PASSED
tests/test_audio_controller.py::test_noop_switch PASSED
tests/test_audio_controller.py::test_switch_to_mpd PASSED
... 14 passed in 0.04s
The test suite patches _isolate and _verify_active so systemd is never called. Lock files use a per-test tempfile path, so no write access to /var/run is needed.
| Web UI | LCD UI |
|---|---|
![]() |
![]() |
- AirPlay 2 support (shairport-sync 4.x)
- Spotify Connect source (librespot)
- Multi-room synchronisation (snapcast)
- Home Assistant MQTT integration
- Touchscreen UI (Waveshare 3.5")
- Per-source volume memory
- Playlist management via Web UI
MIT License β see LICENSE for details.
| Guide | Description |
|---|---|
| docs/INSTALL.md | Complete software installation guide (OS flash β first boot β verify) |
| docs/HARDWARE.md | Hardware assembly, GPIO wiring table, RC filters, testing scripts, troubleshooting |
| config/wiring.conf | Machine-readable GPIO pin assignments (INI format) |
| config/wiring_diagram.txt | ASCII circuit diagrams for all subsystems |
| config/asound.conf | Annotated ALSA configuration |
| SPEC.md | Product specification |
- MPD β Music Player Daemon
- shairport-sync β AirPlay audio receiver
- bluez-alsa β Bluetooth A2DP ALSA integration
- FastAPI β Python web framework
- RPLCD β Raspberry Pi LCD library

