Tempo — self-hosted karaoke / music player (all-in-one)
8.6K
Self-hosted karaoke / music player. Single user, runs on your own machine.
htdemucs) for vocal removal, ffmpeg for transcode, mutagen for tagsapi, worker, redis, webdocker run -d --name tempo \
-p 80:80 -v /srv/tempo-data:/data \
--restart unless-stopped \
awkto/tempo:latest
That's the whole stack — nginx + FastAPI + worker + redis — in one image.
Mount any host directory at /data to persist the SQLite DB, generated
karaoke stems, uploads, and cover art.
git clone https://github.com/awkto/musicmod.git
cd musicmod
cp .env.example .env
docker compose up --build
docker-compose.yml runs the four services (api, worker, redis, web)
separately so the Vite dev server can hot-reload frontend edits and the
API can be iterated without rebuilding the all-in-one image.
When everything is up:
| Service | URL |
|---|---|
| API | http://localhost:8000 |
| API docs | http://localhost:8000/docs |
| Web | http://localhost:5173 |
The data/ directory is bind-mounted and holds the SQLite DB, generated stems, uploads, download cache, and cover art. Back this directory up to keep your library + karaoke versions.
On first start the API auto-creates a demo local source named Local Music pointing at /data/tracks (i.e. ./data/tracks on the host). Drop audio files there and trigger a scan.
Drop files under ./data/tracks/ and trigger a scan:
curl -X POST http://localhost:8000/sources/1/scan
To wire up additional folders:
curl -X POST http://localhost:8000/sources \
-H 'Content-Type: application/json' \
-d '{"type":"local","name":"My Library","config":{"root_path":"/data/extra"}}'
(Mount the host path into both api and worker services first.)
Use the upload dropzone on the Sources page, or call the API:
curl -X POST http://localhost:8000/sources \
-H 'Content-Type: application/json' \
-d '{"type":"upload","name":"Drops","config":{}}'
# returns id=N
curl -X POST http://localhost:8000/sources/N/upload -F [email protected]
curl -X POST http://localhost:8000/sources/N/scan
Files land under ./data/uploads/{source_id}/.
http://localhost:8000/sources/gdrive/callback as an authorised redirect URI.client_secret.json to ./data/gdrive/client_secret.json.curl -X POST http://localhost:8000/sources \
-H 'Content-Type: application/json' \
-d '{"type":"gdrive","name":"Drive","config":{"folder_id":"OPTIONAL"}}'
http://localhost:8000/sources/{id}/gdrive/authorize, follow the URL it returns, complete OAuth.POST /sources/{id}/scan.Drive files are downloaded and cached under ./data/cache/{source_id}/ on first play.
curl -X POST http://localhost:8000/tracks/42/karaoke
# returns { id: <job_id>, status: "queued", ... }
curl http://localhost:8000/jobs/<job_id> # poll for progress
The worker runs Demucs (htdemucs, two-stem split) on the track and writes data/stems/{track_id}/karaoke.mp3 (320 kbps). Once done, GET /tracks/{id}/stream/karaoke returns the karaoke audio, and the in-app A/B toggle crossfades between original ↔ karaoke without a seek.
Only the all-in-one CPU image (awkto/tempo) is published now; CI dropped the -cuda and
awkto/tempo-worker variants in v1.0.35 (April 2026) (.github/workflows/docker-publish.yml keeps a
one-entry matrix, ready for a standalone worker image if one is ever needed again). Demucs runs
on CPU inside the container (~30 s for a 4-min track on a modern CPU). The remote-worker
plumbing is still in the code — TEMPO_REDIS_BIND / TEMPO_REDIS_PASSWORD on the API side
(the container refuses a non-loopback bind without a password), TEMPO_REDIS_URL plus a shared
/data on the worker side, GET /api/workers to confirm registration — so a worker image can
be rebuilt from backend/Dockerfile with INSTALL_WORKER=true when there is a GPU host to use.
The only stateful directory is ./data/. To back up:
docker compose stop # quiesce writes
tar czf tempo-$(date +%F).tar.gz ./data
docker compose start
Restoring is the inverse — replace ./data/ and start the stack.
There is none. Tempo is a single-user, self-hosted app meant to live on a private network or behind a VPN. If you need multi-user later, the natural slot is a FastAPI dependency on the routers in backend/app/routers/.
See CONTRIBUTING.md for the sources adapter contract and how to add a new source type.
Decisions live in docs/architecture/adr/.
backend/ self-hosted server: FastAPI, SQLite, local Demucs (this README)
cloud/ cloud server: zero-knowledge, Postgres, R2, Replicate — see cloud/README.md
frontend/ the one client (Vite + React); cloud-only modules live under frontend/src/cloud/
android/ Capacitor shell + Media3 player
docs/ architecture review and ADRs
The two backends never share code (ADR-003). cloud/ was imported from awkto/musicmod-saas
with history and is frozen until M5: its cloud CI job must stay green, but no feature work
lands there. cloud-v*.*.* tags build awkto/tempo-cloud (private Docker Hub repo; docker login to pull) from cloud/Dockerfile.api.
The schema is versioned with Alembic (backend/alembic/, ADR-002). Both the API and the worker
run alembic upgrade head at startup, so a normal container upgrade migrates the database in
place. A database created before v1.0.109 must be started on v1.0.109 once (the bridge release
that stamps it onto the revision chain); later releases refuse it at boot with a message saying
so, without touching it. To change the schema:
cd backend
# edit app/models/db.py, then
uv run alembic revision --autogenerate -m "add foo to track"
uv run pytest tests/test_migrations.py # fresh DBs reach head; pre-Alembic DBs are refused
Review the generated file before committing — SQLite batch mode is on, and data backfills are
written as explicit migrations (see 0002_backfill_combined_bitrate.py), never as boot code.
Pushing a v*.*.* tag triggers .github/workflows/docker-publish.yml, which:
awkto/tempo:{VERSION} plus :latest (:latest skipped for prereleases — tags containing -).To cut a release:
git tag v0.2.0
git push origin v0.2.0
Required repo secrets: DOCKER_USERNAME, DOCKER_PASSWORD (Docker Hub access token).
http://localhost:8000/docs for the live OpenAPI explorer. Highlights:
| Method | Path | Notes |
|---|---|---|
| GET | /health | liveness |
| GET | /tracks | q, artist, album, sort, order, limit, offset |
| GET | /tracks/{id} | merged metadata (overrides over file tags) |
| PATCH | /tracks/{id}/metadata | stage overrides; pass null to clear |
| POST | /tracks/{id}/metadata/commit | write overrides into the file |
| POST | /tracks/{id}/metadata/lookup | MusicBrainz candidates |
| GET | /tracks/{id}/stream/original | HTTP range, scrub-friendly |
| GET | /tracks/{id}/stream/karaoke | 404 until generated |
| GET | /tracks/{id}/cover | extracted cover art |
| POST | /tracks/{id}/karaoke | enqueue Demucs job |
| GET | /jobs, /jobs/{id} | worker status (poll) |
| POST | /tracks/rehash | admin backfill of content_hash |
* | /playlists, /playlists/{id}/tracks, /playlists/{id}/reorder | full CRUD + ordering |
* | /sources | local, upload, gdrive |
| GET | /workers | live workers (heartbeat within 30 s) |
Content type
Image
Digest
sha256:a1f68cff3…
Size
3.2 GB
Last updated
24 days ago
docker pull awkto/tempo