Cloud Computing

Transcrire des appels téléphoniques avec Azure LLM Speech et MAI-Transcribe-2

Téléphone décroché sur le bureau d’un garage, transcription de l’appel affichée sur un ordinateur portable

Le 26 octobre 2026, Microsoft a annoncé LLM Speech 09-2026, la nouvelle version de sa transcription « augmentée » par un grand modèle de langage. Au programme : 25 langues de plus, une vitesse jusqu’à deux fois supérieure sur les audios courts, moins d’hallucinations sur les silences et une liste de mots métier portée à 2 000 entrées. Le même point d’accès donne aussi MAI-Transcribe-2, le modèle de transcription maison de Microsoft, encore en préversion.

Les annonces parlent de gains mesurés en interne, sur des audios variés. Nous voulions savoir ce que ça donne sur le cas qui nous intéresse : un appel téléphonique en français, en 8 kHz, avec deux interlocuteurs, une immatriculation, un numéro de téléphone et un peu de bruit. Ce tutoriel reprend le code exact que nous avons exécuté, du fichier audio à la fiche structurée, avec les résultats et les pièges rencontrés.

Ce qu’on construit

Quatre scripts Python, sans SDK propriétaire, seulement des appels REST :

  1. make_audio.py fabrique un appel de test : un client appelle un garage, deux voix de synthèse, son téléphonique 8 kHz, puis deux versions bruitées.
  2. transcribe.py envoie l’audio à l’API Fast Transcription avec trois moteurs : la transcription classique, LLM Speech et MAI-Transcribe-2.
  3. compare.py mesure le taux d’erreur par mot et vérifie que les informations clés (immatriculation, téléphone, kilométrage, heure du rendez-vous) sont bien écrites.
  4. extract.py transforme la transcription en fiche JSON avec un modèle Azure OpenAI, en signalant ce qui est douteux au lieu de le corriger en silence.

Le tout tourne en quelques minutes.

Prérequis : une ressource Speech dans la bonne région

C’est le premier piège. LLM Speech et MAI-Transcribe ne sont disponibles que dans six régions : centralindia, eastus, northeurope, southeastasia, westus et westus2. Une ressource en France Central répond Enhanced mode is currently not supported yet, une ressource en Sweden Central répond The specified model is not supported. Pour garder les données dans l’Union européenne, North Europe (Irlande) est la seule option.

Le script suivant crée la ressource et écrit le fichier .env :

#!/usr/bin/env bash
# Crée une ressource Speech dans une région où LLM Speech est disponible
# (centralindia, eastus, northeurope, southeastasia, westus, westus2)
# et écrit le fichier .env. North Europe garde les données dans l'Union européenne.
set -euo pipefail
RG=${RG:-rg-tuto-llm-speech}
NAME=${NAME:-tuto-llm-speech-$RANDOM}
REGION=${REGION:-northeurope}

az group create -n "$RG" -l "$REGION" -o none
az cognitiveservices account create -n "$NAME" -g "$RG" -l "$REGION" \
  --kind SpeechServices --sku S0 --custom-domain "$NAME" --yes -o none

{
  echo "SPEECH_ENDPOINT=https://$NAME.cognitiveservices.azure.com"
  echo "SPEECH_KEY=$(az cognitiveservices account keys list -n "$NAME" -g "$RG" --query key1 -o tsv)"
  echo "TTS_REGION=$REGION"
} > .env
echo "Ressource $NAME créée. Ajoutez AOAI_ENDPOINT, AOAI_KEY et AOAI_DEPLOYMENT à .env pour extract.py."

Ensuite :

python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
requests>=2.32
python-dotenv>=1.0
numpy>=2.0
openai>=1.50

Pour la dernière étape, ajoutez à .env le point d’accès, la clé et le nom d’un déploiement de chat Azure OpenAI (nous avons utilisé gpt-5.4-mini). Le fichier .env.example liste les variables :

