Dès qu'une transcription a des horodatages par mot, repérer des extraits courts dans un long enregistrement ne demande aucun modèle. Une fenêtre de longueur fixe, lancée à chaque mot et notée selon le nombre de mots qui y tiennent, trouve les passages où il se dit le plus de choses. L'éditeur vidéo de l'application de bureau osFoundry propose exactement cela avec son bouton Suggest clips, écrit en Rust (find_highlights dans canvas_transcript.rs). On le reconstruit ici en Python et on le fait tourner sur une vraie interview de 5:07, avec des mesures de l'effet de chaque règle, de l'influence de la longueur de fenêtre sur le résultat, du moment où la sélection « le meilleur d'abord » cesse d'être optimale, et de la façon de rendre le calcul des scores linéaire.
Les données d'entrée
L'enregistrement est une interview de la NASA avec l'astronaute Christina Koch. Sa transcription Whisper compte 1 007 mots sur 306,97 secondes, soit 3,28 mots par seconde en moyenne. Dans ce mode, Whisper renvoie les mots sans ponctuation, et l'algorithme n'a besoin que des temps :
{"words": [ {"index": 0, "text": "And", "start_ms": 0, "end_ms": 160}, {"index": 1, "text": "we'll", "start_ms": 160, "end_ms": 280}, {"index": 2, "text": "get", "start_ms": 280, "end_ms": 380}, ... ], "duration_ms": 306970} Une fenêtre par mot
Chaque mot ouvre une fenêtre candidate. Un mot compte s'il se termine avant la fin de la fenêtre, et le parcours s'arrête au premier mot qui dépasse :
def candidates(words, window_ms): out = [] for i, w in enumerate(words): start = w["start_ms"] end = start + window_ms n = 0 for x in words[i:]: if x["end_ms"] > end: break n += 1 if n: out.append((start, end, n)) return out Faire partir les fenêtres des mots plutôt que d'une grille fixe (toutes les secondes, par exemple) garantit qu'un candidat commence toujours au début d'un mot, jamais au milieu. Le score est divisé par la longueur de la fenêtre, pas par le temps réellement parlé, donc les 44 fenêtres qui démarrent dans les 15 dernières secondes de l'enregistrement obtiennent d'elles-mêmes un score faible, sans cas particulier.
Pour cette interview, cela donne 1 007 candidats. La médiane contient 49 mots, la meilleure 67.
Le meilleur d'abord, sans chevauchement
Les candidats sont triés par score, les égalités départagées par position pour que la sélection soit identique à chaque exécution, puis pris depuis le haut tant qu'ils ne chevauchent rien de ce qui est déjà choisi :
def find_highlights(words, window_ms=15_000, count=3): ranked = sorted(candidates(words, window_ms), key=lambda c: (-c[2], c[0])) chosen = [] for c in ranked: if len(chosen) >= count: break if any(c[0] < k[1] and k[0] < c[1] for k in chosen): continue # overlaps a better window chosen.append(c) return sorted(chosen) Avec les réglages de l'application, 15 secondes et trois extraits, la version Python renvoie les trois mêmes plages que celles affichées par l'application :
| extrait | mots | mots/s |
|---|---|---|
| 0:45.1 – 1:00.1 | 64 | 4,27 |
| 2:03.4 – 2:18.4 | 67 | 4,47 |
| 2:31.5 – 2:46.5 | 65 | 4,33 |
C'est la règle de non-chevauchement qui fait l'essentiel du travail. Sans elle, les trois premiers sont 2:03.4, 2:03.6 et 2:00.2, soit trois fois le même passage, tous à moins de 3,4 secondes les uns des autres. Une parole dense reste dense un moment, donc les meilleures fenêtres se regroupent : les 19 meilleurs candidats commencent tous entre 1:59.7 et 2:04.4. Les deuxième et troisième choix sont les candidats n° 20 et 21 du classement, et il a fallu écarter 18 fenêtres mieux notées pour les atteindre. Les deux ex æquo à 67 (2:03.4 et 2:03.6) montrent pourquoi départager par position est important.
La longueur de fenêtre change la réponse
La même transcription avec d'autres longueurs de fenêtre :
| fenêtre | sélection (début, mots/s) |
|---|---|
| 10 s | 0:51.2 (4,5), 2:08.5 (4,7), 3:15.3 (4,5) |
| 15 s | 0:45.1 (4,27), 2:03.4 (4,47), 2:31.5 (4,33) |
| 30 s | 0:25.1 (3,8), 1:48.5 (3,93), 2:31.5 (3,93) |
| 60 s | 0:00.0 (3,57), 1:23.7 (3,48), 2:31.0 (3,77) |
Les fenêtres courtes récompensent une seule rafale rapide et donnent des pics plus hauts. Les fenêtres longues font la moyenne sur les pauses et les questions et tendent vers le débit global de 3,28. Le passage autour de 2:31 survit à toutes les longueurs de 15 à 60 secondes, ce qui indique assez bien qu'il s'agit d'une réponse soutenue et pas d'une seule phrase rapide. Lancer deux longueurs et chercher ce qu'elles ont en commun est un moyen peu coûteux de classer les candidats sur plus d'un seul chiffre.
Le meilleur d'abord est-il optimal ?
Prendre la meilleure fenêtre en premier est un choix glouton. Pour le vérifier, la réponse exacte s'obtient avec un petit programme dynamique sur les candidats triés par heure de début : pour chaque candidat, soit on le saute, soit on le prend et on passe au premier candidat qui commence après sa fin.
import bisect, functools def best_total(words, window_ms, count): cands = sorted(candidates(words, window_ms)) starts = [c[0] for c in cands] nxt = [bisect.bisect_left(starts, c[1]) for c in cands] @functools.lru_cache(None) def best(i, k): if k == 0 or i >= len(cands): return 0 return max(best(i + 1, k), cands[i][2] + best(nxt[i], k - 1)) return best(0, count) Nombre total de mots dans les fenêtres choisies, glouton contre exact :
| fenêtre × extraits | le meilleur d'abord | exact |
|---|---|---|
| 15 s × 3 | 196 mots, 3 extraits | 196 |
| 15 s × 10 | 595 mots, 10 extraits | 595 |
| 30 s × 5 | 563 mots, 5 extraits | 565 |
| 30 s × 10 | 764 mots, 8 extraits | 989 |
| 60 s × 5 | 849 mots, 5 extraits | 991 |
Pour une courte sélection de fenêtres courtes, le glouton ne perd rien ici : à 15 secondes, il atteint le total exact pour 3, 5 et 10 extraits. Il décroche quand les fenêtres commencent à remplir l'enregistrement. Dix fenêtres de 30 secondes tiennent dans 307 secondes, mais le meilleur d'abord place ses premiers choix là où sont les pics, les trous laissés entre eux font moins de 30 secondes, et il renvoie 8 extraits là où la réponse exacte en loge 10. Le même phénomène coûte 142 mots à 60 s × 5. La version gloutonne est donc le bon outil pour « donne-moi trois candidats » et le mauvais pour « découpe cette conférence en chapitres », où la programmation dynamique ne demande que quelques lignes de plus.
Rendre le calcul des scores linéaire
Le reparcours dans candidates lit chaque mot de chaque fenêtre, donc son coût croît avec la longueur de fenêtre multipliée par le nombre de mots. Comme les heures de début et de fin ne font qu'avancer, le dernier mot de la fenêtre ne fait qu'avancer lui aussi, et un seul pointeur suffit :
def candidates_two_pointer(words, window_ms): out, j = [], 0 for i, w in enumerate(words): j = max(j, i) end = w["start_ms"] + window_ms while j < len(words) and words[j]["end_ms"] <= end: j += 1 if j > i: out.append((w["start_ms"], end, j - i)) return out Les deux versions renvoient des candidats identiques sur cette transcription. Mesures avec Python 3.9 sur un Apple M1, médiane de 7 exécutions, calcul des scores uniquement ; le cas d'1 heure est obtenu en répétant l'interview 12 fois :
| transcription | mots | reparcours | deux pointeurs |
|---|---|---|---|
| 5 min | 1 007 | 2,9 ms | 0,4 ms |
| 1 h | 12 084 | 190,5 ms | 5,4 ms |
La boucle Rust de l'application est la version avec reparcours. En Python, sur un long podcast ou avec plusieurs longueurs de fenêtre à la fois, c'est la version à deux pointeurs qu'il faut garder. Elle suppose toutefois que les heures de fin sont dans l'ordre, ce qui était vrai pour chaque mot ici, mais qui mérite un assert sur des transcriptions fusionnées à partir de plusieurs sources.
Ce que le score ignore
Les mots par seconde mesurent la quantité de choses dites, pas l'intérêt de les écouter. Le commentaire au-dessus de find_highlights dans le code de l'application qualifie la méthode d'heuristique et pose la limite délibérément : un modèle qui choisirait les moments forts serait une autre fonctionnalité, payante et qui demande une clé, et améliorer celle-ci en douce transformerait une opération gratuite en une dépense que l'utilisateur n'a jamais approuvée. Le panneau de l'application dit la même chose à l'utilisateur : « a starting point, not a verdict » (un point de départ, pas un verdict). En pratique, les trois fenêtres indiquent où commencer à écouter, et les points de coupe se placent toujours à l'oreille.
Le script complet, avec toutes les mesures ci-dessus, fait environ 120 lignes de Python avec la seule bibliothèque standard. Dans l'application de bureau osFoundry, la même fonction se trouve derrière le bouton Suggest clips du panneau de transcription de l'éditeur vidéo, à côté d'une étape de transcription qui utilise votre propre clé d'API cloud. L'application est gratuite sur la page de téléchargement d'osFoundry.


