Intelligence Artificielle angularforall.com

- OpenAI Realtime API : assistant vocal temps reel

Openai Realtime-Api Assistant-Vocal Speech-To-Speech Webrtc Websocket Voice-Ai Tool-Use Node-Js Javascript Llm Ia-Generative Vad Temps-Reel
OpenAI Realtime API : assistant vocal temps reel

Construisez un assistant vocal temps reel avec l'OpenAI Realtime API : speech-to-speech, WebRTC navigateur, WebSocket serveur, tool use et gestion des interruptions.

Pourquoi le speech-to-speech change tout

Jusqu'a recemment, construire un assistant vocal imposait un pipeline en trois etapes : transcrire l'audio (Whisper), envoyer le texte a un LLM, puis synthetiser la reponse en voix (TTS). Chaque etape ajoute de la latence et perd des informations : le ton, les hesitations, les emotions disparaissent des que l'on convertit en texte.

L'OpenAI Realtime API supprime ces intermediaires. Le modele traite directement l'audio en entree et produit de l'audio en sortie, en streaming, avec une latence de l'ordre de quelques centaines de millisecondes. Le resultat ressemble enfin a une vraie conversation : on peut interrompre l'assistant, il s'arrete, ecoute, et reprend.

Pipeline classique vs Realtime API

Pipeline STT + LLM + TTS Realtime API (speech-to-speech)
3 appels reseau en serie1 connexion persistante
Latence cumulee : 2 a 4 sLatence : 300 a 800 ms
Perte du ton et des emotionsConserve l'intonation native
Interruptions difficiles a gererInterruptions natives (VAD serveur)
Orchestration manuelleEvenements unifies sur un seul canal
Cas d'usage ideaux : agents de support telephonique, tuteurs linguistiques, assistants mains-libres, accessibilite vocale, prototypes conversationnels. A eviter si la transcription exacte mot-a-mot est l'objectif principal : Whisper reste plus adapte.

Architecture : WebSocket vs WebRTC

La Realtime API expose deux transports. Le choix depend de l'endroit ou tourne votre code et de qui detient la cle API.

WebSocket — cote serveur

Le transport WebSocket est ideal en Node.js : votre backend ouvre la connexion, detient la cle API en securite, et relaie l'audio. C'est le bon choix pour un agent telephonique (relais Twilio) ou une passerelle backend.

WebRTC — cote navigateur

WebRTC gere nativement la capture micro, la lecture audio et l'adaptation au reseau. C'est le transport recommande pour une application web directe. Pour ne jamais exposer votre cle API, le navigateur s'authentifie avec un jeton ephemere genere par votre backend.

Regle de securite absolue : la cle API OPENAI_API_KEY ne doit JAMAIS atteindre le navigateur. Cote client, on utilise toujours un jeton ephemere a duree de vie courte, genere par un endpoint backend que vous controlez.

Prerequis et cle API

Avant de commencer :
  • Compte OpenAI avec acces a la Realtime API et credits actifs
  • Node.js 20+ (support natif de WebSocket et fetch)
  • Cle API stockee dans une variable d'environnement OPENAI_API_KEY
  • Un navigateur moderne pour la partie WebRTC (Chrome, Edge, Firefox)
# Stocker la cle dans un fichier .env (jamais commite)
echo "OPENAI_API_KEY=sk-..." >> .env

# Installer les dependances
npm install ws dotenv
Securite : ajoutez .env a votre .gitignore. Une cle API exposee dans un depot public est automatiquement revoquee par OpenAI, mais elle peut avoir ete utilisee avant detection.

Premiere session en Node.js (WebSocket)

Commencons par le plus simple : une connexion serveur qui configure une session et envoie un message texte pour recevoir une reponse audio. Cela valide la chaine complete avant d'ajouter le micro.

// realtime-session.js — connexion WebSocket cote serveur
import 'dotenv/config';
import WebSocket from 'ws';

// Le modele realtime ; l'URL inclut le nom du modele en query string
const url = 'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview';

const ws = new WebSocket(url, {
  headers: {
    // La cle API reste cote serveur — jamais exposee au client
    Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    'OpenAI-Beta': 'realtime=v1',
  },
});

