Des sous-titres karaoke qui ne sonnent pas IA : Whisper + forced-align expliqué
Les sous-titres bruts de Whisper trahissent la voix synthétique en deux secondes. Voici la technique de forced-align qui aligne au mot près sur le script d'origine, et les deux paramètres qui font la différence entre robotique et naturel.
Un viewer m'a écrit en avril 2026 sous un épisode de Cocorico : « Vos sous-titres sont propres, contrairement à 95% des chaînes IA. Comment vous faites ? ». La question m'a surpris, parce que je n'avais jamais considéré les sous-titres comme un signal de qualité IA — et que pendant les premiers mois de production, les miens étaient exactement aussi médiocres que ceux de tout le monde.
Le déclic est venu en relisant deux versions d'un même épisode. Version 1 : sous-titres générés directement par Whisper, sans intervention. Version 2 : sous-titres alignés sur le script d'origine, mot pour mot, avec timing dérivé de l'audio. La différence dans les deux versions n'était pas dans le contenu transcrit, qui était presque identique. C'était dans la fidélité visuelle au script. La version 1 fragmentait les phrases au mauvais endroit, mangeait des mots, hésitait sur les noms propres. La version 2 affichait exactement ce que la voix prononçait, au moment où elle le prononçait.
Ce qui suit est la technique de forced-align que j'utilise depuis pour produire des sous-titres karaoke qui ne trahissent jamais que la voix est synthétique. Avec les deux paramètres qui pèsent vraiment, et le piège silencieux qu'il faut anticiper.
Le problème de Whisper en sortie brute
Whisper, le modèle open-source de transcription d'OpenAI, est remarquable sur un point : il transcrit la quasi-totalité des langues humaines avec un taux d'erreur honnête. Le souci, c'est ce qu'il fait en plus de transcrire. Pour produire un résultat lisible, Whisper applique automatiquement plusieurs heuristiques qui altèrent le texte de manière subtile mais cumulative :
- Insertion de mots fantômes. Whisper hallucine des « euh », « hum », et des connecteurs (« donc », « alors ») qui n'existent pas dans l'audio mais que le modèle juge linguistiquement plausibles.
- Suppression de mots faibles. Inversement, Whisper élide certains articles et particules qu'il considère négligeables. « Je vais à la maison » devient « vais à maison » dans une transcription pressée.
- Reformulation orthographique. Whisper aligne les nombres écrits sur la convention orthographique standard, parfois en désaccord avec ce que la voix a effectivement prononcé.
- Pause sémantique mal placée. Whisper insère des virgules et des points selon une grammaire écrite, pas selon la cadence orale réelle.
Pour un sous-titre passif (regardé en muet, par exemple), ces altérations sont tolérables. Pour un sous-titre karaoke synchronisé à une voix qu'on entend, elles produisent un décalage permanent entre ce qui est dit et ce qui est écrit. Ce décalage est ce que ressentent les viewers sans toujours pouvoir le nommer.
L'idée du forced-align
Le forced-align inverse la relation. Au lieu de demander à Whisper « retranscris cet audio », on lui demande « aligne cet audio sur ce texte que je te donne ». Le texte de référence est le script d'origine — celui que vous avez écrit et qui a été passé à la synthèse vocale.
Concrètement, Whisper produit ses tokens transcrits avec timing. Un algorithme de matching parcourt ensuite chaque token de l'audio et lui cherche le meilleur correspondant dans le script. Quand un match est trouvé, on prend le timing du token audio et on l'attribue au token script. À la fin, on a un fichier de timing au mot près où le texte affiché est exactement le script, et la chronologie vient de l'audio réel.
Le bénéfice opérationnel est total. Les « euh » insérés par Whisper sont ignorés (ils n'existent pas dans le script). Les mots élidés sont retrouvés (ils existent dans le script). L'orthographe affichée est garantie identique à celle voulue par l'auteur. Et le timing reste fidèle à ce que la voix prononce.
L'implémentation, en gros
Le pipeline tient en trois étapes. En Python avec openai-whisper et un peu de matching custom :
import whisper
from rapidfuzz import process, fuzz
# 1. Whisper transcrit avec timing au mot près
model = whisper.load_model("medium") # 'medium' suffit pour le FR
result = model.transcribe(
"vo.wav",
language="fr",
word_timestamps=True,
)
# Chaque "word" contient {word, start, end, probability}
whisper_words = [w for seg in result["segments"] for w in seg["words"]]
# 2. Charger le script de référence et le tokenizer par whitespace
script_text = open("script.txt").read()
script_tokens = script_text.split()
# 3. Forced-align : matcher chaque token script au token Whisper le plus proche
LOOKAHEAD = 6
MIN_WORD_GAP = 0.08
aligned = []
cursor = 0 # position dans whisper_words
for script_token in script_tokens:
# Chercher dans les LOOKAHEAD prochains tokens Whisper le meilleur match
window = whisper_words[cursor:cursor + LOOKAHEAD]
if not window:
break
best_match = process.extractOne(
script_token,
[w["word"].strip() for w in window],
scorer=fuzz.ratio,
)
if best_match and best_match[1] >= 70: # 70% de similarité min
match_idx = best_match[2]
matched_word = window[match_idx]
aligned.append({
"text": script_token,
"start": matched_word["start"],
"end": matched_word["end"],
})
cursor += match_idx + 1
else:
# Pas de match — on glisse au cursor suivant et on enregistre
# un placeholder qui sera distribué uniformément après
aligned.append({"text": script_token, "start": None, "end": None})
# Post-traitement : combler les None par distribution uniforme entre voisins
# (omis pour la lisibilité — disponible dans le repo Cocorico)
L'idée est simple : pour chaque token du script, on cherche dans les six prochains tokens transcrits par Whisper celui qui ressemble le plus. Si la similarité dépasse 70%, c'est un match — on récupère le timing. Sinon, on continue, et on remplira les trous par interpolation linéaire à la fin.
Les deux constantes en tête (LOOKAHEAD = 6 et MIN_WORD_GAP = 0.08) méritent une explication détaillée.
LOOKAHEAD : combien de tokens fureter avant de renoncer
LOOKAHEAD détermine la fenêtre dans laquelle on cherche un match pour chaque token du script. Sa valeur arbitre un compromis :
- Trop faible (1-2) : si Whisper insère deux « euh » consécutifs, on les confond avec le prochain mot du script, on rate le match, et tout le reste de l'épisode dérive.
- Trop élevée (15+) : si un mot du script n'existe pas dans l'audio (cas rare mais possible), on cherche très loin et on peut accrocher un match incorrect plusieurs phrases plus loin, créant un saut visuel violent dans les sous-titres.
La valeur 6 que j'utilise est empirique. Elle absorbe les artefacts Whisper typiques (insertions d'hésitations, doublons) tout en restant assez courte pour éviter les faux positifs lointains. Sur la dizaine de chaînes que j'ai instrumentées, cette valeur produit un taux de match autour de 96-98%, ce qui veut dire que 2-4% des tokens sont resynchronisés par interpolation — invisible à l'œil.
MIN_WORD_GAP : empêcher les chevauchements
MIN_WORD_GAP impose un délai minimum entre la fin d'un mot et le début du suivant. La valeur 0.08 seconde (80 millisecondes) correspond approximativement à l'intervalle minimal qu'un humain perçoit entre deux mots distincts.
Sans cette contrainte, ElevenLabs (ou tout TTS rapide) peut produire deux mots consécutifs avec une fin du premier et un début du second quasi-superposés. Whisper transcrit chacun à son timing audio réel, et l'alignement reproduit fidèlement le chevauchement. Résultat : les sous-titres karaoke affichent deux mots simultanément pendant 20 millisecondes, ce qui produit un flash visuel désagréable.
En forçant un écart minimal de 80 millisecondes, on garantit que chaque mot a son créneau visuel net. Le coût est négligeable (on déplace très légèrement le début du deuxième mot vers l'avenir), le gain est immédiat à l'œil.
La contrainte qui peut tout casser : l'alignement des tokens
Voici le piège silencieux. Le forced-align fonctionne au niveau du token, séparé par les espaces. Le texte affiché et le texte parlé doivent porter le même nombre de tokens. Sinon l'algorithme avance sur un script qui a plus ou moins de tokens que l'audio, et le matching échoue par décalage cumulatif.
Trois cas typiques qui cassent l'alignement :
Cas 1 — nombre orthographié versus chiffré. Vous écrivez « 3000 » dans le script affiché, mais vous voulez que la voix prononce « trois-mille ». Un seul token dans les deux cas — OK. En revanche, si vous écrivez « 3 000 » avec un espace, c'est deux tokens, et il faut que la voix prononce deux tokens aussi (« trois mille », sans tiret).
Cas 2 — sigle versus expansion. Vous écrivez « AZERTY » (1 token) mais vous voulez que la voix prononce « A Zed E R T Y » (6 tokens). Vous devez faire un choix : soit aligner sur 1 token (vous mettez « azerty » lowercase prononcé en mot, pas en sigle), soit aligner sur 6 tokens (vous écrivez « A Zed E R T Y » dans le script affiché aussi).
Cas 3 — ponctuation collée. *« Bonjour, » écrit comme une virgule collée au mot, vs « Bonjour ,» écrit avec espace. La virgule collée est dans le même token, la virgule détachée est un token à part. Whisper ne transcrit pas la ponctuation séparément, donc l'alignement va merder sur la virgule détachée.
Mon orchestrateur Python valide cette contrainte avant d'émettre le fichier de config. Pour chaque segment, il compte les tokens du text affiché et les tokens du tts parlé. S'ils diffèrent, il émet un avertissement et distribue le timing uniformément sur le segment fautif — pas idéal, mais préférable au crash. La règle pratique que j'ai gravée dans le doc éditorial : autant de tokens parlés que de tokens affichés, sinon vous saurez à la première relecture pourquoi un sous-titre saute.
Le rendu visuel : le karaoke proprement dit
Une fois le fichier d'alignement produit, le rendu karaoke est une question de templating CSS. Voici la formule que j'utilise dans HyperFrames :
<div class="caption-line">
<span class="word" data-start="0.12" data-end="0.34">J'ai</span>
<span class="word" data-start="0.42" data-end="0.78">appris</span>
<span class="word" data-start="0.86" data-end="1.10">un</span>
<!-- ... -->
</div>
Chaque mot porte ses deux timings en attributs. Une timeline GSAP itère sur chaque .word et applique deux états : avant le data-start, le mot est gris ; entre data-start et data-end, il passe à la couleur de signature (par exemple jaune #FFD60A chez WhyFactory) ; après data-end, il reste sur la couleur de signature jusqu'à la fin du segment.
L'effet visuel est ce « light-up » progressif qu'on associe aux karaoke. La synchronisation est au mot près parce que le timing vient de l'audio réel, pas d'une estimation. Et le texte ne dérive jamais du script, parce que c'est le script qui pilote l'affichage.
L'erreur que tout le monde fait au début
Une erreur que j'ai vue répétée par plusieurs créateurs qui démarrent : utiliser directement le result["text"] de Whisper pour les sous-titres, en partant du principe que Whisper transcrit correctement et qu'on évitera la complexité du forced-align.
C'est compréhensible et c'est faux. Le problème n'est pas la précision de Whisper sur un mot isolé ; c'est l'accumulation d'altérations micro sur un script entier. Un short de 60 secondes contient typiquement entre 100 et 150 mots. Si Whisper en altère 3% (insertions + élisions + reformulations), vous avez 4-5 désalignements visibles par épisode. Le viewer ne pourra pas tous les nommer, mais il en repérera assez pour que la chaîne sente « le truc IA mal foutu » plutôt que « le truc IA bien fait ».
Le forced-align règle ce problème en une étape technique de 30 lignes de Python. Le ratio investissement / gain est, à ma connaissance, le meilleur de toute la chaîne de production vidéo IA.
Si vous démarrez
Trois ressources à connaître si vous voulez monter ce pipeline chez vous :
openai-whispersur PyPI. Le modèlemediumest le sweet spot pour le français (qualité quasi-équivalente àlargepour un coût deux fois moindre).rapidfuzzoupython-Levenshteinpour le matching de chaînes. Implementations en C, négligeables en CPU même sur des milliers de tokens.- HyperFrames pour le rendu HTML/CSS animé via GSAP. Le templating de sous-titres karaoke y prend trois cents lignes de HTML/CSS, debuggable comme une page web.
Le seul investissement initial sérieux est de comprendre le forced-align et d'écrire le matcher (les trente lignes de Python ci-dessus, plus le post-traitement par interpolation). Une fois fait, ça tourne en boucle sur tous vos épisodes sans intervention.
La couche publication de ce pipeline — pousser les renders vers YouTube, TikTok, Instagram, Facebook, Threads et LinkedIn depuis Claude via MCP — c'est ce que fait Shortflow. Si vous avez calibré votre captioning et qu'il vous manque l'outillage de publication, créer un compte ouvre un essai gratuit de sept jours.