diff --git a/Makefile b/Makefile deleted file mode 100644 index afaf0bd..0000000 --- a/Makefile +++ /dev/null @@ -1,78 +0,0 @@ -.PHONY: up down build logs migrate seed test test-api test-worker test-watcher lint check format - -up: validate-env - docker compose up -d - -validate-env: - bash scripts/validate-env.sh - -down: - docker compose down - -build: check - docker compose build - -logs: - docker compose logs -f - -# ── Database ────────────────────────────────────────────────────────────────── - -migrate: - docker compose exec api alembic upgrade head - -migrate-auto: - docker compose exec api alembic revision --autogenerate -m "$(m)" - -# ── Setup ───────────────────────────────────────────────────────────────────── - -setup: validate-env up - @echo "Waiting for Nextcloud to initialize (this can take ~60s)..." - @sleep 60 - bash scripts/nc-setup.sh - bash scripts/seed.sh - -# ── Testing ─────────────────────────────────────────────────────────────────── - -test: test-api test-worker test-watcher - -test-api: - cd api && uv run pytest tests/ -v --cov=src/rehearsalhub --cov-report=term-missing - -test-worker: - cd worker && uv run pytest tests/ -v --cov=src/worker --cov-report=term-missing - -test-watcher: - cd watcher && uv run pytest tests/ -v --cov=src/watcher --cov-report=term-missing - -test-integration: - cd api && uv run pytest tests/integration/ -v -m integration - -# ── Linting & type checking ─────────────────────────────────────────────────── - -# check: run all linters + type checkers locally (fast, no Docker) -check: lint typecheck-web - -lint: - cd api && uv run ruff check src/ tests/ && uv run mypy src/ - cd worker && uv run ruff check src/ tests/ - cd watcher && uv run ruff check src/ tests/ - cd web && npm run lint - -typecheck-web: - cd web && npm run typecheck - -format: - cd api && uv run ruff format src/ tests/ - cd worker && uv run ruff format src/ tests/ - cd watcher && uv run ruff format src/ tests/ - -# ── Dev helpers ─────────────────────────────────────────────────────────────── - -shell-api: - docker compose exec api bash - -shell-db: - docker compose exec db psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} - -shell-redis: - docker compose exec redis redis-cli diff --git a/PLAN_waveform_precompute.md b/PLAN_waveform_precompute.md new file mode 100644 index 0000000..eb9c829 --- /dev/null +++ b/PLAN_waveform_precompute.md @@ -0,0 +1,136 @@ +# Plan: Waveform Pre-computation + +**Branch:** `feature/waveform-precompute` +**Goal:** Store waveform peaks in the database during indexing so WaveSurfer renders +the waveform instantly (no waiting for audio decode), and show a mini-waveform in +the library/overview song list. + +## Background + +WaveSurfer v7 supports `ws.load(url, channelData)` — when pre-computed peaks are +passed as a `Float32Array[]`, the waveform renders immediately and audio streams in +the background. Currently the frontend calls `ws.load(url)` which blocks until the +full audio is decoded. + +The worker already generates a 500-point peaks JSON file (`waveform_url`), but: +- It is stored as a file on disk, not inline in the DB +- The frontend never reads it (the `peaksUrl` option in `useWaveform` is wired to + nothing) + +## Architecture Decision + +Add two JSONB columns to `audio_versions`: +- `waveform_peaks` — 500 points, returned inline with version data, passed to WaveSurfer +- `waveform_peaks_mini` — 100 points, returned inline, used for SVG mini-waveform in + library/song list + +This eliminates a separate HTTP round-trip and lets the UI render the waveform the +moment the page loads. + +--- + +## Checklist + +### Backend + +#### B1 — DB: Peaks columns + Alembic migration +- [ ] Write migration test: after upgrade, `audio_versions` table has `waveform_peaks` + and `waveform_peaks_mini` JSONB columns +- [ ] Create `api/alembic/versions/0006_waveform_peaks_in_db.py` +- [ ] Add `waveform_peaks` and `waveform_peaks_mini` JSONB columns to `AudioVersion` + model in `api/src/rehearsalhub/db/models.py` + +#### B2 — Worker: Generate and store both peak resolutions +- [ ] Write unit tests for `extract_peaks()` in `worker/tests/test_waveform.py`: + - Returns exactly `num_points` values + - All values in [0.0, 1.0] + - Empty audio returns list of zeros (no crash) + - 100-point and 500-point both work +- [ ] Update `handle_transcode` in `worker/src/worker/main.py`: + - Generate `peaks_500 = extract_peaks(audio, 500)` + - Generate `peaks_100 = extract_peaks(audio, 100)` + - Store both on `AudioVersion` DB row +- [ ] Write integration test: after `handle_transcode`, row has non-null + `waveform_peaks` (len 500) and `waveform_peaks_mini` (len 100) + +#### B3 — API Schema: Expose peaks in `AudioVersionRead` +- [ ] Write serialization test: `AudioVersionRead.model_validate(orm_obj)` includes + `waveform_peaks: list[float] | None` and `waveform_peaks_mini: list[float] | None` +- [ ] Update `api/src/rehearsalhub/schemas/audio_version.py` — add both fields + +#### B4 — API Router: `/waveform` endpoint reads from DB +- [ ] Write endpoint tests: + - `GET /versions/{id}/waveform` returns `{"data": [...500 floats...]}` from DB + - `GET /versions/{id}/waveform?resolution=mini` returns 100-point peaks + - 404 when version has no peaks yet +- [ ] Update `api/src/rehearsalhub/routers/versions.py` — read from + `version.waveform_peaks` / `version.waveform_peaks_mini` instead of file I/O + +#### B5 — API: Peaks inline on versions list (verify, no change expected) +- [ ] Write test: `GET /songs/{id}/versions` response includes `waveform_peaks` and + `waveform_peaks_mini` on each version object +- [ ] Confirm no router change needed (schema update in B3 is sufficient) + +--- + +### Frontend + +#### F1 — Types: Update `AudioVersionRead` TS type +- [ ] Add `waveform_peaks: number[] | null` and `waveform_peaks_mini: number[] | null` + to the TypeScript version type (wherever API types live) + +#### F2 — `audioService`: Accept and use pre-computed peaks +- [ ] Write unit tests for `AudioService.initialize()`: + - With peaks: calls `ws.load(url, [Float32Array])` → waveform renders immediately + - Without peaks: calls `ws.load(url)` → falls back to audio decode + - Same URL + same peaks → no re-initialization +- [ ] Update `AudioService.initialize(container, url, peaks?: number[])` in + `web/src/services/audioService.ts`: + - Call `ws.load(url, peaks ? [new Float32Array(peaks)] : undefined)` + +#### F3 — `useWaveform` hook: Thread peaks through +- [ ] Write hook test: when `peaks` option is provided, it is forwarded to + `audioService.initialize` +- [ ] Add `peaks?: number[] | null` to `UseWaveformOptions` in + `web/src/hooks/useWaveform.ts` +- [ ] Forward `options.peaks` to `audioService.initialize()` in the effect + +#### F4 — `PlayerPanel`: Pass peaks to hook +- [ ] Write component test: `PlayerPanel` passes `version.waveform_peaks` to + `useWaveform` +- [ ] Update `web/src/components/PlayerPanel.tsx` to extract and forward + `waveform_peaks` + +#### F5 — `MiniWaveform`: New SVG component for library overview +- [ ] Write unit tests: + - Renders SVG with correct number of bars matching peaks length + - Null/empty peaks renders a grey placeholder (no crash) + - Accepts `peaks`, `width`, `height`, `color` props +- [ ] Create `web/src/components/MiniWaveform.tsx` — pure SVG, no WaveSurfer +- [ ] Integrate into song list / library view using `waveform_peaks_mini` + +--- + +## Testing Strategy + +| Layer | Tool | +|------------------|----------------------------------------------| +| Backend unit | pytest, synthetic numpy arrays | +| Backend integration | Real Postgres via docker-compose test profile | +| Frontend unit | Vitest + Testing Library | +| E2E | Playwright — assert waveform visible before audio `canplay` fires | + +--- + +## Implementation Order + +1. B1 — migration + model +2. B2 — worker (TDD: unit tests → implementation → integration test) +3. B3 — schema +4. B4 — router +5. B5 — verify versions list +6. F1 — TS types +7. F2 — audioService +8. F3 — useWaveform +9. F4 — PlayerPanel +10. F5 — MiniWaveform diff --git a/README.md b/README.md index d1fd8cd..1fad58b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Files are **never copied** to RehearsalHub servers. The platform reads recording ## Quick start +> **Docker Registry Setup**: For production deployments using Gitea registry, see [DOCKER_REGISTRY.md](DOCKER_REGISTRY.md) + ### 1. Configure environment ```bash diff --git a/Taskfile.yml b/Taskfile.yml index 3b95075..f3e6a33 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -251,3 +251,19 @@ tasks: interactive: true cmds: - "{{.COMPOSE}} exec redis redis-cli" + +# ── Registry ────────────────────────────────────────────────────────────────── + + registry:login: + desc: Login to Gitea Docker registry + cmds: + - docker login git.sschuhmann.de + + registry:build: + desc: Build all images with version tag (requires git tag) + cmds: + - bash scripts/build-and-push.sh + + registry:push: + desc: Build and push all images to Gitea registry + deps: [registry:login, registry:build] diff --git a/api/alembic/versions/0006_waveform_peaks_in_db.py b/api/alembic/versions/0006_waveform_peaks_in_db.py new file mode 100644 index 0000000..222bd70 --- /dev/null +++ b/api/alembic/versions/0006_waveform_peaks_in_db.py @@ -0,0 +1,35 @@ +"""Store waveform peaks inline in audio_versions table. + +Replaces file-based waveform_url approach with two JSONB columns: +- waveform_peaks: 500-point peaks for the player (passed directly to WaveSurfer) +- waveform_peaks_mini: 100-point peaks for library/overview mini-waveform SVG + +Revision ID: 0006_waveform_peaks_in_db +Revises: 0005_comment_tag +Create Date: 2026-04-10 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0006_waveform_peaks_in_db" +down_revision = "0005_comment_tag" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "audio_versions", + sa.Column("waveform_peaks", JSONB, nullable=True), + ) + op.add_column( + "audio_versions", + sa.Column("waveform_peaks_mini", JSONB, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("audio_versions", "waveform_peaks_mini") + op.drop_column("audio_versions", "waveform_peaks") diff --git a/api/src/rehearsalhub/db/models.py b/api/src/rehearsalhub/db/models.py index 5b9bd0d..bcc8d7e 100755 --- a/api/src/rehearsalhub/db/models.py +++ b/api/src/rehearsalhub/db/models.py @@ -232,6 +232,8 @@ class AudioVersion(Base): nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255)) cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text) waveform_url: Mapped[Optional[str]] = mapped_column(Text) + waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB) + waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB) duration_ms: Mapped[Optional[int]] = mapped_column(Integer) format: Mapped[Optional[str]] = mapped_column(String(10)) file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger) diff --git a/api/src/rehearsalhub/routers/internal.py b/api/src/rehearsalhub/routers/internal.py index 933e7f6..80cd9e7 100755 --- a/api/src/rehearsalhub/routers/internal.py +++ b/api/src/rehearsalhub/routers/internal.py @@ -10,11 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from rehearsalhub.config import get_settings from rehearsalhub.db.engine import get_session -from rehearsalhub.db.models import BandMember, Member +from rehearsalhub.db.models import AudioVersion, BandMember, Member from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.song import SongRepository +from rehearsalhub.queue.redis_queue import RedisJobQueue from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date from rehearsalhub.services.song import SongService @@ -148,3 +149,37 @@ async def nc_upload( ) log.info("nc-upload: registered version %s for song '%s'", version.id, song.title) return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)} + + +@router.post("/reindex-peaks", status_code=200) +async def reindex_peaks( + session: AsyncSession = Depends(get_session), + _: None = Depends(_verify_internal_secret), +): + """Enqueue extract_peaks jobs for every audio_version that has no waveform_peaks yet. + + Safe to call multiple times — only versions with null peaks are targeted. + Useful after: + - Fresh DB creation + directory scan (peaks not yet computed) + - Peak algorithm changes (clear waveform_peaks, then call this) + - Worker was down during initial transcode + """ + result = await session.execute( + select(AudioVersion).where(AudioVersion.waveform_peaks.is_(None)) # type: ignore[attr-defined] + ) + versions = result.scalars().all() + + if not versions: + return {"status": "ok", "queued": 0, "message": "All versions already have peaks"} + + queue = RedisJobQueue(session) + queued = 0 + for version in versions: + await queue.enqueue( + "extract_peaks", + {"version_id": str(version.id), "nc_file_path": version.nc_file_path}, + ) + queued += 1 + + log.info("reindex-peaks: queued %d extract_peaks jobs", queued) + return {"status": "ok", "queued": queued} diff --git a/api/src/rehearsalhub/routers/versions.py b/api/src/rehearsalhub/routers/versions.py index def0dda..51e01ba 100755 --- a/api/src/rehearsalhub/routers/versions.py +++ b/api/src/rehearsalhub/routers/versions.py @@ -180,49 +180,27 @@ async def create_version( @router.get("/versions/{version_id}/waveform") async def get_waveform( version_id: uuid.UUID, + resolution: str = Query("full", pattern="^(full|mini)$"), session: AsyncSession = Depends(get_session), current_member: Member = Depends(get_current_member), ) -> Any: + """Return pre-computed waveform peaks from the database. + + - `resolution=full` (default): 500-point peaks for the WaveSurfer player + - `resolution=mini`: 100-point peaks for the library overview thumbnail + """ version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member) - if not version.waveform_url: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready") - # Use the uploader's NC credentials — invited members may not have NC configured - uploader: Member | None = None - if version.uploaded_by: - uploader = await MemberRepository(session).get_by_id(version.uploaded_by) - storage = NextcloudClient.for_member(uploader) if uploader else NextcloudClient.for_member(current_member) - if storage is None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No storage provider configured for this account" - ) - try: - data = await _download_with_retry(storage, version.waveform_url) - except httpx.ConnectError: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Storage service unavailable." - ) - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Waveform file not found in storage." - ) - else: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Storage returned an error." - ) - except Exception: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch waveform." - ) - import json + if resolution == "mini": + peaks = version.waveform_peaks_mini + if peaks is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mini waveform not ready") + else: + peaks = version.waveform_peaks + if peaks is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready") - return json.loads(data) + return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks} @router.get("/versions/{version_id}/stream") diff --git a/api/src/rehearsalhub/schemas/audio_version.py b/api/src/rehearsalhub/schemas/audio_version.py index 38fb341..c5456fc 100755 --- a/api/src/rehearsalhub/schemas/audio_version.py +++ b/api/src/rehearsalhub/schemas/audio_version.py @@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel): nc_file_etag: str | None = None cdn_hls_base: str | None = None waveform_url: str | None = None + waveform_peaks: list[float] | None = None + waveform_peaks_mini: list[float] | None = None duration_ms: int | None = None format: str | None = None file_size_bytes: int | None = None diff --git a/api/tests/integration/test_waveform_peaks_schema.py b/api/tests/integration/test_waveform_peaks_schema.py new file mode 100644 index 0000000..8e7d281 --- /dev/null +++ b/api/tests/integration/test_waveform_peaks_schema.py @@ -0,0 +1,49 @@ +"""Integration tests for waveform peaks stored inline in audio_versions.""" + +import pytest + +from tests.factories import create_audio_version, create_band, create_member, create_song + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_audio_version_stores_waveform_peaks(db_session, current_member): + """AudioVersion can store waveform_peaks and waveform_peaks_mini JSONB data.""" + from rehearsalhub.repositories.audio_version import AudioVersionRepository + + band = await create_band(db_session, creator_id=current_member.id) + song = await create_song(db_session, band_id=band.id, creator_id=current_member.id) + version = await create_audio_version(db_session, song_id=song.id) + + peaks_500 = [float(i) / 500 for i in range(500)] + peaks_100 = [float(i) / 100 for i in range(100)] + + repo = AudioVersionRepository(db_session) + updated = await repo.update( + version, + waveform_peaks=peaks_500, + waveform_peaks_mini=peaks_100, + ) + await db_session.commit() + + fetched = await repo.get_by_id(updated.id) + assert fetched is not None + assert fetched.waveform_peaks is not None + assert len(fetched.waveform_peaks) == 500 + assert fetched.waveform_peaks_mini is not None + assert len(fetched.waveform_peaks_mini) == 100 + assert fetched.waveform_peaks[0] == pytest.approx(0.0) + assert fetched.waveform_peaks[1] == pytest.approx(1 / 500) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_audio_version_peaks_default_null(db_session, current_member): + """waveform_peaks and waveform_peaks_mini are null by default.""" + band = await create_band(db_session, creator_id=current_member.id) + song = await create_song(db_session, band_id=band.id, creator_id=current_member.id) + version = await create_audio_version(db_session, song_id=song.id) + await db_session.commit() + + assert version.waveform_peaks is None + assert version.waveform_peaks_mini is None diff --git a/api/tests/unit/test_audio_version_schema.py b/api/tests/unit/test_audio_version_schema.py new file mode 100644 index 0000000..e273559 --- /dev/null +++ b/api/tests/unit/test_audio_version_schema.py @@ -0,0 +1,64 @@ +"""Unit tests for AudioVersionRead schema — waveform peaks serialization.""" + +import uuid +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from rehearsalhub.db.models import AudioVersion +from rehearsalhub.schemas.audio_version import AudioVersionRead + + +def _make_version(peaks=None, peaks_mini=None) -> MagicMock: + """Build a mock AudioVersion ORM object.""" + v = MagicMock(spec=AudioVersion) + v.id = uuid.uuid4() + v.song_id = uuid.uuid4() + v.version_number = 1 + v.label = None + v.nc_file_path = "/bands/test/v1.wav" + v.nc_file_etag = "abc123" + v.cdn_hls_base = None + v.waveform_url = None + v.waveform_peaks = peaks + v.waveform_peaks_mini = peaks_mini + v.duration_ms = 5000 + v.format = "wav" + v.file_size_bytes = 1024 + v.analysis_status = "done" + v.uploaded_by = None + v.uploaded_at = datetime.now(timezone.utc) + return v + + +def test_audio_version_read_includes_waveform_peaks(): + peaks = [float(i) / 500 for i in range(500)] + peaks_mini = [float(i) / 100 for i in range(100)] + v = _make_version(peaks=peaks, peaks_mini=peaks_mini) + + schema = AudioVersionRead.model_validate(v) + + assert schema.waveform_peaks is not None + assert len(schema.waveform_peaks) == 500 + assert schema.waveform_peaks_mini is not None + assert len(schema.waveform_peaks_mini) == 100 + + +def test_audio_version_read_peaks_default_null(): + v = _make_version(peaks=None, peaks_mini=None) + + schema = AudioVersionRead.model_validate(v) + + assert schema.waveform_peaks is None + assert schema.waveform_peaks_mini is None + + +def test_audio_version_read_peaks_values_preserved(): + peaks = [0.0, 0.5, 1.0] + v = _make_version(peaks=peaks, peaks_mini=[0.25, 0.75]) + + schema = AudioVersionRead.model_validate(v) + + assert schema.waveform_peaks == [0.0, 0.5, 1.0] + assert schema.waveform_peaks_mini == [0.25, 0.75] diff --git a/api/tests/unit/test_versions_list_peaks.py b/api/tests/unit/test_versions_list_peaks.py new file mode 100644 index 0000000..3bbb02f --- /dev/null +++ b/api/tests/unit/test_versions_list_peaks.py @@ -0,0 +1,38 @@ +"""Confirm that list_versions returns waveform_peaks inline (no extra request needed).""" + +import uuid +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from rehearsalhub.db.models import AudioVersion +from rehearsalhub.schemas.audio_version import AudioVersionRead + + +def test_audio_version_read_includes_peaks_in_list_serialization(): + """AudioVersionRead (used by list_versions) serializes waveform_peaks inline.""" + peaks = [0.1, 0.5, 0.9] + mini = [0.3, 0.7] + + v = MagicMock(spec=AudioVersion) + v.id = uuid.uuid4() + v.song_id = uuid.uuid4() + v.version_number = 1 + v.label = None + v.nc_file_path = "/test/v1.wav" + v.nc_file_etag = "etag" + v.cdn_hls_base = None + v.waveform_url = None + v.waveform_peaks = peaks + v.waveform_peaks_mini = mini + v.duration_ms = 3000 + v.format = "wav" + v.file_size_bytes = 512 + v.analysis_status = "done" + v.uploaded_by = None + v.uploaded_at = datetime.now(timezone.utc) + + schema = AudioVersionRead.model_validate(v) + serialized = schema.model_dump() + + assert serialized["waveform_peaks"] == peaks + assert serialized["waveform_peaks_mini"] == mini diff --git a/api/tests/unit/test_waveform_endpoint.py b/api/tests/unit/test_waveform_endpoint.py new file mode 100644 index 0000000..f83ecb9 --- /dev/null +++ b/api/tests/unit/test_waveform_endpoint.py @@ -0,0 +1,120 @@ +"""Unit tests for GET /versions/{id}/waveform endpoint — reads peaks from DB.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from rehearsalhub.db.models import AudioVersion, Member, Song + + +def _make_member() -> MagicMock: + m = MagicMock(spec=Member) + m.id = uuid.uuid4() + m.nc_url = "http://nc.test" + m.nc_username = "user" + m.nc_password = "pass" + return m + + +def _make_version(peaks=None, peaks_mini=None, has_waveform_url=False) -> MagicMock: + v = MagicMock(spec=AudioVersion) + v.id = uuid.uuid4() + v.song_id = uuid.uuid4() + v.uploaded_by = None + v.waveform_url = "waveforms/test.json" if has_waveform_url else None + v.waveform_peaks = peaks + v.waveform_peaks_mini = peaks_mini + v.cdn_hls_base = None + v.nc_file_path = "/bands/test/v1.wav" + return v + + +def _make_song(band_id: uuid.UUID) -> MagicMock: + s = MagicMock(spec=Song) + s.id = uuid.uuid4() + s.band_id = band_id + return s + + +@pytest.mark.asyncio +async def test_waveform_returns_full_peaks_from_db(mock_session): + """GET /versions/{id}/waveform returns 500-point peaks from DB column.""" + from rehearsalhub.routers.versions import get_waveform + + peaks = [float(i) / 500 for i in range(500)] + version = _make_version(peaks=peaks) + member = _make_member() + band_id = uuid.uuid4() + song = _make_song(band_id) + + with ( + patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", + return_value=(version, song)), + ): + result = await get_waveform(version_id=version.id, session=mock_session, current_member=member) + + assert result["data"] == peaks + assert result["length"] == 500 + assert "mini" not in result + + +@pytest.mark.asyncio +async def test_waveform_returns_mini_peaks_with_resolution_param(mock_session): + """GET /versions/{id}/waveform?resolution=mini returns 100-point peaks.""" + from rehearsalhub.routers.versions import get_waveform + + peaks_mini = [float(i) / 100 for i in range(100)] + version = _make_version(peaks=[0.5] * 500, peaks_mini=peaks_mini) + member = _make_member() + band_id = uuid.uuid4() + song = _make_song(band_id) + + with ( + patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", + return_value=(version, song)), + ): + result = await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini") + + assert result["data"] == peaks_mini + assert result["length"] == 100 + + +@pytest.mark.asyncio +async def test_waveform_404_when_no_peaks_in_db(mock_session): + """GET /versions/{id}/waveform returns 404 when no peaks stored yet.""" + from fastapi import HTTPException + from rehearsalhub.routers.versions import get_waveform + + version = _make_version(peaks=None, peaks_mini=None) + member = _make_member() + song = _make_song(uuid.uuid4()) + + with ( + patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", + return_value=(version, song)), + ): + with pytest.raises(HTTPException) as exc_info: + await get_waveform(version_id=version.id, session=mock_session, current_member=member) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_waveform_mini_404_when_no_mini_peaks(mock_session): + """GET /versions/{id}/waveform?resolution=mini returns 404 when no mini peaks stored.""" + from fastapi import HTTPException + from rehearsalhub.routers.versions import get_waveform + + version = _make_version(peaks=[0.5] * 500, peaks_mini=None) + member = _make_member() + song = _make_song(uuid.uuid4()) + + with ( + patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", + return_value=(version, song)), + ): + with pytest.raises(HTTPException) as exc_info: + await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini") + + assert exc_info.value.status_code == 404 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f9190b9..1d4c78e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,6 +25,7 @@ services: build: context: ./api target: development + command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload" environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com} diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh new file mode 100755 index 0000000..d43d2e1 --- /dev/null +++ b/scripts/build-and-push.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail + +# Configuration +REGISTRY="git.sschuhmann.de/sschuhmann/rehearshalhub" +COMPONENTS=("api" "web" "worker" "watcher") + +# Get version from git tag +get_version() { + local tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [[ -z "$tag" ]]; then + echo "Error: No git tags found. Please create a tag first (e.g., git tag v1.0.0)" >&2 + exit 1 + fi + # Remove v prefix if present for semantic versioning + echo "${tag#v}" +} + +# Main build and push function +build_and_push() { + local version=$1 + echo "Building and pushing version: $version" + + for component in "${COMPONENTS[@]}"; do + echo "Building $component..." + docker build -t "$REGISTRY/$component-$version" -f "$component/Dockerfile" --target production "$component" + + echo "Pushing $component-$version..." + docker push "$REGISTRY/$component-$version" + + # Also tag as latest for convenience + docker tag "$REGISTRY/$component-$version" "$REGISTRY/$component-latest" + docker push "$REGISTRY/$component-latest" + done + + echo "All components built and pushed successfully!" +} + +# Execute +VERSION=$(get_version) +build_and_push "$VERSION" \ No newline at end of file diff --git a/scripts/nc-setup.sh b/scripts/nc-setup.sh deleted file mode 100644 index 69fdbdf..0000000 --- a/scripts/nc-setup.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -echo "→ Checking for Nextcloud service..." - -# Check if nextcloud service exists -if ! docker compose ps | grep -q nextcloud; then - echo " Nextcloud service not found in compose setup" - echo " Skipping Nextcloud configuration (external setup required)" - exit 0 -fi - -echo "→ Configuring Nextcloud via occ..." - -NC="docker compose exec -T nextcloud php occ" - -# Enable recommended apps -$NC app:enable notify_push 2>/dev/null || echo " notify_push not available, skipping" -$NC app:enable files_accesscontrol 2>/dev/null || echo " files_accesscontrol not available, skipping" - -# Create service account for rehearsalhub -$NC user:add \ - --display-name "RehearsalHub Service" \ - --password-from-env \ - rh_service \ - <<< "${NEXTCLOUD_ADMIN_PASSWORD:-change_me}" || echo " Service account may already exist" - -# Set permissions -$NC user:setting rh_service core lang en -$NC config:system:set trusted_domains 1 --value="${DOMAIN:-localhost}" -$NC config:system:set trusted_domains 2 --value="nc.${DOMAIN:-localhost}" - -# Create base folder structure -$NC files:scan --all - -echo "✓ Nextcloud setup complete" diff --git a/scripts/seed.sh b/scripts/seed.sh deleted file mode 100644 index c63b34c..0000000 --- a/scripts/seed.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -API="${API_URL:-http://localhost/api/v1}" - -echo "→ Running database migrations..." -docker compose exec api alembic upgrade head - -echo "→ Seeding admin user..." -REGISTER_RESP=$(curl -sf -X POST "$API/auth/register" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "admin@rehearsalhub.local", - "password": "changeme123!", - "display_name": "Admin" - }') || echo " Admin user may already exist" - -echo "→ Logging in to get token..." -TOKEN_RESP=$(curl -sf -X POST "$API/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email": "admin@rehearsalhub.local", "password": "changeme123!"}') - -TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") - -echo "→ Creating demo band..." -curl -sf -X POST "$API/bands" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ - "name": "Demo Band", - "slug": "demo-band", - "genre_tags": ["rock", "jam"] - }' | python3 -m json.tool - -echo "" -echo "✓ Seed complete!" -echo " Admin: admin@rehearsalhub.local / changeme123!" -echo " API docs: https://${DOMAIN:-localhost}/api/docs" diff --git a/web/src/App.tsx b/web/src/App.tsx index 7aabe5a..9f3198e 100755 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,16 +1,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; +import { BrowserRouter, Route, Routes, Navigate, useParams, useNavigate } from "react-router-dom"; +import { useEffect } from "react"; import "./index.css"; import { isLoggedIn } from "./api/client"; import { AppShell } from "./components/AppShell"; import { LoginPage } from "./pages/LoginPage"; import { HomePage } from "./pages/HomePage"; import { BandPage } from "./pages/BandPage"; -import { BandSettingsPage } from "./pages/BandSettingsPage"; import { SessionPage } from "./pages/SessionPage"; import { SongPage } from "./pages/SongPage"; import { SettingsPage } from "./pages/SettingsPage"; import { InvitePage } from "./pages/InvitePage"; +import { useBandStore } from "./stores/bandStore"; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, @@ -28,6 +29,20 @@ function ShellRoute({ children }: { children: React.ReactNode }) { ); } +// Redirect /bands/:bandId/settings/:panel → /settings?section=:panel, setting bandStore +function BandSettingsRedirect() { + const { bandId, panel } = useParams<{ bandId: string; panel?: string }>(); + const navigate = useNavigate(); + const { setActiveBandId } = useBandStore(); + + useEffect(() => { + if (bandId) setActiveBandId(bandId); + navigate(`/settings${panel ? `?section=${panel}` : ""}`, { replace: true }); + }, [bandId, panel, navigate, setActiveBandId]); + + return null; +} + export default function App() { return ( @@ -51,18 +66,8 @@ export default function App() { } /> - } - /> - - - - } - /> + } /> + } /> acc + c.charCodeAt(0), 31337); + return Array.from({ length: 12 }, () => { + s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0; + return Math.max(15, Math.floor((s / 0xffffffff) * 100)); + }); +} + +function MiniWave({ songId, active }: { songId: string; active: boolean }) { + const bars = useMemo(() => computeWaveBars(songId), [songId]); + return ( +
+ {bars.map((h, i) => ( +
+ ))} +
+ ); +} + +// ── Tag badge ───────────────────────────────────────────────────────────────── + +const TAG_COLORS: Record = { + jam: { bg: "rgba(20,184,166,0.12)", color: "#2dd4bf" }, + riff: { bg: "rgba(34,211,238,0.1)", color: "#67e8f9" }, + idea: { bg: "rgba(52,211,153,0.1)", color: "#6ee7b7" }, +}; + +function TagBadge({ tag }: { tag: string }) { + const style = TAG_COLORS[tag.toLowerCase()] ?? { bg: "rgba(255,255,255,0.06)", color: "rgba(232,233,240,0.45)" }; + return ( + + {tag} + + ); +} + +// ── Track row ───────────────────────────────────────────────────────────────── + +function TrackRow({ + song, + index, + active, + onSelect, +}: { + song: SongSummary; + index: number; + active: boolean; + onSelect: () => void; +}) { + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "9px 20px", + cursor: "pointer", + position: "relative", + background: active ? "rgba(20,184,166,0.06)" : hovered ? "rgba(255,255,255,0.025)" : "transparent", + transition: "background 0.12s", + }} + > + {active && ( +
+ )} + + + {String(index + 1).padStart(2, "0")} + + + + {song.title} + + + {song.tags[0] && } + + +
+ ); +} + +// ── Session group ───────────────────────────────────────────────────────────── + +function SessionGroup({ + bandId, + session, + selectedSongId, + search, + filterTag, + onSelectSong, + defaultOpen, +}: { + bandId: string; + session: SessionSummary; + selectedSongId: string | null; + search: string; + filterTag: string; + onSelectSong: (songId: string) => void; + defaultOpen: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + const { data: detail } = useQuery({ + queryKey: ["session", session.id], + queryFn: () => api.get(`/bands/${bandId}/sessions/${session.id}`), + enabled: isOpen, + }); + + const filteredSongs = useMemo(() => { + if (!detail?.songs) return []; + return detail.songs.filter((song) => { + const matchesSearch = !search || song.title.toLowerCase().includes(search.toLowerCase()); + const matchesTag = !filterTag || song.tags.some((t) => t.toLowerCase() === filterTag); + return matchesSearch && matchesTag; + }); + }, [detail, search, filterTag]); + + return ( +
+ {/* Session header */} +
setIsOpen((o) => !o)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 20px 5px", + cursor: "pointer", + }} + > + + {formatSessionDate(session.date)} + + {session.label && ( + + {session.label} + + )} + {!session.label && } + + {session.recording_count} + + + + +
+ + {/* Track list */} + {isOpen && ( +
+ {!detail && ( +
+ Loading… +
+ )} + {detail && (filteredSongs.length > 0 ? filteredSongs : detail.songs).map((song, i) => ( + onSelectSong(song.id)} + /> + ))} + {detail && detail.songs.length === 0 && ( +
+ No recordings yet. +
+ )} + {detail && search && filteredSongs.length === 0 && detail.songs.length > 0 && ( +
+ No matches in this session. +
+ )} +
+ )} +
+ ); +} + +// ── Filter chips ────────────────────────────────────────────────────────────── + +const FILTER_CHIPS = [ + { label: "All", value: "" }, + { label: "Jam", value: "jam" }, + { label: "Riff", value: "riff" }, + { label: "Idea", value: "idea" }, +]; + +// ── LibraryPanel ────────────────────────────────────────────────────────────── + +interface LibraryPanelProps { + bandId: string; + selectedSongId: string | null; + onSelectSong: (songId: string) => void; +} + +export function LibraryPanel({ bandId, selectedSongId, onSelectSong }: LibraryPanelProps) { + const [search, setSearch] = useState(""); + const [filterTag, setFilterTag] = useState(""); + + const { data: sessions } = useQuery({ + queryKey: ["sessions", bandId], + queryFn: () => api.get(`/bands/${bandId}/sessions`), + enabled: !!bandId, + }); + + const border = "rgba(255,255,255,0.06)"; + + return ( +
+ + {/* Header */} +
+

