Aller au contenu

Audio and MinIO Storage

Version française
Back to index


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

Next steps