ws.on('open', () => {
  console.log('Connexion etablie');

  // 1. Configurer la session : voix, modalites, instructions
  ws.send(JSON.stringify({
    type: 'session.update',
    session: {
      modalities: ['text', 'audio'],         // on veut texte + audio en sortie
      voice: 'alloy',                          // voix de synthese
      instructions: 'Tu es un assistant francais concis et chaleureux.',
      input_audio_format: 'pcm16',             // format audio attendu
      output_audio_format: 'pcm16',
    },
  }));

  // 2. Ajouter un message utilisateur (ici en texte pour tester)
  ws.send(JSON.stringify({
    type: 'conversation.item.create',
    item: {
      type: 'message',
      role: 'user',
      content: [{ type: 'input_text', text: 'Bonjour, qui es-tu ?' }],
    },
  }));

  // 3. Demander une reponse au modele
  ws.send(JSON.stringify({ type: 'response.create' }));
});

Cote reception, on ecoute le flux d'evenements. La reponse arrive par fragments : on accumule le texte et on collecte les chunks audio encodes en base64.

// Suite de realtime-session.js — gestion des evenements entrants
const audioChunks = [];

ws.on('message', (raw) => {
  const event = JSON.parse(raw.toString());

  switch (event.type) {
    // Fragment de texte de la reponse (transcription de l'audio sortant)
    case 'response.audio_transcript.delta':
      process.stdout.write(event.delta);
      break;

    // Fragment audio encode en base64 PCM16
    case 'response.audio.delta':
      audioChunks.push(Buffer.from(event.delta, 'base64'));
      break;

    // Reponse terminee : on peut ecrire le fichier audio
    case 'response.done':
      console.log('\n--- Reponse terminee ---');
      // Concatener les chunks pour obtenir l'audio complet
      const pcm = Buffer.concat(audioChunks);
      console.log(`Audio recu : ${pcm.length} octets`);
      ws.close();
      break;

    // Toujours logger les erreurs explicitement
    case 'error':
      console.error('Erreur API :', event.error);
      break;
  }
});

ws.on('close', () => console.log('Session fermee'));
L'audio sortant est du PCM16 brut (mono, 24 kHz). Pour le lire dans un lecteur classique, ajoutez un en-tete WAV ou convertissez avec ffmpeg. Cote navigateur, WebRTC gere la lecture automatiquement.

Le modele d'evenements de la Realtime API

Toute la communication passe par des evenements JSON typés. On distingue les evenements client vers serveur (ce que vous envoyez) et serveur vers client (ce que vous recevez). Maitriser ce vocabulaire est la cle pour deboguer.

EvenementDirectionRole
session.updateclient → serveurConfigurer voix, instructions, formats
input_audio_buffer.appendclient → serveurEnvoyer un chunk de micro
input_audio_buffer.commitclient → serveurValider la fin d'un tour de parole
response.createclient → serveurDemander une reponse
response.audio.deltaserveur → clientFragment audio de reponse
response.audio_transcript.deltaserveur → clientTranscription de la reponse
input_audio_buffer.speech_startedserveur → clientLe VAD detecte une prise de parole
response.doneserveur → clientReponse complete

Envoyer de l'audio micro depuis Node.js

// Envoyer un buffer audio PCM16 capture (ex: depuis un fichier ou un flux)
function sendAudioChunk(ws, pcm16Buffer) {
  ws.send(JSON.stringify({
    type: 'input_audio_buffer.append',
    // L'audio doit etre encode en base64
    audio: pcm16Buffer.toString('base64'),
  }));
}

// Apres avoir envoye tous les chunks d'un tour de parole
function commitAndRespond(ws) {
  // Valider le buffer audio accumule
  ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
  // Declencher la generation de reponse
  ws.send(JSON.stringify({ type: 'response.create' }));
}

Assistant vocal navigateur en WebRTC

Cote navigateur, WebRTC simplifie enormement : le micro et la lecture audio sont geres par le pipeline media natif. Premiere etape, un endpoint backend qui genere un jeton ephemere.

// server.js (Express) — genere un jeton ephemere pour le navigateur
import 'dotenv/config';
import express from 'express';

const app = express();

app.get('/session', async (req, res) => {
  // Cet appel utilise la VRAIE cle API, cote serveur uniquement
  const r = await fetch('https://api.openai.com/v1/realtime/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o-realtime-preview',
      voice: 'alloy',
    }),
  });
  const data = await r.json();
  // On renvoie UNIQUEMENT le jeton ephemere (client_secret) au navigateur
  res.json({ token: data.client_secret.value });
});

app.use(express.static('public'));
app.listen(3000, () => console.log('http://localhost:3000'));

Cote client, on etablit la connexion WebRTC avec ce jeton, on branche le micro et on joue l'audio recu.

