# 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