- useWaveform: remove globalCurrentTime/globalIsPlaying from useEffect deps; WaveSurfer was re-initializing every 250ms while audio played. Dep array is now [url, songId, bandId]. Store reads inside the effect use getState() snapshots instead of reactive values. - useWaveform: move animationFrameId outside the async function so the useEffect cleanup can actually cancel the RAF loop. Previously the cleanup was returned from the inner async function and React never called it — loops accumulated on every re-render. - audioService: remove isDifferentSong + cleanup() call from play(). cleanup() set this.wavesurfer = null and then play() immediately called this.wavesurfer.play(), throwing a TypeError on every song switch. - audioService: replace new Promise(async executor) anti-pattern in initialize() with a plain executor + extracted onReady().catch(reject) so errors inside the ready handler are always forwarded to the promise. - audioService: remove currentPlayingSongId/currentPlayingBandId private fields whose only reader was the deleted isDifferentSong block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RehearsalHub
A web platform for bands to relisten to recorded rehearsals, drop timestamped comments, annotate moments, and collaborate asynchronously — all on top of your own storage (Nextcloud, Google Drive, S3, local).
Architecture
┌─────────┐ HTTP/WS ┌──────────────┐ asyncpg ┌──────────┐
│ React │ ──────────► │ FastAPI │ ──────────► │ Postgres │
│ (Vite) │ │ (Python) │ └──────────┘
└─────────┘ └──────┬───────┘
│ Redis pub/sub
┌──────────┴──────────┐
│ │
┌──────▼──────┐ ┌──────────▼──────┐
│ Audio Worker │ │ NC Watcher │
│ (waveforms) │ │ (file polling) │
└─────────────┘ └─────────────────┘
| Service | Language | Purpose |
|---|---|---|
web |
TypeScript / React | UI — player, library, settings |
api |
Python / FastAPI | REST + WebSocket backend |
worker |
Python | Audio analysis, waveform generation |
watcher |
Python | Polls Nextcloud for new files |
db |
PostgreSQL 16 | Primary datastore |
redis |
Redis 7 | Task queue, pub/sub |
Files are never copied to RehearsalHub servers. The platform reads recordings directly from your own storage.
Prerequisites
| Tool | Purpose | Install |
|---|---|---|
Podman + podman-compose |
Container runtime | podman.io |
| uv | Python package manager (backend) | curl -Lsf https://astral.sh/uv/install.sh | sh |
| Task | Task runner (Taskfile.yml) |
taskfile.dev |
| Node 20 | Frontend (runs inside podman — not needed locally) | via podman run node:20-alpine |
Node is only required inside a container. All frontend commands pull
node:20-alpinevia podman automatically.
Quick start
1. Configure environment
cp .env.example .env
# Edit .env — set SECRET_KEY, INTERNAL_SECRET, Nextcloud credentials, domain
Generate secrets:
openssl rand -hex 32 # paste as SECRET_KEY
openssl rand -hex 32 # paste as INTERNAL_SECRET
2. Start all services
task up # starts db, redis, api, audio-worker, nc-watcher, web (nginx)
task migrate # run database migrations
Or for first-time setup with Nextcloud scaffolding:
task setup # up + wait for NC + configure NC + seed data
3. Open the app
Visit http://localhost:8080 (or your configured DOMAIN).
Development
Start the backend with hot reload and mount source directories:
task dev:detach # start db, redis, api, worker, watcher in dev mode (background)
task dev:web # start Vite dev server at http://localhost:3000 (proxies /api)
Or run both together:
task dev # foreground, streams all logs
Follow logs:
task logs # all services
task dev:logs SERVICE=api # single service
Restart a single service after a code change:
task dev:restart SERVICE=api
Database migrations
# Apply pending migrations
task migrate
# Create a new migration from model changes
task migrate:auto M="add instrument field to band_member"
Useful shells
task shell:api # bash in the API container
task shell:db # psql
task shell:redis # redis-cli
Testing
After every feature — run this
task test:feature
This runs the full post-feature pipeline (no external services required):
| Step | What it checks |
|---|---|
typecheck:web |
TypeScript compilation errors |
test:web |
React component tests (via podman + vitest) |
test:api:unit |
Python unit tests (no DB needed) |
test:worker |
Worker unit tests |
test:watcher |
Watcher unit tests |
Typical runtime: ~60–90 seconds.
Full CI pipeline
Runs everything including integration tests against a live database.
Requires services to be up (task dev:detach && task migrate).
task ci
Stages:
lint ──► typecheck ──► test:web ──► test:api (unit + integration)
──► test:worker
──► test:watcher
Individual test commands
# Frontend
task test:web # React/vitest tests (podman, no local Node needed)
task typecheck:web # TypeScript type check only
# Backend — unit (no services required)
task test:api:unit # API unit tests
task test:worker # Worker tests
task test:watcher # Watcher tests
# Backend — all (requires DB + services)
task test:api # unit + integration tests with coverage
task test # all backend suites
# Integration only
task test:integration # API integration tests (DB required)
# Lint
task lint # ruff + mypy (Python), eslint (TS)
task format # auto-format Python with ruff
Frontend test details
Frontend tests run inside a node:20-alpine container via podman and do not require Node installed on the host:
task test:web
# equivalent to:
podman run --rm -v ./web:/app:Z -w /app node:20-alpine \
sh -c "npm install --legacy-peer-deps --silent && npm run test"
Tests use vitest + @testing-library/react and are located alongside the source files they test:
web/src/pages/
BandPage.tsx
BandPage.test.tsx ← 7 tests: library view cleanliness
BandSettingsPage.tsx
BandSettingsPage.test.tsx ← 24 tests: routing, access control, mutations
web/src/test/
setup.ts ← jest-dom matchers
helpers.tsx ← QueryClient + MemoryRouter wrapper
Project structure
rehearshalhub/
├── api/ Python / FastAPI backend
│ ├── src/rehearsalhub/
│ │ ├── routers/ HTTP endpoints
│ │ ├── models/ SQLAlchemy ORM models
│ │ ├── repositories/ DB access layer
│ │ ├── services/ Business logic
│ │ └── schemas/ Pydantic request/response schemas
│ └── tests/
│ ├── unit/ Pure unit tests (no DB)
│ └── integration/ Full HTTP tests against a real DB
│
├── web/ TypeScript / React frontend
│ └── src/
│ ├── api/ API client functions
│ ├── components/ Shared components (AppShell, etc.)
│ ├── pages/ Route-level page components
│ └── test/ Test helpers and setup
│
├── worker/ Audio analysis service (Python)
├── watcher/ Nextcloud file polling service (Python)
├── scripts/ nc-setup.sh, seed.sh
├── traefik/ Reverse proxy config
├── docker-compose.yml Production compose
├── docker-compose.dev.yml Dev overrides (hot reload, source mounts)
├── Taskfile.yml Task runner (preferred)
└── Makefile Makefile aliases (same targets)
Key design decisions
- Storage is always yours. RehearsalHub never copies audio files. It reads them directly from Nextcloud (or other providers) on demand.
- Date is the primary axis. The library groups recordings by session date. Filters narrow within that structure — they never flatten it.
- Band switching is tenant-level. Switching bands re-scopes the library, settings, and all band-specific views.
- Settings are band-scoped. Member management, storage configuration, and band identity live at
/bands/:id/settings, not in the library view.
Environment variables
| Variable | Required | Description |
|---|---|---|
SECRET_KEY |
✅ | 32-byte hex, JWT signing key |
INTERNAL_SECRET |
✅ | 32-byte hex, service-to-service auth |
DATABASE_URL |
✅ | PostgreSQL connection string |
REDIS_URL |
✅ | Redis connection string |
NEXTCLOUD_URL |
✅ | Full URL to your Nextcloud instance |
NEXTCLOUD_USER |
✅ | Nextcloud service account username |
NEXTCLOUD_PASS |
✅ | Nextcloud service account password |
DOMAIN |
✅ | Public domain (used by Traefik TLS) |
ACME_EMAIL |
✅ | Let's Encrypt email |
POSTGRES_DB |
✅ | Database name |
POSTGRES_USER |
✅ | Database user |
POSTGRES_PASSWORD |
✅ | Database password |
See .env.example for the full template.