// public/voice.js — assistant vocal dans le navigateur
async function startVoiceAssistant() {
  // 1. Recuperer le jeton ephemere depuis notre backend
  const { token } = await (await fetch('/session')).json();

  // 2. Creer la connexion WebRTC
  const pc = new RTCPeerConnection();

  // 3. Jouer l'audio renvoye par le modele
  const audioEl = document.createElement('audio');
  audioEl.autoplay = true;
  pc.ontrack = (e) => { audioEl.srcObject = e.streams[0]; };

  // 4. Capturer le micro et l'ajouter a la connexion
  const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
  pc.addTrack(mic.getTracks()[0]);

  // 5. Canal de donnees pour les evenements JSON (tool use, transcription)
  const dc = pc.createDataChannel('oai-events');
  dc.onmessage = (e) => {
    const event = JSON.parse(e.data);
    if (event.type === 'response.audio_transcript.done') {
      console.log('Assistant :', event.transcript);
    }
  };

  // 6. Negociation SDP avec l'API Realtime
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  const resp = await fetch(
    'https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview',
    {
      method: 'POST',
      body: offer.sdp,
      headers: {
        Authorization: `Bearer ${token}`,  // jeton ephemere, pas la cle API
        'Content-Type': 'application/sdp',
      },
    }
  );
  await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() });

  console.log('Assistant vocal pret — parlez !');
}

document.getElementById('start').addEventListener('click', startVoiceAssistant);
Pourquoi WebRTC cote client ? Il gere automatiquement l'echo cancellation, la reduction de bruit, l'adaptation de debit et le jitter buffer. Reimplementer cela en WebSocket pur demanderait des centaines de lignes de traitement audio.

Tool use : faire agir l'assistant vocal

Un assistant vocal devient reellement utile quand il peut declencher des actions : consulter une meteo, creer un rendez-vous, interroger une base. La Realtime API supporte le function calling, exactement comme l'API Chat.

// Declarer un outil dans session.update
ws.send(JSON.stringify({
  type: 'session.update',
  session: {
    modalities: ['text', 'audio'],
    voice: 'alloy',
    instructions: 'Tu aides a consulter la meteo. Utilise l\'outil get_weather.',
    tools: [{
      type: 'function',
      name: 'get_weather',
      description: 'Recupere la meteo actuelle pour une ville',
      parameters: {
        type: 'object',
        properties: {
          city: { type: 'string', description: 'Nom de la ville' },
        },
        required: ['city'],
      },
    }],
    tool_choice: 'auto',
  },
}));

Quand le modele decide d'appeler l'outil, il emet un evenement de function call. Vous executez la fonction, renvoyez le resultat, puis demandez une nouvelle reponse.

// Gerer l'appel de fonction emis par le modele
ws.on('message', async (raw) => {
  const event = JSON.parse(raw.toString());

  if (event.type === 'response.function_call_arguments.done') {
    // Le modele a fini d'assembler les arguments JSON
    const args = JSON.parse(event.arguments);
    const result = await getWeather(args.city);   // votre logique metier

    // Renvoyer le resultat de l'outil au modele
    ws.send(JSON.stringify({
      type: 'conversation.item.create',
      item: {
        type: 'function_call_output',
        call_id: event.call_id,             // relie la reponse a l'appel
        output: JSON.stringify(result),
      },
    }));

    // Demander au modele de formuler une reponse vocale avec ce resultat
    ws.send(JSON.stringify({ type: 'response.create' }));
  }
});

async function getWeather(city) {
  // Exemple : appel a une API meteo reelle
  return { city, temp: 21, condition: 'ensoleille' };
}
Le call_id est essentiel : il relie la sortie de la fonction a l'appel d'origine. Sans lui, le modele ne sait pas a quel appel correspond le resultat dans une conversation a plusieurs outils.

Gerer les interruptions et le VAD

Ce qui rend la conversation naturelle, c'est la possibilite d'interrompre l'assistant. La Realtime API integre un VAD (Voice Activity Detection) cote serveur qui detecte quand l'utilisateur reprend la parole.

// Activer le VAD serveur dans session.update
ws.send(JSON.stringify({
  type: 'session.update',
  session: {
    turn_detection: {
      type: 'server_vad',         // detection automatique des tours de parole
      threshold: 0.5,              // sensibilite (0 a 1)
      prefix_padding_ms: 300,      // audio conserve avant la parole detectee
      silence_duration_ms: 500,    // silence avant de considerer le tour fini
    },
  },
}));

Avec le VAD serveur actif, l'API gere automatiquement le commit et le response.create. Quand l'utilisateur interrompt, le serveur emet un evenement pour que vous coupiez l'audio en cours de lecture.

