diff --git a/PLAN_waveform_precompute.md b/PLAN_waveform_precompute.md
new file mode 100644
index 0000000..eb9c829
--- /dev/null
+++ b/PLAN_waveform_precompute.md
@@ -0,0 +1,136 @@
+# Plan: Waveform Pre-computation
+
+**Branch:** `feature/waveform-precompute`
+**Goal:** Store waveform peaks in the database during indexing so WaveSurfer renders
+the waveform instantly (no waiting for audio decode), and show a mini-waveform in
+the library/overview song list.
+
+## Background
+
+WaveSurfer v7 supports `ws.load(url, channelData)` — when pre-computed peaks are
+passed as a `Float32Array[]`, the waveform renders immediately and audio streams in
+the background. Currently the frontend calls `ws.load(url)` which blocks until the
+full audio is decoded.
+
+The worker already generates a 500-point peaks JSON file (`waveform_url`), but:
+- It is stored as a file on disk, not inline in the DB
+- The frontend never reads it (the `peaksUrl` option in `useWaveform` is wired to
+ nothing)
+
+## Architecture Decision
+
+Add two JSONB columns to `audio_versions`:
+- `waveform_peaks` — 500 points, returned inline with version data, passed to WaveSurfer
+- `waveform_peaks_mini` — 100 points, returned inline, used for SVG mini-waveform in
+ library/song list
+
+This eliminates a separate HTTP round-trip and lets the UI render the waveform the
+moment the page loads.
+
+---
+
+## Checklist
+
+### Backend
+
+#### B1 — DB: Peaks columns + Alembic migration
+- [ ] Write migration test: after upgrade, `audio_versions` table has `waveform_peaks`
+ and `waveform_peaks_mini` JSONB columns
+- [ ] Create `api/alembic/versions/0006_waveform_peaks_in_db.py`
+- [ ] Add `waveform_peaks` and `waveform_peaks_mini` JSONB columns to `AudioVersion`
+ model in `api/src/rehearsalhub/db/models.py`
+
+#### B2 — Worker: Generate and store both peak resolutions
+- [ ] Write unit tests for `extract_peaks()` in `worker/tests/test_waveform.py`:
+ - Returns exactly `num_points` values
+ - All values in [0.0, 1.0]
+ - Empty audio returns list of zeros (no crash)
+ - 100-point and 500-point both work
+- [ ] Update `handle_transcode` in `worker/src/worker/main.py`:
+ - Generate `peaks_500 = extract_peaks(audio, 500)`
+ - Generate `peaks_100 = extract_peaks(audio, 100)`
+ - Store both on `AudioVersion` DB row
+- [ ] Write integration test: after `handle_transcode`, row has non-null
+ `waveform_peaks` (len 500) and `waveform_peaks_mini` (len 100)
+
+#### B3 — API Schema: Expose peaks in `AudioVersionRead`
+- [ ] Write serialization test: `AudioVersionRead.model_validate(orm_obj)` includes
+ `waveform_peaks: list[float] | None` and `waveform_peaks_mini: list[float] | None`
+- [ ] Update `api/src/rehearsalhub/schemas/audio_version.py` — add both fields
+
+#### B4 — API Router: `/waveform` endpoint reads from DB
+- [ ] Write endpoint tests:
+ - `GET /versions/{id}/waveform` returns `{"data": [...500 floats...]}` from DB
+ - `GET /versions/{id}/waveform?resolution=mini` returns 100-point peaks
+ - 404 when version has no peaks yet
+- [ ] Update `api/src/rehearsalhub/routers/versions.py` — read from
+ `version.waveform_peaks` / `version.waveform_peaks_mini` instead of file I/O
+
+#### B5 — API: Peaks inline on versions list (verify, no change expected)
+- [ ] Write test: `GET /songs/{id}/versions` response includes `waveform_peaks` and
+ `waveform_peaks_mini` on each version object
+- [ ] Confirm no router change needed (schema update in B3 is sufficient)
+
+---
+
+### Frontend
+
+#### F1 — Types: Update `AudioVersionRead` TS type
+- [ ] Add `waveform_peaks: number[] | null` and `waveform_peaks_mini: number[] | null`
+ to the TypeScript version type (wherever API types live)
+
+#### F2 — `audioService`: Accept and use pre-computed peaks
+- [ ] Write unit tests for `AudioService.initialize()`:
+ - With peaks: calls `ws.load(url, [Float32Array])` → waveform renders immediately
+ - Without peaks: calls `ws.load(url)` → falls back to audio decode
+ - Same URL + same peaks → no re-initialization
+- [ ] Update `AudioService.initialize(container, url, peaks?: number[])` in
+ `web/src/services/audioService.ts`:
+ - Call `ws.load(url, peaks ? [new Float32Array(peaks)] : undefined)`
+
+#### F3 — `useWaveform` hook: Thread peaks through
+- [ ] Write hook test: when `peaks` option is provided, it is forwarded to
+ `audioService.initialize`
+- [ ] Add `peaks?: number[] | null` to `UseWaveformOptions` in
+ `web/src/hooks/useWaveform.ts`
+- [ ] Forward `options.peaks` to `audioService.initialize()` in the effect
+
+#### F4 — `PlayerPanel`: Pass peaks to hook
+- [ ] Write component test: `PlayerPanel` passes `version.waveform_peaks` to
+ `useWaveform`
+- [ ] Update `web/src/components/PlayerPanel.tsx` to extract and forward
+ `waveform_peaks`
+
+#### F5 — `MiniWaveform`: New SVG component for library overview
+- [ ] Write unit tests:
+ - Renders SVG with correct number of bars matching peaks length
+ - Null/empty peaks renders a grey placeholder (no crash)
+ - Accepts `peaks`, `width`, `height`, `color` props
+- [ ] Create `web/src/components/MiniWaveform.tsx` — pure SVG, no WaveSurfer
+- [ ] Integrate into song list / library view using `waveform_peaks_mini`
+
+---
+
+## Testing Strategy
+
+| Layer | Tool |
+|------------------|----------------------------------------------|
+| Backend unit | pytest, synthetic numpy arrays |
+| Backend integration | Real Postgres via docker-compose test profile |
+| Frontend unit | Vitest + Testing Library |
+| E2E | Playwright — assert waveform visible before audio `canplay` fires |
+
+---
+
+## Implementation Order
+
+1. B1 — migration + model
+2. B2 — worker (TDD: unit tests → implementation → integration test)
+3. B3 — schema
+4. B4 — router
+5. B5 — verify versions list
+6. F1 — TS types
+7. F2 — audioService
+8. F3 — useWaveform
+9. F4 — PlayerPanel
+10. F5 — MiniWaveform
diff --git a/api/alembic/versions/0006_waveform_peaks_in_db.py b/api/alembic/versions/0006_waveform_peaks_in_db.py
new file mode 100644
index 0000000..222bd70
--- /dev/null
+++ b/api/alembic/versions/0006_waveform_peaks_in_db.py
@@ -0,0 +1,35 @@
+"""Store waveform peaks inline in audio_versions table.
+
+Replaces file-based waveform_url approach with two JSONB columns:
+- waveform_peaks: 500-point peaks for the player (passed directly to WaveSurfer)
+- waveform_peaks_mini: 100-point peaks for library/overview mini-waveform SVG
+
+Revision ID: 0006_waveform_peaks_in_db
+Revises: 0005_comment_tag
+Create Date: 2026-04-10
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import JSONB
+
+revision = "0006_waveform_peaks_in_db"
+down_revision = "0005_comment_tag"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "audio_versions",
+ sa.Column("waveform_peaks", JSONB, nullable=True),
+ )
+ op.add_column(
+ "audio_versions",
+ sa.Column("waveform_peaks_mini", JSONB, nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("audio_versions", "waveform_peaks_mini")
+ op.drop_column("audio_versions", "waveform_peaks")
diff --git a/api/src/rehearsalhub/db/models.py b/api/src/rehearsalhub/db/models.py
index 5b9bd0d..bcc8d7e 100755
--- a/api/src/rehearsalhub/db/models.py
+++ b/api/src/rehearsalhub/db/models.py
@@ -232,6 +232,8 @@ class AudioVersion(Base):
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
+ waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
+ waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
diff --git a/api/src/rehearsalhub/routers/versions.py b/api/src/rehearsalhub/routers/versions.py
index def0dda..51e01ba 100755
--- a/api/src/rehearsalhub/routers/versions.py
+++ b/api/src/rehearsalhub/routers/versions.py
@@ -180,49 +180,27 @@ async def create_version(
@router.get("/versions/{version_id}/waveform")
async def get_waveform(
version_id: uuid.UUID,
+ resolution: str = Query("full", pattern="^(full|mini)$"),
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
) -> Any:
+ """Return pre-computed waveform peaks from the database.
+
+ - `resolution=full` (default): 500-point peaks for the WaveSurfer player
+ - `resolution=mini`: 100-point peaks for the library overview thumbnail
+ """
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
- if not version.waveform_url:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
- # Use the uploader's NC credentials — invited members may not have NC configured
- uploader: Member | None = None
- if version.uploaded_by:
- uploader = await MemberRepository(session).get_by_id(version.uploaded_by)
- storage = NextcloudClient.for_member(uploader) if uploader else NextcloudClient.for_member(current_member)
- if storage is None:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="No storage provider configured for this account"
- )
- try:
- data = await _download_with_retry(storage, version.waveform_url)
- except httpx.ConnectError:
- raise HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Storage service unavailable."
- )
- except httpx.HTTPStatusError as e:
- if e.response.status_code == 404:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Waveform file not found in storage."
- )
- else:
- raise HTTPException(
- status_code=status.HTTP_502_BAD_GATEWAY,
- detail="Storage returned an error."
- )
- except Exception:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Failed to fetch waveform."
- )
- import json
+ if resolution == "mini":
+ peaks = version.waveform_peaks_mini
+ if peaks is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mini waveform not ready")
+ else:
+ peaks = version.waveform_peaks
+ if peaks is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
- return json.loads(data)
+ return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks}
@router.get("/versions/{version_id}/stream")
diff --git a/api/src/rehearsalhub/schemas/audio_version.py b/api/src/rehearsalhub/schemas/audio_version.py
index 38fb341..c5456fc 100755
--- a/api/src/rehearsalhub/schemas/audio_version.py
+++ b/api/src/rehearsalhub/schemas/audio_version.py
@@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel):
nc_file_etag: str | None = None
cdn_hls_base: str | None = None
waveform_url: str | None = None
+ waveform_peaks: list[float] | None = None
+ waveform_peaks_mini: list[float] | None = None
duration_ms: int | None = None
format: str | None = None
file_size_bytes: int | None = None
diff --git a/api/tests/integration/test_waveform_peaks_schema.py b/api/tests/integration/test_waveform_peaks_schema.py
new file mode 100644
index 0000000..8e7d281
--- /dev/null
+++ b/api/tests/integration/test_waveform_peaks_schema.py
@@ -0,0 +1,49 @@
+"""Integration tests for waveform peaks stored inline in audio_versions."""
+
+import pytest
+
+from tests.factories import create_audio_version, create_band, create_member, create_song
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_audio_version_stores_waveform_peaks(db_session, current_member):
+ """AudioVersion can store waveform_peaks and waveform_peaks_mini JSONB data."""
+ from rehearsalhub.repositories.audio_version import AudioVersionRepository
+
+ band = await create_band(db_session, creator_id=current_member.id)
+ song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
+ version = await create_audio_version(db_session, song_id=song.id)
+
+ peaks_500 = [float(i) / 500 for i in range(500)]
+ peaks_100 = [float(i) / 100 for i in range(100)]
+
+ repo = AudioVersionRepository(db_session)
+ updated = await repo.update(
+ version,
+ waveform_peaks=peaks_500,
+ waveform_peaks_mini=peaks_100,
+ )
+ await db_session.commit()
+
+ fetched = await repo.get_by_id(updated.id)
+ assert fetched is not None
+ assert fetched.waveform_peaks is not None
+ assert len(fetched.waveform_peaks) == 500
+ assert fetched.waveform_peaks_mini is not None
+ assert len(fetched.waveform_peaks_mini) == 100
+ assert fetched.waveform_peaks[0] == pytest.approx(0.0)
+ assert fetched.waveform_peaks[1] == pytest.approx(1 / 500)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_audio_version_peaks_default_null(db_session, current_member):
+ """waveform_peaks and waveform_peaks_mini are null by default."""
+ band = await create_band(db_session, creator_id=current_member.id)
+ song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
+ version = await create_audio_version(db_session, song_id=song.id)
+ await db_session.commit()
+
+ assert version.waveform_peaks is None
+ assert version.waveform_peaks_mini is None
diff --git a/api/tests/unit/test_audio_version_schema.py b/api/tests/unit/test_audio_version_schema.py
new file mode 100644
index 0000000..e273559
--- /dev/null
+++ b/api/tests/unit/test_audio_version_schema.py
@@ -0,0 +1,64 @@
+"""Unit tests for AudioVersionRead schema — waveform peaks serialization."""
+
+import uuid
+from datetime import datetime, timezone
+from unittest.mock import MagicMock
+
+import pytest
+
+from rehearsalhub.db.models import AudioVersion
+from rehearsalhub.schemas.audio_version import AudioVersionRead
+
+
+def _make_version(peaks=None, peaks_mini=None) -> MagicMock:
+ """Build a mock AudioVersion ORM object."""
+ v = MagicMock(spec=AudioVersion)
+ v.id = uuid.uuid4()
+ v.song_id = uuid.uuid4()
+ v.version_number = 1
+ v.label = None
+ v.nc_file_path = "/bands/test/v1.wav"
+ v.nc_file_etag = "abc123"
+ v.cdn_hls_base = None
+ v.waveform_url = None
+ v.waveform_peaks = peaks
+ v.waveform_peaks_mini = peaks_mini
+ v.duration_ms = 5000
+ v.format = "wav"
+ v.file_size_bytes = 1024
+ v.analysis_status = "done"
+ v.uploaded_by = None
+ v.uploaded_at = datetime.now(timezone.utc)
+ return v
+
+
+def test_audio_version_read_includes_waveform_peaks():
+ peaks = [float(i) / 500 for i in range(500)]
+ peaks_mini = [float(i) / 100 for i in range(100)]
+ v = _make_version(peaks=peaks, peaks_mini=peaks_mini)
+
+ schema = AudioVersionRead.model_validate(v)
+
+ assert schema.waveform_peaks is not None
+ assert len(schema.waveform_peaks) == 500
+ assert schema.waveform_peaks_mini is not None
+ assert len(schema.waveform_peaks_mini) == 100
+
+
+def test_audio_version_read_peaks_default_null():
+ v = _make_version(peaks=None, peaks_mini=None)
+
+ schema = AudioVersionRead.model_validate(v)
+
+ assert schema.waveform_peaks is None
+ assert schema.waveform_peaks_mini is None
+
+
+def test_audio_version_read_peaks_values_preserved():
+ peaks = [0.0, 0.5, 1.0]
+ v = _make_version(peaks=peaks, peaks_mini=[0.25, 0.75])
+
+ schema = AudioVersionRead.model_validate(v)
+
+ assert schema.waveform_peaks == [0.0, 0.5, 1.0]
+ assert schema.waveform_peaks_mini == [0.25, 0.75]
diff --git a/api/tests/unit/test_versions_list_peaks.py b/api/tests/unit/test_versions_list_peaks.py
new file mode 100644
index 0000000..3bbb02f
--- /dev/null
+++ b/api/tests/unit/test_versions_list_peaks.py
@@ -0,0 +1,38 @@
+"""Confirm that list_versions returns waveform_peaks inline (no extra request needed)."""
+
+import uuid
+from datetime import datetime, timezone
+from unittest.mock import MagicMock
+
+from rehearsalhub.db.models import AudioVersion
+from rehearsalhub.schemas.audio_version import AudioVersionRead
+
+
+def test_audio_version_read_includes_peaks_in_list_serialization():
+ """AudioVersionRead (used by list_versions) serializes waveform_peaks inline."""
+ peaks = [0.1, 0.5, 0.9]
+ mini = [0.3, 0.7]
+
+ v = MagicMock(spec=AudioVersion)
+ v.id = uuid.uuid4()
+ v.song_id = uuid.uuid4()
+ v.version_number = 1
+ v.label = None
+ v.nc_file_path = "/test/v1.wav"
+ v.nc_file_etag = "etag"
+ v.cdn_hls_base = None
+ v.waveform_url = None
+ v.waveform_peaks = peaks
+ v.waveform_peaks_mini = mini
+ v.duration_ms = 3000
+ v.format = "wav"
+ v.file_size_bytes = 512
+ v.analysis_status = "done"
+ v.uploaded_by = None
+ v.uploaded_at = datetime.now(timezone.utc)
+
+ schema = AudioVersionRead.model_validate(v)
+ serialized = schema.model_dump()
+
+ assert serialized["waveform_peaks"] == peaks
+ assert serialized["waveform_peaks_mini"] == mini
diff --git a/api/tests/unit/test_waveform_endpoint.py b/api/tests/unit/test_waveform_endpoint.py
new file mode 100644
index 0000000..f83ecb9
--- /dev/null
+++ b/api/tests/unit/test_waveform_endpoint.py
@@ -0,0 +1,120 @@
+"""Unit tests for GET /versions/{id}/waveform endpoint — reads peaks from DB."""
+
+import uuid
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from rehearsalhub.db.models import AudioVersion, Member, Song
+
+
+def _make_member() -> MagicMock:
+ m = MagicMock(spec=Member)
+ m.id = uuid.uuid4()
+ m.nc_url = "http://nc.test"
+ m.nc_username = "user"
+ m.nc_password = "pass"
+ return m
+
+
+def _make_version(peaks=None, peaks_mini=None, has_waveform_url=False) -> MagicMock:
+ v = MagicMock(spec=AudioVersion)
+ v.id = uuid.uuid4()
+ v.song_id = uuid.uuid4()
+ v.uploaded_by = None
+ v.waveform_url = "waveforms/test.json" if has_waveform_url else None
+ v.waveform_peaks = peaks
+ v.waveform_peaks_mini = peaks_mini
+ v.cdn_hls_base = None
+ v.nc_file_path = "/bands/test/v1.wav"
+ return v
+
+
+def _make_song(band_id: uuid.UUID) -> MagicMock:
+ s = MagicMock(spec=Song)
+ s.id = uuid.uuid4()
+ s.band_id = band_id
+ return s
+
+
+@pytest.mark.asyncio
+async def test_waveform_returns_full_peaks_from_db(mock_session):
+ """GET /versions/{id}/waveform returns 500-point peaks from DB column."""
+ from rehearsalhub.routers.versions import get_waveform
+
+ peaks = [float(i) / 500 for i in range(500)]
+ version = _make_version(peaks=peaks)
+ member = _make_member()
+ band_id = uuid.uuid4()
+ song = _make_song(band_id)
+
+ with (
+ patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
+ return_value=(version, song)),
+ ):
+ result = await get_waveform(version_id=version.id, session=mock_session, current_member=member)
+
+ assert result["data"] == peaks
+ assert result["length"] == 500
+ assert "mini" not in result
+
+
+@pytest.mark.asyncio
+async def test_waveform_returns_mini_peaks_with_resolution_param(mock_session):
+ """GET /versions/{id}/waveform?resolution=mini returns 100-point peaks."""
+ from rehearsalhub.routers.versions import get_waveform
+
+ peaks_mini = [float(i) / 100 for i in range(100)]
+ version = _make_version(peaks=[0.5] * 500, peaks_mini=peaks_mini)
+ member = _make_member()
+ band_id = uuid.uuid4()
+ song = _make_song(band_id)
+
+ with (
+ patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
+ return_value=(version, song)),
+ ):
+ result = await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
+
+ assert result["data"] == peaks_mini
+ assert result["length"] == 100
+
+
+@pytest.mark.asyncio
+async def test_waveform_404_when_no_peaks_in_db(mock_session):
+ """GET /versions/{id}/waveform returns 404 when no peaks stored yet."""
+ from fastapi import HTTPException
+ from rehearsalhub.routers.versions import get_waveform
+
+ version = _make_version(peaks=None, peaks_mini=None)
+ member = _make_member()
+ song = _make_song(uuid.uuid4())
+
+ with (
+ patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
+ return_value=(version, song)),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await get_waveform(version_id=version.id, session=mock_session, current_member=member)
+
+ assert exc_info.value.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_waveform_mini_404_when_no_mini_peaks(mock_session):
+ """GET /versions/{id}/waveform?resolution=mini returns 404 when no mini peaks stored."""
+ from fastapi import HTTPException
+ from rehearsalhub.routers.versions import get_waveform
+
+ version = _make_version(peaks=[0.5] * 500, peaks_mini=None)
+ member = _make_member()
+ song = _make_song(uuid.uuid4())
+
+ with (
+ patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
+ return_value=(version, song)),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
+
+ assert exc_info.value.status_code == 404
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f9190b9..1d4c78e 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -25,6 +25,7 @@ services:
build:
context: ./api
target: development
+ command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
diff --git a/web/src/components/MiniWaveform.test.tsx b/web/src/components/MiniWaveform.test.tsx
new file mode 100644
index 0000000..2ebaaf0
--- /dev/null
+++ b/web/src/components/MiniWaveform.test.tsx
@@ -0,0 +1,51 @@
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import { MiniWaveform } from "./MiniWaveform";
+
+describe("MiniWaveform", () => {
+ it("renders an SVG element", () => {
+ const peaks = Array.from({ length: 100 }, (_, i) => i / 100);
+ const { container } = render(