fr

Transcription batch d'une archive radio avec Mistral Voxtral

Conception et déploiement d'un système de transcription asynchrone pour 300 émissions MP3 : Mistral Batch API, Voxtral Mini 2602, diarisation, stockage JSON + Typesense.

Une archive de 300 émissions radio numérisées, sans aucun texte indexable. L’objectif : transcrire l’ensemble avec diarisation et timestamps, sans bloquer le serveur, sans casser le budget API. Voici la conception complète et les pièges rencontrés.


1. Le problème

Le site couble.eu expose une collection d’émissions radio RCF animées par Claude Carrez, de 2000 à aujourd’hui. Les métadonnées (titre, date, durée estimée) sont indexées dans Typesense. Les fichiers audio sont servis statiquement.

Mais le contenu parlé est opaque : impossible de chercher “Aragon” ou “Cannes 2000” dans les émissions. La transcription automatique résout ça — à condition de traiter ~300 fichiers MP3 sans écrire un script fragilisé par les timeouts.


2. Conception : pourquoi le mode batch ?

Appeler un modèle de transcription de façon synchrone sur 300 fichiers a plusieurs défauts :

  • Timeout HTTP : une émission de 50 minutes prend plusieurs dizaines de secondes à transcrire. Les proxies et load balancers coupent généralement à 30-60s.
  • Gestion des erreurs : si le script plante à l’émission 150, on perd l’état.
  • Coût des retries : relancer toute une session coûte cher et duplique les appels.

Le mode batch de Mistral (doc officielle) résout les trois : on soumet un fichier JSONL avec toutes les requêtes, Mistral traite en arrière-plan, et on récupère les résultats quand le job est terminé. La facturation est identique au mode synchrone.


3. Voxtral Mini 2602

Le modèle retenu est voxtral-mini-2602 (fiche modèle).

Quelques points à connaître :

ParamètreValeur
Langues supportées13 dont FR, EN, ES, DE, AR…
Durée max par requête3 heures
Alias latestDisponible en synchrone, non supporté en batch
TimestampsPar segment ou par mot (incompatible avec language)
Diarisationdiarize: true
Context biasJusqu’à 100 termes, améliore la précision sur noms propres

Attention : l’alias voxtral-mini-latest fonctionne en appel synchrone (POST /v1/audio/transcriptions) mais est rejeté en batch avec une erreur invalid_model. Utiliser impérativement l’identifiant versionné voxtral-mini-2602.

Configuration utilisée :

{
  "model": "voxtral-mini-2602",
  "file_url": "https://couble.eu/static/radio-claude/2000/MEDIAGO%2020000324_20h45_Pierre%20PERRAULT.MP3",
  "diarize": true,
  "timestamp_granularities": ["segment"],
  "context_bias": ["Claude Carrez", "RCF", "foi", "Église", "Cannes", "..."]
}

Le context_bias est construit dynamiquement à partir des termes fixes du contexte RCF + les tokens du titre de l’émission. Cela améliore la reconnaissance des noms propres peu courants dans les corpus d’entraînement.


4. Architecture

Filesystem MP3


readRadioFilesFromFS()    ← liste les fichiers, skip ceux déjà transcrits


buildBatchJsonl()         ← construit le JSONL (1 ligne = 1 requête)


POST /v1/files            ← upload du JSONL (purpose: "batch")


POST /v1/batch/jobs       ← création du job
      │   (traitement asynchrone Mistral, quelques minutes)

GET /v1/batch/jobs/{id}   ← polling manuel via route API

      ├─▶ GET /v1/files/{output_file}/content   ← résultats OK
      └─▶ GET /v1/files/{error_file}/content    ← erreurs


     saveTranscriptFile()   ← JSON sur disque
     documents(id).update() ← champs Typesense (transcript, transcript_status)

Double stockage : les résultats sont sauvegardés à deux endroits.

  • JSON sur le filesystem (radio-claude/2000/MEDIAGO 20000324_20h45_Pierre PERRAULT.json) : persistant, contient le texte complet + tous les segments avec timestamps et speakers. Sert aussi de marqueur “déjà transcrit” pour les batchs suivants.
  • Champs Typesense (transcript, transcript_status) : permet la recherche full-text dans le contenu des émissions.

Les segments détaillés (timestamps, numéros de speaker) ne sont pas indexés dans Typesense — trop verbeux pour l’index. Ils restent dans le JSON.

Persistance des job IDs : chaque job créé est enregistré dans batch-jobs.jsonl à la racine du répertoire radio. Sans ça, un job lancé la veille est introuvable si on perd l’onglet HTTP.

{
  "jobId": "d36d457f-...",
  "createdAt": "2026-03-25T...",
  "fileCount": 100,
  "status": "pending"
}

5. Les 3 endpoints

Tous protégés par le header Authorization: <token>, calqués sur le pattern existant GET /api/radios/index-cron.

GET /api/radios/transcribe-start

Lance un batch job Mistral.

ParamètreTypeDescription
dryRunbooleanConstruit le JSONL sans uploader
limitnumberLimite le nombre de fichiers (test)

Le dryRun=true est indispensable avant le premier vrai lancement : il permet de vérifier que les URLs sont correctement construites et encodées.

Réponse :

{ "status": "started", "jobId": "8af08eae-...", "fileCount": 287 }

GET /api/radios/transcribe-poll?jobId=xxx

Récupère et importe les résultats d’un job terminé. À appeler manuellement, quelques minutes à quelques heures après le lancement selon le volume.

  • Si le job est encore en cours : retourne { "status": "RUNNING" }
  • Si terminé : télécharge les résultats, écrit les JSON sur disque, met à jour Typesense

