Sign inSign up

awkto/tempo

By awkto

•Updated 24 days ago

Tempo — self-hosted karaoke / music player (all-in-one)

Image
Buildkit cache
0

8.6K

awkto/tempo repository overview

⁠Tempo

Self-hosted karaoke / music player. Single user, runs on your own machine.

⁠Stack

  • Backend — FastAPI + SQLModel (SQLite) + ARQ workers + Redis
  • Audio — Demucs (htdemucs) for vocal removal, ffmpeg for transcode, mutagen for tags
  • Frontend — Vite + React + TypeScript + Tailwind, dual-source audio engine (SoundTouch worklet)
  • Deploy — Docker Compose, four services: api, worker, redis, web

⁠Quickstart

⁠Deploy (single container)
docker 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.

⁠Develop (split services with HMR)
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:

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.

⁠Adding music

⁠Local folder

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.)

⁠Upload (browser-friendly)

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}/.

⁠Google Drive
  1. In Google Cloud Console, create OAuth credentials of type Web application. Add http://localhost:8000/sources/gdrive/callback as an authorised redirect URI.
  2. Download client_secret.json to ./data/gdrive/client_secret.json.
  3. Restart the stack.
  4. Create the source:
    curl -X POST http://localhost:8000/sources \
      -H 'Content-Type: application/json' \
      -d '{"type":"gdrive","name":"Drive","config":{"folder_id":"OPTIONAL"}}'
    
  5. Visit http://localhost:8000/sources/{id}/gdrive/authorize, follow the URL it returns, complete OAuth.
  6. POST /sources/{id}/scan.

Drive files are downloaded and cached under ./data/cache/{source_id}/ on first play.

⁠Karaoke generation

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.

⁠GPU and remote workers

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.

⁠Backups

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.

⁠Auth

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/.

⁠Architecture notes

See CONTRIBUTING.md⁠ for the sources adapter contract and how to add a new source type. Decisions live in docs/architecture/adr/⁠.

⁠Repo layout
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.

⁠Database migrations

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.

⁠Releases

Pushing a v*.*.* tag triggers .github/workflows/docker-publish.yml, which:

  1. Builds the all-in-one CPU image (the only variant since v1.0.35).
  2. Pushes it to Docker Hub as awkto/tempo:{VERSION} plus :latest (:latest skipped for prereleases — tags containing -).
  3. Updates the Docker Hub short description on each repo.
  4. Cuts a GitHub Release with pull/run instructions.

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).

⁠API reference

http://localhost:8000/docs for the live OpenAPI explorer. Highlights:

MethodPathNotes
GET/healthliveness
GET/tracksq, artist, album, sort, order, limit, offset
GET/tracks/{id}merged metadata (overrides over file tags)
PATCH/tracks/{id}/metadatastage overrides; pass null to clear
POST/tracks/{id}/metadata/commitwrite overrides into the file
POST/tracks/{id}/metadata/lookupMusicBrainz candidates
GET/tracks/{id}/stream/originalHTTP range, scrub-friendly
GET/tracks/{id}/stream/karaoke404 until generated
GET/tracks/{id}/coverextracted cover art
POST/tracks/{id}/karaokeenqueue Demucs job
GET/jobs, /jobs/{id}worker status (poll)
POST/tracks/rehashadmin backfill of content_hash
*/playlists, /playlists/{id}/tracks, /playlists/{id}/reorderfull CRUD + ordering
*/sourceslocal, upload, gdrive
GET/workerslive workers (heartbeat within 30 s)

Tag summary

Content type

Image

Digest

sha256:a1f68cff3…

Size

3.2 GB

Last updated

24 days ago

docker pull awkto/tempo