SPEECH_ENDPOINT=https://<ressource>.cognitiveservices.azure.com
SPEECH_KEY=<clé de la ressource Speech>
TTS_REGION=northeurope
AOAI_ENDPOINT=https://<ressource>.openai.azure.com/
AOAI_KEY=<clé Azure OpenAI>
AOAI_DEPLOYMENT=<nom du déploiement, par exemple gpt-5.4-mini>

Étape 1 : fabriquer un appel de test reproductible

Pour comparer des moteurs, il faut connaître le texte exact prononcé. Nous générons donc l’appel avec deux voix neuronales Azure (une pour l’agent du garage, une pour le client), directement au format téléphonique riff-8khz-16bit-mono-pcm. Le script écrit aussi reference.txt, le texte de référence, et deux copies bruitées : bruit de fond à 10 dB sous la voix, puis à 0 dB, c’est-à-dire aussi fort que la voix.

"""Fabrique un appel de test : deux voix Azure, son téléphonique 8 kHz.

Produit appel.wav (propre), appel_bruit.wav (bruit à 10 dB), appel_tres_bruit.wav (0 dB)
et reference.txt (le texte exact prononcé, pour mesurer les erreurs).
"""

import os
import wave

import numpy as np
import requests
from dotenv import load_dotenv

load_dotenv()

DIALOGUE = [
    ("agent", "Garage du Centre, bonjour. Je suis l’assistante vocale du garage, une intelligence artificielle. Que puis-je faire pour vous ?"),
    ("client", "Bonjour, oui, j’ai un voyant moteur orange qui s’est allumé hier sur ma Clio 4, et en plus j’entends un bruit de frottement à l’avant quand je freine."),
    ("agent", "D’accord. Pouvez-vous me donner l’immatriculation du véhicule ?"),
    ("client", "Oui, c’est GH-482-TK."),
    ("agent", "Merci. Et le kilométrage approximatif ?"),
    ("client", "138 000 kilomètres à peu près. Et je crois que la distribution n’a jamais été faite."),
    ("agent", "Je note. Je peux vous proposer jeudi à 8 h 30 pour un diagnostic, avec un contrôle des plaquettes et des disques. Cela vous convient ?"),
    ("client", "Jeudi 8 h 30, c’est parfait. Mon numéro, c’est le 06 12 34 56 78."),
    ("agent", "C’est noté. Rendez-vous jeudi à 8 h 30 pour la Clio immatriculée GH-482-TK. Bonne journée."),
]
VOICES = {"agent": "fr-FR-DeniseNeural", "client": "fr-FR-HenriNeural"}


def ssml() -> str:
    parts = [f'<voice name="{VOICES[who]}">{text}</voice>' for who, text in DIALOGUE]
    return (
        '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="fr-FR">'
        + "".join(parts)
        + "</speak>"
    )


def synthesize(path: str) -> None:
    r = requests.post(
        f"https://{os.environ['TTS_REGION']}.tts.speech.microsoft.com/cognitiveservices/v1",
        headers={
            "Ocp-Apim-Subscription-Key": os.environ["SPEECH_KEY"],
            "Content-Type": "application/ssml+xml",
            "X-Microsoft-OutputFormat": "riff-8khz-16bit-mono-pcm",
            "User-Agent": "tuto-llm-speech",
        },
        data=ssml().encode("utf-8"),
        timeout=60,
    )
    r.raise_for_status()
    with open(path, "wb") as f:
        f.write(r.content)


def add_noise(src: str, dst: str, snr_db: float = 10.0) -> None:
    """Ajoute un bruit de fond (bruit rose approché) au rapport signal/bruit voulu."""
    with wave.open(src) as w:
        params = w.getparams()
        signal = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float64)
    rng = np.random.default_rng(42)
    noise = np.cumsum(rng.normal(size=signal.size))  # marche aléatoire = bruit grave
    noise -= np.convolve(noise, np.ones(400) / 400, mode="same")
    noise += rng.normal(scale=noise.std() * 0.5, size=signal.size)
    gain = np.sqrt(np.mean(signal**2) / (np.mean(noise**2) * 10 ** (snr_db / 10)))
    mixed = np.clip(signal + gain * noise, -32768, 32767).astype(np.int16)
    with wave.open(dst, "wb") as w:
        w.setparams(params)
        w.writeframes(mixed.tobytes())


