starter “WebRTC Realtime pur” avec React (front) + Node/Express (bridge token + tools + webhooks).
Parfait : voici un starter “WebRTC Realtime pur” avec React (front) + Node/Express (bridge token + tools + webhooks).
Le front parle en direct au Realtime API d’OpenAI en WebRTC (speech-in/speech-out). Le bridge ne touche jamais l’audio : il signe la session (token éphémère) et expose vos tools (function calling) + webhooks.
Réfs officielles (pour ajuster les détails d’API/headers selon votre compte) : Guide WebRTC & Realtime & Server-side controls & Function/Tools & Webhooks. OpenAI Platform+4OpenAI Platform+4OpenAI Platform+4
1) Arbo & prérequis
webrtc-realtime-starter/ .env # OPENAI_API_KEY=sk-... server/ package.json server.ts tools.ts verifySignature.ts web/ package.json src/VoiceRealtime.tsx src/main.tsx index.html
- Node 18+, pnpm/npm au choix.
- ffmpeg non requis (l’audio transite direct WebRTC).
- .env (côté serveur uniquement) :
OPENAI_API_KEY=sk-xxxxx OPENAI_PROJECT=proj_xxxxx # si vous segmentez par projet OPENAI_WEBHOOK_SECRET=whsec_xxx # si vous activez les webhooks
2) Bridge Node (Express) — token éphémère + tools + webhooks
Le token éphémère empêche d’exposer votre clé au navigateur. On suit le pattern recommandé : le serveur crée un secret client court (1–2 min) que le front utilisera pour son POST /v1/realtime?... (SDP). Ajustez selon votre tenant (OpenAI/Azure OpenAI). OpenAI Platform+1
server/package.json
{ "name": "realtime-bridge", "type": "module", "scripts": { "dev": "tsx watch server.ts" }, "dependencies": { "dotenv": "^16.4.5", "express": "^4.19.2", "node-fetch": "^3.3.2" }, "devDependencies": { "tsx": "^4.19.1" } }
server/tools.ts — (déclarez vos outils)
export const tools = [ { name: "getOrderStatus", description: "Retourne le statut d'une commande Odoo par ID.", parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] } }, { name: "createTicket", description: "Crée un ticket support minimal.", parameters: { type: "object", properties: { email: { type: "string", format: "email" }, subject: { type: "string" }, body: { type: "string" } }, required: ["email", "subject"] } } ] as const;
server/verifySignature.ts — (facultatif si vous activez des webhooks OpenAI)
import crypto from "crypto"; export function verifySignature(rawBody: string, headerSig: string, secret: string) { const hmac = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); // Format des en-têtes: ajustez selon la doc Webhooks de votre projet return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(headerSig || "", "hex")); }
server/server.ts — (Express)
import "dotenv/config"; import express from "express"; import fetch from "node-fetch"; import { tools } from "./tools.js"; import { verifySignature } from "./verifySignature.js"; const app = express(); // 1) Endpoint pour obtenir un "client_secret" éphémère (front l'appelle AVANT WebRTC) app.post("/session", async (req, res) => { // ⚠️ Implémentation indicative : suivez le guide "Realtime WebRTC" pour le format exact // de création de secret éphémère dans VOTRE compte (OpenAI/Azure). // Idée générale : créer un token restreint "client-side" très court (TTL ~60s). 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", ...(process.env.OPENAI_PROJECT ? { "OpenAI-Project": process.env.OPENAI_PROJECT } : {}) }, body: JSON.stringify({ // Modèle Realtime — ajustez au libellé actuel (ex: "gpt-realtime") model: "gpt-realtime", // Instructions système par défaut pour cette session instructions: "Vous êtes un conseiller vocal francophone, poli et concis.", // Activation du tool calling tools, // TTL court pour le client_secret expires_in: 90 }) }); if (!r.ok) { const text = await r.text(); return res.status(500).json({ error: "session_create_failed", detail: text }); } const session = await r.json(); // session.client_secret (ou équivalent) selon la doc courante res.json({ client_secret: session.client_secret, expires_in: session.expires_in }); }); // 2) Webhook "tool calls" (server-side controls) — OpenAI invoque vos outils ici app.use("/webhooks/openai", express.raw({ type: "*/*" })); // pour signature app.post("/webhooks/openai", async (req, res) => { try { const raw = req.body.toString("utf-8"); const sig = req.header("OpenAI-Signature") || ""; const ok = verifySignature(raw, sig, process.env.OPENAI_WEBHOOK_SECRET!); if (!ok) return res.status(401).send("invalid signature"); const event = JSON.parse(raw); if (event.type === "realtime.tool_call") { const { name, arguments: args, call_id, response_url } = event.data; let output: any = {}; if (name === "getOrderStatus") { // TODO: votre lookup Odoo ici output = { order_id: args.order_id, status: "SHIPPED", eta: "2025-10-15" }; } else if (name === "createTicket") { // TODO: créer un ticket via votre service output = { ticket_id: "TIC-12345", ok: true }; } // Envoyer le résultat de l’outil au modèle await fetch(response_url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "realtime.tool_result", data: { call_id, output } }) }); } res.sendStatus(200); } catch (e) { console.error(e); res.sendStatus(500); } }); const PORT = process.env.PORT || 8080; app.listen(PORT, () => console.log(`Bridge token+tools prêt sur :${PORT}`));
À noter : la forme exacte des endpoints/objets (/v1/realtime/sessions, champs client_secret, response_url, types d’événements) peut évoluer. Alignez-vous sur vos docs : Realtime WebRTC, Realtime (overview), Server-side controls, Function calling, Webhooks. OpenAI Platform+3OpenAI Platform+3OpenAI Platform+3
3) Front React — connexion WebRTC directe au Realtime
web/package.json
{ "name": "webrtc-realtime-web", "type": "module", "scripts": { "dev": "vite" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "vite": "^5.3.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0" } }
web/src/VoiceRealtime.tsx
import { useEffect, useRef, useState } from "react"; export default function VoiceRealtime() { const pcRef = useRef<RTCPeerConnection | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null); const [ready, setReady] = useState(false); const [speaking, setSpeaking] = useState(false); useEffect(() => { (async () => { // 1) Obtenir un secret client éphémère depuis notre bridge const sess = await fetch("/session", { method: "POST" }).then(r => r.json()); // 2) Créer la PeerConnection const pc = new RTCPeerConnection(); pcRef.current = pc; // 3) Piste distante (TTS du modèle) pc.ontrack = (e) => { if (!audioRef.current) { audioRef.current = new Audio(); audioRef.current.autoplay = true; } audioRef.current.srcObject = e.streams[0]; }; // 4) Micro local const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); stream.getAudioTracks().forEach(t => pc.addTrack(t, stream)); // (Optionnel) DataChannel pour envoyer des commandes (ex: "reset", "persona") pc.createDataChannel("client-events"); // 5) SDP offer const offer = await pc.createOffer({ offerToReceiveAudio: true }); await pc.setLocalDescription(offer); // 6) Envoyer l’offer au Realtime API (WebRTC) // ⚠️ Ajustez l’URL/headers selon votre doc (OpenAI/Azure) const r = await fetch("https://api.openai.com/v1/realtime?model=gpt-realtime", { method: "POST", headers: { Authorization: `Bearer ${sess.client_secret}`, "Content-Type": "application/sdp" }, body: offer.sdp }); const answerSDP = await r.text(); await pc.setRemoteDescription({ type: "answer", sdp: answerSDP }); setReady(true); })(); return () => pcRef.current?.close(); }, []); return ( <div className="p-4"> <div>Session: {ready ? "connectée" : "init..."}</div> <button onClick={() => setSpeaking(s => !s)} disabled={!ready} style={{ marginTop: 12 }} > {speaking ? "⏸️ Pause" : "🎙️ Parler"} </button> {/* Pour un vrai barge-in : vous pouvez couper/relancer la capture ou envoyer des client events */} </div> ); }
Le flux offer/answer SDP + Bearer token éphémère suit le guide WebRTC Realtime officiel. Pour personnaliser la session (instructions, voix, transcriptions-only, etc.), utilisez les server-side controls / client events décrits dans la doc. OpenAI Platform+1
4) Paramétrage “tools” (function calling)
- Vos tools sont déclarés côté serveur (schémas JSON).
- Quand le modèle décide d’appeler un tool, il déclenche un webhook realtime.tool_call.
- Votre bridge exécute l’action (Odoo, HTTP…) puis répond via realtime.tool_result.
- Pattern documenté ici : Function calling + Server-side controls + Webhooks. OpenAI Platform+2OpenAI Platform+2
5) Sécurité & prod (checklist)
- Tokens éphémères (TTL court), jamais de clé API dans le navigateur. OpenAI Platform
- Signer/valider les webhooks (horodatage + HMAC), rate limiting et WAF côté bridge. OpenAI Platform
- Barge-in : côté UI, si l’utilisateur reparle, coupez la lecture et laissez Realtime gérer le turn-taking (client events). OpenAI Platform
- Observabilité : logguez latence aller/retour, événements session (création, tool_call, tool_result), erreurs.
- Modèles : vérifiez la disponibilité GA de gpt-realtime et les dépréciations (ex-previews). OpenAI+2OpenAI+2
6) Prochaines étapes
- Je peux vous ajouter une UI (vu-mètre, mute, barre de volume, bouton “Répéter”, mode transcription-only) et brancher un tool Odoo réel (auth, endpoints REST).
- Si vous prévoyez multi-participants, on garde ce même bridge et on ajoute une room SFU (ex. Daily) avec un bot qui relaie ↔ Realtime. (Même logique tools.) Microsoft Learn
> ajouter une UI (vu-mètre, mute, barre de volume, bouton “Répéter”, mode transcription-only) et brancher un tool Odoo réel
voilà un starter complet qui ajoute l’UI audio (vu-mètre, mute, volume, “Répéter”, mode transcription-only) et un tool Odoo réel (auth + endpoints JSON-RPC) branché au Realtime WebRTC.
1) Bridge Node — tools Odoo (JSON-RPC, Odoo Online/Enterprise)
.env (serveur)
OPENAI_API_KEY=sk-... OPENAI_PROJECT=proj_... # optionnel OPENAI_WEBHOOK_SECRET=whsec_... # si webhooks activés ODOO_BASE_URL=https://votre-instance.odoo.com ODOO_DB=nom_de_base ODOO_LOGIN=votre.email@domaine.com ODOO_API_KEY=odoo_xxxxx_api_key
server/odoo.ts — client JSON-RPC minimal
import fetch from "node-fetch"; const ODOO_URL = process.env.ODOO_BASE_URL!; const ODOO_DB = process.env.ODOO_DB!; const ODOO_LOGIN = process.env.ODOO_LOGIN!; const ODOO_API_KEY = process.env.ODOO_API_KEY!; export async function odooLogin(): Promise<number> { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j: any = await r.json(); if (!j.result || !j.result.uid) throw new Error("Odoo auth failed"); return j.result.uid as number; } async function callKw(model: string, method: string, args: any[], kwargs: any = {}) { await odooLogin(); // session cookie maintenu par node-fetch si vous stockez le jar ; pour simplicité, on réauth à chaque appel MVP const r = await fetch(`${ODOO_URL}/web/dataset/call_kw/${model}/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { model, method, args, kwargs } }) }); const j: any = await r.json(); if (j.error) throw new Error(JSON.stringify(j.error)); return j.result; } // === EXEMPLES TOOLS === // 1) Statut d'une commande (sale.order) export async function odooGetOrderStatus(orderId: number) { const recs = await callKw("sale.order", "read", [[orderId], ["name", "state", "commitment_date"]]); if (!recs?.length) return { order_id: orderId, status: "NOT_FOUND" }; const so = recs[0]; return { order_id: orderId, name: so.name, status: so.state, // e.g. draft/sent/sale/done/cancel eta: so.commitment_date || null }; } // 2) Créer un ticket (helpdesk.ticket) export async function odooCreateTicket(email: string, subject: string, body?: string) { const vals = { name: subject, email: email, description: body || "" }; const id = await callKw("helpdesk.ticket", "create", [[vals]]); return { ticket_id: id, ok: true }; }
server/tools.ts — déclare les tools (schémas JSON) et mappez la logique
export const tools = [ { name: "getOrderStatus", description: "Récupère le statut d'une commande Odoo par ID (sale.order).", parameters: { type: "object", properties: { order_id: { type: "integer", minimum: 1 } }, required: ["order_id"] } }, { name: "createTicket", description: "Crée un ticket Helpdesk (helpdesk.ticket) minimal.", parameters: { type: "object", properties: { email: { type: "string", format: "email" }, subject: { type: "string" }, body: { type: "string" } }, required: ["email", "subject"] } } ] as const;
server/server.ts — ajoutez l’implémentation des tool calls
import "dotenv/config"; import express from "express"; import fetch from "node-fetch"; import { tools } from "./tools.js"; import { odooGetOrderStatus, odooCreateTicket } from "./odoo.js"; const app = express(); app.use(express.json()); // 1) Crée une session Realtime (client_secret éphémère) + attache tools app.post("/session", async (_req, res) => { 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", ...(process.env.OPENAI_PROJECT ? { "OpenAI-Project": process.env.OPENAI_PROJECT } : {}) }, body: JSON.stringify({ model: "gpt-realtime", instructions: "Vous êtes un conseiller vocal FR, concis. Utilisez les tools avec parcimonie.", tools, expires_in: 90 }) }); if (!r.ok) return res.status(500).send(await r.text()); const j = await r.json(); res.json({ client_secret: j.client_secret, expires_in: j.expires_in }); }); // 2) Webhook des tools (appelé par OpenAI Realtime quand le modèle invoque un tool) app.post("/webhooks/openai/realtime", async (req, res) => { const event = req.body; // si vous signez, utilisez express.raw + vérification HMAC if (event?.type === "realtime.tool_call") { const { name, arguments: args, call_id, response_url } = event.data; let output: any = {}; try { if (name === "getOrderStatus") { output = await odooGetOrderStatus(Number(args.order_id)); } else if (name === "createTicket") { output = await odooCreateTicket(args.email, args.subject, args.body); } await fetch(response_url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "realtime.tool_result", data: { call_id, output } }) }); } catch (e: any) { await fetch(response_url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "realtime.tool_result", data: { call_id, error: String(e?.message || e) } }) }); } } res.sendStatus(200); }); app.listen(8080, () => console.log("Bridge prêt sur :8080"));
2) Front React — UI audio complète (vu-mètre, mute, volume, Répéter, transcription)
web/src/VoiceRealtime.tsx
import { useEffect, useRef, useState } from "react"; type Mode = "voice" | "transcription"; // transcription-only (on coupe la sortie vocale) export default function VoiceRealtime() { const pcRef = useRef<RTCPeerConnection | null>(null); const remoteAudioRef = useRef<HTMLAudioElement | null>(null); const localStreamRef = useRef<MediaStream | null>(null); const analyserRef = useRef<AnalyserNode | null>(null); const rafRef = useRef<number | null>(null); const [connected, setConnected] = useState(false); const [muted, setMuted] = useState(false); const [volume, setVolume] = useState(1); // 0..1 const [level, setLevel] = useState(0); // 0..100 const [mode, setMode] = useState<Mode>("voice"); const [transcript, setTranscript] = useState<string>(""); // "Répéter" : on enregistre à la volée le flux TTS distant dans un buffer circulaire const remoteReplayChunks = useRef<Blob[]>([]); const remoteMediaRecorder = useRef<MediaRecorder | null>(null); const lastReplyBlob = useRef<Blob | null>(null); // DataChannel pour recevoir texte / événements (si le modèle envoie des transcripts) const dataChannelRef = useRef<RTCDataChannel | null>(null); useEffect(() => { (async () => { // 1) Token éphémère const sess = await fetch("/session", { method: "POST" }).then(r => r.json()); // 2) PeerConnection const pc = new RTCPeerConnection(); pcRef.current = pc; // 3) Canal data pour recevoir du texte (optionnel) pc.ondatachannel = (e) => { const ch = e.channel; ch.onmessage = (ev) => { try { const msg = JSON.parse(String(ev.data)); if (msg.type === "transcript.delta") { setTranscript(prev => prev + msg.text); } else if (msg.type === "transcript.reset") { setTranscript(""); } } catch { // fallback: concat brut setTranscript(prev => prev + " " + String(ev.data)); } }; }; // 4) Piste distante (TTS du modèle) pc.ontrack = (e) => { if (!remoteAudioRef.current) { remoteAudioRef.current = new Audio(); remoteAudioRef.current.autoplay = true; } remoteAudioRef.current.srcObject = e.streams[0]; // Enregistreur pour "Répéter" remoteMediaRecorder.current?.stop(); remoteReplayChunks.current = []; remoteMediaRecorder.current = new MediaRecorder(e.streams[0], { mimeType: "audio/webm" }); remoteMediaRecorder.current.ondataavailable = (ev) => { if (ev.data.size) { remoteReplayChunks.current.push(ev.data); // Ne gardez que ~15 s const MAX = 60; // 60 * 250ms ≈ 15s si timeslice=250 if (remoteReplayChunks.current.length > MAX) remoteReplayChunks.current.shift(); lastReplyBlob.current = new Blob(remoteReplayChunks.current, { type: "audio/webm" }); } }; remoteMediaRecorder.current.start(250); updateOutput(); }; // 5) Micro local + vu-mètre const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); localStreamRef.current = stream; stream.getAudioTracks().forEach(t => pc.addTrack(t, stream)); setupVUMeter(stream); // 6) DataChannel côté client pour commandes (optionnel) const ch = pc.createDataChannel("client-events"); dataChannelRef.current = ch; // 7) SDP const offer = await pc.createOffer({ offerToReceiveAudio: true }); await pc.setLocalDescription(offer); const r = await fetch("https://api.openai.com/v1/realtime?model=gpt-realtime", { method: "POST", headers: { Authorization: `Bearer ${sess.client_secret}`, "Content-Type": "application/sdp" }, body: offer.sdp }); const answerSDP = await r.text(); await pc.setRemoteDescription({ type: "answer", sdp: answerSDP }); setConnected(true); })(); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); remoteMediaRecorder.current?.stop(); pcRef.current?.close(); }; }, []); function setupVUMeter(stream: MediaStream) { const ctx = new AudioContext(); const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 512; src.connect(analyser); analyserRef.current = analyser; const data = new Uint8Array(analyser.frequencyBinCount); const loop = () => { analyser.getByteTimeDomainData(data); // amplitude RMS approx let sum = 0; for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; } const rms = Math.sqrt(sum / data.length); setLevel(Math.min(100, Math.round(rms * 140))); rafRef.current = requestAnimationFrame(loop); }; loop(); } function updateOutput() { if (!remoteAudioRef.current) return; remoteAudioRef.current.muted = muted || mode === "transcription"; remoteAudioRef.current.volume = volume; // 0..1 } // UI actions const toggleMute = () => { setMuted(m => { const v = !m; setTimeout(updateOutput, 0); return v; }); }; const onVolume = (e: any) => { const v = Number(e.target.value); setVolume(v); setTimeout(updateOutput, 0); }; const onModeChange = (m: Mode) => { setMode(m); setTranscript(""); // Optionnel: envoyer un event au modèle si vous avez un switch "no_speech_output" dataChannelRef.current?.send(JSON.stringify({ type: "client.mode", mode: m })); updateOutput(); }; const onRepeat = async () => { if (!lastReplyBlob.current) return; const url = URL.createObjectURL(lastReplyBlob.current); const a = new Audio(url); a.play(); }; return ( <div className="p-4 space-y-3" style={{ maxWidth: 420 }}> <div>Connexion: {connected ? "OK" : "…"} </div> {/* Vu-mètre */} <div style={{ height: 8, background: "#eee", borderRadius: 4, overflow: "hidden" }}> <div style={{ width: `${level}%`, height: "100%" }} /> </div> {/* Contrôles audio */} <div className="space-x-2"> <button onClick={toggleMute}>{(muted || mode==="transcription") ? "🔇 Unmute" : "🔊 Mute"}</button> <label style={{ marginLeft: 8 }}> Volume <input type="range" min={0} max={1} step={0.01} value={volume} onChange={onVolume} style={{ width: 160, verticalAlign: "middle", marginLeft: 8 }} /> </label> <button onClick={onRepeat} title="Répéter la dernière réponse">🔁 Répéter</button> </div> {/* Mode voice vs transcription-only */} <div className="space-x-2"> <label> <input type="radio" checked={mode==="voice"} onChange={() => onModeChange("voice")} /> Voix </label> <label> <input type="radio" checked={mode==="transcription"} onChange={() => onModeChange("transcription")} /> Transcription-only </label> </div> {/* Zone transcript (si le modèle émet du texte via datachannel) */} <textarea value={transcript} readOnly rows={6} style={{ width: "100%" }} placeholder="Transcription en direct…" /> </div> ); }
Notes UI
- Vu-mètre : Web Audio API (AnalyserNode) sur le micro local.
- Mute/Volume : agit sur la piste distante (TTS). Le mode transcription-only coupe l’audio et laisse le texte (si le modèle envoie des delta transcripts via data channel — vous pouvez aussi afficher le texte d’un tool dédié).
- Répéter : on enregistre en boucle la piste distante avec un MediaRecorder pour rejouer la dernière réponse.
- DataChannel : pour recevoir du texte (transcripts ou “assistant thinking”), et envoyer des commandes (ex. client.mode).
3) Sécurité & prod (rappels)
- Jamais de clé OpenAI dans le navigateur — uniquement le client_secret éphémère (TTL ~60–90 s).
- Webhooks signés : si activés, validez l’empreinte HMAC.
- Odoo : utilisez l’API Key (non le mot de passe) ; limitez l’accès par rôles et éventuellement servez vos tools via un backend proxy (pas d’accès direct).
- Observabilité : journalisez tool_call ↔ tool_result, latences (ASR/LLM/TTS), et erreurs Odoo.
4) À adapter selon vos besoins
- Personna & voix : configurez dans /session (instructions, voix, langue).
- Transcripts : si vous voulez toujours le texte, créez un tool “emit_transcript” qui pousse les deltas via votre bridge (SSE/WebSocket) ou via data channel si supporté par votre setup.
- Barge-in : côté front, si l’utilisateur reparle, vous pouvez baisser remoteAudioRef.current.volume à 0 (ou mute) pour éviter le double-talk ; le turn-taking est géré par Realtime.
ANNEXES