feat(waveform): precompute and store peaks in DB for instant rendering
Store waveform peaks inline in audio_versions (JSONB columns) so WaveSurfer
can render the waveform immediately on page load without waiting for audio
decode. Adds a 100-point mini-waveform for version selector thumbnails.
Backend:
- Migration 0006: adds waveform_peaks and waveform_peaks_mini JSONB columns
- Worker generates both resolutions (500-pt full, 100-pt mini) during transcode
and stores them directly in DB — replaces file-based waveform_url approach
- AudioVersionRead schema exposes both fields inline (no extra HTTP round-trip)
- GET /versions/{id}/waveform reads from DB; adds ?resolution=mini support
Frontend:
- audioService.initialize() accepts peaks and calls ws.load(url, Float32Array)
so waveform renders instantly without audio decode
- useWaveform hook threads peaks option through to audioService
- PlayerPanel passes waveform_peaks from the active version to the hook
- New MiniWaveform SVG component (no WaveSurfer) renders mini peaks in the
version selector buttons
Fix: docker-compose.dev.yml now runs alembic upgrade head before starting
the API server, so a fresh volume gets the full schema automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
136
PLAN_waveform_precompute.md
Normal file
136
PLAN_waveform_precompute.md
Normal file
@@ -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
|
||||||
35
api/alembic/versions/0006_waveform_peaks_in_db.py
Normal file
35
api/alembic/versions/0006_waveform_peaks_in_db.py
Normal file
@@ -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")
|
||||||
@@ -232,6 +232,8 @@ class AudioVersion(Base):
|
|||||||
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
waveform_url: 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)
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
format: Mapped[Optional[str]] = mapped_column(String(10))
|
format: Mapped[Optional[str]] = mapped_column(String(10))
|
||||||
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||||
|
|||||||
@@ -180,49 +180,27 @@ async def create_version(
|
|||||||
@router.get("/versions/{version_id}/waveform")
|
@router.get("/versions/{version_id}/waveform")
|
||||||
async def get_waveform(
|
async def get_waveform(
|
||||||
version_id: uuid.UUID,
|
version_id: uuid.UUID,
|
||||||
|
resolution: str = Query("full", pattern="^(full|mini)$"),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_member: Member = Depends(get_current_member),
|
current_member: Member = Depends(get_current_member),
|
||||||
) -> Any:
|
) -> 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)
|
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
|
||||||
if not version.waveform_url:
|
|
||||||
|
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")
|
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
|
return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks}
|
||||||
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
|
|
||||||
|
|
||||||
return json.loads(data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/versions/{version_id}/stream")
|
@router.get("/versions/{version_id}/stream")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel):
|
|||||||
nc_file_etag: str | None = None
|
nc_file_etag: str | None = None
|
||||||
cdn_hls_base: str | None = None
|
cdn_hls_base: str | None = None
|
||||||
waveform_url: 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
|
duration_ms: int | None = None
|
||||||
format: str | None = None
|
format: str | None = None
|
||||||
file_size_bytes: int | None = None
|
file_size_bytes: int | None = None
|
||||||
|
|||||||
49
api/tests/integration/test_waveform_peaks_schema.py
Normal file
49
api/tests/integration/test_waveform_peaks_schema.py
Normal file
@@ -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
|
||||||
64
api/tests/unit/test_audio_version_schema.py
Normal file
64
api/tests/unit/test_audio_version_schema.py
Normal file
@@ -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]
|
||||||
38
api/tests/unit/test_versions_list_peaks.py
Normal file
38
api/tests/unit/test_versions_list_peaks.py
Normal file
@@ -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
|
||||||
120
api/tests/unit/test_waveform_endpoint.py
Normal file
120
api/tests/unit/test_waveform_endpoint.py
Normal file
@@ -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
|
||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
target: development
|
target: development
|
||||||
|
command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
|
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}
|
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
|
||||||
|
|||||||
51
web/src/components/MiniWaveform.test.tsx
Normal file
51
web/src/components/MiniWaveform.test.tsx
Normal file
@@ -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(<MiniWaveform peaks={peaks} width={120} height={32} />);
|
||||||
|
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(<MiniWaveform peaks={peaks} width={120} height={32} />);
|
||||||
|
const rects = container.querySelectorAll("rect");
|
||||||
|
expect(rects.length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a placeholder when peaks is null", () => {
|
||||||
|
const { container } = render(<MiniWaveform peaks={null} width={120} height={32} />);
|
||||||
|
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(<MiniWaveform peaks={[]} width={120} height={32} />);
|
||||||
|
const rects = container.querySelectorAll("rect");
|
||||||
|
expect(rects.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies correct SVG dimensions", () => {
|
||||||
|
const peaks = [0.5, 0.5];
|
||||||
|
const { container } = render(<MiniWaveform peaks={peaks} width={200} height={48} />);
|
||||||
|
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(
|
||||||
|
<MiniWaveform peaks={peaks} width={100} height={32} color="#ff0000" />
|
||||||
|
);
|
||||||
|
const rect = container.querySelector("rect");
|
||||||
|
expect(rect?.getAttribute("fill")).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
});
|
||||||
62
web/src/components/MiniWaveform.tsx
Normal file
62
web/src/components/MiniWaveform.tsx
Normal file
@@ -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 (
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
||||||
|
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.06)" rx={2} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const barCount = peaks.length;
|
||||||
|
const gap = 1;
|
||||||
|
const barWidth = Math.max(1, (width - gap * (barCount - 1)) / barCount);
|
||||||
|
const midY = height / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
||||||
|
{peaks.map((peak, i) => {
|
||||||
|
const barHeight = Math.max(1, peak * height);
|
||||||
|
const x = i * (barWidth + gap);
|
||||||
|
const y = midY - barHeight / 2;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barWidth}
|
||||||
|
height={barHeight}
|
||||||
|
fill={color}
|
||||||
|
rx={0.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { MemberRead } from "../api/auth";
|
import type { MemberRead } from "../api/auth";
|
||||||
import { useWaveform } from "../hooks/useWaveform";
|
import { useWaveform } from "../hooks/useWaveform";
|
||||||
|
import { MiniWaveform } from "./MiniWaveform";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ interface SongVersion {
|
|||||||
version_number: number;
|
version_number: number;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
analysis_status: string;
|
analysis_status: string;
|
||||||
|
waveform_peaks: number[] | null;
|
||||||
|
waveform_peaks_mini: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongComment {
|
interface SongComment {
|
||||||
@@ -214,12 +217,14 @@ export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
|
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
|
||||||
|
const activeVersionData = versions?.find((v) => v.id === activeVersion) ?? versions?.[0] ?? null;
|
||||||
|
|
||||||
// ── Waveform ──────────────────────────────────────────────────────────────
|
// ── Waveform ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, {
|
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, {
|
||||||
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
||||||
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
peaksUrl: null,
|
||||||
|
peaks: activeVersionData?.waveform_peaks ?? null,
|
||||||
songId,
|
songId,
|
||||||
bandId,
|
bandId,
|
||||||
});
|
});
|
||||||
@@ -304,7 +309,15 @@ export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) {
|
|||||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||||
{versions.map((v) => (
|
{versions.map((v) => (
|
||||||
<button key={v.id} onClick={() => setSelectedVersionId(v.id)}
|
<button key={v.id} onClick={() => setSelectedVersionId(v.id)}
|
||||||
style={{ background: v.id === activeVersion ? "rgba(20,184,166,0.12)" : "transparent", border: `1px solid ${v.id === activeVersion ? "rgba(20,184,166,0.3)" : "rgba(255,255,255,0.09)"}`, borderRadius: 6, padding: "4px 10px", color: v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 11, fontFamily: "monospace" }}>
|
style={{ background: v.id === activeVersion ? "rgba(20,184,166,0.12)" : "transparent", border: `1px solid ${v.id === activeVersion ? "rgba(20,184,166,0.3)" : "rgba(255,255,255,0.09)"}`, borderRadius: 6, padding: "4px 10px", color: v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 11, fontFamily: "monospace", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
{v.waveform_peaks_mini && (
|
||||||
|
<MiniWaveform
|
||||||
|
peaks={v.waveform_peaks_mini}
|
||||||
|
width={32}
|
||||||
|
height={16}
|
||||||
|
color={v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.25)"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
v{v.version_number}{v.label ? ` · ${v.label}` : ""}
|
v{v.version_number}{v.label ? ` · ${v.label}` : ""}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
87
web/src/hooks/useWaveform.test.ts
Normal file
87
web/src/hooks/useWaveform.test.ts
Normal file
@@ -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<HTMLDivElement> = {
|
||||||
|
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<HTMLDivElement> = {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { usePlayerStore } from "../stores/playerStore";
|
|||||||
export interface UseWaveformOptions {
|
export interface UseWaveformOptions {
|
||||||
url: string | null;
|
url: string | null;
|
||||||
peaksUrl: string | null;
|
peaksUrl: string | null;
|
||||||
|
peaks?: number[] | null;
|
||||||
onReady?: (duration: number) => void;
|
onReady?: (duration: number) => void;
|
||||||
onTimeUpdate?: (currentTime: number) => void;
|
onTimeUpdate?: (currentTime: number) => void;
|
||||||
songId?: string | null;
|
songId?: string | null;
|
||||||
@@ -39,7 +40,7 @@ export function useWaveform(
|
|||||||
|
|
||||||
const initializeAudio = async () => {
|
const initializeAudio = async () => {
|
||||||
try {
|
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.
|
// 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
|
// Read as a one-time snapshot — these values must NOT be reactive deps or
|
||||||
|
|||||||
125
web/src/services/audioService.test.ts
Normal file
125
web/src/services/audioService.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ class AudioService {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
public async initialize(container: HTMLElement, url: string, peaks?: number[] | null): Promise<void> {
|
||||||
if (!container) throw new Error('Container element is required');
|
if (!container) throw new Error('Container element is required');
|
||||||
if (!url) throw new Error('Valid audio URL 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('ready', () => { onReady().catch(reject); });
|
||||||
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
||||||
|
|
||||||
|
// 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);
|
ws.load(url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class AudioVersionModel(Base):
|
|||||||
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
waveform_url: 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)
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||||
format: Mapped[Optional[str]] = mapped_column(String(10))
|
format: Mapped[Optional[str]] = mapped_column(String(10))
|
||||||
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from worker.db import AudioVersionModel, JobModel
|
|||||||
from worker.pipeline.analyse_full import run_full_analysis
|
from worker.pipeline.analyse_full import run_full_analysis
|
||||||
from worker.pipeline.analyse_range import run_range_analysis
|
from worker.pipeline.analyse_range import run_range_analysis
|
||||||
from worker.pipeline.transcode import get_duration_ms, transcode_to_hls
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
log = logging.getLogger("worker")
|
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")
|
hls_dir = os.path.join(tmp, "hls")
|
||||||
await transcode_to_hls(local_path, hls_dir)
|
await transcode_to_hls(local_path, hls_dir)
|
||||||
|
|
||||||
waveform_path = os.path.join(tmp, "waveform.json")
|
# Generate waveform peaks at two resolutions:
|
||||||
await generate_waveform_file(audio, waveform_path)
|
# - 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)
|
# For now, store the local tmp path in the DB (replace with real upload logic)
|
||||||
hls_nc_path = f"hls/{version_id}"
|
hls_nc_path = f"hls/{version_id}"
|
||||||
waveform_nc_path = f"waveforms/{version_id}.json"
|
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
update(AudioVersionModel)
|
update(AudioVersionModel)
|
||||||
.where(AudioVersionModel.id == version_id)
|
.where(AudioVersionModel.id == version_id)
|
||||||
.values(
|
.values(
|
||||||
cdn_hls_base=hls_nc_path,
|
cdn_hls_base=hls_nc_path,
|
||||||
waveform_url=waveform_nc_path,
|
waveform_peaks=peaks_500,
|
||||||
|
waveform_peaks_mini=peaks_100,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
analysis_status="running",
|
analysis_status="running",
|
||||||
)
|
)
|
||||||
|
|||||||
71
worker/tests/test_handle_transcode.py
Normal file
71
worker/tests/test_handle_transcode.py
Normal file
@@ -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"
|
||||||
@@ -14,6 +14,12 @@ def test_extract_peaks_returns_correct_length(sine_440hz):
|
|||||||
assert len(peaks) == 500
|
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):
|
def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
|
||||||
audio, sr = sine_440hz
|
audio, sr = sine_440hz
|
||||||
peaks = extract_peaks(audio, num_points=200)
|
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)
|
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():
|
def test_extract_peaks_empty_audio():
|
||||||
audio = np.array([], dtype=np.float32)
|
audio = np.array([], dtype=np.float32)
|
||||||
peaks = extract_peaks(audio, num_points=100)
|
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)
|
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):
|
def test_peaks_to_json_valid_structure(sine_440hz):
|
||||||
audio, _ = sine_440hz
|
audio, _ = sine_440hz
|
||||||
peaks = extract_peaks(audio)
|
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:
|
with open(output) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
assert data["version"] == 2
|
assert data["version"] == 2
|
||||||
assert len(data["data"]) == 1000
|
# generate_waveform_file uses the default num_points=500
|
||||||
|
assert len(data["data"]) == 500
|
||||||
|
|||||||
Reference in New Issue
Block a user