if __name__ == "__main__":
    synthesize("appel.wav")
    add_noise("appel.wav", "appel_bruit.wav", snr_db=10)
    add_noise("appel.wav", "appel_tres_bruit.wav", snr_db=0)
    with open("reference.txt", "w") as f:
        f.write("\n".join(text for _, text in DIALOGUE))
    print("appel.wav, appel_bruit.wav et reference.txt écrits")

L’appel dure 66 secondes. Il contient ce qui pose problème dans un vrai appel de service : une immatriculation dictée (« GH-482-TK »), un numéro de téléphone, un kilométrage, un horaire et du vocabulaire métier (distribution, plaquettes).

Étape 2 : transcrire avec trois moteurs, un seul point d’accès

Les trois moteurs passent par la même URL, /speechtotext/transcriptions:transcribe?api-version=2025-10-15. Seul le champ definition change.

Transcription classique

On impose la langue et on active la séparation des locuteurs (diarisation) :

{
  "locales": [
    "fr-FR"
  ],
  "diarization": {
    "enabled": true,
    "maxSpeakers": 2
  }
}

LLM Speech

On active enhancedMode avec la tâche transcribe. Pas besoin de préciser la langue : le mode multilingue est celui par défaut.

{
  "enhancedMode": {
    "enabled": true,
    "task": "transcribe"
  },
  "diarization": {
    "enabled": true,
    "maxSpeakers": 2
  }
}

MAI-Transcribe-2

On désigne le modèle dans enhancedMode. Attention, MAI-Transcribe-2 refuse maxSpeakers : l’API répond maxSpeakers is not supported for MAI transcription yet. On active donc la diarisation sans plafond.

{
  "enhancedMode": {
    "enabled": true,
    "model": "MAI-Transcribe-2"
  },
  "diarization": {
    "enabled": true
  }
}

Le script complet ajoute une liste de mots métier (phraseList), une option de consigne (prompt) que nous testons plus bas, et une reprise automatique sur les erreurs 429. Sur une ressource toute neuve, le quota par minute est vite atteint quand on enchaîne synthèse vocale et transcriptions.

"""Transcrit un appel avec l'API Fast Transcription d'Azure AI Speech.

Trois moteurs, même point d'accès :
  classique  -> le modèle de transcription habituel, langue imposée
  llm        -> LLM Speech (enhancedMode, task=transcribe)
  mai        -> MAI-Transcribe-2 (enhancedMode, model=MAI-Transcribe-2)

Usage : python transcribe.py appel.wav llm
"""

import json
import os
import sys
import time

import requests
from dotenv import load_dotenv

load_dotenv()

API_VERSION = "2025-10-15"

# Mots du métier que le modèle doit privilégier.
PHRASES = ["Clio", "distribution", "plaquettes", "kilométrage", "immatriculation"]


# Consigne pour LLM Speech (en anglais, comme le recommande la documentation).
PROMPT = (
    "This is a French phone call to a car garage. French license plates follow the "
    "format AA-123-AA: two letters, three digits, two letters. Write them in that format."
)


def definition(engine: str, phrases: bool = True, prompt: bool = False) -> dict:
    # MAI-Transcribe-2 refuse maxSpeakers (« not supported for MAI transcription yet »).
    d: dict = {"diarization": {"enabled": True} if engine == "mai" else {"enabled": True, "maxSpeakers": 2}}
    if engine == "classique":
        d["locales"] = ["fr-FR"]
    elif engine == "llm":
        d["enhancedMode"] = {"enabled": True, "task": "transcribe"}
        if prompt:
            d["enhancedMode"]["prompt"] = [PROMPT]
    elif engine == "mai":
        d["enhancedMode"] = {"enabled": True, "model": "MAI-Transcribe-2"}
    else:
        raise ValueError(engine)
    if phrases:
        d["phraseList"] = {"phrases": PHRASES}
    return d


