pneuma is an open-source, self-hostable, and local-first music project, designed to give a Spotify-like experience. It is composed of a desktop application for local music playback, a server for music storage and streaming, and a web player for accessible music playback.
Public demo: https://pneuma.johncarlomanuel.com/
Register an account, and play a couple of songs from the Library of Congress in the web! The songs can also be streamed on the desktop application.
Web player is aesthetically identical to the desktop application.
NOTE: This project is currently under active development. Expect bugs and possibly breaking changes.
- Self-organizing music library: A music library is organized using metadata from the music files themselves. Playlists can be created by users to create custom collections of music. When using the server to store and stream your music, it makes use of fingerprinting via metadata and hashing to detect duplicate songs.
- Real-time playback sync: WebSocket-driven playback engines keep playback state, queues, and progress tightly in sync between the server and the local client. This is useful when playing music in a playlist with a mix of local and remote audio tracks.
- Automatic library monitoring: Background directory watchers automatically detect newly added or removed music files and update your library in real-time.
- Cross-platform: pneuma natively supports Windows, macOS, and Linux.
- Offline-first: pneuma is designed to work entirely offline. The desktop application can be used without the server, focusing on local playback for music on your own machine.
- Multi-user ready: The server includes a built-in admin web dashboard to manage itself and allows multiple users to maintain their own isolated profiles and custom playlists on a single instance.
pneuma was built to address some problems I've had with Spotify.
As a premium user since 2018, I've noticed that Spotify's UX gradually worsened. It worsened by bloating the service with features such as short-form content, social integration (combining a music streaming and social media service into one), and the sudden increase in AI-generated content. These changes have made it difficult to find and listen to music I enjoy.
pneuma is built with:
- Go
- TypeScript
- Wails
- SQLite
- sqlc
- Svelte
- Docker (for server deployment)
pneuma supports the following metadata for each individual track.
- Title
- Artist
- Album
- Album Artist
- Track Number
- Disc Number
- Duration
- Album Artwork
- Sample Rate
- Bitrate
- Genre
Install the following:
For Docker, ensure you have Docker's BuildX installed. Run the following to verify it is installed:
docker buildx versionRun to set up frontend dependencies:
bun installRun wails dev to start the desktop application in development mode. By default, it runs both the frontend and the Go process. It will also install frontend dependencies if not already installed.
Run wails build to build the desktop application. The output executable will be build/bin/pneuma (or whatever executable your OS supports).
Desktop data is profile-aware:
wails devuses thedevprofile by default and stores data under${OS_CONFIG}/pneuma-dev/wails buildexecutables use theprodprofile by default and store data under${OS_CONFIG}/pneuma/
For cache data (thumbnail artwork), the app uses ${OS_CACHE}/pneuma-dev/ for dev and ${OS_CACHE}/pneuma/ for prod.
You can override profile detection at runtime with PNEUMA_DESKTOP_PROFILE=dev or PNEUMA_DESKTOP_PROFILE=prod.
See os.UserConfigDir() and os.UserCacheDir() for OS-specific directory locations.
On Linux, the desktop application requires GStreamer plugins for audio playback. This addresses the error: GStreamer element autoaudiosink not found. Install it using your distribution's package manager:
# Debian/Ubuntu
sudo apt-get install gstreamer1.0-plugins-good
# Fedora
sudo dnf install gstreamer1-plugins-good
# Arch Linux
sudo pacman -S gst-plugins-goodSource: https://wails.io/docs/guides/linux/#gstreamer-error-when-using-audio-or-video-elements
To run the server, run:
# this will compile web/ and dashboard/, embed them into the server binary,
# and run the server
bun run serverUpon first start, the server will create a directory ${HOME}/.pneuma/ for storing its SQLite database and other types of data. Visit localhost:8989 to register an admin user and perform operations like managing music files for others to stream.
Then run the commands below.
# build normally
docker build -t pneuma:latest .
# or if you want to be more explicit with the platform:
# supported OS/arch:
# 1. linux/amd64
# 2. linux/arm64
docker build --platform <placeholder> -t pneuma-server .
docker run -d -p 8989:8989 pneuma
# use docker stop <container id> if you want to stop itTo build the server without the UI (for faster builds for testing):
docker build -t pneuma:latest --build-arg EMBED_UI=false .
docker run -p 8989:8989 pneumaservices:
server:
image: ghcr.io/johncmanuel/pneuma/server:latest
container_name: pneuma-server
restart: unless-stopped
ports:
- "8989:8989"
volumes:
# Persistent application data (database, cached artwork, uploads, etc.)
- pneuma_data:/data
# Mount your music directory (read-only recommended)
# Replace `./music` with the actual path to your local music directory
- ./music:/music:ro
environment:
# Core configuration
- PNEUMA_SERVER_HOST=0.0.0.0
- PNEUMA_DATA_DIR=/data
# Point the music scanner to the mounted volume
- PNEUMA_LIBRARY_WATCH_FOLDERS=/music
# Full rescan cadence in minutes with 120 as the default, but increase for low-power devices
# - PNEUMA_LIBRARY_SCAN_INTERVAL_MINUTES=240
# Security (this'll be auto generated if not provided and placed in the config file)
# - PNEUMA_AUTH_SECRET_KEY=change-this-to-a-secure-random-string
# Rate limiting (defaults to true)
# - PNEUMA_RATE_LIMITING_ENABLED=true
# Increase upload limit if needed (500 MB is default)
# - PNEUMA_UPLOAD_MAX_SIZE_MB=500
# Optional stream transcoding cache for mobile profiles
# - PNEUMA_TRANSCODING_FFMPEG_PATH=ffmpeg
# - PNEUMA_TRANSCODING_CACHE_DIR=/data/transcode-cache
# - PNEUMA_TRANSCODING_CACHE_MAX_SIZE_MB=2048
# - PNEUMA_TRANSCODING_MAX_CONCURRENT_JOBS=1
volumes:
pneuma_data:There are a handful of ways to test HTTPS locally. One way is to use tailscale to enable testing across not just the current machine, but through other devices, including mobile. Configure HTTPS for your tailnet.
- Build and start the staging stack:
docker compose -f .staging/docker-compose.staging.yml \
--project-directory .staging \
up --build -d- Expose the app to your tailnet:
sudo tailscale serve http://0.0.0.0:8989- Access from another device via the designated:
https://<hostname>.<your tailnet domain>
- Stop staging when done:
docker compose -f .staging/docker-compose.staging.yml \
--project-directory .staging \
down
# ctrl+c to exit tailscale serveNotes:
- Always use
/player/(with trailing slash) for proper service worker scope. - If using
tailscale servefor production, usetailscale serve --bg http://0.0.0.0:8989(or whatever port you're using). Usetailscale serve --https=443 offto turn it off if needed.
Run bun fmt to format TypeScript, Svelte, and Go code.
Run bun lint to lint TypeScript, Svelte, and Go code.
Run bun knip to check for unused TypeScript and Svelte code, dependencies, and exports.
sqlc is used to generate Go code from SQL queries.
Add SQL query files under internal/store/sqlite/<desktop or server>/query/.
Once done so, run sqlc generate to generate the Go code equivalent of the queries. The generated code will be placed under internal/store/sqlite/<desktop or server>db/ with the file extension .sql.go. They can be imported from the the package, <desktop or server>db.
The config file, sqlc.yaml is found at the root.
The server defaults to a single SQLite connection for reliability.
- TOML: set
database.max_open_connsinconfig.toml - ENV: set
PNEUMA_DATABASE_MAX_OPEN_CONNS
Increase carefully (e.g. 2 or 4) and monitor for lock contention.
Create new migration SQL files under internal/store/sqlite/<desktop or server>/migrations/.
Run go run ./cmd/dbmigrate up to apply all pending migrations.
Run go run ./cmd/dbmigrate down [N] to roll back N steps (default 1).
Run go run ./cmd/dbmigrate force <version> to force schema version and clear the dirty flag.
Run go run ./cmd/dbmigrate version to print current version and dirty status.
If wanted, use golang-migrate's CLI tool to do this instead. The custom CLI in /cmd/dbmigrate is a wrapper over the Go library version; it is for those that don't want to install another external tool.
Eventually, yes. There are three options I'm looking at: wait for Wails to support mobile platforms, optimize the web player to be a progressive web app (PWA), or look into tools like Capacitor for building mobile applications with Svelte support.



