Aller au contenu

Audio et stockage MinIO

English version
Retour au sommaire


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

Étapes suivantes