Réponse finale :

{ "status": "done", "jobId": "...", "processed": 285, "failed": 2 }

GET /api/radios/transcribe-jobs

Liste tous les jobs enregistrés dans batch-jobs.jsonl. Permet de retrouver un jobId sans creuser les logs.

[
  {
    "jobId": "8af08eae-...",
    "createdAt": "2026-03-25T...",
    "fileCount": 100,
    "status": "done"
  },
  {
    "jobId": "d36d457f-...",
    "createdAt": "2026-03-25T...",
    "fileCount": 287,
    "status": "pending"
  }
]

6. Pièges rencontrés

6.1 Format du JSONL batch audio

La documentation batch générale de Mistral montre un format messages (chat). Pour l’audio, le format est différent :

{ "custom_id": "mon-id", "body": { "model": "voxtral-mini-2602", "file_url": "https://...", "diarize": true, ... } }

Le champ s’appelle file_url, pas file. Envoyer file produit une erreur 422 avec 50 lignes de validation Pydantic listant tous les types de requêtes incompatibles — difficile à lire.

6.2 voxtral-mini-latest invalide en batch

L’alias fonctionne en synchrone, pas en batch. Source : Mistral models docs. Utiliser voxtral-mini-2602.

6.3 Encodage des URLs

Les noms de fichiers contiennent des espaces, accents et apostrophes. Mistral construit l’URL depuis file_url sans ré-encodage. Il faut encoder chaque segment du chemin :

f.path.split("/").map(encodeURIComponent).join("/");

encodeURIComponent encode les espaces (%20), accents (%C3%A9) mais pas les tirets (-), underscores (_) ni apostrophes ('). Ces derniers passent sans problème dans les URLs.

6.4 Content-Disposition: attachment bloque Mistral

Le serveur Apache renvoyait Content-Disposition: attachment pour tous les fichiers statiques, y compris les MP3. Le fetcher de Mistral ne sait pas gérer cette disposition et échoue silencieusement avec File could not be fetched (code 3310).

Correction Apache :

<FilesMatch "\.(mp3|MP3)$">
    Header unset Content-disposition
</FilesMatch>

6.5 Upsert Typesense sans les champs obligatoires

L’action upsert de Typesense requiert tous les champs définis dans le schéma, y compris le champ de tri par défaut (date). Pour une mise à jour partielle, utiliser update sur le document :

// ❌ upsert requiert date, title, year...
await indexer.collections("radioClaude").documents().upsert({ id, transcript });

// ✅ update partiel
await indexer
  .collections("radioClaude")
  .documents(id)
  .update({ transcript, transcript_status: "done" });

6.6 Permissions disque

Le répertoire radio appartient à un autre utilisateur (rapha:shared_rapha). Le process API (yco) peut lire les fichiers mais pas écrire les JSON de transcription.

Solution : configurer radioTranscriptDir dans config/production.json pour pointer vers un répertoire appartenant à yco, séparé du répertoire source.


7. Guide pratique

Lancement initial

### 1. Vérifier les fichiers sans transcript (dry run)
GET https://couble.eu/api/radios/transcribe-start?dryRun=true
Authorization: <token>

### 2. Lancer sur 1 fichier pour tester
GET https://couble.eu/api/radios/transcribe-start?limit=1
Authorization: <token>

### 3. Attendre 2-5 minutes, puis poller
GET https://couble.eu/api/radios/transcribe-poll?jobId=<id-retourné>
Authorization: <token>

### 4. Vérifier le JSON sur disque
# ls /home/yco/couble-eu/radio-claude/2000/*.json

Batch complet

### Lancer sur tous les fichiers non transcrits
GET https://couble.eu/api/radios/transcribe-start
Authorization: <token>

### Attendre (variable selon volume : 5min pour 10 fichiers, 2h pour 300)
### Poller jusqu'à status != RUNNING
GET https://couble.eu/api/radios/transcribe-poll?jobId=<id>
Authorization: <token>

Retrouver un job ID perdu

GET https://couble.eu/api/radios/transcribe-jobs
Authorization: <token>

Relancer après erreurs partielles

Les fichiers en erreur (pas de JSON sur disque) sont automatiquement inclus dans le prochain transcribe-start. Pas besoin d’intervention manuelle.

Vérifier qu’un fichier est bien dans Typesense

POST https://couble.eu/search/collections/radioClaude/documents/search
Content-Type: application/json

{ "q": "Aragon", "query_by": "transcript,title", "per_page": 5 }

8. Structure des fichiers produits

Chaque transcription crée un fichier JSON à côté du MP3 :

{
  "text": "Claude Carrez : Bonsoir, ce soir nous recevons...",
  "language": "fr",
  "segments": [
    {
      "id": 0,
      "start": 0.0,
      "end": 4.2,
      "text": "Bonsoir, ce soir nous recevons",
      "speaker": 0
    },
    {
      "id": 1,
      "start": 4.2,
      "end": 8.1,
      "text": "notre invité qui va nous parler de...",
      "speaker": 1
    }
  ],
  "transcribed_at": "2026-03-25T08:30:00.000Z"
}

La diarisation retourne des speaker entiers (0, 1, 2…) sans nom associé. L’identification “Claude Carrez = speaker 0” demande une passe manuelle ou une étape de clustering supplémentaire.


Pour aller plus loin

  • Ajouter un webhook Mistral pour déclencher automatiquement le poll à la fin du job (non disponible à ce jour dans l’API batch).
  • Identifier automatiquement les speakers avec un modèle de classification par embeddings audio.
  • Exposer les segments dans le frontend pour une navigation temporelle dans la transcription.