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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user