def transcribe(audio_path: str, engine: str, phrases: bool = True, prompt: bool = False) -> tuple[dict, float]:
    url = f"{os.environ['SPEECH_ENDPOINT']}/speechtotext/transcriptions:transcribe?api-version={API_VERSION}"
    for attempt in range(5):
        with open(audio_path, "rb") as audio:
            start = time.perf_counter()
            r = requests.post(
                url,
                headers={"Ocp-Apim-Subscription-Key": os.environ["SPEECH_KEY"]},
                files={
                    "audio": (os.path.basename(audio_path), audio, "audio/wav"),
                    "definition": (None, json.dumps(definition(engine, phrases, prompt)), "application/json"),
                },
                timeout=300,
            )
            elapsed = time.perf_counter() - start
        if r.status_code != 429:
            break
        # Quota par minute dépassé : on attend ce que demande le service, sinon 2, 4, 8 s…
        time.sleep(float(r.headers.get("Retry-After", 2 ** (attempt + 1))))
    if not r.ok:
        raise RuntimeError(f"{engine}: {r.status_code} {r.text}")
    return r.json(), elapsed


def dialogue(result: dict) -> str:
    """Une ligne par prise de parole, avec le locuteur détecté."""
    lines = []
    for p in result.get("phrases", []):
        speaker = p.get("speaker", "?")
        start = p["offsetMilliseconds"] / 1000
        lines.append(f"[{start:5.1f}s] locuteur {speaker} : {p['text']}")
    return "\n".join(lines)


if __name__ == "__main__":
    audio, engine = sys.argv[1], sys.argv[2]
    result, elapsed = transcribe(audio, engine)
    print(dialogue(result))
    print(f"\n{engine} : {elapsed:.1f} s pour {result['durationMilliseconds'] / 1000:.0f} s d'audio")

Exemple :

python transcribe.py appel.wav llm
[ 49.9s] locuteur 2 : Mon numéro, c'est le 06 12 34 56 78.
[ 56.1s] locuteur 1 : C'est noté.
[ 57.8s] locuteur 1 : Rendez-vous jeudi à 8h30 pour la Clio immatriculée GH 482 TK.
[ 63.9s] locuteur 1 : Bonne journée.

llm : 2.5 s pour 66 s d'audio

Un détail qui casse les scripts : MAI-Transcribe-2 numérote les locuteurs à partir de 0, les deux autres moteurs à partir de 1. LLM Speech découpe aussi plus finement, souvent une phrase par segment, quand MAI-Transcribe-2 regroupe chaque tour de parole.

Étape 3 : comparer les moteurs

Le script de comparaison calcule le taux d’erreur par mot (WER) contre le texte de référence, après normalisation (minuscules, ponctuation retirée, « km » et « kilomètres » considérés comme identiques, « 8 h 30 » et « 8h30 » aussi). Il compte ensuite chaque occurrence des informations clés : 9 au total dans l’appel, dont l’immatriculation prononcée deux fois et l’horaire trois fois.

"""Compare les trois moteurs sur l'audio propre et l'audio bruité.

Pour chaque essai : taux d'erreur par mot (WER) contre reference.txt,
informations clés retrouvées, nombre de locuteurs détectés, temps de réponse.
Les transcriptions brutes sont gardées dans resultats/.
"""

import json
import os
import re
import unicodedata

from transcribe import transcribe

# Ce que l'agent doit absolument retrouver dans l'appel.
ENTITIES = {
    "immatriculation": r"gh\s*-?\s*482\s*-?\s*tk",
    "téléphone": r"06\s*12\s*34\s*56\s*78",
    "kilométrage": r"138\s*000",
    "heure": r"8\s*h\s*30",
    "modèle": r"clio\s*4",
    "distribution": r"distribution",
}


