Tout est parti d'un défi simple en apparence : sous-titrer automatiquement 43 heures de vidéo japonaise vers le français. Le faire à la main aurait pris des mois. J'ai donc construit un pipeline Python qui fait tout automatiquement, de la vidéo brute au fichier SRT prêt à l'emploi.
Le résultat : un outil générique et open-source qui fonctionne avec n'importe quelle vidéo — URL YouTube ou fichier local — dans n'importe quelle paire de langues. Coût total pour 43 heures de vidéo : environ 20€.
Cet article est à la fois un retour d'expérience (ce qui a marché, ce qui a échoué) et un guide technique pour utiliser et comprendre le pipeline.
Le problème : aucun outil ne fait tout bien
Avant de construire quoi que ce soit, j'ai testé les solutions existantes. Le constat est toujours le même :
- Whisper (via Groq) : transcription excellente avec des timestamps précis au mot près (±10ms). Mais aucune information sur qui parle — quand deux personnages dialoguent, tout est mélangé dans un bloc.
- AssemblyAI : très bon pour la diarisation (identifier les changements de locuteur). Mais les timestamps sont moins chirurgicaux, et la transcription japonaise est parfois approximative.
L'idée clé : utiliser les deux en parallèle et fusionner leurs forces. Whisper fournit le métronome (timing précis), AssemblyAI fournit le chef d'orchestre (qui parle quand).
L'architecture du pipeline
Voici le parcours complet d'une vidéo, de l'entrée à la sortie :
- Récupération vidéo — URL YouTube (via yt-dlp) ou fichier local
- Extraction audio — ffmpeg → MP3 mono 96kbps 16kHz
- Transcription Whisper — via Groq, timestamps mot-par-mot
- Transcription AssemblyAI — diarisation des locuteurs
- Segmentation hybride — fusion intelligente des deux sources
- Traduction par lots — Claude Sonnet, 40 segments par appel
- Génération SRT — fichier de sous-titres standard
- Incrustation (optionnel) — ffmpeg embarque les sous-titres dans la vidéo
Étape 1-2 : de la vidéo à l'audio
Le script accepte deux modes d'entrée. Pour une URL YouTube, yt-dlp télécharge la vidéo automatiquement. Pour un fichier local, on passe directement à l'extraction audio :
# Depuis une URL YouTube
python3 subtitle.py --url "https://youtube.com/watch?v=xxx" --source ja --target fr
# Depuis un fichier local
python3 subtitle.py --file video.mp4 --source en --target es
# Avec sous-titres embarqués dans la vidéo
python3 subtitle.py --file video.mp4 --source ja --target fr --output ./subs/ --embed
L'extraction audio est volontairement agressive en compression : mono, 16kHz, 96kbps. Un épisode de 23 minutes pèse environ 4 Mo en audio — suffisant pour la reconnaissance vocale, et bien plus rapide à uploader vers les APIs.
def extract_audio(video_path, audio_path):
subprocess.run([
"ffmpeg", "-i", video_path, "-vn", "-ac", "1", "-ab", "96k",
"-ar", "16000", "-f", "mp3", audio_path, "-y", "-loglevel", "error",
], check=True)
Étape 3 : Whisper via Groq — le timing précis
L'appel à Groq utilise Whisper large-v3 avec le format verbose_json et la granularité word. C'est ce qui donne les timestamps mot-par-mot :
resp = requests.post(
"https://api.groq.com/openai/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {groq_key}"},
files={"file": ("audio.mp3", f, "audio/mpeg")},
data={
"model": "whisper-large-v3",
"response_format": "verbose_json",
"language": source_lang, # "ja", "en", "fr"...
"temperature": 0.0,
"timestamp_granularities[]": "word",
},
timeout=300,
)
Le résultat contient un tableau words où chaque mot a un start et un end en secondes. C'est la colonne vertébrale de la segmentation.
Piège rencontré : Groq applique un rate limiting (erreur 429). Le script gère ça avec un retry après 60 secondes — suffisant pour traiter des dizaines de vidéos en batch sans intervention.
Étape 4 : AssemblyAI — qui parle quand
AssemblyAI est appelé avec speaker_labels: true pour obtenir la diarisation. L'audio est d'abord uploadé, puis la transcription est lancée en asynchrone :
resp = requests.post(
"https://api.assemblyai.com/v2/transcript",
headers={"authorization": aai_key},
json={
"audio_url": upload_url,
"language_code": source_lang,
"speaker_labels": True,
"speech_models": ["universal-3-pro", "universal-2"],
},
)
Le résultat contient des utterances, chacune avec un identifiant de locuteur (speaker: "A", "B"...). On n'a pas besoin de la transcription elle-même — on veut juste savoir à quel moment le locuteur change.
Étape 5 : la segmentation hybride — le cœur du système
C'est l'étape qui fait toute la différence. On part des mots Whisper (timing fin) et on décide où couper en combinant plusieurs signaux :
# Paramètres de segmentation
MIN_DISPLAY_DURATION = 1.2 # secondes minimum à l'écran
MAX_SEGMENT_DURATION = 5.0 # secondes maximum
MAX_WORDS_PER_SEGMENT = 10 # mots maximum par sous-titre
PAUSE_THRESHOLD = 0.3 # pause entre mots → nouveau segment
SPEAKER_CHANGE_PAUSE = 0.05 # pause minimale pour couper au changement de locuteur
Pour chaque mot Whisper, on vérifie quatre conditions de coupe :
should_cut = False
if pause > PAUSE_THRESHOLD: # Pause naturelle dans la parole
should_cut = True
elif duration > MAX_SEGMENT_DURATION: # Segment trop long
should_cut = True
elif len(current_words) >= MAX_WORDS_PER_SEGMENT: # Trop de mots
should_cut = True
elif pause > 0.05 and is_near_speaker_change(word_start, tolerance=2.0):
should_cut = True # Changement de locuteur détecté
La dernière condition est la plus subtile : on utilise les données AssemblyAI pour détecter si on est proche d'un changement de locuteur (tolérance de 2 secondes). Si oui, même une micro-pause de 50ms suffit à couper — parce qu'un sous-titre ne devrait jamais mélanger deux personnages.
Deux post-traitements complètent la segmentation :
- Mots isolés gonflés : si un segment de 1 à 3 caractères dure plus de 3 secondes (artefact Whisper), on réduit sa durée à 1 seconde.
- Durée minimale : tout segment sous 1.2 secondes est étiré — un sous-titre trop bref est illisible.
Étape 6 : traduction par lots avec Claude
Traduire segment par segment serait incohérent (le modèle perd le contexte) et coûteux en appels API. Le pipeline envoie 40 segments par requête à Claude Sonnet :
prompt = f"""Traduis ces sous-titres du {source_name} vers le {target_name}.
Règles :
- Retourne UNIQUEMENT un tableau JSON, sans texte autour, sans backticks.
- Conserve le champ "index". Ajoute un champ "tr" avec la traduction.
- Langage naturel et fluide, adapté aux sous-titres.
- Noms propres conservés tels quels.
- Onomatopées entre parenthèses.
Entrée :
{json.dumps(segments_for_api, ensure_ascii=False)}
Sortie :
[{{"index": 0, "tr": "..."}}]"""
Le format JSON strict permet un remapping fiable : chaque segment traduit conserve son index, donc on retrouve facilement la correspondance avec les timestamps originaux. Si un batch échoue (rare), le texte source est conservé tel quel — mieux vaut un sous-titre en japonais qu'un trou.
Les approches abandonnées
Le pipeline actuel est le résultat de plusieurs itérations. Deux approches prometteuses sur le papier se sont révélées inadaptées :
alass (recalage automatique)
alass est un outil de synchronisation de sous-titres. L'idée était séduisante : générer des sous-titres approximatifs, puis les recaler automatiquement sur l'audio. En pratique, alass ne corrige qu'un décalage global (offset constant). Or le vrai problème n'est pas un décalage, c'est une segmentation incorrecte — et ça, alass ne sait pas le corriger.
Détection de silence avec ffmpeg
Utiliser le filtre silencedetect de ffmpeg pour trouver les points de coupe semblait logique. Mais dans un anime, il n'y a presque jamais de vrai silence : musique de fond, bruitages, ambiance sonore... Le filtre ne détecte rien d'exploitable. Les micro-pauses dans la diction (fournies par Whisper au niveau mot) se sont avérées bien plus fiables que le silence audio brut.
Le coût : ~35-40$ pour 43 heures
Voici la répartition des coûts pour les 43 heures de vidéo :
- Groq (Whisper) : ~0.50€ — la transcription est quasi gratuite
- AssemblyAI : ~17$ — le plus gros poste après Claude
- Claude Sonnet (traduction) : ~15-20€ — raisonnable avec le batching par 40
Soit environ 0.30$ par épisode, ou 0.85$ par heure de vidéo. À comparer avec un traducteur humain (30-50€/heure) ou des plateformes SaaS de sous-titrage (souvent 1-5€/minute).
Utiliser l'outil
Prérequis : Python 3, ffmpeg, yt-dlp (si URL YouTube), et trois clés API en variables d'environnement :
export GROQ_API_KEY="gsk_..."
export ASSEMBLYAI_API_KEY="..."
export ANTHROPIC_API_KEY="sk-ant-..."
Ensuite, trois cas d'usage typiques :
# Sous-titrer une vidéo YouTube du japonais vers le français
python3 subtitle.py --url "https://youtube.com/watch?v=xxx" --source ja --target fr
# Sous-titrer un fichier local de l'anglais vers l'espagnol
python3 subtitle.py --file conference.mp4 --source en --target es --output ./subs/
# Sous-titrer et embarquer les sous-titres dans la vidéo
python3 subtitle.py --file episode.mp4 --source ja --target fr --embed
Le script gère le nettoyage des fichiers temporaires, le caching des transcriptions intermédiaires, et le retry en cas de rate limiting. Un épisode de 23 minutes se traite en 2 à 3 minutes.
Conclusion
Ce projet m'a appris que la qualité des sous-titres automatiques ne dépend pas d'un seul modèle miracle, mais de la combinaison intelligente de plusieurs signaux. Whisper seul produit un timing excellent mais des blocs illisibles. AssemblyAI seul identifie les locuteurs mais dérive en timing. C'est leur fusion qui donne un résultat regardable.
L'outil est générique : il fonctionne avec n'importe quelle vidéo, dans n'importe quelle paire de langues supportée par Whisper et Claude. Si vous avez des conférences, des cours, des podcasts vidéo ou des archives à sous-titrer — le pipeline est prêt.
Le code source est disponible sur GitLab.

Commentaires