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/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/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/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 index 8995c28..134cebd 100644 --- a/web/src/components/PlayerPanel.tsx +++ b/web/src/components/PlayerPanel.tsx @@ -4,6 +4,7 @@ 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 ───────────────────────────────────────────────────────────────────── @@ -24,6 +25,8 @@ interface SongVersion { version_number: number; label: string | null; analysis_status: string; + waveform_peaks: number[] | null; + waveform_peaks_mini: number[] | null; } interface SongComment { @@ -214,12 +217,14 @@ export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) { }); 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: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null, + peaksUrl: null, + peaks: activeVersionData?.waveform_peaks ?? null, songId, bandId, }); @@ -304,7 +309,15 @@ export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) {
{versions.map((v) => ( ))} diff --git a/web/src/hooks/useWaveform.test.ts b/web/src/hooks/useWaveform.test.ts new file mode 100644 index 0000000..07f8f2d --- /dev/null +++ b/web/src/hooks/useWaveform.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { RefObject } from "react"; + +// ── Hoist mocks so they're available in vi.mock factories ───────────────────── + +const { audioServiceMock } = vi.hoisted(() => ({ + audioServiceMock: { + initialize: vi.fn().mockResolvedValue(undefined), + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn(), + seekTo: vi.fn(), + getDuration: vi.fn(() => 0), + isWaveformReady: vi.fn(() => false), + }, +})); + +vi.mock("../services/audioService", () => ({ + audioService: audioServiceMock, +})); + +vi.mock("../stores/playerStore", () => ({ + usePlayerStore: vi.fn((selector: (s: unknown) => unknown) => + selector({ isPlaying: false, currentTime: 0, duration: 0 }) + ), +})); + +// ── Import after mocks ───────────────────────────────────────────────────────── + +import { useWaveform } from "./useWaveform"; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("useWaveform", () => { + beforeEach(() => { + vi.clearAllMocks(); + audioServiceMock.initialize.mockResolvedValue(undefined); + }); + + it("forwards peaks to audioService.initialize when provided", async () => { + const containerRef: RefObject = { + current: document.createElement("div"), + }; + const peaks = Array.from({ length: 500 }, (_, i) => i / 500); + + renderHook(() => + useWaveform(containerRef, { + url: "http://localhost/song.mp3", + peaksUrl: null, + peaks, + songId: "song-1", + bandId: "band-1", + }) + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(audioServiceMock.initialize).toHaveBeenCalledOnce(); + const [, , passedPeaks] = audioServiceMock.initialize.mock.calls[0]; + expect(passedPeaks).toEqual(peaks); + }); + + it("passes undefined when no peaks provided", async () => { + const containerRef: RefObject = { + current: document.createElement("div"), + }; + + renderHook(() => + useWaveform(containerRef, { + url: "http://localhost/song.mp3", + peaksUrl: null, + songId: "song-1", + bandId: "band-1", + }) + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(audioServiceMock.initialize).toHaveBeenCalledOnce(); + const [, , passedPeaks] = audioServiceMock.initialize.mock.calls[0]; + expect(passedPeaks).toBeUndefined(); + }); +}); diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index badf588..12d2c8f 100755 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -5,6 +5,7 @@ import { usePlayerStore } from "../stores/playerStore"; export interface UseWaveformOptions { url: string | null; peaksUrl: string | null; + peaks?: number[] | null; onReady?: (duration: number) => void; onTimeUpdate?: (currentTime: number) => void; songId?: string | null; @@ -39,7 +40,7 @@ export function useWaveform( const initializeAudio = async () => { try { - await audioService.initialize(containerRef.current!, options.url!); + await audioService.initialize(containerRef.current!, options.url!, options.peaks ?? undefined); // Restore playback if this song was already playing when the page loaded. // Read as a one-time snapshot — these values must NOT be reactive deps or diff --git a/web/src/services/audioService.test.ts b/web/src/services/audioService.test.ts new file mode 100644 index 0000000..df4db33 --- /dev/null +++ b/web/src/services/audioService.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AudioService } from "./audioService"; + +// ── WaveSurfer mock ──────────────────────────────────────────────────────────── + +const mockLoad = vi.fn(); +const mockOn = vi.fn(); +const mockUnAll = vi.fn(); +const mockDestroy = vi.fn(); +const mockGetDuration = vi.fn(() => 180); +const mockSetOptions = vi.fn(); +const mockCreate = vi.fn(); + +vi.mock("wavesurfer.js", () => ({ + default: { + create: (opts: unknown) => { + mockCreate(opts); + return { + load: mockLoad, + on: mockOn, + unAll: mockUnAll, + destroy: mockDestroy, + getDuration: mockGetDuration, + setOptions: mockSetOptions, + isPlaying: vi.fn(() => false), + }; + }, + }, +})); + +// ── Zustand store mock ───────────────────────────────────────────────────────── + +vi.mock("../stores/playerStore", () => ({ + usePlayerStore: { + getState: vi.fn(() => ({ + isPlaying: false, + currentTime: 0, + duration: 0, + currentSongId: null, + currentBandId: null, + batchUpdate: vi.fn(), + setDuration: vi.fn(), + setCurrentSong: vi.fn(), + })), + subscribe: vi.fn(), + }, +})); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeContainer(): HTMLDivElement { + return document.createElement("div"); +} + +function triggerWaveSurferReady() { + // WaveSurfer fires the "ready" event via ws.on("ready", cb) + const readyCb = mockOn.mock.calls.find(([event]) => event === "ready")?.[1]; + readyCb?.(); +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("AudioService.initialize()", () => { + let service: AudioService; + + beforeEach(() => { + AudioService.resetInstance(); + service = AudioService.getInstance(); + vi.clearAllMocks(); + mockGetDuration.mockReturnValue(180); + }); + + afterEach(() => { + AudioService.resetInstance(); + }); + + it("calls ws.load(url) without peaks when no peaks provided", async () => { + const container = makeContainer(); + const url = "http://localhost/audio/song.mp3"; + + const initPromise = service.initialize(container, url); + triggerWaveSurferReady(); + await initPromise; + + expect(mockLoad).toHaveBeenCalledOnce(); + const [calledUrl, calledPeaks] = mockLoad.mock.calls[0]; + expect(calledUrl).toBe(url); + expect(calledPeaks).toBeUndefined(); + }); + + it("calls ws.load(url, [Float32Array]) when peaks are provided", async () => { + const container = makeContainer(); + const url = "http://localhost/audio/song.mp3"; + const peaks = Array.from({ length: 500 }, (_, i) => i / 500); + + const initPromise = service.initialize(container, url, peaks); + triggerWaveSurferReady(); + await initPromise; + + expect(mockLoad).toHaveBeenCalledOnce(); + const [calledUrl, calledChannelData] = mockLoad.mock.calls[0]; + expect(calledUrl).toBe(url); + expect(calledChannelData).toHaveLength(1); + expect(calledChannelData[0]).toBeInstanceOf(Float32Array); + expect(calledChannelData[0]).toHaveLength(500); + expect(calledChannelData[0][0]).toBeCloseTo(0); + expect(calledChannelData[0][499]).toBeCloseTo(499 / 500); + }); + + it("does not re-initialize for the same url and container", async () => { + const container = makeContainer(); + const url = "http://localhost/audio/song.mp3"; + const peaks = [0.5, 0.5, 0.5]; + + const p1 = service.initialize(container, url, peaks); + triggerWaveSurferReady(); + await p1; + + vi.clearAllMocks(); + + // Second call with same URL + container: should no-op + await service.initialize(container, url, peaks); + expect(mockLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/services/audioService.ts b/web/src/services/audioService.ts index 0356888..1d4448e 100755 --- a/web/src/services/audioService.ts +++ b/web/src/services/audioService.ts @@ -41,7 +41,7 @@ class AudioService { return el; } - public async initialize(container: HTMLElement, url: string): Promise { + public async initialize(container: HTMLElement, url: string, peaks?: number[] | null): Promise { if (!container) throw new Error('Container element is required'); if (!url) throw new Error('Valid audio URL is required'); @@ -98,7 +98,14 @@ class AudioService { ws.on('ready', () => { onReady().catch(reject); }); ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err)))); - ws.load(url); + + // Pass pre-computed peaks to WaveSurfer so the waveform renders immediately + // without waiting for the full audio to decode (WaveSurfer v7 feature). + if (peaks && peaks.length > 0) { + ws.load(url, [new Float32Array(peaks)]); + } else { + ws.load(url); + } }); } diff --git a/worker/src/worker/db.py b/worker/src/worker/db.py index aaf6a4c..5abf278 100644 --- a/worker/src/worker/db.py +++ b/worker/src/worker/db.py @@ -26,6 +26,8 @@ class AudioVersionModel(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/worker/src/worker/main.py b/worker/src/worker/main.py index 2c72db7..aa1f88d 100644 --- a/worker/src/worker/main.py +++ b/worker/src/worker/main.py @@ -21,7 +21,7 @@ from worker.db import AudioVersionModel, JobModel from worker.pipeline.analyse_full import run_full_analysis from worker.pipeline.analyse_range import run_range_analysis from worker.pipeline.transcode import get_duration_ms, transcode_to_hls -from worker.pipeline.waveform import generate_waveform_file +from worker.pipeline.waveform import extract_peaks, generate_waveform_file logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") log = logging.getLogger("worker") @@ -59,20 +59,24 @@ async def handle_transcode(payload: dict, session: AsyncSession, settings) -> No hls_dir = os.path.join(tmp, "hls") await transcode_to_hls(local_path, hls_dir) - waveform_path = os.path.join(tmp, "waveform.json") - await generate_waveform_file(audio, waveform_path) + # Generate waveform peaks at two resolutions: + # - 500-point full peaks passed to WaveSurfer for instant render in player + # - 100-point mini peaks for the library/overview SVG thumbnail + loop = asyncio.get_event_loop() + peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500) + peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100) - # TODO: Upload HLS segments and waveform back to Nextcloud / object storage + # TODO: Upload HLS segments back to Nextcloud / object storage # For now, store the local tmp path in the DB (replace with real upload logic) hls_nc_path = f"hls/{version_id}" - waveform_nc_path = f"waveforms/{version_id}.json" stmt = ( update(AudioVersionModel) .where(AudioVersionModel.id == version_id) .values( cdn_hls_base=hls_nc_path, - waveform_url=waveform_nc_path, + waveform_peaks=peaks_500, + waveform_peaks_mini=peaks_100, duration_ms=duration_ms, analysis_status="running", ) diff --git a/worker/tests/test_handle_transcode.py b/worker/tests/test_handle_transcode.py new file mode 100644 index 0000000..ad1e0ad --- /dev/null +++ b/worker/tests/test_handle_transcode.py @@ -0,0 +1,71 @@ +"""Unit tests for handle_transcode waveform peaks storage.""" + +from unittest.mock import AsyncMock, MagicMock, patch, call +import uuid + +import numpy as np +import pytest + + +@pytest.fixture +def mock_audio(sine_440hz): + audio, sr = sine_440hz + return audio, sr + + +@pytest.mark.asyncio +async def test_handle_transcode_stores_both_peak_resolutions(mock_audio): + """After handle_transcode, waveform_peaks (500) and waveform_peaks_mini (100) are stored in DB.""" + audio, sr = mock_audio + version_id = uuid.uuid4() + + # Capture the statement passed to session.execute + executed_stmts = [] + + async def capture_execute(stmt): + executed_stmts.append(stmt) + return MagicMock() + + mock_session = AsyncMock() + mock_session.execute = capture_execute + mock_session.commit = AsyncMock() + + mock_settings = MagicMock() + mock_settings.nextcloud_url = "http://nc.test" + mock_settings.nextcloud_user = "user" + mock_settings.nextcloud_pass = "pass" + mock_settings.target_sample_rate = 44100 + mock_settings.audio_tmp_dir = "/tmp" + + payload = { + "version_id": str(version_id), + "nc_file_path": "/bands/test/songs/test/v1.wav", + } + + with ( + patch("worker.main.load_audio", return_value=(audio, sr, "/tmp/v1.wav")), + patch("worker.main.get_duration_ms", return_value=5000), + patch("worker.main.transcode_to_hls", new_callable=AsyncMock), + patch("worker.main.run_full_analysis", new_callable=AsyncMock), + ): + from worker.main import handle_transcode + await handle_transcode(payload, mock_session, mock_settings) + + assert len(executed_stmts) == 1, "Expected exactly one UPDATE statement" + stmt = executed_stmts[0] + + # Extract the values dict from the SQLAlchemy Update statement + values = stmt._values + value_keys = {col.key for col, _ in values.items()} + + assert "waveform_peaks" in value_keys, f"waveform_peaks not in UPDATE values: {value_keys}" + assert "waveform_peaks_mini" in value_keys, f"waveform_peaks_mini not in UPDATE values: {value_keys}" + + # Resolve the actual peak lists from the BindParameter objects + peaks_500 = next(val.value for col, val in values.items() if col.key == "waveform_peaks") + peaks_100 = next(val.value for col, val in values.items() if col.key == "waveform_peaks_mini") + + assert len(peaks_500) == 500, f"Expected 500 peaks, got {len(peaks_500)}" + assert len(peaks_100) == 100, f"Expected 100 mini peaks, got {len(peaks_100)}" + assert all(0.0 <= p <= 1.0 for p in peaks_500), "Full peaks out of [0, 1] range" + assert all(0.0 <= p <= 1.0 for p in peaks_100), "Mini peaks out of [0, 1] range" diff --git a/worker/tests/test_waveform.py b/worker/tests/test_waveform.py index 9d6e8b1..7aa7ec7 100644 --- a/worker/tests/test_waveform.py +++ b/worker/tests/test_waveform.py @@ -14,6 +14,12 @@ def test_extract_peaks_returns_correct_length(sine_440hz): assert len(peaks) == 500 +def test_extract_peaks_mini_returns_correct_length(sine_440hz): + audio, sr = sine_440hz + peaks = extract_peaks(audio, num_points=100) + assert len(peaks) == 100 + + def test_extract_peaks_normalized_between_0_and_1(sine_440hz): audio, sr = sine_440hz peaks = extract_peaks(audio, num_points=200) @@ -21,6 +27,13 @@ def test_extract_peaks_normalized_between_0_and_1(sine_440hz): assert max(peaks) == pytest.approx(1.0, abs=0.01) +def test_extract_peaks_mini_normalized_between_0_and_1(sine_440hz): + audio, sr = sine_440hz + peaks = extract_peaks(audio, num_points=100) + assert all(0.0 <= p <= 1.0 for p in peaks) + assert max(peaks) == pytest.approx(1.0, abs=0.01) + + def test_extract_peaks_empty_audio(): audio = np.array([], dtype=np.float32) peaks = extract_peaks(audio, num_points=100) @@ -28,6 +41,14 @@ def test_extract_peaks_empty_audio(): assert all(p == 0.0 for p in peaks) +def test_extract_peaks_custom_num_points(sine_440hz): + audio, _ = sine_440hz + for n in [50, 100, 250, 500]: + peaks = extract_peaks(audio, num_points=n) + assert len(peaks) == n, f"Expected {n} peaks, got {len(peaks)}" + assert all(0.0 <= p <= 1.0 for p in peaks) + + def test_peaks_to_json_valid_structure(sine_440hz): audio, _ = sine_440hz peaks = extract_peaks(audio) @@ -46,4 +67,5 @@ async def test_generate_waveform_file_writes_json(tmp_path, sine_440hz): with open(output) as f: data = json.load(f) assert data["version"] == 2 - assert len(data["data"]) == 1000 + # generate_waveform_file uses the default num_points=500 + assert len(data["data"]) == 500