development #1
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))
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
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:
|
||||
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}
|
||||
|
||||
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 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) {
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
{versions.map((v) => (
|
||||
<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}` : ""}
|
||||
</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 {
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (!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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user