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 serie | 1 connexion persistante |
| Latence cumulee : 2 a 4 s | Latence : 300 a 800 ms |
| Perte du ton et des emotions | Conserve l'intonation native |
| Interruptions difficiles a gerer | Interruptions natives (VAD serveur) |
| Orchestration manuelle | Evenements unifies sur un seul canal |
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.
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
- Compte OpenAI avec acces a la Realtime API et credits actifs
- Node.js 20+ (support natif de
WebSocketetfetch) - 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
.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'));
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.
| Evenement | Direction | Role |
|---|---|---|
session.update | client → serveur | Configurer voix, instructions, formats |
input_audio_buffer.append | client → serveur | Envoyer un chunk de micro |
input_audio_buffer.commit | client → serveur | Valider la fin d'un tour de parole |
response.create | client → serveur | Demander une reponse |
response.audio.delta | serveur → client | Fragment audio de reponse |
response.audio_transcript.delta | serveur → client | Transcription de la reponse |
input_audio_buffer.speech_started | serveur → client | Le VAD detecte une prise de parole |
response.done | serveur → client | Reponse 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);
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' };
}
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)
}
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.
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)
- 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;
}
- 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
erroret 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.
- 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