// Reagir a une interruption de l'utilisateur
ws.on('message', (raw) => {
  const event = JSON.parse(raw.toString());

  // L'utilisateur reprend la parole alors que l'assistant parle
  if (event.type === 'input_audio_buffer.speech_started') {
    console.log('Interruption detectee — on coupe la reponse en cours');
    // Annuler la reponse en cours de generation
    ws.send(JSON.stringify({ type: 'response.cancel' }));
    // Cote navigateur : vider le buffer audio en lecture
    stopLocalPlayback();
  }
});

function stopLocalPlayback() {
  // Vider la file d'attente audio cote client (selon votre implementation)
}
VAD serveur vs manuel : en mode server_vad, l'API gere les tours de parole automatiquement (ideal pour un usage mains-libres). En mode manuel (turn_detection: null), vous controlez exactement quand commiter — utile pour un bouton push-to-talk.

Couts, limites et optimisation

La Realtime API facture les tokens audio en entree et en sortie, sensiblement plus chers que les tokens texte. Une conversation se chiffre rapidement : surveiller la duree des sessions est indispensable.

Estimation des couts : les tokens audio (entree et sortie) sont factures a un tarif bien superieur au texte. Comptez de l'ordre de quelques centimes par minute de conversation active. Une session laissee ouverte avec le micro actif continue de facturer le silence traite par le VAD.

Strategies de reduction des couts

// 1. Couper le micro hors interaction (push-to-talk)
//    Evite de facturer le silence traite par le VAD serveur.

// 2. Limiter la longueur des reponses via les instructions
session: {
  instructions: 'Reponds en une a deux phrases maximum, va a l\'essentiel.',
  max_response_output_tokens: 200,   // plafonne la sortie
}

// 3. Fermer la session des la fin de l'interaction
function endSession(ws) {
  ws.close();   // chaque seconde de connexion peut etre facturee
}

// 4. Utiliser une voix unique et un format audio efficace (pcm16)
Limites a connaitre :
  • Duree maximale de session limitee (reconnecter au-dela)
  • Le modele realtime est distinct du modele texte standard
  • Rate limits par minute sur les sessions concurrentes
  • Latence reseau additionnelle selon la region

Checklist production

Passer d'un prototype a un assistant vocal fiable en production demande quelques garde-fous, notamment sur la securite et la gestion des erreurs.

// Reconnexion automatique avec backoff exponentiel
function connectWithRetry(attempt = 0) {
  const ws = new WebSocket(url, { headers });

  ws.on('close', () => {
    // Backoff : 1s, 2s, 4s, 8s... plafonne a 30s
    const delay = Math.min(1000 * 2 ** attempt, 30000);
    console.log(`Reconnexion dans ${delay}ms`);
    setTimeout(() => connectWithRetry(attempt + 1), delay);
  });

  ws.on('open', () => { attempt = 0; });   // reset du compteur a la reussite
  return ws;
}
Checklist avant mise en production :
  • Cle API uniquement cote serveur, jamais dans le navigateur
  • Jetons ephemeres a duree de vie courte pour le client WebRTC
  • Reconnexion automatique avec backoff exponentiel
  • Timeout de session et fermeture propre apres inactivite
  • Logging des evenements error et metriques de latence
  • Plafond de duree par utilisateur pour maitriser les couts
  • Filtrage des inputs (moderation) avant traitement sensible

Conclusion

L'OpenAI Realtime API marque un tournant pour les interfaces vocales : en supprimant le pipeline STT + LLM + TTS, elle rend possible une conversation naturelle, interruptible et capable d'agir via le tool use. WebSocket cote serveur, WebRTC cote navigateur avec jeton ephemere : ces deux transports couvrent la majorite des architectures.

Le principal point de vigilance reste le cout : les tokens audio sont chers, et une session oubliee facture en continu. Coupez le micro hors interaction, plafonnez les reponses et fermez les sessions inactives. Avec ces garde-fous, vous obtenez un assistant vocal de qualite production, complementaire de Whisper pour la transcription et des agents IA pour les actions complexes.

A retenir :
  • Speech-to-speech : une seule connexion, latence sub-seconde
  • WebRTC pour le navigateur, WebSocket pour le serveur
  • Jamais la cle API cote client — toujours un jeton ephemere
  • VAD serveur pour des interruptions naturelles
  • Tool use pour transformer la voix en actions concretes
  • Surveiller les couts audio en permanence

Partager