La session Live avec ADK
Premier essai d'un assistant vocal, en test interne : la voix de l'assistant sort accélérée et aiguë, comme un disque en 45 tours. Deuxième essai : la voix est correcte, mais l'assistant attend quatre secondes de silence avant de répondre. Troisième essai : il se coupe lui-même la parole. Les trois défauts viennent de la même couche, celle qui transporte l'audio et découpe les tours de parole, et aucun ne se voit dans un test en texte.
Le mécanisme : une file en entrée, un flux d'événements en sortie
ADK expose la Live API par Runner.run_live(). On ne lui envoie pas des messages : on remplit une LiveRequestQueue (audio en continu, ou contenu texte), et on lit un flux d'événements jusqu'à la fin de l'appel.
sequenceDiagram participant M as "Micro (16 kHz)" participant Q as "LiveRequestQueue" participant R as "run_live (ADK)" participant L as "Live API" participant O as "Outils" M->>Q: send_realtime(Blob PCM 100 ms) Q->>L: audio en continu L-->>R: input_transcription (partielle puis finale) L-->>R: function_call chercher_creneaux R->>O: exécute l'outil O-->>R: dict résultat R->>L: function_response L-->>R: audio 24 kHz + output_transcription L-->>R: turn_complete
Les formats sont fixés par l'API : entrée PCM 16 bits mono 16 kHz, sortie PCM 16 bits mono 24 kHz. Lire la sortie à 16 kHz donne une voix ralentie et grave ; lire l'entrée comme du 24 kHz donne le « 45 tours » de l'introduction. Les deux constantes sont dans audio.py et nulle part ailleurs.
Mise en œuvre : ouvrir la session
rc = RunConfig(
streaming_mode=StreamingMode.BIDI,
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=self.cfg.voix))),
input_audio_transcription=types.AudioTranscriptionConfig(),
output_audio_transcription=types.AudioTranscriptionConfig(),
)
# L'appelant décroche : le modèle parle en premier (accueil + mention de l'assistant automatique).
self._fin_parole, self._accueil = time.time(), True
self.queue.send_content(types.Content(role="user", parts=[types.Part(text="[décroché]")]))
async for ev in self.runner.run_live(user_id=self.id, session_id=session.id,
live_request_queue=self.queue, run_config=rc):
await self.traiter(ev)Les deux AudioTranscriptionConfig activent la transcription des deux voix. Elles servent trois fois : à l'affichage, au journal, et au garde-fou de confirmation (la dernière phrase transcrite de l'appelant est ce que reserver_creneau vérifie).
Le message [décroché] règle un détail d'ergonomie téléphonique : c'est l'appelé qui parle en premier. La consigne dit exactement quoi répondre à ce signal, y compris la phrase « Vous êtes en ligne avec l'assistant vocal automatique du cabinet ».
Mise en œuvre : traduire les événements
traiter(ev) est le seul endroit qui connaît la forme des événements ADK. Il les traduit en état, en journal et en messages pour l'interface :
| Champ de l'événement | Ce qu'on en fait |
|---|---|
input_transcription | bulle de l'appelant ; mise à jour de derniere_phrase_appelant |
content.parts[].inline_data (audio) | relais au navigateur, mesure de latence, enregistrement si consenti |
function_call / function_response | journal (numéro masqué), événement « outil » à l'écran |
output_transcription | bulle de l'assistant |
usage_metadata | jetons par modalité (texte, audio), base du calcul de coût |
interrupted | l'appelant a coupé la parole : le navigateur vide son tampon de lecture |
turn_complete | fin du tour ; si un transfert a eu lieu, fin de l'appel |
Un piège : un turn_complete arrive aussi après un tour qui n'a produit qu'un appel d'outil, sans un mot. Le code ne considère un tour terminé que s'il a contenu de l'audio (_audio_ce_tour), sinon le testeur automatique reprendrait la parole pendant que l'assistant s'apprête à répondre.
La latence de première réponse
La mesure qui compte pour l'appelant : entre la fin de sa phrase et le premier son de l'assistant. Le code la prend au premier fragment audio reçu après fin_de_parole(t) :
async def _audio_assistant(self, pcm: bytes, t: float) -> None:
if self._fin_parole is not None:
lat = round(t - self._fin_parole, 2)
self._fin_parole = NoneDans les appels de test, fin_de_parole est appelée à l'instant exact où le fichier audio se termine. Au micro, on ne connaît pas cet instant : le code prend le dernier fragment de transcription reçu, ce qui sous-estime la latence (commentaire ponytail: dans le code ; l'extrait de journal plus bas montre pourquoi). La latence d'accueil (décroché → première syllabe) est mesurée à part : elle inclut l'ouverture de la session.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Voix accélérée ou ralentie | fréquence d'échantillonnage confondue | 16 kHz en entrée, 24 kHz en sortie, déclarés dans mime_type="audio/pcm;rate=16000" |
| L'assistant ne répond jamais après la dernière phrase | le micro s'arrête net, la VAD n'entend jamais de silence | continuer d'envoyer du silence tant que l'appel est ouvert (c'est ce que fait jouer()) |
| Le testeur parle par-dessus l'assistant | turn_complete après un tour sans audio | ne compter que les tours qui ont produit de l'audio |
| L'assistant coupe la parole pendant la dictée d'un numéro | la VAD du modèle prend chaque pause pour une fin de tour (constaté avec gemini-live-2.5-flash-native-audio) | SILENCE_MS=800 : realtime_input_config avec un silence de fin de tour plus long, au prix d'environ 0,5 s de latence (leçon 5) |
1008 … Publisher model … was not found sur Vertex AI | le modèle Live n'existe pas dans cette région | vérifié le 2026-10-16 : gemini-3.8-live en us-central1, gemini-live-2.5-flash-native-audio en europe-west1 |
| Coupure en plein appel après une dizaine de minutes | une connexion Live dure environ 10 min, une session audio sans compression 15 min (documentation « Session management ») | les appels de test durent 26 à 91 s ; pour des appels longs, activez la reprise de session et la compression du contexte |
La règle de décision
Passez par ADK (run_live) quand l'assistant a des outils : ADK exécute les appels de fonction, renvoie les réponses au modèle et garde la session. Utilisez le SDK google-genai directement (client.aio.live.connect) pour un flux audio sans outil, ou si vous avez besoin d'un champ de configuration qu'ADK ne transmet pas encore. Dans les deux cas, gardez un seul module qui connaît la forme des événements.
Résultat obtenu
Extrait réel du journal d'un appel de test, exécution du 2026-10-15 (sorties/appels/e2e-reservation/journal.jsonl, numéro masqué par le code) :
{"t": 1.145, "appel": "e2e-reservation", "type": "latence_accueil", "secondes": 1.13}
{"t": 25.968, "appel": "e2e-reservation", "type": "transcription", "role": "appelant", "texte": "Bonjour, je m'appelle Claire Martin. Je voudrais un rendez-vous de kiné pour une rééducation du genou. […]"}
{"t": 25.97, "appel": "e2e-reservation", "type": "latence", "secondes": 0.68}
{"t": 37.226, "appel": "e2e-reservation", "type": "transcription", "role": "appelant", "texte": "plutôt le matin à partir de lundi prochain"}
{"t": 37.228, "appel": "e2e-reservation", "type": "outil", "nom": "chercher_creneaux", "args": {"a_partir_du": "2026-10-19", "type_seance": "kinesitherapie", "moment": "matin"}}
{"t": 37.23, "appel": "e2e-reservation", "type": "resultat", "nom": "chercher_creneaux", "resultat": {"jour_demande_disponible": true, "creneaux": [{"creneau_id": 27, "quand": "lundi 19 octobre à 8h30", "praticien": "Mme Durand"}, […]]}}
{"t": 37.231, "appel": "e2e-reservation", "type": "usage", "entree": 2687, "sortie": 48, "total": 2735, "entree_detail": {"TEXT": 1447, "AUDIO": 979}, "sortie_detail": {}}
{"t": 37.931, "appel": "e2e-reservation", "type": "latence", "secondes": 1.47}Deux lectures utiles. L'outil répond en 2 ms : la latence de 1,47 s de ce tour est celle du modèle, pas de SQLite. Et la transcription finale de l'appelant arrive au moment où l'assistant commence à répondre (25,968 s puis 25,97 s) : c'est pourquoi le serveur ne peut pas s'en servir pour dater la fin de parole.
.venv/bin/python -m pytest -q tests/test_appel.py tests/test_audio.py # 10 tests, faux événements ADKPratique
Vos appels de test passent tous, mais au micro les patients se plaignent que l'assistant « met longtemps à répondre », alors que votre tableau de bord affiche une médiane de 1,1 s. Que soupçonnez-vous d'abord ?
Le modèle est le même. Au micro, la fin de parole réelle n'est pas observée par le serveur : la mesure démarre plus tard qu'elle ne le devrait. Il faut l'instrumenter côté navigateur (énergie du signal) ou activer les signaux de VAD explicites avant de conclure. Le réseau est possible, mais se vérifie après avoir corrigé la mesure.
Un collègue propose de remplacer la transcription d'entrée par un second appel à un modèle texte « plus fiable ». Quel est le coût caché ?
La transcription de la Live API est produite pendant l'écoute, sans aller-retour supplémentaire. La remplacer ajoute une étape séquentielle par tour, et crée deux vérités : ce que le modèle a compris et ce que le garde-fou lit. Le coût en jetons existe aussi, mais c'est le moindre problème.
Exercice. Écrivez un test sans réseau qui vérifie qu'un événement interrupted envoie bien {"type": "interrompu"} à l'interface. Ajoutez-le à test_appel.py, qui fournit déjà la fabrique ev() et les fixtures.
Voir la solution
def test_interruption_relayee(cfg, agenda):
recus = []
async def emettre(m):
recus.append(m)
async def scenario():
a = Appel(cfg, agenda, emettre, appel_id="t4")
await a.traiter(ev(interrupted=True))
a.fermer()
asyncio.run(scenario())
assert {"type": "interrompu"} in recusev() fabrique un Event ADK : aucune session Live n'est ouverte, le test vérifie uniquement l'aiguillage.
- Je sais quels formats audio la Live API attend et renvoie
- Je sais lire un flux d'événements
run_liveet dire lequel clôt un tour - Je sais mesurer une latence de première réponse et dire ce que ma mesure sous-estime