L’API Realtime d’OpenAI permet au navigateur d’envoyer le son du microphone et de lire une réponse vocale via WebRTC. Ce guide crée une conversation locale avec gpt-realtime-2.1. La clé API reste dans un serveur Node sur votre ordinateur. Il faut un projet API OpenAI avec accès au modèle et facturation ; un abonnement ChatGPT ne paie pas l’usage de l’API. BIG CHANGE a vérifié la documentation actuelle et le code, sans ouvrir de session payante ni entendre de réponse.

Le grand changement

  • Ce qui change : OpenAI documente une connexion WebRTC de navigateur pour une conversation Realtime, avec un serveur local qui échange l’offre de connexion du navigateur contre une réponse tout en conservant la clé du projet.
  • Pourquoi c’est important : Un développeur peut vérifier le parcours complet du microphone à la réponse vocale dans une page locale avant de concevoir un produit plus vaste. Le propriétaire du projet contrôle la clé et paie l’usage de l’API.
  • À surveiller : Un échange local réussi ne prouve pas que le système est prêt pour la production. Une version partagée nécessite l’authentification des appelants et des contrôles de dépenses ; une requête annulée ou expirée peut déjà être parvenue à OpenAI.

Avant de commencer

Installez Node.js 22.6 ou ultérieur et utilisez un navigateur actuel avec microphone et haut-parleur fonctionnels. L’accès au microphone exige un contexte sécurisé ; http://localhost convient à cet exemple local. Préparez une clé de projet API OpenAI avec accès à gpt-realtime-2.1 et facturation activée. Vérifiez d’abord les limites de débit et de dépenses du projet. La page du modèle indique que le niveau Free n’est pas pris en charge. Gardez la clé dans l’environnement du processus serveur via votre gestionnaire de secrets ou votre configuration shell locale habituelle. Ne la placez pas dans index.html, ne la commitez pas dans un fichier .env et ne la collez pas dans une transcription. Le guide WebRTC d’OpenAI documente l’échange avec le serveur de confiance ; la page du modèle indique les accès et prix actuels.

Il s’agit de la route unifiée /v1/realtime/calls de l’API Realtime. Le guide WebRTC décrit aussi GPT-Live via /v1/live/sessions, avec un protocole de session différent. Conservez ensemble la route et la charge utile ci-dessous. L’exemple est limité à votre ordinateur ; ses contrôles locaux Host et Origin n’authentifient pas les utilisateurs d’un service partagé.

  1. Créez un répertoire vide pour la démonstration. Depuis ce répertoire, installez Express :
Terminal
npm init -y
npm install express

L’installation crée package.json et ajoute la dépendance du serveur web. Enregistrez les deux fichiers suivants dans ce même répertoire. Le code utilise les fonctions intégrées de Node fetch, FormData et AbortSignal.timeout ; la configuration vérifiée exige Node.js 22.6 ou ultérieur.

  1. Enregistrez ce qui suit sous server.mjs. Le serveur reçoit l’offre SDP du navigateur (une description de la connexion WebRTC proposée) et l’envoie à OpenAI avec les paramètres de session Realtime. La clé API standard est lue uniquement sur ce serveur.
JavaScript
import express from "express";

const app = express();
// This demo accepts only the local page, including protection against DNS rebinding.
app.use((req, res, next) => {
  const hosts = ["localhost:3000", "127.0.0.1:3000"];
  if (!hosts.includes(req.get("host"))) return res.status(403).send("Local host required");
  if (req.method === "POST" && req.get("origin") !== `http://${req.get("host")}`) {
    return res.status(403).send("Same-origin local page required");
  }
  next();
});
app.use(express.text({ type: "application/sdp", limit: "64kb" }));

app.get("/", (_req, res) => res.sendFile("index.html", { root: process.cwd() }));