def normalize(text: str) -> list[str]:
    text = unicodedata.normalize("NFC", text.lower()).replace("’", "'")
    text = re.sub(r"\bkm\b", "kilomètres", text)
    text = re.sub(r"(\d)\s*h\s*(\d)", r"\1h\2", text)
    text = re.sub(r"(\d)\s+(\d{3})\b", r"\1\2", text)  # 138 000 -> 138000
    text = re.sub(r"[^\w'\s]", " ", text)
    return text.split()


def wer(reference: str, hypothesis: str) -> float:
    ref, hyp = normalize(reference), normalize(hypothesis)
    prev = list(range(len(hyp) + 1))
    for i, r in enumerate(ref, 1):
        cur = [i] + [0] * len(hyp)
        for j, h in enumerate(hyp, 1):
            cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (r != h))
        prev = cur
    return prev[-1] / len(ref)


def found(text: str, reference: str) -> dict[str, tuple[int, int]]:
    """Pour chaque information : (occurrences retrouvées, occurrences prononcées)."""
    return {
        name: (min(len(re.findall(p, text.lower())), n := len(re.findall(p, reference.lower()))), n)
        for name, p in ENTITIES.items()
    }


if __name__ == "__main__":
    reference = open("reference.txt").read()
    os.makedirs("resultats", exist_ok=True)
    print(f"{'audio':<22}{'moteur':<11}{'WER':>6}  {'infos clés':<11}{'locuteurs':>9}{'temps':>8}  manquantes")
    for audio in ("appel.wav", "appel_bruit.wav", "appel_tres_bruit.wav"):
        for engine in ("classique", "llm", "mai"):
            result, elapsed = transcribe(audio, engine)
            name = f"{audio.removesuffix('.wav')}_{engine}"
            with open(f"resultats/{name}.json", "w") as f:
                json.dump(result, f, ensure_ascii=False, indent=2)
            text = " ".join(p["text"] for p in result["phrases"])
            counts = found(text, reference)
            got, total = sum(g for g, _ in counts.values()), sum(n for _, n in counts.values())
            speakers = len({p.get("speaker") for p in result["phrases"]})
            missing = ", ".join(f"{k} {g}/{n}" for k, (g, n) in counts.items() if g < n) or "-"
            print(
                f"{audio:<22}{engine:<11}{wer(reference, text):>6.1%}  "
                f"{got:>2}/{total:<8}{speakers:>9}{elapsed:>7.1f}s  {missing}"
            )

Nos résultats :

Audio Moteur Erreurs par mot Infos clés Temps
propre Transcription classique 4.5% 8/9 1.9s
propre LLM Speech 0.8% 8/9 2.5s
propre MAI-Transcribe-2 0.8% 8/9 1.2s
bruit 10 dB Transcription classique 4.5% 7/9 2.1s
bruit 10 dB LLM Speech 0.8% 8/9 2.5s
bruit 10 dB MAI-Transcribe-2 0.8% 8/9 1.4s
bruit 0 dB Transcription classique 6.8% 7/9 2.0s
bruit 0 dB LLM Speech 3.0% 7/9 2.5s
bruit 0 dB MAI-Transcribe-2 1.5% 7/9 1.2s

Ce qu’on en retient :

  • LLM Speech et MAI-Transcribe-2 font 5 à 6 fois moins d’erreurs que la transcription classique sur l’audio propre (0,8 % contre 4,5 %). L’écart tient surtout à des détails qui comptent à la lecture : « immatricuée », la ponctuation, les tours de parole.
  • Le bruit fort dégrade surtout la diarisation du modèle classique. À 0 dB, il attribue le « Merci, et le kilométrage approximatif ? » de l’agent au client. LLM Speech et MAI-Transcribe-2 gardent les deux locuteurs sans erreur sur les trois audios.
  • MAI-Transcribe-2 est le plus rapide : environ 1,2 s pour 66 s d’audio, contre 2 à 2,5 s pour les deux autres, mesuré depuis Paris vers North Europe.
  • LLM Speech n’est pas déterministe. Sur l’audio à 0 dB, deux passages successifs ont donné 1,5 % puis 3,0 % d’erreurs. Pour évaluer un moteur, faites plusieurs passages.
  • Aucun moteur ne retrouve l’immatriculation dictée. Les trois écrivent « GH 482 ETK » : la voix de synthèse marque une liaison avant « TK », et les modèles l’entendent comme une lettre. Dans le bruit, la seconde occurrence devient « GH 482 CK ». Les chiffres, le numéro de téléphone et l’horaire, eux, sont justes partout.

