Changer le thème

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

Easton editorial illustration: one live assistant core connected to audio and video stream cards

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 :

CoucheTechnologieUsage
FrontendJavaScript natif + Web Audio APICapture, lecture, traitement temps réel via AudioWorklet
BackendPython 3.9+ + bibliothèque websocketsProxy WebSocket, détection VAD, gestion de session
ProtocoleWebSocket + JSONCommunication 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ère Charon, 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’interruption
  • end_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 :

  1. Transmission réseau : temps aller-retour navigateur → serveur → Gemini
  2. Encodage/décodage audio : compression/décompression PCM (overhead faible, PCM sans perte)
  3. 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 ?
La clé API doit rester côté backend, jamais exposée dans le JavaScript frontend. Si le navigateur se connecte directement à Gemini, n'importe qui ouvre les outils de développement et récupère la clé — abus et facture astronomique garantis. Le frontend se connecte via WebSocket au proxy Python backend, qui relaie les requêtes vers Gemini Live API.
Pourquoi choisir un taux d'échantillonnage de 16 kHz ?
La voix humaine couvre environ 85–255 Hz ; selon le théorème de Nyquist, 8 kHz suffit en théorie. Mais 16 kHz conserve plus de détails — le sweet spot entre qualité et volume de données. Gemini recommande aussi 16 kHz pour un bon taux de reconnaissance vocale tout en maîtrisant la bande passante.
Comment régler le paramètre aggressiveness du VAD ?
aggressiveness va de 0 à 3 : plus la valeur est haute, plus la détection est stricte (plus facile de classer la parole en silence). Commencez à 2 : trop bas, le bruit ambiant est pris pour de la parole et consomme de la bande passante ; trop haut, les voix basses peuvent être manquées. Ajustez selon le niveau de bruit de votre environnement.
Faut-il développer la fonction Barge-in séparément ?
Gemini Live API prend en charge nativement le Barge-in : activez automatic_activity_detection dans la configuration. Le client doit écouter l'événement interrupted et arrêter immédiatement la lecture audio. L'essentiel : bien gérer l'arrêt du lecteur audio — vider la file et stopper la lecture en cours.

10 min de lecture · Publié le: 27 févr. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog