Dans le cadre du travail sur Bl!ndt?st, le jeu de blind test multijoueur, j’ai dû préparer le terrain pour une version Android de l’app. L’app était iOS-only, et la fonctionnalité critique — la reconnaissance vocale des noms d’artistes — reposait entièrement sur une spécificité d’iOS.

En creusant le sujet, j’ai découvert une asymétrie profonde entre les deux plateformes, et ça m’a amené à explorer des algorithmes de string matching que je n’avais pas touchés depuis longtemps. Ce qui suit n’est pas un tutoriel exhaustif — c’est le journal d’une petite étude de faisabilité.

Le problème

Un joueur entend un morceau de musique et dit le nom de l’artiste dans son téléphone. Le moteur de reconnaissance vocale transcrit ce qu’il entend en texte. Le serveur compare cette transcription au nom attendu. Si ça matche, le joueur marque des points.

Le problème : les moteurs de reconnaissance vocale sont entraînés sur du langage courant, pas sur des noms d’artistes. “Daft Punk” peut sortir comme “draft punk”, “daft pong”, “des poncs”. “Nirvana” peut devenir “nervana”. “Beyoncé” peut sortir comme “bionce”, “beyonce”, ou même “bion c’est”.

le pipeline de reconnaissance — ios vs androidJoueur dit “Daft Punk”audio capturé par le microiOS — SFSpeechRecognizercontextualStrings: [“Daft Punk”, “Nirvana”, …]le moteur est biaisé vers les mots attendus→ “Daft Punk” (correct)Android — SpeechRecognizerpas de vocabulary hintstranscription libre, non contrainte→ “draft punk” (à matcher)

iOS : le moteur guidé

Sur iOS, SFSpeechRecognizer (disponible depuis iOS 10, amélioré à chaque version) propose une propriété contextualStrings sur l’objet SFSpeechRecognitionRequest. C’est un tableau de chaînes de caractères — des mots ou phrases que le moteur devrait reconnaître même s’ils ne sont pas dans son vocabulaire standard. Apple recommande de ne pas dépasser 100 éléments.

let request = SFSpeechAudioBufferRecognitionRequest()
request.contextualStrings = [
    "Daft Punk", "Nirvana", "Led Zeppelin", "Beyoncé",
    "AC/DC", "Guns N' Roses", "Stromae", "Angèle",
    // ... les artistes de la playlist en cours
]
request.shouldReportPartialResults = true

C’est exactement ce qu’il nous faut. Avant chaque round, le bridge natif iOS reçoit la liste des artistes de la playlist courante et la passe en contextualStrings. Le moteur de reconnaissance est biaisé vers ces mots. “Daft Punk” sort comme “Daft Punk”, pas comme “draft punk”. Les accents sont gérés (“Beyoncé” plutôt que “bionce”). Les noms composés avec des caractères spéciaux (“AC/DC”, “Guns N’ Roses”) ont beaucoup plus de chances d’être correctement transcrits.

Ce n’est pas une contrainte dure — le moteur peut toujours retourner n’importe quel mot. Mais le biais est suffisant pour que, dans un contexte contrôlé (le joueur sait qu’il doit dire un nom d’artiste), la reconnaissance soit fiable. En test, le taux de reconnaissance correcte passe d’environ 60% sans contextualStrings à plus de 90% avec, sur un panel de 50 artistes français et internationaux.

Android : la transcription libre

Sur Android, l’API SpeechRecognizer (via RecognizerIntent) ne propose rien d’équivalent. Pas de vocabulary hints, pas de contextual strings, pas de moyen de biaiser le moteur vers des mots spécifiques. La transcription est libre, le moteur renvoie ce qu’il pense avoir entendu, point.

Ce qu’on récupère :

Voice.startSpeech(locale, dictionary, {
  EXTRA_LANGUAGE_MODEL: 'LANGUAGE_MODEL_FREE_FORM',
  EXTRA_MAX_RESULTS: 5,        // jusqu'à 5 alternatives
  EXTRA_PARTIAL_RESULTS: true,  // résultats intermédiaires
  REQUEST_PERMISSIONS_AUTO: true,
}, callback);

Le EXTRA_MAX_RESULTS: 5 est important. Android renvoie jusqu’à 5 alternatives de transcription, classées par confiance décroissante. Pour “Daft Punk”, on peut recevoir :

  1. "draft punk" (confiance : 0.85)
  2. "daft punk" (confiance : 0.72)
  3. "das punk" (confiance : 0.45)
  4. "daft pong" (confiance : 0.38)
  5. "des poncs" (confiance : 0.21)

La bonne réponse est parfois en deuxième ou troisième position. C’est une information qu’il serait dommage de jeter. L’idée : au lieu de ne matcher que la première transcription, on matche les 5 alternatives contre la liste des artistes possibles, et on accepte si n’importe laquelle matche.

Mais “matcher” quoi contre quoi, et comment ? C’est là que les algorithmes de similarité de chaînes entrent en jeu.

Les algorithmes de matching : du simple au raisonnable

La normalisation par slugify + indexOf utilisée côté serveur (détaillée dans l’article principal) fonctionne quand la transcription contient le nom exact de l’artiste. Mais sur Android, sans biais du moteur, on se retrouve souvent avec des transcriptions phonétiquement proches mais textuellement différentes. Il faut du fuzzy matching.

J’ai testé plusieurs approches, de la plus simple à la plus sophistiquée. Le contexte : c’est une étude de faisabilité pour le POC Android, pas un moteur de NLP industriel. L’objectif est de trouver le meilleur rapport fiabilité/simplicité.

1. Knuth-Morris-Pratt : le matching exact rapide

Le point de départ. L’algorithme de Knuth-Morris-Pratt (1977) fait du pattern matching exact en O(n+m) — il cherche si une chaîne est contenue dans une autre, sans jamais revenir en arrière dans le texte grâce à une table de préfixes précalculée. C’est ce que fait le indexOf de JavaScript en interne (les moteurs V8 et JavaScriptCore utilisent des variantes optimisées).

Pour notre cas, KMP est utile comme premier filtre : si la transcription normalisée contient exactement le nom de l’artiste normalisé, c’est gagné, pas besoin d’aller plus loin. C’est le chemin rapide, celui qui gère les 60-70% de cas où la transcription est correcte ou presque (différences d’accents, de casse, de mots de liaison).

// Le chemin rapide : matching exact après normalisation
const normalizedGuess = normalize(transcription); // slugify + uppercase + remove fillers
const exactMatch = answers.some(a => normalizedGuess.includes(normalize(a)));
if (exactMatch) return true;

Quand ça ne matche pas, il faut passer au fuzzy.

2. Jaro-Winkler : la similarité pour les noms courts

L’algorithme de Jaro (1989), amélioré par Winkler (1990), calcule un score de similarité entre 0 et 1 basé sur le nombre de caractères communs et leur ordre. Sa particularité : il accorde un bonus de préfixe — les caractères qui matchent en début de chaîne pèsent plus que ceux du milieu ou de la fin. C’est conçu pour la comparaison de noms de personnes, où le début est généralement la partie la plus fiable.

Pour des noms d’artistes, c’est pertinent. “Nirvana” vs “Nervana” : le préfixe “N” est commun, la plupart des caractères matchent, le score Jaro-Winkler est élevé (~0.93). “Daft Punk” vs “Draft Punk” : préfixe “D” commun, transposition du “r” et du “a”, score ~0.96. “Beyoncé” vs “Bionce” : préfixe “B” commun, score ~0.80.

// Jaro-Winkler : bon pour les noms courts avec des erreurs phonétiques
function jaroWinkler(s1: string, s2: string): number {
  const jaroScore = jaro(s1, s2);
  // Bonus pour le préfixe commun (jusqu'à 4 caractères)
  let prefix = 0;
  for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
    if (s1[i] === s2[i]) prefix++;
    else break;
  }
  return jaroScore + prefix * 0.1 * (1 - jaroScore);
}

Le seuil d’acceptation que j’ai trouvé empiriquement : 0.85. En dessous, trop de faux positifs (des noms d’artistes qui se ressemblent vaguement). Au-dessus, trop de faux négatifs (des transcriptions approximatives mais correctes qui sont rejetées). 0.85 est le sweet spot pour un panel de 50 artistes testés à la main.

3. Coefficient de Dice : la tolérance aux réarrangements

Le coefficient de Sorensen-Dice (1945/1948, repopularisé par la librairie npm string-similarity) découpe les deux chaînes en bigrammes (paires de caractères consécutifs), puis mesure le chevauchement : 2 * |intersection| / (|bigrams_A| + |bigrams_B|).

Sa force : il tolère les réarrangements de mots. “Red Hot Chili Peppers” vs “Chili Peppers Red Hot” aura un score élevé parce que les bigrammes sont les mêmes, juste dans un ordre différent. C’est utile quand un joueur dit “c’est les Peppers, les Red Hot Chili Peppers !” et que le moteur vocal ne transcrit pas les mots dans le bon ordre.

function diceCoefficient(s1: string, s2: string): number {
  const bigrams1 = new Set<string>();
  const bigrams2 = new Set<string>();

  for (let i = 0; i < s1.length - 1; i++) bigrams1.add(s1.substr(i, 2));
  for (let i = 0; i < s2.length - 1; i++) bigrams2.add(s2.substr(i, 2));

  let intersection = 0;
  bigrams1.forEach(bg => { if (bigrams2.has(bg)) intersection++; });

  return (2 * intersection) / (bigrams1.size + bigrams2.size);
}

4. Levenshtein : la distance d’édition

La distance de Levenshtein (1965) compte le nombre minimum d’insertions, suppressions et substitutions pour transformer une chaîne en une autre. C’est l’algorithme le plus connu pour le fuzzy matching, celui qu’on trouve dans tous les correcteurs orthographiques.

Pour notre cas, la distance brute est moins utile qu’un ratio normalisé. “Daft Punk” (9 caractères) vs “Draft Punk” (10 caractères) a une distance de 2 (insertion du “r”, pas de suppression grâce à la transposition). Le ratio 1 - distance / max(len1, len2) donne 0.80 — acceptable. Mais “AC/DC” (5 caractères) vs “ACDC” (4 caractères) a une distance de 1, ratio 0.80 aussi — et c’est clairement le même artiste. La normalisation par longueur est essentielle pour ne pas pénaliser les noms courts.

comparaison des algorithmes — exemples concretsAttenduTranscription AndroidJaro-WinklerDiceLevenshteinVerdictDAFT PUNKDRAFT PUNK0.960.820.80matchNIRVANANERVANA0.930.830.86matchBEYONCEBIONCE0.800.670.71limiteLED ZEPPELINLEAD ZEPPLIN0.940.860.83matchSTROMAESTROMA0.950.770.86matchANGELEANGEL0.960.670.83faux positif ?DAFT PUNKDES PONCS0.690.350.44rejet (ok)≥ 0.85 : match0.70-0.85 : zone grise< 0.70 : rejetscores calculés après normalisation (slugify + uppercase)

L’approche combinée retenue

Aucun algorithme seul ne couvre tous les cas. Jaro-Winkler est bon sur les noms courts mais peut donner des faux positifs sur des mots courts et communs (“Angèle” vs “Angel”). Dice tolère les réarrangements mais perd en précision sur les noms très courts. Levenshtein est le plus intuitif mais le plus coûteux en temps de calcul (O(n×m) pour chaque comparaison).

L’approche du POC combine les trois en cascade :

function matchTranscription(
  transcriptions: string[], // les 5 alternatives Android
  artists: string[],        // les artistes de la playlist
): string | null {
  for (const transcription of transcriptions) {
    const normalized = normalize(transcription);

    for (const artist of artists) {
      const normalizedArtist = normalize(artist);

      // 1. Matching exact (KMP/indexOf) — le chemin rapide
      if (normalized.includes(normalizedArtist)) {
        return artist;
      }

      // 2. Jaro-Winkler — bon pour les petites erreurs phonétiques
      if (jaroWinkler(normalized, normalizedArtist) >= 0.88) {
        return artist;
      }

      // 3. Dice — tolérant aux réarrangements de mots
      if (diceCoefficient(normalized, normalizedArtist) >= 0.75) {
        return artist;
      }
    }
  }

  return null; // aucune correspondance trouvée
}

Les seuils (0.88 pour Jaro-Winkler, 0.75 pour Dice) sont plus conservateurs que ce que j’aurais mis pour un seul algorithme. L’idée est que chaque étage rattrape les cas que le précédent rate, sans ouvrir la porte aux faux positifs. Le matching exact attrape les cas triviaux, Jaro-Winkler les petites erreurs phonétiques (“Nervana” → “Nirvana”), Dice les mots réordonnés ou partiellement transcrits.

La piste phonétique : Metaphone et Double Metaphone

J’ai aussi exploré les algorithmes d’encodage phonétique. L’idée : au lieu de comparer les chaînes telles quelles, on les encode en représentation phonétique, et on compare les codes. Double Metaphone (Lawrence Philips, 2000) est le plus intéressant car il génère deux codes par mot — une prononciation principale et une alternative — ce qui gère mieux les noms d’origine non anglaise.

Par exemple :

  • “Nirvana” → Double Metaphone : NRFN

  • “Nervana” → Double Metaphone : NRFN

  • Les codes sont identiques, donc match phonétique.

  • “Daft Punk” → TFTPNK

  • “Draft Punk” → TRFTPNK

  • Les codes diffèrent légèrement, mais un Levenshtein sur les codes (distance = 1) suffit à matcher.

Le problème : Metaphone est conçu pour l’anglais. Les noms d’artistes français (Stromae, Angèle, Mylène Farmer) sont mal encodés. “Stromae” devient STRM, “Stroma” aussi → faux positif. Et les noms en langues non latines passent encore moins bien. Pour un produit français avec des playlists mixtes (variété française + pop internationale), le phonétique seul ne suffit pas.

En revanche, comme filtre complémentaire pour les artistes anglophones, c’est un bon outil. Le package natural pour Node.js fournit Double Metaphone prêt à l’emploi :

import { DoubleMetaphone } from 'natural';

const dm = new DoubleMetaphone();
const [primary1, alternate1] = dm.process('Nirvana');  // ['NRFN', 'NRFN']
const [primary2, alternate2] = dm.process('Nervana');  // ['NRFN', 'NRFN']

if (primary1 === primary2 || primary1 === alternate2
    || alternate1 === primary2) {
  // Match phonétique
}

Je ne l’ai pas intégré dans le POC final — ça ajoutait de la complexité pour un gain marginal par rapport à la cascade Jaro-Winkler + Dice. Mais c’est une piste à creuser si le produit passe en production sur Android avec des playlists principalement anglophones.

Les résultats du POC

Sur un panel de 50 artistes testés manuellement (25 francophones, 25 anglophones), avec 3 enregistrements par artiste sur un Pixel 4 :

  • iOS avec contextualStrings : 92% de reconnaissance correcte (la transcription contient directement le bon nom après normalisation)
  • Android avec matching exact seul (indexOf après normalisation) : 68%
  • Android avec cascade Jaro-Winkler + Dice : 84%
  • Android avec cascade + 5 alternatives : 89%

L’écart iOS/Android passe de 24 points (92% vs 68%) à 3 points (92% vs 89%) avec la cascade de fuzzy matching sur les 5 alternatives. Ce n’est pas la parité, mais c’est jouable pour un produit de soirée d’entreprise où la tolérance aux erreurs fait partie de l’amusement.

taux de reconnaissance — 50 artistes × 3 essaisiOS + contextualStrings92%Android + indexOf seul68%Android + fuzzy (1 alt.)84%Android + fuzzy (5 alt.)89%panel de test : 25 artistes francophones + 25 anglophones · 3 enregistrements par artiste · Pixel 4

Pourquoi ça ne part pas en prod

Les résultats sont encourageants, mais le POC reste un POC. La raison pour laquelle cette approche ne va pas en production est un problème d’équité, pas de technique.

Bl!ndt?st est un jeu compétitif. Pendant une soirée, des joueurs iOS et des joueurs Android sont dans la même salle, sur la même partie, en compétition directe. Si un joueur iOS bénéficie d’un taux de reconnaissance de 92% grâce aux contextualStrings natifs, et qu’un joueur Android est à 89% avec du fuzzy matching compensatoire, l’écart de 3 points se traduit concrètement en rounds perdus. Sur une phase de 15 rounds, c’est potentiellement un round où le joueur Android a dit le bon nom mais n’est pas reconnu, alors que son adversaire iOS l’aurait été. Dans un jeu social, ce genre d’injustice perçue est toxique — même si elle est statistiquement marginale.

Le client est clair : il veut le même algorithme de reconnaissance sur les deux plateformes. Pas deux chemins de code avec des taux de succès différents, même si la différence est réduite. C’est une contrainte produit qui prime sur la contrainte technique.

Pour atteindre la parité, il faudrait soit abandonner les contextualStrings sur iOS (se priver d’un avantage natif pour aligner vers le bas — personne ne veut ça), soit trouver un moyen d’atteindre les mêmes 92% sur Android avec du fuzzy matching seul. 89% c’est bien, mais ce n’est pas 92%. Les 3 derniers points sont les plus durs à gagner — ce sont les cas où la transcription Android est tellement éloignée du nom attendu que même la cascade Jaro-Winkler + Dice ne rattrape pas, alors qu’iOS avec son biais vocabulaire l’aurait eu directement.

Une piste serait d’utiliser un service de reconnaissance vocale tiers (Google Cloud Speech-to-Text offre des speechContexts avec des phrases hints, l’équivalent exact des contextualStrings d’Apple) au lieu du moteur on-device. Mais ça ajoute de la latence réseau, un coût par requête, et une dépendance à un service cloud dans un produit qui tourne parfois sur des Wi-Fi d’entreprise capricieux. Pour l’instant, le compromis n’est pas trouvé.

Le POC a rempli son rôle : démontrer que la reconnaissance vocale sur Android est faisable, quantifier l’écart, et poser clairement le problème d’équité cross-platform. La décision de ne pas le shipper est la bonne.

Ce qu’on en retient

L’asymétrie iOS/Android en reconnaissance vocale est réelle et structurelle. Apple permet de guider le moteur, Google non. Ce n’est pas un bug ou un retard — ce sont deux philosophies différentes de l’API. Sur iOS, le développeur collabore avec le moteur. Sur Android, le développeur reçoit un texte brut et se débrouille.

Le fuzzy matching compensatoire fonctionne, mais ne comble pas totalement l’écart. La cascade normalisation → Jaro-Winkler → Dice ramène l’écart de 24 à 3 points. C’est impressionnant techniquement, mais dans un jeu compétitif, 3 points d’écart entre plateformes, c’est 3 points de trop.

Les 5 alternatives Android sont une mine d’or. La bonne réponse est souvent en 2e ou 3e position. Ne matcher que la première transcription, c’est jeter 21 points de taux de reconnaissance (68% → 89%).

La normalisation reste la première ligne de défense. Avant tout algorithme de similarité, slugify + uppercase + suppression des mots de liaison règle la majorité des cas. Le fuzzy matching ne devrait intervenir que quand la normalisation échoue.

Un problème technique résolu ne suffit pas si le produit ne l’accepte pas. L’écart résiduel de 3% est acceptable techniquement, mais pas du point de vue de l’équité du gameplay. C’est un bon rappel que la faisabilité technique n’est qu’une partie de l’équation — la décision finale est une décision produit.

Et accessoirement, replonger dans Jaro-Winkler et Dice après des années, c’est un rappel que les algorithmes de base — ceux qu’on apprend en cours et qu’on oublie en entreprise — sont souvent les bons outils pour les problèmes concrets. Pas besoin d’un réseau de neurones pour matcher “Nervana” avec “Nirvana”.