Tutoriel pas à pas : construire un assistant IA audio-vidéo à faible latence avec Gemini Multimodal Live API

Gemini Live API prend en charge nativement l’entrée et la sortie audio ; son architecture de bout en bout maintient la latence sous 500 ms, sans relais ASR/TTS. Cet article montre comment construire un assistant IA capable de dialoguer en temps réel avec cette API.
Qu’est-ce que Gemini Multimodal Live API ?
Commençons par poser les bases. Comment fonctionne l’API Gemini classique ? Vous envoyez du texte, vous recevez du texte — simple. Mais pour une interaction vocale, il faut brancher ASR (reconnaissance vocale) et TTS (synthèse vocale) vous-même ; les allers-retours intermédiaires font grimper la latence.
Gemini Multimodal Live API est différent : il prend en charge nativement l’entrée et la sortie audio. Le son capté par le micro part directement vers le modèle, qui renvoie un flux audio pur — pas de conversion intermédiaire. Cette architecture de bout en bout maintient la latence sous 500 ms.
Lors d’un projet domotique, j’ai testé cette fonctionnalité. L’utilisateur disait « baisse un peu la lumière du salon », et l’IA répondait presque dès la fin de la phrase — une fluidité qui fait oublier qu’on parle à un programme.
Le modèle supporté actuellement est gemini-2.0-flash-native-audio-preview. Notez ce numéro de version : Google itère rapidement ; surveillez les mises à jour.
Conception de l’architecture et choix techniques
Passons à la construction du système. Je recommande une architecture front/back séparée, pour une raison simple : la clé API ne doit jamais être exposée côté frontend.
Le flux de données global :
[Navigateur] --WebSocket--> [Proxy backend Python] --WebSocket--> [Gemini Live API]
| | |
Capture micro Relais + logique métier Traitement IA
Haut-parleur VAD / contrôle Barge-in Génération audio
Pourquoi ne pas connecter le navigateur directement à Gemini ? Techniquement possible, mais cela implique d’écrire la clé API dans le JavaScript — n’importe qui ouvre les outils de développement et la récupère. Je l’ai fait une fois ; la facture du lendemain m’a servi de leçon.
Notre stack technique :
| Couche | Technologie | Usage |
|---|---|---|
| Frontend | JavaScript natif + Web Audio API | Capture, lecture, traitement temps réel via AudioWorklet |
| Backend | Python 3.9+ + bibliothèque websockets | Proxy WebSocket, détection VAD, gestion de session |
| Protocole | WebSocket + JSON | Communication bidirectionnelle avec Gemini |
AudioWorklet dans Web Audio API est un atout : il traite l’audio dans un thread dédié sans bloquer le thread principal. Le code concret suit plus bas.
Établissement de la connexion WebSocket et gestion de session
Place au code. Première étape : se connecter au service Gemini.
L’endpoint WebSocket de Live API :
wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=YOUR_API_KEY
Notez le v1alpha : version preview, l’interface peut évoluer — prudence en production.
Une fois connecté, envoyez d’abord le message Setup pour indiquer à Gemini comment dialoguer :
import asyncio
import json
import websockets
GEMINI_API_KEY = "your-api-key-here"
GEMINI_WS_URL = (
f"wss://generativelanguage.googleapis.com/ws/"
f"google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent"
f"?key={GEMINI_API_KEY}"
)
CONFIG = {
"setup": {
"model": "models/gemini-2.0-flash-native-audio-preview",
"generation_config": {
"response_modalities": ["AUDIO"],
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Charon" # Options : Charon, Aoede, etc.
}
}
}
},
"system_instruction": {
"parts": [{"text": "Tu es un assistant IA utile, réponds de façon concise et naturelle."}]
}
}
}
async def connect():
async with websockets.connect(GEMINI_WS_URL) as ws:
# Envoyer la configuration setup
await ws.send(json.dumps(CONFIG))
# Attendre la réponse setup complete
response = await ws.recv()
data = json.loads(response)
if "setupComplete" in data:
print("✅ Connexion établie, prêt à dialoguer")
return ws
else:
raise Exception(f"Setup échoué : {data}")
Quelques paramètres à noter :
response_modalities:["AUDIO"]pour une réponse vocale uniquement. Pour du texte aussi, utilisez["AUDIO", "TEXT"]voice_name: Gemini propose plusieurs voix prédéfinies ; je préfèreCharon, plus posée
Pour la reconnexion après déconnexion, je recommande un backoff exponentiel — pas de rafales de tentatives qui surchargent le service :
async def connect_with_retry(max_retries=5):
for attempt in range(max_retries):
try:
return await connect()
except Exception as e:
wait_time = min(2 ** attempt, 30) # Maximum 30 secondes
print(f"Échec de connexion ({e}), nouvelle tentative dans {wait_time}s...")
await asyncio.sleep(wait_time)
raise Exception("Impossible de se connecter après plusieurs tentatives")
Capture et transmission du flux audio PCM 16 kHz
Connexion établie : d’où vient l’audio et comment l’envoyer ?
Pourquoi 16 kHz ? La voix humaine couvre généralement 85 Hz à 255 Hz (voix masculine plus basse, féminine plus haute). Selon Nyquist, 8 kHz suffit en théorie. En pratique, 16 kHz est le sweet spot : qualité correcte sans volume de données excessif. Gemini recommande aussi ce taux.
Code de capture côté frontend :
class AudioRecorder {
constructor() {
this.sampleRate = 16000;
this.bufferSize = 1024;
this.audioContext = null;
this.workletNode = null;
this.stream = null;
this.onAudioData = null; // Fonction de callback
}
async start() {
// Demander l'accès au micro
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
// Créer AudioContext avec taux d'échantillonnage forcé
this.audioContext = new AudioContext({
sampleRate: 16000
});
// Charger le processeur AudioWorklet
await this.audioContext.audioWorklet.addModule('pcm-processor.js');
const source = this.audioContext.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor');
// Traiter les données audio
this.workletNode.port.onmessage = (event) => {
const float32Data = event.data;
// Convertir en Int16 PCM
const int16Data = this.float32ToInt16(float32Data);
// Encoder en Base64 puis envoyer
const base64Data = btoa(String.fromCharCode(...new Uint8Array(int16Data.buffer)));
if (this.onAudioData) {
this.onAudioData(base64Data);
}
};
source.connect(this.workletNode);
console.log('🎤 Capture audio démarrée');
}
float32ToInt16(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
// Float32 (-1.0 ~ 1.0) -> Int16 (-32768 ~ 32767)
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Array;
}
stop() {
if (this.workletNode) {
this.workletNode.disconnect();
}
if (this.audioContext) {
this.audioContext.close();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
console.log('🛑 Capture audio arrêtée');
}
}
AudioWorklet nécessite un fichier séparé pcm-processor.js :
// pcm-processor.js
class PCMProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input && input[0]) {
// Envoyer au thread principal
this.port.postMessage(input[0].slice());
}
return true; // Maintenir le processeur actif
}
}
registerProcessor('pcm-processor', PCMProcessor);
Côté backend, relayer les données vers Gemini :
async def send_audio(ws, base64_pcm_data):
"""Envoyer les données audio à Gemini"""
message = {
"realtime_input": {
"media_chunks": [{
"mime_type": "audio/pcm;rate=16000",
"data": base64_pcm_data
}]
}
}
await ws.send(json.dumps(message))
Piège à connaître : certains navigateurs ignorent le sampleRate spécifié dans getUserMedia et renvoient du 44,1 kHz ou 48 kHz. Par sécurité, resamplez dans AudioContext ou utilisez une bibliothèque comme audiobuffer-to-wav.
Implémentation de la détection d’activité vocale (VAD)
Problème suivant : envoyer tout l’audio à Gemini, y compris le silence, gaspille bande passante et budget. Entre en jeu le VAD (Voice Activity Detection, détection d’activité vocale).
Le VAD répond à une question simple : y a-t-il de la parole dans ce segment ? On n’envoie que quand quelqu’un parle.
Je recommande WebRTC VAD de Google — léger, rapide, efficace. Python dispose du wrapper webrtcvad :
import webrtcvad
import collections
import numpy as np
class VADProcessor:
def __init__(self, aggressiveness=2, frame_duration_ms=20):
"""
aggressiveness: 0-3, plus haut = plus strict (parole classée silence)
frame_duration_ms: 10, 20, or 30
"""
self.vad = webrtcvad.Vad(aggressiveness)
self.frame_duration_ms = frame_duration_ms
self.sample_rate = 16000
# Tampon circulaire pour lissage
self.ring_buffer = collections.deque(maxlen=30) # 600ms
self.triggered = False
def process_frame(self, pcm_bytes):
"""
Traiter une trame audio, retourne si envoi nécessaire
"""
is_speech = self.vad.is_speech(pcm_bytes, self.sample_rate)
if not self.triggered:
# État non déclenché : accumuler les trames vocales
self.ring_buffer.append((pcm_bytes, is_speech))
num_voiced = sum(1 for _, speech in self.ring_buffer if speech)
# Si 90 % des trames sont vocales, déclencher
if num_voiced > 0.9 * self.ring_buffer.maxlen:
self.triggered = True
# Envoyer aussi les données du tampon
return b''.join([f for f, _ in self.ring_buffer])
return None
else:
# État déclenché
if is_speech:
self.ring_buffer.append((pcm_bytes, True))
return pcm_bytes
else:
self.ring_buffer.append((pcm_bytes, False))
num_unvoiced = sum(1 for _, speech in self.ring_buffer if not speech)
# Si 90 % de silence, fin du déclenchement
if num_unvoiced > 0.9 * self.ring_buffer.maxlen:
self.triggered = False
self.ring_buffer.clear()
return pcm_bytes
Utilisation typique :
vad = VADProcessor(aggressiveness=2)
async def handle_client_audio(websocket, gemini_ws):
async for message in websocket:
data = json.loads(message)
if 'audio' in data:
pcm_bytes = base64.b64decode(data['audio'])
# Détection VAD
result = vad.process_frame(pcm_bytes)
if result:
# Parole détectée, relayer vers Gemini
await send_audio(gemini_ws, base64.b64encode(result).decode())
Le paramètre aggressiveness est délicat : trop bas, le bruit ambiant passe pour de la parole ; trop haut, les voix basses peuvent être manquées. Commencez à 2 et ajustez selon votre scénario.
Si webrtcvad n’est pas installable dans votre environnement, une détection par seuil d’énergie suffit en secours :
// Secours frontend : détection simple basée sur l'énergie RMS
function detectVoiceActivity(audioData, threshold = 0.015) {
const sum = audioData.reduce((acc, val) => acc + val * val, 0);
const rms = Math.sqrt(sum / audioData.length);
return rms > threshold;
}
Implémentation du Barge-in (interruption naturelle)
Vous connaissez cette frustration : certains assistants vocaux partent dans un long monologue et vous devez attendre — impossible d’interrompre.
Le Barge-in (interruption) règle ce problème. Pendant que l’IA parle, l’utilisateur peut reprendre la parole ; l’IA arrête immédiatement sa sortie et écoute.
Bonne nouvelle : Gemini Live API le prend en charge nativement. Activez la détection automatique d’activité dans la configuration :
CONFIG = {
"setup": {
"model": "models/gemini-2.0-flash-native-audio-preview",
"generation_config": {
"response_modalities": ["AUDIO"],
},
"realtime_input_config": {
"automatic_activity_detection": {
"disabled": False,
"start_of_speech_sensitivity": "START_SENSITIVITY_HIGH",
"end_of_speech_sensitivity": "END_SENSITIVITY_LOW"
}
}
}
}
Réglages de sensitivity :
start_of_speech_sensitivityàHIGH: l’IA détecte plus facilement le début de parole de l’utilisateur et déclenche l’interruptionend_of_speech_sensitivityàLOW: l’IA attend plus longtemps pour confirmer que l’utilisateur a fini, évitant les faux positifs
Côté client, écoutez l’événement interrupted et arrêtez la lecture :
class GeminiClient {
constructor() {
this.audioQueue = [];
this.isPlaying = false;
this.currentSource = null;
}
async handleMessage(event) {
const message = JSON.parse(event.data);
// Gérer le signal d'interruption
if (message.server_content?.interrupted) {
console.log('⚡ Interruption utilisateur, arrêt de la lecture');
this.stopPlayback();
return;
}
// Traiter l'audio renvoyé par l'IA
if (message.server_content?.model_turn) {
const parts = message.server_content.model_turn.parts;
for (const part of parts) {
if (part.inline_data?.mime_type.startsWith('audio/')) {
const audioData = base64ToArrayBuffer(part.inline_data.data);
this.queueAudio(audioData);
}
}
}
}
stopPlayback() {
// Vider la file de lecture
this.audioQueue = [];
this.isPlaying = false;
// Arrêter la lecture en cours
if (this.currentSource) {
try {
this.currentSource.stop();
} catch (e) {
// Peut déjà être arrêté
}
this.currentSource = null;
}
}
async queueAudio(audioData) {
this.audioQueue.push(audioData);
if (!this.isPlaying) {
this.playNext();
}
}
async playNext() {
if (this.audioQueue.length === 0) {
this.isPlaying = false;
return;
}
this.isPlaying = true;
const audioData = this.audioQueue.shift();
// Décoder et lire
const audioBuffer = await this.audioContext.decodeAudioData(audioData.slice());
this.currentSource = this.audioContext.createBufferSource();
this.currentSource.buffer = audioBuffer;
this.currentSource.connect(this.audioContext.destination);
this.currentSource.onended = () => {
this.playNext();
};
this.currentSource.start();
}
}
Détail important : stop() peut lever une exception si l’audio s’est déjà terminé naturellement — d’où le try-catch pour éviter une console pleine d’erreurs.
Optimisation des performances et contrôle de la latence
Comment maintenir la latence au minimum.
Sources de latence :
- Transmission réseau : temps aller-retour navigateur → serveur → Gemini
- Encodage/décodage audio : compression/décompression PCM (overhead faible, PCM sans perte)
- Accumulation du tampon : profondeur de tampon pour une lecture fluide
Optimisations éprouvées :
Réduire la profondeur du tampon
Le tampon de lecture doit suffire, sans excès. Je vise 100–200 ms :
// Tampon réduit
const audioContext = new AudioContext({
sampleRate: 16000,
latencyHint: 'interactive' // Mode faible latence
});
Tampon adaptatif
En cas de jitter réseau, augmentez légèrement le tampon ; en réseau stable, réduisez-le.
Annulation d’écho locale
Avec haut-parleur au lieu de casque, la voix de l’IA est captée par le micro — boucle possible. getUserMedia inclut l’annulation d’écho :
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
Métriques de surveillance
Pour mesurer l’efficacité des optimisations, utilisez l’API Performance :
// Enregistrer les métriques de latence
class LatencyMonitor {
constructor() {
this.metrics = [];
}
recordSendTime() {
this.lastSendTime = performance.now();
}
recordReceiveTime() {
const latency = performance.now() - this.lastSendTime;
this.metrics.push(latency);
// Conserver les 100 dernières mesures
if (this.metrics.length > 100) {
this.metrics.shift();
}
// Calculer la latence moyenne
const avg = this.metrics.reduce((a, b) => a + b, 0) / this.metrics.length;
console.log(`📊 Latence moyenne : ${avg.toFixed(2)}ms`);
}
}
Mesures en environnement de test :
- Latence de bout en bout : 300–500 ms (selon le réseau)
- Temps de réponse au premier paquet : 200–400 ms
- Latence en dialogue continu : 150–300 ms
Si votre latence dépasse nettement ces valeurs, vérifiez :
- La connexion WebSocket passe-t-elle par HTTPS/WSS ? HTTP ajoute de l’overhead
- Où est déployé le serveur ? Plus proche des datacenters Google = mieux
- Le VAD introduit-il trop de délai ? Essayez une durée de trame plus courte
- Le tampon de lecture frontend est-il trop grand ?
Autre piège : Chrome exige une interaction utilisateur avant la lecture audio — ajoutez un bouton « Démarrer la conversation », pas de lecture automatique au chargement.
Conclusion
Nous avons parcouru le flux complet de développement d’une application Gemini Live API : concept, architecture, connexion WebSocket, capture audio, VAD, Barge-in et optimisation des performances — avec les pièges que j’ai rencontrés.
L’interaction vocale en temps réel évolue vite ; Gemini Live API aussi. Mais cette architecture de base tient la route — mon propre projet tourne depuis plusieurs mois avec une bonne stabilité.
Si vous rencontrez des difficultés en développement, n’hésitez pas à échanger. Avancer seul, c’est plus lent ; discuter ensemble accélère les progrès.
FAQ
Pourquoi faut-il obligatoirement une architecture front/back séparée ?
Pourquoi choisir un taux d'échantillonnage de 16 kHz ?
Comment régler le paramètre aggressiveness du VAD ?
Faut-il développer la fonction Barge-in séparément ?
10 min de lecture · Publié le: 27 févr. 2026 · Mis à jour le: 27 juil. 2026
Maîtrise de Google AI
Vous lisez le premier article de cette série. Continuez avec le suivant ou ouvrez le hub de la série pour voir tout le parcours.
Précédent
Vous êtes au début de cette série.
Suivant
Faut-il abandonner la base vectorielle ? Gemini 2M tokens, Context Caching : performance et coûts
Évaluation approfondie du contexte long Gemini et du mécanisme Context Caching, comparaison avec le RAG classique pour éclairer votre choix d'architecture.
Partie 2 sur 7



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire