📍 Guide principal de la série : Agents vocaux IA en 2026 : architecture, modèles, latence.
Brancher un agent vocal sur un numéro de téléphone classique reste la voie la plus directe pour automatiser des appels entrants ou sortants. Twilio Voice avec Media Streams est aujourd’hui le pivot le plus utilisé : un numéro à environ 1 USD/mois aux États-Unis, des appels reçus facturés 0,0085 USD/min sur un numéro local US, un protocole WebSocket bidirectionnel qui pipe l’audio des deux côtés. Ce tutoriel câble pas à pas un agent OpenAI Realtime sur un numéro Twilio entrant — depuis l’achat du numéro jusqu’à la gestion des interruptions et à la conversion μ-law ↔ PCM16.
Prérequis
- Un compte Twilio actif avec une carte bancaire validée
- Une clé API OpenAI avec accès à l’API Realtime
- Python 3.10+, FastAPI et uvicorn
- Un tunnel HTTPS public pour les tests (ngrok, Cloudflare Tunnel, localtunnel)
- Notions de TwiML, WebSocket et asyncio
- Temps estimé : 90 minutes
Étape 1 — Acheter un numéro Twilio capable de Voice
Le plus simple est de partir sur un numéro local US à 1,15 USD/mois (0,0085 USD/min reçu) ou un numéro français à 1,35 USD/mois (0,01 USD/min reçu). Tous les numéros Twilio sortis du Phone Numbers dashboard supportent par défaut Voice ; il suffit de cocher la capacité Voice dans le filtre de recherche. Pour un agent francophone, un numéro français ou belge donne la meilleure qualité de codec et la latence la plus basse vers les datacenters européens.
# Authentifier le CLI Twilio (une seule fois)
pip install twilio
twilio login # ou export TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN
# Acheter un numéro local français
twilio api:core:incoming-phone-numbers:local:list --country-code FR --limit 5
twilio api:core:incoming-phone-numbers:create --phone-number "+33XXXXXXXXX"
La commande renvoie un objet JSON avec le sid du numéro et son phone_number. La carte bancaire est facturée immédiatement de la mensualité. À ce stade, le numéro existe mais n’est rattaché à aucun webhook : un appel entrant tombera sur un message d’erreur Twilio. C’est l’objet de l’étape suivante.
Étape 2 — Mettre en place le webhook TwiML d’accueil
Quand quelqu’un appelle le numéro, Twilio fait un POST sur l’URL Voice URL configurée et attend en réponse une instruction TwiML (XML). Pour un agent vocal, la réponse instruit Twilio d’ouvrir un Media Stream bidirectionnel vers notre serveur.
# server.py
from fastapi import FastAPI, Request, WebSocket
from fastapi.responses import Response
app = FastAPI()
@app.post("/twilio/incoming")
async def incoming(request: Request):
host = request.url.hostname
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="wss://{host}/twilio/media-stream"/>
</Connect>
</Response>"""
return Response(content=twiml, media_type="application/xml")
Trois éléments structurent ce TwiML. <Connect> active le mode bidirectionnel — l’audio circule dans les deux sens, indispensable pour un agent qui parle. <Stream url="wss://..."/> indique l’endpoint WebSocket auquel Twilio se connectera pour pousser l’audio entrant et recevoir l’audio sortant. Twilio impose le protocole wss (TLS) et n’accepte aucun ws://, ce qui rend obligatoire un certificat valide ou un tunnel HTTPS pour les tests locaux.
Étape 3 — Exposer le serveur en HTTPS pour les tests
Le webhook et le WebSocket de Twilio doivent pointer sur une URL publique en HTTPS. En développement, le tunnel ngrok ou Cloudflare Tunnel est la voie la plus directe.
# Terminal 1 : lancer le serveur Python
uvicorn server:app --host 0.0.0.0 --port 8000
# Terminal 2 : exposer en HTTPS
ngrok http 8000
# → Forwarding https://abcd-1234.ngrok-free.app -> localhost:8000
# Terminal 3 : configurer le webhook côté Twilio
twilio phone-numbers:update +33XXXXXXXXX \
--voice-url=https://abcd-1234.ngrok-free.app/twilio/incoming \
--voice-method=POST
Une fois la commande passée, on peut composer le numéro Twilio depuis n’importe quel téléphone : Twilio fait un POST sur /twilio/incoming, le serveur répond avec le TwiML, et Twilio tente d’ouvrir le WebSocket. Si la réponse TwiML est mal formée ou si l’URL ne répond pas, Twilio joue un message d’erreur pré-enregistré ; le tableau de bord Logs → Calls indique précisément quelle étape a échoué.
Étape 4 — Recevoir l’audio Twilio en μ-law 8 kHz
Le WebSocket de Twilio Media Streams envoie quatre types de messages : connected, start, media et stop. Les messages media contiennent du payload audio en μ-law 8 kHz mono encodé en base64 — pas du PCM16. Il faudra convertir avant d’envoyer à OpenAI Realtime qui attend du PCM16 16 kHz.
import base64, json, audioop
from fastapi import WebSocket
@app.websocket("/twilio/media-stream")
async def media_stream(ws: WebSocket):
await ws.accept()
stream_sid = None
async for raw in ws.iter_text():
msg = json.loads(raw)
if msg["event"] == "start":
stream_sid = msg["start"]["streamSid"]
print("Stream started", stream_sid)
elif msg["event"] == "media":
# μ-law 8 kHz → PCM16 16 kHz
ulaw = base64.b64decode(msg["media"]["payload"])
pcm8k = audioop.ulaw2lin(ulaw, 2)
pcm16k, _ = audioop.ratecv(pcm8k, 2, 1, 8000, 16000, None)
# → à envoyer à OpenAI Realtime ici
elif msg["event"] == "stop":
print("Stream ended")
break
Trois mots sur la conversion. audioop.ulaw2lin reconvertit le payload G.711 en PCM16, audioop.ratecv ré-échantillonne 8 kHz vers 16 kHz, et l’ensemble doit ensuite être envoyé à OpenAI sous la forme input_audio_buffer.append. Note : le module audioop a été déprécié en Python 3.13 ; les projets nouveaux peuvent utiliser scipy.signal.resample ou la bibliothèque pydub. La latence ajoutée par cette conversion est négligeable (< 5 ms par chunk de 20 ms).
Étape 5 — Bridger Twilio et OpenAI Realtime
Le pattern qui fonctionne en production est simple : un coroutine pousse l’audio de Twilio vers OpenAI, un autre pousse l’audio d’OpenAI vers Twilio, et un orchestrateur gère les événements de contrôle (start, stop, interruption).
import asyncio, websockets, audioop
async def bridge(twilio_ws, stream_sid):
OAI_URI = "wss://api.openai.com/v1/realtime?model=gpt-realtime"
HEADERS = {"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
"OpenAI-Beta": "realtime=v1"}
async with websockets.connect(OAI_URI, additional_headers=HEADERS) as oai # websockets >= 14 ; pour < 14 utiliser extra_headers:
await oai.send(json.dumps({"type": "session.update", "session": {
"modalities": ["audio", "text"],
"instructions": "Tu es Aïda, agent téléphonique francophone…",
"voice": "alloy",
"input_audio_format": "g711_ulaw", # ← Twilio natif
"output_audio_format": "g711_ulaw",
"turn_detection": {"type": "server_vad", "silence_duration_ms": 600},
}}))
async def twilio_to_oai():
async for raw in twilio_ws.iter_text():
msg = json.loads(raw)
if msg["event"] == "media":
await oai.send(json.dumps({
"type": "input_audio_buffer.append",
"audio": msg["media"]["payload"],
}))
async def oai_to_twilio():
async for raw in oai:
ev = json.loads(raw)
if ev["type"] == "response.audio.delta":
await twilio_ws.send_text(json.dumps({
"event": "media",
"streamSid": stream_sid,
"media": {"payload": ev["delta"]},
}))
await asyncio.gather(twilio_to_oai(), oai_to_twilio())
Deux astuces majeures. OpenAI Realtime accepte le format g711_ulaw en sortie depuis octobre 2024 ; côté entrée, le pattern le plus fiable en 2026 reste la conversion μ-law → PCM16 côté serveur, ce qui évite les bugs de format documentés sur les tickets GitHub. La séparation en deux coroutines permet de garder la latence basse même si une direction est temporairement bloquée. Le résultat est un agent qui répond entre 700 et 900 ms après la fin de phrase de l'appelant — soit dans le budget perceptif acceptable malgré le saut téléphonie + WebSocket + OpenAI.
Étape 6 — Gérer les interruptions et la fin d'appel
L'utilisateur peut raccrocher à tout moment et l'agent doit le détecter. Twilio envoie un message stop et coupe la connexion WebSocket. Côté interruption (l'utilisateur reprend la parole), le server_vad d'OpenAI émet input_audio_buffer.speech_started, qu'on relaye en envoyant un clear à Twilio pour vider le buffer audio en cours de lecture.
elif ev["type"] == "input_audio_buffer.speech_started":
# interruption détectée — vider la file Twilio
await twilio_ws.send_text(json.dumps({
"event": "clear", "streamSid": stream_sid,
}))
await oai.send(json.dumps({"type": "response.cancel"}))
Sans cet event: clear, Twilio continue à jouer la file audio bufferisée même après l'arrêt côté OpenAI : l'utilisateur entend la fin de la phrase précédente sur sa nouvelle prise de parole, ce qui ruine l'expérience. Le message clear est documenté dans le protocole Media Streams et est instantané (< 50 ms).
Étape 7 — Lancer un appel sortant programmé
Pour un agent qui appelle (rappel automatique, prise de rendez-vous proactive), Twilio expose l'endpoint Calls qui accepte un From, un To et une URL TwiML à exécuter quand l'appel est décroché.
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"],
os.environ["TWILIO_AUTH_TOKEN"])
call = client.calls.create(
to="+33611111111",
from_="+33XXXXXXXXX",
url="https://abcd-1234.ngrok-free.app/twilio/incoming",
machine_detection="DetectMessageEnd",
)
print("Call SID:", call.sid)
Le paramètre machine_detection permet à Twilio de détecter un répondeur et de basculer sur un comportement différent (laisser un message, raccrocher). En 2026 c'est un mécanisme stable, avec un faux taux de détection ~5 % en environnement francophone. Pour un cas d'usage commercial, prévoir un consentement préalable et respecter le cadre RGPD : enregistrement, opt-out à la voix, durée maximale d'appel.
Étape 8 — Tester et mesurer la qualité
Le tableau de bord Twilio fournit nativement la durée d'appel, le coût et la qualité réseau côté trunk. Pour la latence end-to-end de l'agent, l'instrumentation côté serveur est la seule source fiable : enregistrer le timestamp de chaque input_audio_buffer.speech_stopped et le comparer au premier response.audio.delta.
last_user_end = None
async for raw in oai:
ev = json.loads(raw)
if ev["type"] == "input_audio_buffer.speech_stopped":
last_user_end = time.perf_counter()
elif ev["type"] == "response.audio.delta" and last_user_end:
ms = (time.perf_counter() - last_user_end) * 1000
print(f"Latence E2E téléphonique : {ms:.0f} ms")
last_user_end = None
Les valeurs typiques observées sur Twilio FR ↔ OpenAI EU se situent entre 750 et 950 ms. Si la latence dépasse régulièrement 1 100 ms, vérifier dans cet ordre : la région du serveur de bridge, le format g711_ulaw bien activé, et la présence de silence_duration_ms à 500 plutôt que la valeur par défaut de 700.
Erreurs fréquentes
| Symptôme | Cause | Solution |
|---|---|---|
| Twilio joue "application error" | Webhook ne renvoie pas de TwiML valide | Vérifier Content-Type: application/xml |
| Pas d'audio sortant | streamSid manquant dans le payload | Toujours inclure "streamSid": stream_sid |
| Distorsion métallique sur la voix | Conversion μ-law mal faite | Utiliser g711_ulaw directement côté OpenAI |
| Coupures de l'agent à mi-phrase | Pas de clear sur interruption |
Implémenter le bloc speech_started |
| Coût Twilio inattendu | Numéros premium ou roaming | Activer le filtre Voice Geo Permissions |
| Appels coupés à 4 heures | Limite session WebSocket OpenAI | Reconnecter avec un nouveau session.id |
À lire ensuite
- Documentation Twilio Media Streams
- TwiML <Stream>
- Tutoriel officiel Twilio + OpenAI Realtime + Python
- 🔝 Retour au guide principal : Agents vocaux IA en 2026 : architecture, modèles, latence
- Tutoriel précédent : OpenAI Realtime API en WebRTC
- Tutoriel suivant : RAG conversationnel pour agent vocal