Audio et stockage MinIO
Architecture générale
Locuteur (browser)
│
└─ POST /api/audio/upload
│
├─ Auth (code speaker → table speakers)
├─ FFmpeg (conversion → mono MP3 96kbps)
└─ MinIO PUT (bucket: audios)
│
└─ UPDATE items SET audio_url = [url_publique]
Joueur (browser)
│
├─ Production : GET [AUDIO_CDN_URL]/[chemin].mp3
│ (accès direct MinIO — zéro serveur Next.js)
│
└─ Développement : GET /api/audio/serve/[chemin].mp3
(proxy Next.js → MinIO)
Bucket MinIO
| Paramètre | Valeur |
|---|---|
| Nom | audios (configurable via MINIO_BUCKET) |
| Politique d'accès | public-read (lecture publique sans authentification) |
| Versioning | Activé (chaque ré-enregistrement crée une nouvelle version) |
Pourquoi le versioning ? - Permet de revenir à une version précédente si un nouvel enregistrement est de mauvaise qualité - Évite la suppression irréversible des anciens fichiers
Structure des clés dans le bucket
Les fichiers audio sont stockés selon la convention :
audios/
└── [code_langue]/
└── [uuid_item]/
└── audio.mp3
Exemple :
audios/pul/3f8a9b2c-1234-5678-abcd-ef0123456789/audio.mp3
L'URL publique complète (stockée dans items.audio_url) :
https://[MINIO_PUBLIC_URL]/audios/pul/[uuid_item]/audio.mp3
Flux d'upload (locuteur → MinIO)
Endpoint : POST /api/audio/upload
Auth : code speaker dans le header ou cookie de session
Étapes côté serveur
// src/app/api/audio/upload/route.ts
// 1. Vérification de l'authentification speaker
const speaker = await getSpeakerFromRequest(request);
// 2. Réception du fichier (FormData multipart)
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('Fichier trop volumineux');
// 4. Traitement FFmpeg
const processedBuffer = await convertAudioToMp3(file.buffer);
// - Conversion vers MP3
// - Compression 96 kbps
// - Conversion en mono
// - Normalisation du volume
// 5. Upload vers MinIO
const key = `${speaker.languageCode}/${itemId}/audio.mp3`;
await minioClient.putObject(BUCKET, key, processedBuffer, {
'Content-Type': 'audio/mpeg'
});
// 6. Mise à jour de la base de données
const audioUrl = `${MINIO_PUBLIC_URL}/${BUCKET}/${key}`;
await pool.query(
'UPDATE items SET audio_url = $1 WHERE id = $2',
[audioUrl, itemId]
);
Paramètres FFmpeg
// src/lib/audio-processing.ts
ffmpeg(inputStream)
.audioCodec('libmp3lame') // Codec MP3
.audioBitrate(96) // 96 kbps — optimal pour la voix
.audioChannels(1) // Mono (réduction taille × 2)
.audioFrequency(44100) // 44.1 kHz sample rate
.audioFilters('loudnorm') // Normalisation du volume EBU R128
.format('mp3')
Génération des URLs audio (client)
Fichier : src/lib/audio-url.ts
export function getAudioUrl(audioUrl: string): string {
// En production : AUDIO_CDN_URL est injecté dans window.__RACINES_CDN__
const cdnBase = typeof window !== 'undefined'
? window.__RACINES_CDN__
: process.env.AUDIO_CDN_URL;
if (!cdnBase) {
// Fallback : utiliser le proxy Next.js
return `/api/audio/serve/${extractPath(audioUrl)}`;
}
// Remplacer l'URL MinIO brute par l'URL CDN
return audioUrl.replace(MINIO_PUBLIC_URL, cdnBase);
}
window.__RACINES_CDN__ est injecté par RootLayout :
// src/app/layout.tsx
<script
dangerouslySetInnerHTML={{
__html: `window.__RACINES_CDN__=${JSON.stringify(process.env.AUDIO_CDN_URL || '')}`
}}
/>
Proxy fallback (/api/audio/serve/)
Utilisé en développement ou quand le CDN est indisponible.
Endpoint : GET /api/audio/serve/[[...path]]
// src/app/api/audio/serve/[[...path]]/route.ts
// Validation du chemin (protection path traversal)
if (path.includes('..') || path.startsWith('/')) {
return new Response('Chemin invalide', { status: 400 });
}
// Récupération depuis MinIO
const stream = await minioClient.getObject(BUCKET, path);
return new Response(stream, {
headers: { 'Content-Type': 'audio/mpeg' }
});
Cache audio (Service Worker)
Le Service Worker met les audios en cache avec la stratégie Cache First :
// public/sw.js
const AUDIO_CACHE = 'racines-audio-cache-v3';
// ⚠️ ignoreVary: true — le CDN envoie Vary: Accept-Encoding
// qui casse cache.match() sans cette option
const cachedResponse = await cache.match(request.url, { ignoreVary: true });
if (cachedResponse) return cachedResponse;
// Sinon, fetch et mise en cache
const response = await fetch(request);
if (response.ok) {
await cache.put(request.url, response.clone());
}
return response;
Nom du cache : racines-audio-cache-v3
Incrémentez le numéro si vous modifiez la stratégie de cache.
CORS MinIO
MinIO doit être configuré pour autoriser les requêtes cross-origin depuis l'application :
{
"CORSRules": [
{
"AllowedOrigins": ["https://[DOMAINE_APP]"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
}
Via mc (MinIO Client) :
mc admin config set local/ cors cors_rules='[{"AllowedOrigins":["*"],"AllowedMethods":["GET","HEAD"]}]'
Diagnostics
Vérifier que les audios sont accessibles
# Test direct CDN
curl -I https://[AUDIO_CDN_URL]/[code_langue]/[uuid_item]/audio.mp3
# Réponse attendue : HTTP 200, Content-Type: audio/mpeg
# Test CORS (simuler une requête depuis le navigateur)
curl -I -H "Origin: https://[DOMAINE_APP]" \
https://[AUDIO_CDN_URL]/[code_langue]/[uuid_item]/audio.mp3
# Vérifier le header: Access-Control-Allow-Origin
Problèmes courants
| Symptôme | Cause probable | Solution |
|---|---|---|
| Audios silencieux en production | AUDIO_CDN_URL vide |
Définir la variable d'environnement |
| Erreur CORS dans la console | CORS MinIO non configuré | Configurer les règles CORS MinIO |
| Erreur 403 sur les fichiers | Bucket pas en public-read | Définir la politique public sur le bucket |
| Bouton 🔊 absent | audio_url NULL en base |
Le locuteur doit enregistrer l'item |
| Ancien audio toujours servi | Cache Service Worker ou CDN | Vider le cache ou attendre l'expiration |