AI-translated from English; not yet reviewed by a fluent editor.

# Créer un agent vocal Realtime local avec des appels d’outils en lecture seule

> Créez une conversation vocale en direct dans le navigateur avec OpenAI Realtime et deux outils fictifs en lecture seule. Le code complet du serveur, du navigateur et du contrôleur montre la correspondance des identifiants d’appel, la reprise sûre, l’interruption et la libération du microphone.

By BIG CHANGE Editorial

Published: 2026-10-19T23:39:53.236Z
Updated: 2026-10-19T23:39:53.236Z
Canonical: https://bigchange.ai/blog/openai-realtime-voice-read-only-tool-calls-guide

![Conceptual charcoal illustration of a person speaking into a tabletop microphone while wearing headphones, with a compact computer and two separate unmarked modules nearby.](https://bigchange.ai/api/media/file/realtime-voice-tool-calls-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE; no actual OpenAI interface, API session, device test or heard answer is depicted.

Une conversation vocale dans le navigateur se complique lorsqu’une réponse orale nécessite plusieurs recherches. Le modèle peut renvoyer plusieurs appels de fonction dans une même réponse terminée. Chaque résultat doit être renvoyé sous son propre `call_id`, et une nouvelle réponse ne doit commencer qu’après l’envoi des résultats. Ce guide fournit aux développeurs un exemple localhost complet : entrée microphone en direct, lecture WebRTC en continu, deux outils fictifs en lecture seule, tours successifs et nettoyage lors des interruptions. Il prolonge notre [guide vocal de base dans le navigateur](https://bigchange.ai/blog/openai-realtime-browser-voice-webrtc-guide), qui produit une réponse orale sans outils externes.

## Le grand changement

- **Ce qui change :** L’[API Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations) d’OpenAI peut renvoyer des appels de fonction pendant une session vocale en direct. Cet exemple associe les appels terminés aux résultats locaux par `call_id` avant de demander au modèle de continuer.
- **Pourquoi c’est important :** Un développeur peut tester une réponse orale dépendant de plusieurs recherches en lecture seule, tout en conservant la capture et la lecture audio dans le navigateur et la clé API standard sur un serveur local.
- **À surveiller :** Les vérifications locales testent l’ordre des événements et le nettoyage avec des événements fictifs. Votre propre session payante doit établir si votre compte, votre navigateur, vos appareils et votre réseau produisent des réponses audibles, exactes et la facture attendue.

L’exemple utilise le point de terminaison WebRTC Realtime standard d’OpenAI `/v1/realtime/calls` avec `gpt-realtime-2.1`. Il n’utilise pas le protocole de session GPT-Live distinct. Les deux valeurs de « bibliothèque » ci-dessous sont des données de test fictives, pas des informations sur un lieu réel. BIG CHANGE a vérifié la documentation actuelle de [WebRTC](https://developers.openai.com/api/docs/guides/voice-webrtc), des [conversations](https://developers.openai.com/api/docs/guides/realtime-conversations), des [outils](https://developers.openai.com/api/docs/guides/realtime-mcp) et des [événements](https://developers.openai.com/api/reference/resources/realtime/client-events) le 20 octobre 2026. Nous avons exécuté des vérifications locales fictives du contrôleur et du navigateur, mais n’avons ouvert aucune session API payante ni testé une vraie capture microphone ou le son.

## 1. Vérifier l’accès et créer les cinq fichiers

Il vous faut Node.js 22.6 ou ultérieur, un navigateur récent avec microphone et haut-parleurs, et un projet API OpenAI ayant accès à `gpt-realtime-2.1`. Sa [page du modèle](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) indique que le niveau Free n’est pas pris en charge. Vérifiez d’abord la facturation API et les limites de débit et de dépense du projet ; un abonnement ChatGPT ne couvre pas l’API. Placez `OPENAI_API_KEY` dans l’**environnement du processus serveur** avec votre mécanisme local habituel de gestion des secrets. Ne l’insérez pas dans ces fichiers, le code du navigateur ou les transcriptions. Utilisez des paroles que vous êtes autorisé à envoyer à OpenAI.

Créez un nouveau dossier et enregistrez chacun des cinq blocs complets ci-dessous sous le nom de fichier qui le précède. Conservez les extensions `.mjs` et placez tous les fichiers dans le même dossier. Le serveur écoute sur `127.0.0.1:3000`. Il accepte l’offre SDP de la page locale, la transmet à OpenAI avec la clé côté serveur et renvoie la réponse SDP. Le navigateur ne reçoit aucune clé API standard. Ses contrôles locaux Host et Origin aident à limiter cette démonstration à localhost ; ils n’authentifient pas les utilisateurs d’un service partagé.

**server.mjs**

```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.get("/controller.mjs", (_req, res) => res.sendFile("controller.mjs", { 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: "Answer questions about the fictional demo library only using its read-only tools. State clearly that the data is synthetic software test data, not real venue information. For a lookup that may take noticeable time, give one short spoken progress preamble when useful, then call the tools. A preamble is never a result. Do not invent missing information. If a tool result is an error, say that lookup failed. Respond concisely in speech.",
    tools: [
      { type: "function", name: "get_demo_hours", description: "Read synthetic demo-library opening hours; no external account or network.", parameters: { type: "object", properties: {}, additionalProperties: false } },
      { type: "function", name: "get_demo_address", description: "Read synthetic demo-library address; no external account or network.", parameters: { type: "object", properties: {}, additionalProperties: false } }
    ],
    tool_choice: "auto"
  }));

  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"));
```

**index.html**

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Local Realtime voice and read-only tool check</title>
<h1>Local voice and tool check</h1>
<p>The library data in this demo is synthetic software test data.</p>
<button id="start">Start voice</button>
<button id="stop" disabled>Stop and release microphone</button>
<p id="status" role="status">Idle</p>
<audio id="reply" autoplay controls></audio>
<pre id="events" aria-label="Event status"></pre>
<script type="module">
import { ToolController } from "/controller.mjs";

const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const status = document.querySelector("#status");
const events = document.querySelector("#events");
const reply = document.querySelector("#reply");
let current;

function note(line) {
  events.textContent = `${line}\n${events.textContent}`.slice(0, 3000);
}

// These values are synthetic test fixtures, never real business records.
async function runDemoTool(call, signal) {
  if (signal.aborted) throw Error("interrupted");
  let args;
  try { args = JSON.parse(call.arguments); } catch { return { ok: false, error: "Invalid arguments" }; }
  if (!args || Array.isArray(args) || typeof args !== "object" || Object.keys(args).length) {
    return { ok: false, error: "Expected an empty object" };
  }
  if (call.name === "get_demo_hours") return { ok: true, value: "Synthetic demo library hours: Monday-Friday 09:00-17:00." };
  if (call.name === "get_demo_address") return { ok: true, value: "Synthetic demo library address: 1 Example Lane. This is not a real venue." };
  return { ok: false, error: "Tool is not allowed" };
}

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

start.onclick = async () => {
  const session = { abort: new AbortController(), closed: false };
  current = session;
  start.disabled = true;
  stop.disabled = false;
  status.textContent = "Requesting microphone";
  events.textContent = "";
  session.setupTimer = 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) return;
      reply.srcObject = event.streams[0];
      reply.play().catch(() => note("Playback blocked; press Play on the audio control"));
    };
    pc.onconnectionstatechange = () => {
      if (session.closed) return;
      note(`Peer: ${pc.connectionState}`);
      if (pc.connectionState === "connected") {
        clearTimeout(session.setupTimer);
        clearTimeout(session.sessionTimer);
        session.sessionTimer = setTimeout(() => closeSession(session, "Local session limit reached; microphone stopped"), 55 * 60000);
        status.textContent = "Connected; ask about the synthetic demo library";
      }
      if (["failed", "closed", "disconnected"].includes(pc.connectionState)) closeSession(session, "Connection ended; microphone stopped");
    };
    pc.addTrack(mic.getAudioTracks()[0], mic);
    const dc = session.dc = pc.createDataChannel("oai-events");
    const send = event => {
      if (session.closed || dc.readyState !== "open") throw Error("Event channel is not open");
      dc.send(JSON.stringify(event));
    };
    session.controller = new ToolController({
      send, runTool: runDemoTool,
      status: note,
      fatal: message => closeSession(session, `${message}; microphone stopped`)
    });
    dc.onmessage = message => {
      if (session.closed) return;
      let event;
      try { event = JSON.parse(message.data); } catch { closeSession(session, "Invalid event; microphone stopped"); return; }
      if (event.type === "session.created") note("Realtime session created");
      if (event.type === "output_audio_buffer.cleared") note("Server cleared unplayed speech after interruption");
      session.controller.handle(event).catch(() => closeSession(session, "Controller failed; microphone stopped"));
    };
    dc.onclose = () => { if (!session.closed) closeSession(session, "Event channel closed; microphone stopped"); };
    dc.onerror = () => closeSession(session, "Event channel failed; microphone stopped");
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    if (session.closed) return;
    const response = await fetch("/session", {
      method: "POST", headers: { "Content-Type": "application/sdp" },
      body: offer.sdp, signal: session.abort.signal
    });
    if (!response.ok) throw Error(`Session request failed (${response.status})`);
    const answer = await response.text();
    if (session.closed) return;
    await pc.setRemoteDescription({ type: "answer", sdp: answer });
    if (!session.closed) 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>
```

**controller.mjs**

```javascript
// The same controller runs in the browser and in the synthetic local checks.
export class ToolController {
  constructor({ send, runTool, status, fatal, setTimer = setTimeout, clearTimer = clearTimeout }) {
    Object.assign(this, { send, runTool, status, fatal, setTimer, clearTimer });
    this.epoch = 0;
    this.rounds = 0;
    this.counter = 0;
    this.responses = new Map();
    this.pending = null;
    this.closed = false;
  }

  abortPending() {
    if (!this.pending) return;
    this.pending.abort.abort();
    if (this.pending.timer) this.clearTimer(this.pending.timer);
    this.pending = null;
  }

  close() {
    this.closed = true;
    this.epoch++;
    this.abortPending();
    this.responses.clear();
  }

  fail(message) {
    if (this.closed) return;
    this.status(message);
    this.close();
    this.fatal(message);
  }

  interrupt() {
    if (this.closed) return;
    this.epoch++;
    this.rounds = 0;
    this.abortPending();
    this.status("Speech detected; interrupted result discarded");
  }

  async handle(event) {
    if (this.closed) return;
    if (event.type === "error") {
      this.fail(`Realtime error: ${event.error?.code || event.error?.message || "unknown"}`);
      return;
    }
    if (event.type === "input_audio_buffer.speech_started") { this.interrupt(); return; }
    if (event.type === "response.created") {
      if (event.response?.id) this.responses.set(event.response.id, this.epoch);
      return;
    }
    if (event.type === "conversation.item.done") {
      const p = this.pending;
      const id = event.item?.id;
      if (!p || p.epoch !== this.epoch || !p.acks.has(id)) return;
      p.acks.delete(id);
      if (p.acks.size === 0) {
        if (p.timer) this.clearTimer(p.timer);
        this.pending = null;
        if (!p.abort.signal.aborted && !this.closed && p.epoch === this.epoch) {
          this.status("All tool outputs acknowledged; resuming response");
          this.send({ type: "response.create" });
        }
      }
      return;
    }
    if (event.type !== "response.done") return;
    const response = event.response;
    const id = response?.id;
    const responseEpoch = this.responses.get(id);
    this.responses.delete(id);
    if (responseEpoch === undefined || responseEpoch !== this.epoch) return;
    this.status(`Response ${id}: ${response.status || "unknown"}`);
    if (response.status !== "completed") return; // Never execute partial/cancelled calls.
    const calls = (response.output || []).filter(item => item.type === "function_call");
    if (!calls.length) return;
    if (this.pending) { this.fail("Overlapping tool batches in one turn"); return; }
    if (calls.length > 8 || ++this.rounds > 3) { this.fail("Tool call or round bound reached"); return; }
    const ids = new Set();
    for (const call of calls) {
      if (call.status !== "completed" || !call.call_id || ids.has(call.call_id)) {
        this.fail("Incomplete or repeated function call ID"); return;
      }
      ids.add(call.call_id);
    }
    const p = { epoch: this.epoch, abort: new AbortController(), acks: new Set(), timer: null };
    this.pending = p;
    this.status(`Executing ${calls.length} read-only local call(s) from ${id}`);
    const outputs = await Promise.all(calls.map(async call => {
      try { return { call, result: await this.runTool(call, p.abort.signal) }; }
      catch { return { call, result: { ok: false, error: "Local lookup failed" } }; }
    }));
    if (this.closed || this.pending !== p || p.epoch !== this.epoch || p.abort.signal.aborted) return;
    try {
      for (const { call, result } of outputs) {
        const itemId = `tool_output_${++this.counter}`;
        p.acks.add(itemId);
        this.send({
          type: "conversation.item.create",
          event_id: `tool_event_${this.counter}`,
          item: { id: itemId, type: "function_call_output", call_id: call.call_id, output: JSON.stringify(result) }
        });
        this.status(`Sent result for ${call.call_id}`);
      }
      p.timer = this.setTimer(() => this.fail("Tool output acknowledgement timed out"), 8000);
    } catch { this.fail("Could not send tool outputs"); }
  }
}
```

**protocol-check.mjs**

```javascript
import assert from "node:assert/strict";
import { ToolController } from "./controller.mjs";

const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
const tick = () => new Promise(resolve => setImmediate(resolve));
const call = (id, name = "get_demo_hours") => ({ type: "function_call", status: "completed", call_id: id, name, arguments: "{}" });
const created = id => ({ type: "response.created", response: { id } });
const done = (id, status, output = []) => ({ type: "response.done", response: { id, status, output } });

function harness(runTool = async x => ({ ok: true, value: x.name })) {
  const sent = [], statuses = [], failures = [];
  const timers = new Map(); let nextTimer = 0;
  const controller = new ToolController({
    send: e => sent.push(e), runTool,
    status: s => statuses.push(s), fatal: e => failures.push(e),
    setTimer: callback => { const id = ++nextTimer; timers.set(id, callback); return id; },
    clearTimer: id => timers.delete(id)
  });
  return { controller, sent, statuses, failures, timers };
}

// Mixed audio/message output, two asynchronous calls resolved in reverse order.
{
  const a = deferred(), b = deferred();
  const h = harness(x => x.call_id === "call_A" ? a.promise : b.promise);
  await h.controller.handle(created("resp_1"));
  const work = h.controller.handle(done("resp_1", "completed", [
    { type: "message", status: "completed", content: [{ type: "output_audio", transcript: "Checking." }] },
    call("call_A"), call("call_B", "get_demo_address")
  ]));
  b.resolve({ ok: true, value: "address" }); await tick();
  assert.equal(h.sent.length, 0, "first finished lookup must not send or resume early");
  a.resolve({ ok: true, value: "hours" }); await work;
  assert.deepEqual(h.sent.map(x => x.item?.call_id), ["call_A", "call_B"]);
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
  await h.controller.handle({ type: "conversation.item.done", item: { id: h.sent[1].item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
  await h.controller.handle({ type: "conversation.item.done", item: { id: h.sent[0].item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 1, "one resume after both item acknowledgements");
}

// One local lookup error does not erase the other result.
{
  const h = harness(async x => { if (x.call_id === "bad") throw Error("synthetic failure"); return { ok: true, value: "fixture" }; });
  await h.controller.handle(created("resp_2"));
  await h.controller.handle(done("resp_2", "completed", [call("bad"), call("good")]));
  assert.equal(JSON.parse(h.sent[0].item.output).ok, false);
  assert.equal(JSON.parse(h.sent[1].item.output).ok, true);
  for (const e of h.sent.slice()) await h.controller.handle({ type: "conversation.item.done", item: { id: e.item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 1);
}

// Partial/cancelled responses never dispatch a call.
for (const status of ["failed", "incomplete", "cancelled"]) {
  const h = harness();
  await h.controller.handle(created(`resp_${status}`));
  await h.controller.handle(done(`resp_${status}`, status, [call("ignored")]));
  assert.equal(h.sent.length, 0);
}

// Distinct rounds are bounded, and missing/duplicate IDs are rejected.
{
  const h = harness();
  for (let i = 1; i <= 3; i++) {
    await h.controller.handle(created(`round_${i}`));
    await h.controller.handle(done(`round_${i}`, "completed", [call(`call_${i}`)]));
    const last = h.sent.at(-1);
    await h.controller.handle({ type: "conversation.item.done", item: { id: last.item.id } });
  }
  await h.controller.handle(created("round_4"));
  await h.controller.handle(done("round_4", "completed", [call("too_many")]));
  assert.match(h.failures[0], /bound/);
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 3);
}
{
  const h = harness();
  await h.controller.handle(created("dup"));
  await h.controller.handle(done("dup", "completed", [call("same"), call("same")]));
  assert.match(h.failures[0], /repeated/);
}

// User speech or Stop invalidates in-flight lookups and ignores their late results.
for (const mode of ["speech", "stop"]) {
  const d = deferred(), h = harness(() => d.promise);
  await h.controller.handle(created(`resp_${mode}`));
  const work = h.controller.handle(done(`resp_${mode}`, "completed", [call("late")]));
  if (mode === "speech") await h.controller.handle({ type: "input_audio_buffer.speech_started" });
  else h.controller.close();
  d.resolve({ ok: true, value: "late fixture" }); await work;
  assert.equal(h.sent.length, 0);
}

// A late response completion or acknowledgement from the interrupted turn cannot resume it.
{
  const h = harness();
  await h.controller.handle(created("old"));
  await h.controller.handle({ type: "input_audio_buffer.speech_started" });
  await h.controller.handle(done("old", "completed", [call("late_call")]));
  assert.equal(h.sent.length, 0);
  await h.controller.handle(created("new"));
  await h.controller.handle(done("new", "completed", [call("fresh_call")]));
  const output = h.sent[0];
  await h.controller.handle({ type: "input_audio_buffer.speech_started" });
  await h.controller.handle({ type: "conversation.item.done", item: { id: output.item.id } });
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
}

// Missing server acknowledgement closes instead of speculatively resuming.
{
  const h = harness();
  await h.controller.handle(created("no_ack"));
  await h.controller.handle(done("no_ack", "completed", [call("one")]));
  assert.equal(h.timers.size, 1);
  [...h.timers.values()][0]();
  assert.match(h.failures[0], /timed out/);
  assert.equal(h.sent.filter(x => x.type === "response.create").length, 0);
}

console.log("Synthetic controller checks passed: ordering, single resume, error isolation, bounds and interruption.");
```

**browser-check.mjs**

```javascript
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
import { ToolController } from "./controller.mjs";

const html = fs.readFileSync(new URL("./index.html", import.meta.url), "utf8");
const script = html.match(/<script type="module">([\s\S]*?)<\/script>/)?.[1]
  .replace('import { ToolController } from "/controller.mjs";', "");
assert.ok(script, "inline browser module found");

function fakePage(mediaPromise) {
  const nodes = Object.fromEntries(["#start", "#stop", "#status", "#events", "#reply"].map(k => [k, {
    disabled: k === "#stop", textContent: "", srcObject: null,
    pause() { this.paused = true; }, play() { return Promise.resolve(); }
  }]));
  let fetches = 0, peer;
  class FakePeer {
    constructor() { peer = this; this.connectionState = "new"; }
    addTrack() {}
    createDataChannel() { return this.dc = { readyState: "open", close() { this.closed = true; }, send() {} }; }
    async createOffer() { return { sdp: "v=0" }; }
    async setLocalDescription() {}
    async setRemoteDescription() {}
    close() { this.closed = true; }
  }
  vm.runInNewContext(script, {
    ToolController, document: { querySelector: k => nodes[k] },
    navigator: { mediaDevices: { getUserMedia: () => mediaPromise } },
    RTCPeerConnection: FakePeer,
    fetch: async () => { fetches++; return { ok: true, text: async () => "answer" }; },
    window: { addEventListener() {} },
    AbortController, setTimeout, clearTimeout, console
  });
  return { nodes, peer: () => peer, fetches: () => fetches };
}

// Stop while permission is pending; any late track is released without a session request.
{
  let allowMic;
  const page = fakePage(new Promise(resolve => { allowMic = resolve; }));
  const starting = page.nodes["#start"].onclick();
  page.nodes["#stop"].onclick();
  const track = { stopped: false, stop() { this.stopped = true; } };
  allowMic({ getTracks: () => [track], getAudioTracks: () => [track] });
  await starting;
  assert.equal(track.stopped, true);
  assert.equal(page.fetches(), 0);
  assert.equal(page.nodes["#start"].disabled, false);
}

// Stop after signaling: microphone, data channel, peer and playback all close.
{
  const track = { stopped: false, stop() { this.stopped = true; } };
  const page = fakePage(Promise.resolve({ getTracks: () => [track], getAudioTracks: () => [track] }));
  await page.nodes["#start"].onclick();
  page.nodes["#stop"].onclick();
  assert.equal(page.fetches(), 1);
  assert.equal(track.stopped, true);
  assert.equal(page.peer().closed, true);
  assert.equal(page.peer().dc.closed, true);
  assert.equal(page.nodes["#reply"].paused, true);
  assert.equal(page.nodes["#reply"].srcObject, null);
}

console.log("Synthetic browser lifecycle checks passed: late microphone and full Stop cleanup.");
```

Dans ce dossier, installez l’unique dépendance du serveur et démarrez-le :

```bash
npm init -y
npm install express
node server.mjs
```

Ouvrez `http://localhost:3000`. La page doit afficher **Start voice**, **Stop and release microphone**, un lecteur audio et l’état des événements. Seuls la page du navigateur et le module contrôleur sont servis ; les deux fonctions fictives s’exécutent dans le navigateur et n’accèdent à aucun lieu, calendrier, CRM ou compte professionnel.

## 2. Démarrer, écouter et inspecter le résultat

Cliquez sur **Start voice**, autorisez l’accès au microphone et attendez **Connected**. Demandez les horaires et l’adresse de la bibliothèque fictive de démonstration. Le navigateur envoie le son du microphone sur sa piste média WebRTC et relie la parole distante au lecteur audio dès l’arrivée de cette piste. Si la lecture automatique est bloquée, appuyez sur **Play** dans le lecteur visible. L’instruction serveur demande une brève annonce orale de progression si utile. Le modèle peut l’omettre ; en entendre une ne prouve pas qu’une recherche a réussi.

Le panneau d’événements indique les états des réponses, les identifiants d’appel et les accusés de réception des sorties, sans afficher votre clé, le son brut ni une transcription. Le modèle peut choisir zéro, un ou plusieurs outils. Lorsqu’un `response.done` contient `status: completed`, le contrôleur examine **tous** les éléments de sortie pour y trouver des éléments `function_call` terminés et vérifie que chaque `call_id` est présent et unique. Il exécute les recherches autorisées, y compris plusieurs appels en parallèle, puis envoie un `function_call_output` pour chaque identifiant d’origine. Une recherche en échec devient un résultat d’erreur pour cet appel ; les autres résultats sont conservés. Le contrôleur attend `conversation.item.done` pour chaque identifiant d’élément de sortie attribué avant d’envoyer un seul `response.create`. Cette attente des accusés de réception est un choix prudent d’ordonnancement propre à cet exemple, pas une exigence de l’API. Il autorise au plus huit appels par réponse et trois séries d’outils par tour ininterrompu ; ce sont des limites locales.

Repérez **Realtime session created**, la connexion du pair, les éventuels identifiants d’appel d’outils, les accusés de réception des sorties et une réponse de suivi terminée. Écoutez la réponse orale et comparez les horaires et l’adresse annoncés aux chaînes fictives dans `index.html`. Un événement de réponse terminée ne suffit pas à établir une lecture audible ou l’exactitude de la réponse. Ces observations nécessitent votre propre essai facturé dans le navigateur ; BIG CHANGE n’en a pas effectué.

## 3. Répéter un tour, interrompre et libérer le microphone

Posez une autre question sans redémarrer. Pour tester une interruption, parlez pendant que le modèle parle ou qu’une recherche est en attente. Avec WebRTC et la détection d’activité vocale, OpenAI documente l’annulation et la troncature, gérée côté serveur, du son non encore lu. À la réception de `input_audio_buffer.speech_started`, le contrôleur local invalide également les recherches en attente pour empêcher qu’un résultat tardif ne relance la réponse interrompue. Ce code ne met pas en œuvre le curseur de lecture et les événements de troncature distincts nécessaires à un lecteur audio WebSocket.

Cliquez sur **Stop and release microphone** et vérifiez que l’indicateur microphone du navigateur s’éteint. L’arrêt fonctionne aussi pendant une demande d’autorisation : si l’autorisation arrive tard, la piste obtenue est immédiatement arrêtée. Le même nettoyage arrête les pistes, abandonne la requête de session en cours, ferme le canal de données et le pair, suspend et détache la lecture, annule les outils et supprime les temporisateurs à la sortie de la page, à l’expiration du démarrage ou de la session locale, ou en cas d’échec de connexion. L’exemple utilise un délai de démarrage de 30 secondes et une durée de session locale de 55 minutes ; OpenAI documente un maximum Realtime de 60 minutes.

## 4. Exécuter les vérifications locales et résoudre les échecs

Dans le même dossier, exécutez :

```bash
node --check server.mjs
node --check controller.mjs
node protocol-check.mjs
node browser-check.mjs
```

Les deux scripts utilisent des événements fictifs et des simulations du navigateur. Ils vérifient une réponse mêlant message et fonctions, deux appels finissant dans l’ordre inverse, toutes les sorties et leurs accusés de réception avant une reprise unique, l’isolation des erreurs par appel, l’exécution des seuls appels terminés, les limites de séries, l’interruption vocale, la libération après une autorisation tardive et le nettoyage par Stop. Ils ne font aucun appel API. Leur réussite ne mesure ni les délais réseau, ni les vrais appareils, ni la qualité vocale, ni la fiabilité en production.

Si **Start** échoue, consultez l’état visible et le statut HTTP amont du serveur local. Vérifiez l’autorisation, `localhost:3000`, la clé de l’environnement serveur, l’accès au modèle, la facturation, les limites et le réseau. Un code 502 peut venir d’un rejet amont, d’une panne réseau ou du délai de requête de 25 secondes de l’exemple ; il ne prouve pas qu’OpenAI n’a jamais reçu la requête. Si le pair se connecte sans son, vérifiez les périphériques d’entrée et de sortie et appuyez sur **Play**. Une réponse en échec, incomplète ou annulée ne peut pas lancer des appels partiels. Si un accusé de réception expire, le contrôleur ferme la session au lieu d’envoyer une reprise dont le doublon est incertain. Si l’issue du démarrage ou du réseau est inconnue, arrêtez la session, confirmez la libération du microphone et vérifiez l’usage du projet avant de réessayer ; une requête abandonnée peut déjà avoir entraîné des frais.

## Coût et limites

La [page du modèle](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) et le [tableau des tarifs](https://developers.openai.com/api/docs/pricing) d’OpenAI, vérifiés le 20 octobre 2026, indiquent 4 $/24 $ par million de jetons pour le texte en entrée/sortie sans cache et 32 $/64 $ pour l’audio en entrée/sortie. Les frais réels dépendent des jetons, de l’audio et du contexte répété des conversations. Nous n’avons mesuré aucune facture, latence, utilisation d’outil en direct ni annonce préalable. Le son du microphone est transmis à OpenAI. Les fonctions fictives fixes n’autorisent ni intégration professionnelle réelle ni écriture. Un déploiement partagé nécessiterait sa propre identité utilisateur, ses limites, l’autorisation des outils, la surveillance et la revue.

### Sources et lectures complémentaires

- [Guide WebRTC d’OpenAI](https://developers.openai.com/api/docs/guides/voice-webrtc): piste média du navigateur, lecture distante, canal de données et échange SDP via un serveur de confiance. Il ne teste pas ce navigateur ou ce compte particulier.
- [Conversations Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations): sortie complète des réponses terminées, séquence de résultats de fonctions, interruption et durée maximale de 60 minutes. Il ne promet pas qu’un modèle donné appellera un outil.
- [Outils Realtime](https://developers.openai.com/api/docs/guides/realtime-mcp): appels de fonction gérés par l’application et `function_call_output`. Il ne démontre pas la réussite de ces données de test locales.
- [Événements client](https://developers.openai.com/api/reference/resources/realtime/client-events) et [serveur](https://developers.openai.com/api/reference/resources/realtime/server-events) : identifiants d’éléments, accusés de réception et formes des événements de réponse. L’attente de tous les accusés de réception est un choix local à l’exemple.
- [Instructions Realtime](https://developers.openai.com/api/docs/guides/voice-prompting): conseils pour une annonce orale préalable conditionnelle ; ce n’est pas une garantie de son audible.
- [Accès au modèle](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) et [tarifs API](https://developers.openai.com/api/docs/pricing): niveaux d’accès et tarifs de jetons actuellement affichés. Consultez l’usage de votre projet pour connaître sa facture réelle.

## Sources

- [Guide WebRTC d’OpenAI](https://developers.openai.com/api/docs/guides/voice-webrtc) — Documente les pistes média du microphone et du son distant, le canal de données oai-events et l’échange SDP par un serveur de confiance avec une clé API standard via /v1/realtime/calls. Ne prouve pas le comportement réel des appareils ou du réseau avec cet exemple.
- [Conversations OpenAI Realtime](https://developers.openai.com/api/docs/guides/realtime-conversations) — Documente la sortie complète de response.done, la reprise après résultats de fonctions, l’interruption WebRTC VAD avec troncature serveur du son non lu et le maximum de 60 minutes. Ne garantit ni utilisation d’outil ni exactitude orale dans l’essai du lecteur.
- [OpenAI Realtime avec des outils](https://developers.openai.com/api/docs/guides/realtime-mcp) — Documente les fonctions gérées par l’application, les éléments function_call_output associés au call_id d’origine et la reprise par response.create. Attendre chaque accusé de réception est un choix local supplémentaire.
- [Événements client OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/client-events) — Définit les formes de conversation.item.create, des identifiants d’élément, de function_call_output et de response.create utilisées par l’exemple ; la référence n’est pas un test d’intégration.
- [Événements serveur OpenAI Realtime](https://developers.openai.com/api/reference/resources/realtime/server-events) — Définit les formes des réponses terminées, des accusés de réception des éléments finalisés et des erreurs utilisées par l’exemple. L’attente de tous les accusés est un choix local, pas une exigence de l’API ni un test d’intégration.
- [Détection d’activité vocale OpenAI](https://developers.openai.com/api/docs/guides/realtime-vad) — Documente speech_started et speech_stopped pour invalider le travail local ; ne teste pas ici une vraie interruption.
- [Instructions OpenAI Realtime](https://developers.openai.com/api/docs/guides/voice-prompting) — Permet de demander une brève annonce de progression, de manière conditionnelle. Aucune annonce n’a été observée dans une session payante.
- [Modèle OpenAI gpt-realtime-2.1](https://developers.openai.com/api/docs/models/gpt-realtime-2.1) — Indique l’identifiant du modèle, l’absence de prise en charge du niveau Free et les tarifs par million de jetons sans cache : texte entrée/sortie 4 $/24 $, audio entrée/sortie 32 $/64 $. Le lecteur doit vérifier l’accès et les frais de son projet.
- [Tarifs API OpenAI](https://developers.openai.com/api/docs/pricing) — Confirme les tarifs de jetons texte et audio affichés pour gpt-realtime-2.1 ; ne donne aucun coût fixe pour la session de ce guide.
- [Facturation API OpenAI et ChatGPT](https://help.openai.com/en/articles/9039756-managing-billing-for-chatgpt-and-the-api-platform) — OpenAI précise que l’API est facturée séparément de l’abonnement ChatGPT. Ne détermine ni le solde actuel ni les limites du projet du lecteur.