Le champ prompt ne sauve pas l’immatriculation

La documentation de LLM Speech décrit un champ prompt pour orienter la transcription. Nous lui avons donné le format des plaques françaises (deux lettres, trois chiffres, deux lettres), en anglais comme le recommande la documentation. Résultat identique, au caractère près, avec ou sans consigne :

from transcribe import transcribe

for audio in ("appel.wav", "appel_tres_bruit.wav"):
    for prompt in (False, True):
        result, _ = transcribe(audio, "llm", prompt=prompt)
        ...
# appel.wav sans    ['GH 482 ETK', 'GH 482 TK']
# appel.wav prompt  ['GH 482 ETK', 'GH 482 TK']

C’est cohérent avec le tableau des fonctionnalités de la même documentation, qui marque la consigne personnalisée comme non prise en charge pour les trois moteurs. La documentation précise aussi que la version de LLM Speech est gérée par le service : on ne choisit pas 09-2026, elle arrive quand elle arrive. Au 1er novembre, la page de référence annonçait encore la version 07 2026 comme courante.

Étape 4 : de la transcription à une fiche exploitable

Une transcription ne sert à rien si quelqu’un doit la relire pour en tirer le rendez-vous. Dernière étape : un modèle Azure OpenAI remplit une fiche JSON avec un schéma strict (response_format de type json_schema). La consigne lui demande de ne rien inventer et de signaler dans a_confirmer toute information qui apparaît sous deux formes ou ne respecte pas son format.

"""Transforme la transcription d'un appel en fiche structurée (JSON).

Le modèle ne corrige rien en silence : si une information apparaît sous deux
formes différentes, il la marque « à confirmer ».

Usage : python extract.py resultats/appel_llm.json
"""

import json
import os
import sys

from dotenv import load_dotenv
from openai import AzureOpenAI

load_dotenv()

SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "required": ["motif", "vehicule", "immatriculation", "kilometrage", "rendez_vous", "telephone", "a_confirmer"],
    "properties": {
        "motif": {"type": "string"},
        "vehicule": {"type": "string"},
        "immatriculation": {"type": ["string", "null"]},
        "kilometrage": {"type": ["integer", "null"]},
        "rendez_vous": {"type": ["string", "null"]},
        "telephone": {"type": ["string", "null"]},
        "a_confirmer": {"type": "array", "items": {"type": "string"}},
    },
}

INSTRUCTIONS = (
    "Tu reçois la transcription automatique d’un appel à un garage. Remplis la fiche "
    "à partir de ce qui est dit, sans rien inventer. Une immatriculation française a le "
    "format AA-123-AA. Si une information est absente, mets null. Si elle apparaît sous "
    "des formes différentes ou ne respecte pas son format, garde la forme la plus probable "
    "et explique le doute dans a_confirmer."
)


def dialogue_text(result: dict) -> str:
    return "\n".join(f"Locuteur {p.get('speaker', '?')} : {p['text']}" for p in result["phrases"])


def extract(result: dict) -> dict:
    client = AzureOpenAI(
        azure_endpoint=os.environ["AOAI_ENDPOINT"],
        api_key=os.environ["AOAI_KEY"],
        api_version="2024-10-21",
    )
    response = client.chat.completions.create(
        model=os.environ["AOAI_DEPLOYMENT"],
        messages=[
            {"role": "system", "content": INSTRUCTIONS},
            {"role": "user", "content": dialogue_text(result)},
        ],
        response_format={"type": "json_schema", "json_schema": {"name": "fiche_appel", "strict": True, "schema": SCHEMA}},
    )
    return json.loads(response.choices[0].message.content)


if __name__ == "__main__":
    with open(sys.argv[1]) as f:
        print(json.dumps(extract(json.load(f)), ensure_ascii=False, indent=2))

Sur la transcription LLM Speech de l’audio le plus bruité :

{
  "motif": "Voyant moteur orange allumé et bruit de frottement à l'avant au freinage",
  "vehicule": "Renault Clio 4",
  "immatriculation": "GH-482-ETK",
  "kilometrage": 138000,
  "rendez_vous": "jeudi à 8h30",
  "telephone": "06 12 34 56 78",
  "a_confirmer": [
    "L'immatriculation a été donnée oralement comme \"GH 482 ETK\" puis reformulée en fin d'appel comme \"GH482CK\" ; forme retenue la plus probable : GH-482-ETK.",
    "Le client mentionne que la distribution n'a jamais été faite, sans confirmation."
  ]
}

Deux choses à voir. Le modèle a bien repéré que l’immatriculation était incohérente et l’a signalée, mais il a retenu la mauvaise forme, « GH-482-ETK », qui ne respecte même pas le format qu’on lui avait donné. Et il a écrit « Renault Clio 4 » alors que la marque n’est jamais prononcée. Sur la transcription de l’audio propre par MAI-Transcribe-2, un premier passage a retenu « GH-482-TK », la bonne valeur, et un second « GH-482-ETK ». Le choix change d’un passage à l’autre ; le signalement, lui, revient à chaque fois.

La leçon tient en une phrase : le champ a_confirmer est plus utile que le champ immatriculation. Une fiche qui affiche ses doutes permet à quelqu’un de vérifier en dix secondes. Une fiche qui corrige en silence produit des erreurs qu’on découvre le jour du rendez-vous.

Ce que ça change pour un agent vocal

Nous construisons des agents vocaux qui répondent au téléphone pour des entreprises. Ce test confirme plusieurs règles que nous appliquons :

  • Une donnée dictée se fait relire. Une immatriculation, une référence de dossier ou une adresse e-mail épelée se confirme à voix haute pendant l’appel, lettre par lettre, plutôt que de faire confiance à la transcription après coup.
  • Les chiffres passent bien, les lettres moins. Numéro de téléphone, kilométrage et horaire sont justes dans 100 % de nos essais, même à 0 dB. Les suites de lettres sont le point faible.
  • La séparation des locuteurs compte autant que les mots. Un résumé qui attribue la question de l’agent au client se trompe sur le fond. Sur ce point, LLM Speech et MAI-Transcribe-2 sont nettement plus robustes.
  • La région fait partie du choix. Si vos appels doivent rester dans l’Union européenne, seul North Europe propose ces moteurs aujourd’hui.

Limites de ce test

Un seul appel, des voix de synthèse et un bruit artificiel : ce n’est pas un banc d’essai. Les voix neuronales sont plus nettes qu’un vrai client dans sa voiture, et le bruit synthétique ne ressemble pas à une rue ou à un open space. Les écarts entre moteurs sont nets, mais les valeurs absolues seront plus élevées sur de vrais appels. Le code est fait pour ça : remplacez appel.wav et reference.txt par vos enregistrements et leur transcription corrigée, et relancez compare.py.

Pour nettoyer après le test, supprimez le groupe de ressources :

az group delete -n rg-tuto-llm-speech --yes

Sources

  • Microsoft Foundry Blog, « Announcing Azure AI Speech LLM 09-2026: Broader locale support », 26 octobre 2026.
  • Documentation Azure AI Speech : LLM Speech, MAI-Transcribe, régions et langues prises en charge (pages consultées le 1er novembre 2026).