+ Library +

+ + {/* Search */} +
(e.currentTarget.style.borderColor = "rgba(20,184,166,0.4)")} + onBlurCapture={(e) => (e.currentTarget.style.borderColor = border)} + > + + + + + setSearch(e.target.value)} + placeholder="Search recordings…" + style={{ background: "none", border: "none", outline: "none", fontFamily: "inherit", fontSize: 13, color: "#e8e9f0", flex: 1, caretColor: "#2dd4bf" }} + /> + {search && ( + + )} +
+
+ + {/* Filter chips */} +
+ {FILTER_CHIPS.map((chip) => { + const on = filterTag === chip.value; + return ( + + ); + })} +
+ + {/* Session list */} +
+ + {!sessions && ( +
Loading sessions…
+ )} + {sessions?.length === 0 && ( +
+ No sessions yet. Go to Storage settings to scan your Nextcloud folder. +
+ )} + {sessions?.map((session, i) => ( + + ))} + {/* Bottom padding for last item breathing room */} +
+
+
+ ); +} diff --git a/web/src/components/MiniWaveform.test.tsx b/web/src/components/MiniWaveform.test.tsx new file mode 100644 index 0000000..2ebaaf0 --- /dev/null +++ b/web/src/components/MiniWaveform.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { MiniWaveform } from "./MiniWaveform"; + +describe("MiniWaveform", () => { + it("renders an SVG element", () => { + const peaks = Array.from({ length: 100 }, (_, i) => i / 100); + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + }); + + it("renders the correct number of bars matching peaks length", () => { + const peaks = Array.from({ length: 100 }, (_, i) => i / 100); + const { container } = render(); + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBe(100); + }); + + it("renders a placeholder when peaks is null", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + // With null peaks, no bars — just the placeholder rect + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBe(1); // single placeholder bar + }); + + it("renders a placeholder when peaks is empty array", () => { + const { container } = render(); + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBe(1); + }); + + it("applies correct SVG dimensions", () => { + const peaks = [0.5, 0.5]; + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("width")).toBe("200"); + expect(svg?.getAttribute("height")).toBe("48"); + }); + + it("uses the provided color for bars", () => { + const peaks = [0.5, 0.5]; + const { container } = render( + + ); + const rect = container.querySelector("rect"); + expect(rect?.getAttribute("fill")).toBe("#ff0000"); + }); +}); diff --git a/web/src/components/MiniWaveform.tsx b/web/src/components/MiniWaveform.tsx new file mode 100644 index 0000000..d9515d0 --- /dev/null +++ b/web/src/components/MiniWaveform.tsx @@ -0,0 +1,62 @@ +/** + * MiniWaveform — pure SVG component for rendering waveform_peaks_mini. + * + * Renders pre-computed 100-point peaks as vertical bars. No WaveSurfer dependency — + * lightweight enough to use in library/song list views for every song card. + * + * Props: + * peaks — array of 0-1 normalized peak values (ideally 100 points), or null + * width — SVG width in px + * height — SVG height in px + * color — bar fill color (default: teal accent) + */ + +interface MiniWaveformProps { + peaks: number[] | null; + width: number; + height: number; + color?: string; +} + +export function MiniWaveform({ + peaks, + width, + height, + color = "#14b8a6", +}: MiniWaveformProps) { + const isEmpty = !peaks || peaks.length === 0; + + if (isEmpty) { + return ( + + ); + } + + const barCount = peaks.length; + const gap = 1; + const barWidth = Math.max(1, (width - gap * (barCount - 1)) / barCount); + const midY = height / 2; + + return ( + + ); +} diff --git a/web/src/components/PlayerPanel.tsx b/web/src/components/PlayerPanel.tsx new file mode 100644 index 0000000..134cebd --- /dev/null +++ b/web/src/components/PlayerPanel.tsx @@ -0,0 +1,485 @@ +import { useRef, useState, useCallback, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api/client"; +import type { MemberRead } from "../api/auth"; +import { useWaveform } from "../hooks/useWaveform"; +import { MiniWaveform } from "./MiniWaveform"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SongRead { + id: string; + band_id: string; + session_id: string | null; + title: string; + status: string; + tags: string[]; + global_key: string | null; + global_bpm: number | null; + version_count: number; +} + +interface SongVersion { + id: string; + version_number: number; + label: string | null; + analysis_status: string; + waveform_peaks: number[] | null; + waveform_peaks_mini: number[] | null; +} + +interface SongComment { + id: string; + song_id: string; + body: string; + author_id: string; + author_name: string; + author_avatar_url: string | null; + timestamp: number | null; + tag: string | null; + created_at: string; +} + +interface SessionInfo { + id: string; + band_id: string; + date: string; + label: string | null; + songs: { id: string; title: string; status: string; tags: string[] }[]; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${String(s).padStart(2, "0")}`; +} + +function getInitials(name: string): string { + return name.split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2); +} + +const MEMBER_COLORS = [ + { bg: "rgba(91,156,240,0.18)", border: "rgba(91,156,240,0.6)", text: "#7aabf0" }, + { bg: "rgba(200,90,180,0.18)", border: "rgba(200,90,180,0.6)", text: "#d070c0" }, + { bg: "rgba(52,211,153,0.18)", border: "rgba(52,211,153,0.6)", text: "#34d399" }, + { bg: "rgba(20,184,166,0.18)", border: "rgba(20,184,166,0.6)", text: "#2dd4bf" }, + { bg: "rgba(34,211,238,0.18)", border: "rgba(34,211,238,0.6)", text: "#22d3ee" }, +]; + +function memberColor(authorId: string) { + let h = 0; + for (let i = 0; i < authorId.length; i++) h = (h * 31 + authorId.charCodeAt(i)) >>> 0; + return MEMBER_COLORS[h % MEMBER_COLORS.length]; +} + +const TAG_STYLES: Record = { + suggestion: { bg: "rgba(91,156,240,0.1)", color: "#7aabf0" }, + issue: { bg: "rgba(244,63,94,0.1)", color: "#f87171" }, + keeper: { bg: "rgba(52,211,153,0.1)", color: "#34d399" }, +}; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function Avatar({ name, avatarUrl, authorId, size = 24 }: { name: string; avatarUrl: string | null; authorId: string; size?: number }) { + const mc = memberColor(authorId); + if (avatarUrl) return {name}; + return ( +
+ {getInitials(name)} +
+ ); +} + +function TransportBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) { + const [hovered, setHovered] = useState(false); + return ( + + ); +} + +function WaveformPins({ + comments, duration, containerWidth, onSeek, onScrollToComment, +}: { + comments: SongComment[]; duration: number; containerWidth: number; + onSeek: (t: number) => void; onScrollToComment: (id: string) => void; +}) { + const [hoveredId, setHoveredId] = useState(null); + const pinned = comments.filter((c) => c.timestamp != null); + + return ( +
+ {pinned.map((c) => { + const pct = duration > 0 ? c.timestamp! / duration : 0; + const left = Math.round(pct * containerWidth); + const mc = memberColor(c.author_id); + const isHovered = hoveredId === c.id; + + return ( +
setHoveredId(c.id)} onMouseLeave={() => setHoveredId(null)} + onClick={() => { onSeek(c.timestamp!); onScrollToComment(c.id); }} + > + {isHovered && ( +
+
+
+ {getInitials(c.author_name)} +
+ {c.author_name} + {formatTime(c.timestamp!)} +
+
+ {c.body.length > 80 ? c.body.slice(0, 80) + "…" : c.body} +
+
+ )} +
+ {getInitials(c.author_name)} +
+
+
+ ); + })} +
+ ); +} + +// ── Icons ───────────────────────────────────────────────────────────────────── + +function IconSkipBack() { + return ; +} +function IconSkipFwd() { + return ; +} +function IconPlay() { + return ; +} +function IconPause() { + return ; +} + +// ── PlayerPanel ─────────────────────────────────────────────────────────────── + +interface PlayerPanelProps { + songId: string; + bandId: string; + /** Called when back button is clicked. If omitted, navigates to /bands/:bandId */ + onBack?: () => void; +} + +export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) { + const navigate = useNavigate(); + const qc = useQueryClient(); + const waveformRef = useRef(null); + const waveformContainerRef = useRef(null); + + const [selectedVersionId, setSelectedVersionId] = useState(null); + const [commentBody, setCommentBody] = useState(""); + const [selectedTag, setSelectedTag] = useState(""); + const [composeFocused, setComposeFocused] = useState(false); + const [waveformWidth, setWaveformWidth] = useState(0); + // State resets automatically because BandPage passes key={selectedSongId} to PlayerPanel + + // ── Queries ────────────────────────────────────────────────────────────── + + const { data: me } = useQuery({ queryKey: ["me"], queryFn: () => api.get("/auth/me") }); + + const { data: song } = useQuery({ + queryKey: ["song", songId], + queryFn: () => api.get(`/songs/${songId}`), + enabled: !!songId, + }); + + const { data: versions } = useQuery({ + queryKey: ["versions", songId], + queryFn: () => api.get(`/songs/${songId}/versions`), + enabled: !!songId, + }); + + const { data: session } = useQuery({ + queryKey: ["session", song?.session_id], + queryFn: () => api.get(`/bands/${bandId}/sessions/${song!.session_id}`), + enabled: !!song?.session_id && !!bandId, + }); + + const { data: comments } = useQuery({ + queryKey: ["comments", songId], + queryFn: () => api.get(`/songs/${songId}/comments`), + enabled: !!songId, + }); + + const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null; + const activeVersionData = versions?.find((v) => v.id === activeVersion) ?? versions?.[0] ?? null; + + // ── Waveform ────────────────────────────────────────────────────────────── + + const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, { + url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null, + peaksUrl: null, + peaks: activeVersionData?.waveform_peaks ?? null, + songId, + bandId, + }); + + useEffect(() => { + const el = waveformContainerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => setWaveformWidth(entries[0].contentRect.width)); + ro.observe(el); + setWaveformWidth(el.offsetWidth); + return () => ro.disconnect(); + }, []); + + // Space bar shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return; + if (e.code === "Space") { e.preventDefault(); if (isPlaying) pause(); else if (isReady) play(); } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isPlaying, isReady, play, pause]); + + // ── Mutations ───────────────────────────────────────────────────────────── + + const addCommentMutation = useMutation({ + mutationFn: ({ body, timestamp, tag }: { body: string; timestamp: number; tag: string }) => + api.post(`/songs/${songId}/comments`, { body, timestamp, tag: tag || null }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["comments", songId] }); setCommentBody(""); setSelectedTag(""); }, + }); + + const deleteCommentMutation = useMutation({ + mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }), + }); + + const scrollToComment = useCallback((commentId: string) => { + document.getElementById(`comment-${commentId}`)?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, []); + + const handleBack = () => { + if (onBack) onBack(); + else navigate(`/bands/${bandId}`); + }; + + const border = "rgba(255,255,255,0.06)"; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ + {/* Breadcrumb / header */} +
+ + {session && ( + <> + + + + )} + + + {song?.title ?? "…"} + + + {/* Version selector */} + {versions && versions.length > 1 && ( +
+ {versions.map((v) => ( + + ))} +
+ )} + + +
+ + {/* Body */} +
+ + {/* Waveform section */} +
+
+
+ {song?.title ?? "…"} + {isReady ? formatTime(duration) : "—"} +
+ + {/* Pin layer + canvas */} +
+ {isReady && duration > 0 && comments && ( + + )} + {!isReady &&
} +
+ {error &&
Audio error: {error}
} + {!isReady && !error &&
Loading audio…
} +
+ + {/* Time bar */} +
+ {formatTime(currentTime)} + {isReady && duration > 0 ? formatTime(duration / 2) : "—"} + {isReady ? formatTime(duration) : "—"} +
+
+ + {/* Transport */} +
+ seekTo(Math.max(0, currentTime - 30))} title="−30s"> + + seekTo(currentTime + 30)} title="+30s"> +
+
+ + {/* Comments section */} +
+ + {/* Header */} +
+ Comments + {comments && comments.length > 0 && ( + {comments.length} + )} +
+ + {/* Compose */} +
+
+ {me ? ( + + ) : ( +
+ )} +
+
+
+ {isPlaying &&
} + {formatTime(currentTime)} +
+ · pins to playhead +
+