Audio and MinIO Storage
General architecture
Speaker (browser)
│
└─ POST /api/audio/upload
│
├─ Auth (speaker code → speakers table)
├─ FFmpeg (conversion → mono MP3 96kbps)
└─ MinIO PUT (bucket: audios)
│
└─ UPDATE items SET audio_url = [public_url]
Player (browser)
│
├─ Production: GET [AUDIO_CDN_URL]/[path].mp3
│ (direct MinIO access — zero Next.js server)
│
└─ Development: GET /api/audio/serve/[path].mp3
(Next.js proxy → MinIO)
MinIO bucket
| Parameter | Value |
|---|---|
| Name | audios (configurable via MINIO_BUCKET) |
| Access policy | public-read (public read without authentication) |
| Versioning | Enabled (each re-recording creates a new version) |
Why versioning? - Allows rolling back to a previous version if a new recording is poor quality - Prevents irreversible deletion of old files
Key structure in the bucket
Audio files are stored following this convention:
audios/
└── [language_code]/
└── [item_uuid]/
└── audio.mp3
Example:
audios/pul/3f8a9b2c-1234-5678-abcd-ef0123456789/audio.mp3
The full public URL (stored in items.audio_url):
https://[MINIO_PUBLIC_URL]/audios/pul/[item_uuid]/audio.mp3
Upload flow (speaker → MinIO)
Endpoint: POST /api/audio/upload
Auth: speaker code in the header or session cookie
Server-side steps
// src/app/api/audio/upload/route.ts
// 1. Speaker authentication check
const speaker = await getSpeakerFromRequest(request);
// 2. Receive the file (multipart FormData)
const formData = await request.formData();
const file = formData.get('audio') as File;
const itemId = formData.get('itemId') as string;
// 3. Validation
if (file.size > 50 * 1024 * 1024) throw new Error('File too large');
// 4. FFmpeg processing
const processedBuffer = await convertAudioToMp3(file.buffer);
// - Conversion to MP3
// - 96 kbps compression
// - Mono conversion
// - Volume normalization
// 5. Upload to MinIO
const key = `${speaker.languageCode}/${itemId}/audio.mp3`;
await minioClient.putObject(BUCKET, key, processedBuffer, {
'Content-Type': 'audio/mpeg'
});
// 6. Database update
const audioUrl = `${MINIO_PUBLIC_URL}/${BUCKET}/${key}`;
await pool.query(
'UPDATE items SET audio_url = $1 WHERE id = $2',
[audioUrl, itemId]
);
FFmpeg parameters
// src/lib/audio-processing.ts
ffmpeg(inputStream)
.audioCodec('libmp3lame') // MP3 codec
.audioBitrate(96) // 96 kbps — optimal for voice
.audioChannels(1) // Mono (halves file size)
.audioFrequency(44100) // 44.1 kHz sample rate
.audioFilters('loudnorm') // EBU R128 volume normalization
.format('mp3')
Audio URL generation (client)
File: src/lib/audio-url.ts
export function getAudioUrl(audioUrl: string): string {
// In production: AUDIO_CDN_URL is injected into window.__RACINES_CDN__
const cdnBase = typeof window !== 'undefined'
? window.__RACINES_CDN__
: process.env.AUDIO_CDN_URL;
if (!cdnBase) {
// Fallback: use the Next.js proxy
return `/api/audio/serve/${extractPath(audioUrl)}`;
}
// Replace the raw MinIO URL with the CDN URL
return audioUrl.replace(MINIO_PUBLIC_URL, cdnBase);
}
window.__RACINES_CDN__ is injected by RootLayout:
// src/app/layout.tsx
<script
dangerouslySetInnerHTML={{
__html: `window.__RACINES_CDN__=${JSON.stringify(process.env.AUDIO_CDN_URL || '')}`
}}
/>
Fallback proxy (/api/audio/serve/)
Used in development or when the CDN is unavailable.
Endpoint: GET /api/audio/serve/[[...path]]
// src/app/api/audio/serve/[[...path]]/route.ts
// Path validation (path traversal protection)
if (path.includes('..') || path.startsWith('/')) {
return new Response('Invalid path', { status: 400 });
}
// Fetch from MinIO
const stream = await minioClient.getObject(BUCKET, path);
return new Response(stream, {
headers: { 'Content-Type': 'audio/mpeg' }
});
Audio cache (Service Worker)
The Service Worker caches audio using the Cache First strategy:
// public/sw.js
const AUDIO_CACHE = 'racines-audio-cache-v3';
// ⚠️ ignoreVary: true — the CDN sends Vary: Accept-Encoding
// which breaks cache.match() without this option
const cachedResponse = await cache.match(request.url, { ignoreVary: true });
if (cachedResponse) return cachedResponse;
// Otherwise, fetch and cache
const response = await fetch(request);
if (response.ok) {
await cache.put(request.url, response.clone());
}
return response;
Cache name: racines-audio-cache-v3
Increment the number if you change the caching strategy.
MinIO CORS
MinIO must be configured to allow cross-origin requests from the application:
{
"CORSRules": [
{
"AllowedOrigins": ["https://[APP_DOMAIN]"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
}
Via mc (MinIO Client):
mc admin config set local/ cors cors_rules='[{"AllowedOrigins":["*"],"AllowedMethods":["GET","HEAD"]}]'
Diagnostics
Verify that audio is accessible
# Direct CDN test
curl -I https://[AUDIO_CDN_URL]/[language_code]/[item_uuid]/audio.mp3
# Expected response: HTTP 200, Content-Type: audio/mpeg
# CORS test (simulate a browser request)
curl -I -H "Origin: https://[APP_DOMAIN]" \
https://[AUDIO_CDN_URL]/[language_code]/[item_uuid]/audio.mp3
# Check header: Access-Control-Allow-Origin
Common issues
| Symptom | Likely cause | Solution |
|---|---|---|
| Silent audio in production | AUDIO_CDN_URL is empty |
Set the environment variable |
| CORS error in the console | MinIO CORS not configured | Configure MinIO CORS rules |
| 403 error on files | Bucket not in public-read | Set public policy on the bucket |
| 🔊 button absent | audio_url NULL in database |
The speaker must record the item |
| Old audio still served | Service Worker or CDN cache | Clear the cache or wait for expiration |