Setup Sounds
Integrate LotsOfSounds audio into your video editor via a server-side proxy
RVE has a built-in LotsOfSounds audio adaptor. It uses a proxy pattern. Your server holds the API key. The editor calls your local routes and never calls the LotsOfSounds API directly.
Why a proxy?
The editor runs in the browser. If you called the LotsOfSounds API directly from the client, your API key would be exposed in network requests for anyone to steal. By routing through your own server, the key stays secret and never leaves your backend.
How it works
Editor UI calls /api/sounds/search?q=..., which calls your server and api.lotsofsounds.com
Editor UI calls /api/sounds/stream/[id], which calls your server and api.lotsofsounds.comIf you do not set adaptors.audio, the editor uses the built-in LotsOfSounds adaptor. You only add two server-side proxy routes.
Step 1: Get a LotsOfSounds API key
Sign up at lotsofsounds.com and get an API key. Add it to your environment:
NEXT_LOTSOFSOUNDS_API_KEY=your_api_key_hereStep 2: Create the search proxy route
Create app/api/sounds/search/route.ts:
import { NextRequest, NextResponse } from 'next/server';
const LOTSOFSOUNDS_API_URL = 'https://api.lotsofsounds.com';
export async function GET(request: NextRequest) {
const apiKey = process.env.NEXT_LOTSOFSOUNDS_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'API key not configured' }, { status: 500 });
}
const { searchParams } = new URL(request.url);
const params = new URLSearchParams();
const q = searchParams.get('q');
if (q) params.set('q', q);
params.set('limit', searchParams.get('limit') || '20');
params.set('page', searchParams.get('page') || '1');
const response = await fetch(
`${LOTSOFSOUNDS_API_URL}/api/v1/sounds?${params.toString()}`,
{ headers: { 'x-api-key': apiKey } }
);
if (!response.ok) {
return NextResponse.json(
{ error: `API error: ${response.status}` },
{ status: response.status }
);
}
return NextResponse.json(await response.json());
}Step 3: Create the stream proxy route
Create app/api/sounds/stream/[id]/route.ts. The route forwards the browser Range header to the audio file. The browser uses range requests to seek in a sound, so the route must return the upstream status (200, 206, or 416) and the range headers.
import { NextRequest, NextResponse } from 'next/server';
const LOTSOFSOUNDS_API_URL = 'https://api.lotsofsounds.com';
/** The upstream headers that the client needs for range requests. */
const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const apiKey = process.env.NEXT_LOTSOFSOUNDS_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'API key not configured' }, { status: 500 });
}
const { id } = await params;
if (!id) {
return NextResponse.json({ error: 'Sound ID is required' }, { status: 400 });
}
// 1. Get a signed download URL. The API key stays on the server.
const downloadResponse = await fetch(
`${LOTSOFSOUNDS_API_URL}/api/v1/sounds/${encodeURIComponent(id)}/download`,
{ headers: { 'x-api-key': apiKey } }
);
if (!downloadResponse.ok) {
return NextResponse.json(
{ error: `API error: ${downloadResponse.status}` },
{ status: downloadResponse.status }
);
}
const data = await downloadResponse.json();
const signedUrl = data?.data?.download_url;
if (!signedUrl) {
return NextResponse.json({ error: 'No download URL returned' }, { status: 502 });
}
// 2. Fetch the audio with the Range header from the browser.
const range = request.headers.get('range');
const audioResponse = await fetch(signedUrl, range ? { headers: { Range: range } } : undefined);
if (![200, 206, 416].includes(audioResponse.status)) {
return NextResponse.json({ error: 'Failed to fetch audio file' }, { status: 502 });
}
// 3. A 416 has no audio, so only the Content-Range header goes back.
if (audioResponse.status === 416) {
const contentRange = audioResponse.headers.get('content-range');
return new NextResponse(null, {
status: 416,
headers: contentRange ? { 'Content-Range': contentRange } : undefined,
});
}
// 4. Return the upstream status (200 or 206) and the range headers.
const headers = new Headers({ 'Cache-Control': 'public, max-age=3600' });
for (const name of PASSTHROUGH_HEADERS) {
const value = audioResponse.headers.get(name);
if (value) headers.set(name, value);
}
return new NextResponse(audioResponse.body, { status: audioResponse.status, headers });
}Forward the Range header
If the route always returns 200 with the full file, the browser cannot seek in the sound. Then a sound can start late or play from the wrong position.
Step 4: Open the Sound panel
If you do not set adaptors.audio, the editor uses the built-in LotsOfSounds adaptor. The Sound panel shows in the editor sidebar with search and pagination.
The built-in LotsOfSounds adaptor is not a public export. If you set adaptors.audio, your array replaces the built-in adaptor. To keep LotsOfSounds and add other sources, write an adaptor for each source in your app.
Custom sound adaptors
A custom adaptor implements the SoundAdaptor type. search returns the audio items for one page of results. getAudioUrl returns the URL that the editor plays.
import { ReactVideoEditor } from '@reactvideoeditor/react-video-editor/react-video-editor';
import type { SoundAdaptor } from '@reactvideoeditor/react-video-editor/types';
const mySoundAdaptor: SoundAdaptor = {
name: 'my-sounds',
displayName: 'My Sounds',
description: 'Sound effects from my library',
requiresAuth: false,
search: async ({ query = '', page = 1, perPage = 20 }) => {
const params = new URLSearchParams({ q: query, page: String(page), limit: String(perPage) });
const response = await fetch(`/api/my-sounds?${params}`);
const result = await response.json();
return {
items: result.sounds.map((sound) => ({
id: sound.id,
title: sound.name,
artist: 'My Library',
duration: sound.durationSeconds, // seconds
file: `/api/my-sounds/stream/${sound.id}`,
// Optional. Use null for a value that your source does not measure.
timing: { onsetMs: sound.onsetMs, peakMs: sound.peakMs, tailMs: sound.tailMs },
loudness: { integratedLufs: sound.lufs, truePeakDb: sound.truePeakDb },
})),
totalCount: result.total,
hasMore: page * perPage < result.total,
};
},
getAudioUrl: (audio) => audio.file,
};
<ReactVideoEditor
projectId="my-project"
adaptors={{ audio: [mySoundAdaptor] }}
/>Each audio item has these fields:
| Field | Type | Description |
|---|---|---|
id* | string | A unique id for the sound |
title* | string | The name in the Sound panel |
artist* | string | The author or the source |
duration* | number | The length of the sound, in seconds |
file* | string | The URL of the audio file |
thumbnail | string | A waveform or cover image URL |
attribution | { author?, source?, license?, url? } | Licence and credit data |
timing | { onsetMs, peakMs, tailMs } | null | Time points in milliseconds from the start of the file. onsetMs is the time where the sound becomes audible. peakMs is the loudest point (the "hit"). tailMs is the time where the sound becomes silent again. Each value can be null. |
loudness | { integratedLufs, truePeakDb } | null | The integrated loudness in LUFS and the true peak in dBTP. Each value can be null. |
The built-in LotsOfSounds adaptor fills timing and loudness from the LotsOfSounds API.
Sounds load before they start
The editor mounts each sound 30 frames before its start frame. Thus a short sound loads in time and does not play late. If your audio route is slow, make sure that it supports range requests and caching.