app.post("/session", async (req, res) => {
  if (!process.env.OPENAI_API_KEY) return res.status(500).send("Server API key missing");
  if (typeof req.body !== "string" || !req.body.startsWith("v=")) {
    return res.status(400).send("Expected an SDP offer");
  }

  const form = new FormData();
  form.set("sdp", req.body);
  form.set("session", JSON.stringify({
    type: "realtime",
    model: "gpt-realtime-2.1",
    output_modalities: ["audio"],
    audio: { output: { voice: "marin" } },
    instructions: "Be a concise, helpful voice assistant. Do not claim access to external tools."
  }));

  try {
    const upstream = await fetch("https://api.openai.com/v1/realtime/calls", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
      body: form,
      signal: AbortSignal.timeout(25000)
    });
    const answer = await upstream.text();
    if (!upstream.ok) {
      console.error("Realtime session rejected", upstream.status);
      return res.status(502).send(`OpenAI session request failed (${upstream.status})`);
    }
    res.type("application/sdp").send(answer);
  } catch (error) {
    console.error("Realtime session network failure", error.name);
    res.status(502).send("Could not reach the Realtime API");
  }
});

app.listen(3000, "127.0.0.1", () => console.log("Open http://localhost:3000"));

Le serveur écoute sur 127.0.0.1:3000, n’accepte que l’hôte et l’origine POST de la page locale et renvoie la réponse SDP au navigateur. Le délai amont de 25 secondes est un choix de cet exemple. Un dépassement ou une déconnexion ne prouve pas qu’OpenAI n’a pas accepté la requête : inspectez l’usage avant de réessayer un démarrage incertain.

  1. Enregistrez ce qui suit sous index.html. La page demande l’autorisation du microphone après Start voice, envoie l’offre au serveur local et lit le son reçu. Stop ferme la connexion et arrête les pistes du microphone, même pendant l’établissement. Le délai initial de 30 secondes est aussi un choix d’exemple, pas une garantie de l’API.
HTML
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local Realtime voice check</title>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<script type="module">
  const start = document.querySelector("#start");
  const stop = document.querySelector("#stop");
  const status = document.querySelector("#status");
  const reply = document.querySelector("#reply");
  let current;

  function closeSession(session = current, message = "Stopped") {
    if (!session) return;
    session.closed = true;
    clearTimeout(session.timer);
    session.abort.abort();
    session.mic?.getTracks().forEach(track => track.stop());
    if (session.pc) session.pc.onconnectionstatechange = null;
    session.dc?.close();
    session.pc?.close();
    if (current === session) {
      current = null;
      reply.srcObject = null;
      start.disabled = false;
      stop.disabled = true;
      status.textContent = message;
    }
  }

  start.onclick = async () => {
    const session = { abort: new AbortController(), closed: false };
    current = session;
    start.disabled = true;
    stop.disabled = false;
    status.textContent = "Requesting microphone";
    session.timer = setTimeout(() => closeSession(session, "Setup timed out; microphone stopped"), 30000);
    try {
      const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
      if (session.closed) { mic.getTracks().forEach(track => track.stop()); return; }
      session.mic = mic;
      const pc = session.pc = new RTCPeerConnection();
      pc.ontrack = event => { if (!session.closed) reply.srcObject = event.streams[0]; };
      pc.onconnectionstatechange = () => {
        if (session.closed) return;
        status.textContent = `Connection: ${pc.connectionState}`;
        if (pc.connectionState === "connected") clearTimeout(session.timer);
        if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
          closeSession(session, "Connection ended; microphone stopped. Start a new session.");
        }
      };
      pc.addTrack(mic.getAudioTracks()[0], mic);
      const dc = session.dc = pc.createDataChannel("oai-events");
      dc.onmessage = message => {
        if (session.closed) return;
        try {
          const event = JSON.parse(message.data);
          if (event.type === "session.created") status.textContent = "Session ready: speak when connected";
          if (event.type === "response.done") {
            status.textContent = `Response status: ${event.response?.status || "unknown"}; verify audible playback`;
          }
          if (event.type === "error") closeSession(session, `Realtime error: ${event.error?.message || "unknown"}; stopped`);
        } catch { closeSession(session, "Invalid event; stopped"); }
      };
      dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; stopped"); };
      dc.onerror = () => closeSession(session, "Event channel failed; stopped");
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      if (session.closed) return;
      // The official WebRTC example submits the generated offer directly.
      const response = await fetch("/session", {
        method: "POST",
        headers: { "Content-Type": "application/sdp" },
        body: offer.sdp,
        signal: session.abort.signal
      });
      if (!response.ok) throw new Error(await response.text());
      const answer = await response.text();
      if (session.closed) return;
      await pc.setRemoteDescription({ type: "answer", sdp: answer });
      if (!session.closed && pc.connectionState !== "connected") status.textContent = "Signaling complete; waiting for connection";
    } catch (error) {
      if (!session.closed) closeSession(session, `Start failed: ${error.message}`);
    }
  };
  stop.onclick = () => closeSession();
  window.addEventListener("pagehide", () => closeSession());
</script>
</html>

Le navigateur ferme également la session en cas de défaillance du pair ou du canal d’événements et lorsque vous quittez la page. L’autorisation du micro peut arriver après un arrêt ou un délai dépassé ; la piste tardive est alors arrêtée. La ligne d’état affiche response.done avec son statut déclaré. Cet événement seul ne montre pas que la réponse était audible ni qu’une réponse annulée ou échouée a réussi.

  1. Démarrez le serveur depuis le répertoire contenant les deux fichiers, avec OPENAI_API_KEY déjà défini dans l’environnement du processus serveur :
Terminal
node server.mjs

Ouvrez http://localhost:3000. Vous devriez voir Start voice, Stop, l’état Idle et un lecteur audio. Cliquez sur Start voice, autorisez le microphone et posez une brève question sans information sensible. Le guide des conversations Realtime indique que la détection d’activité vocale par défaut peut déclencher une réponse après une pause. Pour réussir votre essai, vérifiez que le navigateur signale un micro actif, que la connexion atteint connected, qu’un événement session.created apparaît, que vous entendez la voix et que response.done signale une réponse terminée. Cliquez sur Stop et vérifiez que le témoin du microphone s’éteint. Ces vérifications vous incombent ; BIG CHANGE n’a pas effectué cette session payante dans le navigateur.

Si la vérification échoue

Si l’autorisation du micro est refusée ou qu’aucun appareil n’apparaît, vérifiez les permissions du navigateur et l’appareil, puis utilisez l’URL locale localhost ou HTTPS. Si la page signale un échec /session, regardez le statut HTTP côté serveur et vérifiez présence de clé, accès du projet au modèle, facturation, limites de débit et point de terminaison. Le serveur n’imprime volontairement ni la clé ni le corps de réponse d’OpenAI. Un code 502 peut aussi indiquer un problème réseau ou le délai de 25 secondes ; il ne prouve pas qu’une requête amont incertaine a été acceptée ou refusée.

Si la signalisation réussit mais que la connexion échoue, vérifiez son état, les restrictions réseau et la réponse SDP. Si elle est active mais silencieuse, vérifiez le microphone et le périphérique de sortie. Le navigateur peut bloquer la lecture automatique : cliquez sur Play dans le lecteur, puis arrêtez et ouvrez une nouvelle session si nécessaire. En cas d’échec, utilisez Stop et confirmez l’extinction du témoin du micro. Vérifiez l’usage du projet avant des démarrages répétés si le résultat d’une requête est incertain.

Coût, confidentialité et limites

OpenAI indique pour gpt-realtime-2.1 des tarifs texte non mis en cache en entrée/sortie de $4/$24 par million de tokens et audio en entrée/sortie de $32/$64 par million de tokens, vérifiés le 19 octobre 2026. Le coût dépend des tokens consommés, y compris les tours ultérieurs reprenant du contexte précédent. Vérifiez les prix et limites du modèle et l’usage de votre projet avant utilisation ; la durée seule ne permet pas d’estimer la facture. Le guide des conversations Realtime fixe une session maximale de 60 minutes.

Le son de votre microphone est transmis à OpenAI pendant la session. Utilisez des paroles que vous êtes autorisé à transmettre et évitez le son privé ou tiers pour cet essai. Cette page locale n’a ni connexion utilisateur ni budget individuel. Un service partagé nécessiterait un accès /session authentifié et limité en débit ainsi qu’un identifiant de sécurité calculé côté serveur ; les contrôles Host et Origin locaux ne suffisent pas. L’exemple n’appelle pas d’outils externes et ne passe pas d’appels téléphoniques. Aucun résultat de latence, fiabilité, qualité vocale, économie ou déploiement en production n’a été mesuré.

Sources et lectures complémentaires