Skip to content

WebRTC Fundamentals — Le tuyau qui porte la voix de votre agent IA

TL;DR — WebRTC est le standard de facto pour transporter audio / vidéo temps réel browser ↔ serveur ↔ agent IA. Comprendre la stack est non-négociable pour un voicebot autre que téléphonique : signaling SDP (offer/answer via WebSocket), ICE candidates + STUN (NAT discovery) + TURN (relay), Opus (codec, 6-510 kbps, FEC, DTX), DataChannel (metadata side-channel), DTLS-SRTP (chiffrement obligatoire). En 2026, on n'écrit jamais WebRTC à la main : LiveKit Cloud (FR datacenter), Daily.co, Pion (Go), mediasoup (Node) abstraient. Pour la visio + agent IA en banque privée / télémédecine, la conformité (MIF II, HDS, eIDAS) impose chiffrement bout-en-bout et résidence des médias. TJM mission « intégration WebRTC + agent IA » : 1 300-1 500 €/j sur 8-14 semaines.


🧠 Mental model

┌──────────────────────────┐                ┌──────────────────────────┐
│  Browser / mobile        │                │  Serveur agent IA        │
│  (HTML <video><audio>)   │                │  (LiveKit + Anthropic)   │
└──────────┬───────────────┘                └──────────┬───────────────┘
           │                                            │
           │   1. Signaling (WebSocket / HTTPS)         │
           │   « Voici mon SDP offer » ────────────────►│
           │◄────── « Voici mon SDP answer »            │
           │   « ICE candidate »   ◄──────────────────► │
           │                                            │
           │   2. ICE / STUN / TURN                     │
           │   ┌─ STUN (discovery NAT public IP) ────┐  │
           │   ├─ TURN (relay si symmetric NAT) ─────┤  │
           │   └─ Direct P2P UDP si possible ────────┘  │
           │                                            │
           │   3. DTLS handshake → SRTP keys            │
           │   ─────────────────────────────────────►   │
           │                                            │
           │   4. Media tracks (Opus audio, VP9 video) │
           │   ◄═════════════════════════════════════► │  encrypted SRTP
           │                                            │
           │   5. DataChannel (metadata, transcripts)   │
           │   ◄═════════════════════════════════════►  │

Analogie — WebRTC c'est un standardiste téléphonique (signaling) qui aide deux personnes à se trouver (ICE) malgré qu'elles soient chacune derrière un standard d'entreprise (NAT), puis qui établit une ligne chiffrée directe entre elles (DTLS-SRTP). Le codec Opus est l'amplificateur basse-fidélité qui compresse la voix sans la déformer. LiveKit / Daily sont les « opérateurs téléphoniques modernes » qui vous évitent de monter votre propre central.

Position dans l'archi voice agent

[user mic] ──► WebRTC track Opus 48kHz ──► SFU/MCU ──► STT → LLM → TTS
[user speaker] ◄── WebRTC track Opus ───── SFU/MCU ◄── PCM resampled to Opus

                          [agent IA processus Node/Python]

Topologie — P2P mesh vs SFU vs MCU (le premier choix d'archi)

C'est la décision structurante : elle fixe votre latence, votre coût serveur, et votre capacité à enregistrer / injecter un agent IA. Un voicebot avec agent IA participant n'est jamais du P2P mesh : l'agent doit recevoir tous les flux et republier, ce qui impose un point central.

TopologieQui mixe / forwardeLatenceCoût serveurScaleQuand pour un voicebot
P2P meshPersonne (N² connexions)La plus basseQuasi nul (juste signaling)s'effondre > 4 pairsDémo 1↔1 sans agent serveur, jamais en prod régulée
SFU (LiveKit, mediasoup)Forwarde sans transcoderBasse (+1 hop)Linéaire (bande passante)Excellent (millions de min)Défaut voicebot : agent IA = participant, egress recording, faible CPU
MCUMixe tous les flux en 1Haute (decode+encode)Élevé (CPU transcode)CoûteuxConférence > 50 part., ou client legacy 1 seul flux entrant

Raisonnement staff — l'agent IA a besoin de s'abonner aux tracks (STT) et de publier sa voix (TTS) : un SFU le fait nativement comme n'importe quel participant. Le MCU ne se justifie que si le terminal client ne peut recevoir qu'un flux (SIP gateway, terminal embarqué pauvre) ou pour des conférences massives où le nombre de flux descendants devient le goulet.

Configurer Opus comme un senior (pas « 48 kHz stéréo par défaut »)

Le codec par défaut du navigateur sur-dimensionne pour de la parole. Pour un voicebot, vous choisissez le profil selon le canal, pas par habitude :

ProfilsampleRateCanauxBitrate cibleFEC / DTXUsage
Voix narrowband16 kHzmono16-24 kbpsFEC on, DTX onSTT-only, télémédecine, réseau contraint
Voix wideband (défaut voicebot)48 kHzmono24-32 kbpsFEC on, DTX onUX naturelle navigateur, coût maîtrisé
HiFi / musique d'attente48 kHzstéréo64-128 kbpsFEC onrare, jamais pour la parole agent

Trois leviers que le navigateur ne règle pas tout seul (à pousser via SDP fmtp ou plugin SFU) : maxaveragebitrate (plafonne le coût bande passante), useinbandfec=1 (récupère 5-10 % de perte sans glitch), usedtx=1 (frames silence légères, -50-70 % en bande passante sur les blancs). ptime=20 (et non 60) : trames de 20 ms = moins de latence de paquetisation, au prix d'un léger overhead d'en-têtes — le bon défaut temps réel.


🛠️ Code minimal

Browser : offer / answer + ICE

ts
// client.ts — minimal browser side
const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    { urls: "turn:turn.example.fr:3478", username: "u", credential: "p" },
  ],
});

// micro local → track sortant
const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true,
           channelCount: 1, sampleRate: 48000 },
});
for (const track of stream.getTracks()) pc.addTrack(track, stream);

// audio entrant agent IA
pc.ontrack = (ev) => {
  const audioEl = document.querySelector<HTMLAudioElement>("#agent")!;
  audioEl.srcObject = ev.streams[0];
};

// DataChannel pour metadata
const dc = pc.createDataChannel("meta", { ordered: true, maxRetransmits: 3 });
dc.onmessage = (e) => console.log("meta:", JSON.parse(e.data));

// signaling via WebSocket
const ws = new WebSocket("wss://agent.example.fr/signal");
pc.onicecandidate = (e) => {
  if (e.candidate) ws.send(JSON.stringify({ kind: "ice", candidate: e.candidate }));
};

ws.onmessage = async (e) => {
  const msg = JSON.parse(e.data);
  if (msg.kind === "answer") await pc.setRemoteDescription(msg.sdp);
  else if (msg.kind === "ice") await pc.addIceCandidate(msg.candidate);
};

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ kind: "offer", sdp: offer }));

Côté serveur LiveKit (Node SDK)

ts
// server-livekit.ts
import { AccessToken, RoomServiceClient } from "livekit-server-sdk";

const lk = new RoomServiceClient(
  process.env.LIVEKIT_URL!, process.env.LIVEKIT_KEY!, process.env.LIVEKIT_SECRET!,
);

export function issueClientToken(userId: string, roomName: string) {
  const at = new AccessToken(
    process.env.LIVEKIT_KEY!, process.env.LIVEKIT_SECRET!,
    { identity: userId, ttl: 3600 },
  );
  at.addGrant({
    room: roomName, roomJoin: true,
    canPublish: true, canSubscribe: true, canPublishData: true,
  });
  return at.toJwt();
}

Agent IA participant (LiveKit Agents framework)

ts
// agent.ts — agent qui rejoint la room comme participant
import { defineAgent, JobContext, AutoSubscribe } from "@livekit/agents";
import { VoicePipelineAgent } from "@livekit/agents-pipeline";
import { deepgramSTT } from "@livekit/agents-plugin-deepgram";
import { anthropicLLM } from "@livekit/agents-plugin-anthropic";
import { elevenlabsTTS } from "@livekit/agents-plugin-elevenlabs";
import { sileroVAD } from "@livekit/agents-plugin-silero";

export default defineAgent({
  entry: async (ctx: JobContext) => {
    await ctx.connect(undefined, AutoSubscribe.AUDIO_ONLY);

    const agent = new VoicePipelineAgent({
      vad: sileroVAD({ minSilenceDurationMs: 700 }),
      stt: deepgramSTT({ model: "nova-3", language: "fr", smartFormat: true }),
      llm: anthropicLLM({
        model: "claude-sonnet-4-6",
        temperature: 0.3,
        system: "Tu es une télé-conseillère banque privée. Discrète, factuelle.",
      }),
      tts: elevenlabsTTS({
        voiceId: process.env.BRAND_VOICE!, model: "eleven_flash_v2_5",
      }),
      interruptSpeechDuration: 0.5,
      interruptMinWords: 2,
    });

    agent.start(ctx.room);
    await agent.say("Bonjour, je suis Claire de Banque Privée du Faubourg.");
  },
});

DataChannel pour metadata (transcript live, indicateurs MIF II)

ts
// envoyer le transcript en parallèle du flux audio
function sendMeta(dc: RTCDataChannel, payload: any) {
  if (dc.readyState === "open") dc.send(JSON.stringify(payload));
}

// quand STT final
sendMeta(dc, { kind: "transcript", role: "client", text: finalText, ts: Date.now() });

// quand on détecte un keyword sensible (« virement >50k€ »)
sendMeta(dc, { kind: "compliance-flag", code: "MIFII-PRO-RISK-2", confidence: 0.91 });

🎬 Cas d'usage concrets

1. Visio télémédecine — médecin / patient avec assistant diagnostic IA

Contexte client — Plateforme française de téléconsultation (1.4 M consultations/an, agréée HDS). Veut ajouter un assistant IA qui écoute la consultation (avec consentement), transcrit, et propose au médecin :

  • Suggestions de diagnostic différentiel
  • Détection d'incohérences entre symptômes décrits et antécédents
  • Génération du compte-rendu post-consultation

Stack

  • WebRTC via mediasoup self-host (datacenter HDS Cellnex Paris)
  • TURN serveur Coturn en cluster (3 nœuds, 200 Gbps)
  • Codec Opus mono 16 kHz (suffit pour parole, économise bande passante)
  • Agent IA participant en mode « écoute silencieuse » (publie pas d'audio, juste DataChannel)
  • WhisperX self-host pour STT (HDS), Sonnet 4.6 via Anthropic Vertex EU Paris (beta 2026)
  • Suggestions affichées dans une side-panel du médecin, jamais audibles par patient

Résultats — Médecins gagnent 4 minutes par consultation. 94 % des comptes-rendus auto-générés validés sans modification majeure. Conforme HDS et certificat hébergeur HDS publié.

TJM/budget — 280 k€ sur 16 semaines, 4 ingés. TJM 1 350 €/j architecte WebRTC senior FR.

2. Formation pro RH à distance — coach IA en visio

Contexte — Organisme de formation continue (1 200 entreprises FR clientes), modules « soft skills » (négociation, prise de parole, gestion conflit). Veut un coach virtuel qui anime les sessions de role-play en visio.

Stack

  • LiveKit Cloud région eu-paris
  • Agent IA avec avatar vidéo (Synthesia API) + voix ElevenLabs Flash v2.5
  • Multi-participants : apprenant + IA coach + parfois formateur humain en observation
  • Enregistrement des sessions (consentement RGPD explicite) pour debrief
  • Score automatique post-session : clarté, écoute active, gestion silences (analyse vocale + textuelle)

Résultats — 78 % satisfaction apprenants (vs 81 % coach humain). Coût par session : 4,20 € (vs 80 € coach humain). Adoption +40 % vs présentiel.

TJM/budget — 145 k€ MVP + 38 k€ premier scale.

3. Voicebot e-commerce intégré au site (pas de téléphone)

Contexte — Site e-commerce luxe FR (maroquinerie, 90 M€ CA), conversion mobile faible (1.2 %). Hypothèse : un agent vocal natif site (bouton micro) augmente la conversion par accompagnement personnalisé.

Stack

  • WebRTC browser ↔ agent IA via LiveKit Cloud
  • Pas de TURN externe nécessaire (la plupart des clients sur réseau résidentiel acceptent UDP)
  • Anthropic Sonnet 4.6 avec accès tools : catalog search, stock check, panier add, livraison estimate
  • ElevenLabs Flash v2.5 voix « ambassadrice maison » clonée
  • Mode push-to-talk pour respecter l'intimité (pas de micro always-on)

Résultats — 2.8 % conversion sur sessions vocales vs 1.2 % sans. Panier moyen +18 % grâce au cross-sell vocal.

TJM/budget — 95 k€ projet + 6 200 €/mois SaaS opérationnel.


🛠️ Exemple end-to-end

Cas — Visio télé-conseillère banque privée avec transcription temps réel, agent IA en assistance discrète au conseiller (suggestions in-ear via panel), compliance MIF II.

ts
// teleconseillere-banque.ts — full stack
// dépendances : @livekit/agents, @livekit/agents-plugin-*, @anthropic-ai/sdk, ws, zod
import {
  defineAgent, JobContext, AutoSubscribe, llm, multimodal,
} from "@livekit/agents";
import {
  VoicePipelineAgent, transcription,
} from "@livekit/agents-pipeline";
import { deepgramSTT } from "@livekit/agents-plugin-deepgram";
import { anthropicLLM } from "@livekit/agents-plugin-anthropic";
import { elevenlabsTTS } from "@livekit/agents-plugin-elevenlabs";
import { sileroVAD } from "@livekit/agents-plugin-silero";
import { RoomEvent, DataPacket_Kind, RemoteParticipant } from "livekit-client";
import { z } from "zod";

interface ComplianceFlag {
  code: string;        // ex: "MIFII-INVEST-RISK-WARN"
  severity: "info" | "warn" | "block";
  reason: string;
  source_quote: string;
}

const SUITABILITY_KEYWORDS = [
  "produit structuré", "obligation perpétuelle", "EMTN", "warrant",
  "private equity", "crypto-actifs", "tokenisation",
];

const RISK_PROFILE_REQUIRED_DISCLOSURES = [
  "risque de perte en capital",
  "horizon de placement",
  "frais d'entrée",
  "fiscalité applicable",
];

// ── Agent IA participe à la room comme observateur ──
export default defineAgent({
  entry: async (ctx: JobContext) => {
    await ctx.connect(undefined, AutoSubscribe.AUDIO_ONLY);

    // Récupération des metadata participants (conseiller vs client)
    const advisor = ctx.room.localParticipant; // nous ne sommes pas le conseiller
    // dans cette archi l'agent IA = participant tiers "compliance-watcher"

    const stt = deepgramSTT({
      model: "nova-3", language: "fr", smartFormat: true,
      interimResults: true, diarize: true,
      keywords: ["MIF", "PEA", "assurance vie", "private equity"],
    });

    const flags: ComplianceFlag[] = [];

    // Souscrire aux tracks audio des deux participants humains
    ctx.room.on(RoomEvent.TrackSubscribed, async (track, _publication, participant) => {
      if (track.kind !== "audio") return;
      const role = participant.metadata === "advisor" ? "advisor" : "client";
      const sttStream = stt.stream();

      // Forward audio frames vers Deepgram
      const audioStream = track.attach();
      audioStream.on("data", (frame) => sttStream.pushFrame(frame));

      for await (const event of sttStream) {
        if (event.type === "FINAL") {
          await onUtterance(role, event.text, ctx, flags);
        }
      }
    });

    // Surveille la conversation toutes les 30s : check disclosures MIF II
    const checker = setInterval(async () => {
      await checkRequiredDisclosures(ctx, flags);
    }, 30_000);

    ctx.room.on(RoomEvent.Disconnected, () => clearInterval(checker));
  },
});

const conversationLog: { role: "advisor" | "client"; text: string; ts: number }[] = [];

async function onUtterance(
  role: "advisor" | "client", text: string,
  ctx: JobContext, flags: ComplianceFlag[],
) {
  conversationLog.push({ role, text, ts: Date.now() });

  // 1. transcription temps réel → tous les participants
  publishMeta(ctx, { kind: "transcript", role, text, ts: Date.now() });

  // 2. detection keywords risque (rule-based, latence < 5ms)
  for (const kw of SUITABILITY_KEYWORDS) {
    if (text.toLowerCase().includes(kw.toLowerCase())) {
      const flag: ComplianceFlag = {
        code: "MIFII-PRODUCT-RISK-MENTION",
        severity: "warn",
        reason: `Mention de "${kw}" détectée — vérifier suitability test client`,
        source_quote: text,
      };
      flags.push(flag);
      publishMeta(ctx, { kind: "compliance", flag });
    }
  }

  // 3. analyse contextuelle Sonnet 4.6 (toutes les 4 utterances client)
  const clientCount = conversationLog.filter(u => u.role === "client").length;
  if (role === "client" && clientCount % 4 === 0) {
    const analysis = await analyzeWithLLM(conversationLog.slice(-20));
    if (analysis.suggestions.length) {
      publishMeta(ctx, { kind: "advisor-hint", suggestions: analysis.suggestions });
    }
    flags.push(...analysis.flags);
    for (const f of analysis.flags) publishMeta(ctx, { kind: "compliance", flag: f });
  }
}

import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
// `z` est déjà importé en tête de fichier (ne pas redéclarer — erreur TS2300).

// AsyncAnthropic-équivalent : le client TS est déjà asynchrone. On configure
// max_retries (backoff exponentiel sur 429/5xx/529) et un timeout par appel —
// indispensable dans une boucle voix où un appel bloqué fige tout le pipeline.
const anthropic = new Anthropic({ maxRetries: 2, timeout: 8_000 });

const SYS_COMPLIANCE = `Tu es un assistant compliance MIF II pour télé-conseiller banque privée FR.
À partir de la transcription, tu identifies:
1. SUGGESTIONS pour le conseiller (questions à poser, produits à éviter, info à donner)
2. FLAGS de compliance (manque de disclosure, désalignement profil/produit, lapse réglementaire)

JAMAIS de conseil financier direct. Tu aides le CONSEILLER, pas le client.`;

// Schéma natif : on ne « prompt » plus du JSON à la main, on contraint la sortie.
// messages.parse() + output_config.format garantit une réponse valide ou un refus —
// plus de regex /\{[\s\S]*\}/ fragile, plus de JSON tronqué silencieux.
const AnalysisSchema = z.object({
  suggestions: z.array(z.string()),
  flags: z.array(z.object({
    code: z.string(),
    severity: z.enum(["info", "warn", "block"]),
    reason: z.string(),
    source_quote: z.string(),
  })),
});

async function analyzeWithLLM(log: typeof conversationLog) {
  const transcript = log.map(u => `${u.role.toUpperCase()}: ${u.text}`).join("\n");
  try {
    const resp = await anthropic.messages.parse({
      model: "claude-sonnet-4-6",
      max_tokens: 800,
      // cache_control sur le préfixe stable (system) : ~90 % moins cher sur les
      // appels répétés d'une même session. Le transcript volatil reste hors cache.
      system: [{ type: "text", text: SYS_COMPLIANCE, cache_control: { type: "ephemeral" } }],
      output_config: { format: zodOutputFormat(AnalysisSchema) },
      messages: [{
        role: "user",
        content: `Analyse les 20 derniers tours:\n\n${transcript}`,
      }],
    });
    // observabilité coût : logguer usage à chaque appel (input/cache/output)
    console.debug("llm.usage", resp.usage);
    if (resp.stop_reason === "refusal") return { suggestions: [], flags: [] };
    return resp.parsed_output ?? { suggestions: [], flags: [] };
  } catch (err) {
    // exceptions typées : ne pas faire planter la boucle voix sur un 429/timeout
    if (err instanceof Anthropic.RateLimitError || err instanceof Anthropic.APITimeoutError) {
      console.warn("llm.degraded", err.constructor.name);
      return { suggestions: [], flags: [] }; // fallback rule-based déjà actif
    }
    throw err;
  }
}

async function checkRequiredDisclosures(ctx: JobContext, flags: ComplianceFlag[]) {
  const fullText = conversationLog
    .filter(u => u.role === "advisor")
    .map(u => u.text.toLowerCase()).join(" ");
  for (const disclosure of RISK_PROFILE_REQUIRED_DISCLOSURES) {
    if (!fullText.includes(disclosure.toLowerCase())) {
      const flag: ComplianceFlag = {
        code: "MIFII-MISSING-DISCLOSURE",
        severity: "warn",
        reason: `Disclosure "${disclosure}" non prononcée par le conseiller`,
        source_quote: "",
      };
      // n'ajoute qu'une seule fois par disclosure
      if (!flags.some(f => f.code === flag.code && f.reason === flag.reason)) {
        flags.push(flag);
        publishMeta(ctx, { kind: "compliance", flag });
      }
    }
  }
}

function publishMeta(ctx: JobContext, payload: any) {
  const data = new TextEncoder().encode(JSON.stringify(payload));
  ctx.room.localParticipant.publishData(data, {
    reliable: true,
    destinationIdentities: ["advisor"], // que pour le conseiller, pas le client
  });
}

Côté conseiller (panel React qui lit le DataChannel) :

tsx
// advisor-panel.tsx
import { useDataChannel, useRoomContext } from "@livekit/components-react";

export function AdvisorPanel() {
  const room = useRoomContext();
  const { send: _, message } = useDataChannel("compliance");
  const [flags, setFlags] = React.useState<ComplianceFlag[]>([]);
  const [hints, setHints] = React.useState<string[]>([]);

  React.useEffect(() => {
    if (!message) return;
    const msg = JSON.parse(new TextDecoder().decode(message.payload));
    if (msg.kind === "compliance") setFlags(f => [...f, msg.flag]);
    else if (msg.kind === "advisor-hint") setHints(msg.suggestions);
  }, [message]);

  return (
    <div className="advisor-panel">
      <section>
        <h3>Compliance</h3>
        {flags.map((f, i) => (
          <div key={i} className={`flag flag-${f.severity}`}>
            <strong>{f.code}</strong> — {f.reason}
          </div>
        ))}
      </section>
      <section>
        <h3>Suggestions</h3>
        <ul>{hints.map((h, i) => <li key={i}>{h}</li>)}</ul>
      </section>
    </div>
  );
}

Configuration LiveKit room (côté API serveur de signaling) :

ts
// room-create.ts
import { RoomServiceClient } from "livekit-server-sdk";

await roomService.createRoom({
  name: `bp-${clientRef}-${Date.now()}`,
  emptyTimeout: 60,
  maxParticipants: 3, // client + conseiller + agent IA
  metadata: JSON.stringify({
    audit_required: true,
    recording_enabled: true,
    compliance_regime: "MIFII",
    region: "eu-paris",
  }),
});

🎯 Patterns courants

  • SFU plutôt que MCU — pour les voicebots, un SFU (Selective Forwarding Unit) comme LiveKit forwarde les flux sans transcoder = latence basse, scale linéaire. MCU justifié seulement pour conférences > 50 participants.
  • Coturn dédié pour environnements régulés — TURN auto-hébergé en datacenter HDS / SecNumCloud quand le médias ne doit pas transiter par un opérateur tiers.
  • Trickle ICE — ne pas attendre tous les ICE candidates avant d'envoyer l'offer ; envoyer au fur et à mesure → -300-800 ms de setup.
  • Opus DTX — Discontinuous Transmission : envoie des frames silence très légères pendant les blancs, économise 50-70 % de bande passante.
  • Opus FEC — Forward Error Correction : redondance de la voix précédente dans la frame courante, compense 5-10 % de packet loss sans audio glitch.
  • Echo cancellation côté client — toujours activer dans getUserMedia (AEC, NS, AGC). Côté serveur agent, RNNoise / Krisp en complément si environnement noisy.
  • Token-scoped + TTL court — les tokens LiveKit / Daily sont des JWT ; émettre avec TTL 5-15 min, jamais long terme côté browser.
  • Adaptive bitrate — Opus peut descendre à 6 kbps en cas de réseau dégradé, monter à 64 kbps en HiFi. Configurer via SDP munge ou plugin SDK.
  • DataChannel ordered=false pour latence — pour metadata non-critique (live transcript), accepter le désordre = -50 ms vs reliable.
  • Recording côté SFU — toujours enregistrer au niveau SFU (composite egress), pas côté client (perte si client crash).

🔄 Versions & écosystème 2026

SolutionModèleRégions FRConformitéPrix typique
LiveKit CloudSaaSeu-parisHDS partenaire0,005 $ / min participant
LiveKit OSSself-hostpartouttoutinfra only
Daily.coSaaSeu-frankfurtRGPD0,004 $ / min
Twilio Programmable VideoSaaSEURGPD, HIPAA US0,004 $ / min
Vonage / OpenTokSaaSEUtout0,003 $ / min
Pion (Go OSS)self-hostpartouttoutinfra only
mediasoup (Node OSS)self-hostpartouttoutinfra only
Cellnex / OVH visiohébergeur FRFRHDS, SNCsur devis

Évolutions WebRTC 2026

  • WebTransport (HTTP/3 QUIC) supplante progressivement WebSocket pour signaling et data → moins de NAT issues, multiplexing natif.
  • Insertable Streams API + WebCodecs stable partout : permet de chiffrer end-to-end (E2EE) au-delà de DTLS-SRTP, exigé en banque privée et défense.
  • AV1 codec audio expérimental (LC3, LC3plus) pour ultra-low bitrate (audio prothèses, embarqué).
  • WebRTC Encoded Transform GA Chrome 122+ : injection IA dans le flux (noise suppression, voice enhancement) sans buffer external.

⚠️ Pitfalls

  1. Oublier TURN — 25-30 % des clients FR sont derrière un NAT symétrique (4G, certains FAI corporate) ; sans TURN, audio ne s'établit pas.
  2. TURN sans authentification — TURN ouvert = relay public abusé pour traffic illégal. Toujours auth via short-lived credentials (HMAC).
  3. Mauvais codec config — 48 kHz stéréo Opus 256 kbps pour voicebot = inutile et coûteux ; 16 kHz mono Opus 24 kbps largement suffisant pour parole.
  4. Echo en agent IA mode interactif — quand l'agent et le client parlent en simultané (interruption), il faut un échantillonnage croisé + AEC, sinon le bot s'entend lui-même.
  5. Pas de réauth ICE periodic — un long appel (> 30 min) sur réseau qui change (Wi-Fi → 4G) requiert ICE restart, sinon coupe.
  6. TLS 1.2 sur signaling — certains régulateurs exigent TLS 1.3 minimum (ANSSI recommendations 2025). Vérifiez votre WS server.
  7. Recording sans consentement — l'enregistrement d'une conversation banque/santé sans accord explicite = condamnation possible (article 226-1 CP). Mention vocale obligatoire au début.
  8. DataChannel sans backpressure — envoyer 10 000 messages/s satures le buffer SCTP, drops. Toujours respecter bufferedAmount.
  9. Cross-origin iframe — embedder votre voicebot dans iframe sans allow="microphone; camera" → mic bloqué silencieusement, debug pénible.
  10. Pas de fallback HTTP audio — sur certains réseaux corporate FR, UDP est bloqué partout. Prévoir un fallback HTTP audio streaming (perte qualité mais service maintenu).

💰 Pricing / ROI client

Coûts opérationnels WebRTC + agent IA

ComposantCoût indicatifVolume référence
LiveKit Cloud0,005 $ / participant.minute50 k réunions × 25 min × 3 participants = 18 750 $
Coturn self-host (3 nœuds + 100 Gbps OVH)1 800 €/moistrafic illimité
Bande passante WebRTC~30 Kbps audio + ~500 Kbps vidéo500 Kbps × 25 min × 50 k = 7,8 To/mois
OVH/Cellnex HDS hosting4-8 k€/moisinfra agent + signaling FR
Anthropic Sonnet 4.6 + cache3 $ input cache hit / 1M tokens1,2 k€/mois pour 50 k sessions
Deepgram Nova-3 FR0,0043 $ / min audio50 k × 25 min = 5 375 $
ElevenLabs Flash v2.518 $ / 1M car50 k × 4 min agent parle × 800 car/min = 2 880 $

TJM mission freelance type

  • Architecture WebRTC + agent IA + conformité : audit 14-22 k€ (3-4 semaines)
  • Implémentation MVP (1 cas d'usage) : 95-160 k€ (10-14 semaines)
  • Mise en production multi-tenants : +60-110 k€ (8-10 semaines)
  • TJM 1 350-1 500 €/j architecte voix + WebRTC senior FR
  • Maintenance + optimisation : 14-32 k€/an forfait

ROI banque privée

  • 1 200 RDV/mois × durée -20 % grâce à compliance auto + résumé auto = 240 h gagnées/mois
  • 240 h × 80 €/h (coût télé-conseiller) = 19 200 €/mois économisés
  • Réduction risque sanction ACPR sur défaut MIF II : estimé 1.2 M€/an (probabilité × montant moyen sanction)

📊 Observabilité & budget de latence

Le mental model du staff engineer — un voicebot WebRTC est une chaîne où chaque maillon ajoute de la latence, et la perception utilisateur d'« interactivité naturelle » casse au-delà de ~800 ms de silence après la fin de parole. Vous devez raisonner en budget de latence end-to-end, pas en latences isolées.

MaillonLatence typiqueLevier
Capture mic → frame Opus (20 ms ptime)20-40 msptime 20 ms (pas 60 ms)
Réseau RTP (un sens)15-60 msTURN proche, P2P si possible
Jitter buffer (adaptatif)20-100 msborner le max, NetEQ
VAD endpointing (fin de parole)300-700 msminSilenceDurationMs — le plus gros poste
STT streaming (interim → final)100-300 msinterim results, modèle streaming
LLM TTFT (time-to-first-token)200-600 msstreaming, prompt caching, modèle plus petit
TTS TTFB (first audio chunk)80-200 msmodèle « flash », streaming
Total ressenti (fin parole → 1er son agent)~1.0-1.8 sviser < 1.2 s p95

Ce qu'un senior instrumente (chaque trace doit porter ces spans, corrélés par session_id + turn_id) :

  • vad.endpoint_ms, stt.final_ms, llm.ttft_ms, tts.ttfb_ms, e2e.first_audio_ms — en histogramme, p50/p95/p99.
  • webrtc.rtt_ms, webrtc.packet_loss_pct, webrtc.jitter_ms via getStats() (échantillonné toutes les 2 s), exportés en time-series.
  • llm.usage (input / cache_read / output tokens) → coût par tour, agrégé par session pour facturation client.
  • ice.selected_candidate_type (host / srflx / relay) : un pic de relay signale un problème NAT/firewall côté flotte.

Failure modes à alerter : p95 e2e.first_audio_ms > 1.5 s (UX cassée) ; packet_loss_pct > 5 % soutenu (FEC sature) ; ratio TURN relay > 35 % (TURN sous-dimensionné ou UDP bloqué massivement) ; llm.ttft_ms qui dérive (cache froid → vérifier cache_read_input_tokens).

Sécurité — la checklist non négociable en régulé : DTLS-SRTP obligatoire (jamais de média en clair), TURN avec credentials HMAC court-terme (REST API ephemeral, jamais un user/pass statique embarqué côté browser comme dans l'exemple « Code minimal »), JWT LiveKit TTL ≤ 15 min, TLS 1.3 sur le signaling, et aucun secret LLM côté client — l'agent IA appelle Anthropic depuis le serveur, jamais depuis le navigateur. E2EE (Insertable Streams) en sus de DTLS quand le média ne doit même pas être déchiffrable par le SFU.


🧪 Testing / Eval

Tests WebRTC obligatoires

ts
// e2e-webrtc.test.ts — Playwright + Chromium
import { test, expect, chromium } from "@playwright/test";

test("agent répond en moins de 1.5s sur réseau dégradé", async () => {
  const browser = await chromium.launch({
    args: [
      "--use-fake-ui-for-media-stream",
      "--use-fake-device-for-media-stream",
      "--use-file-for-fake-audio-capture=./test-audio.wav",
    ],
  });
  const ctx = await browser.newContext();

  // simule NAT symétrique + 100 Kbps + 80 ms latence
  await ctx.route("**/*", route => route.continue());

  const page = await ctx.newPage();
  await page.goto("https://demo.example.fr/voicebot");
  await page.click("#start");

  const t0 = Date.now();
  // attente du premier chunk audio agent
  await page.waitForFunction(() => (window as any).__firstAgentAudioMs);
  const latency = await page.evaluate(() => (window as any).__firstAgentAudioMs);

  expect(latency - t0).toBeLessThan(1500);
});

Checklist tests

  • Setup time SDP offer → first audio frame (target < 800 ms p95)
  • Reconnect time après ICE restart (target < 2 s)
  • Packet loss tolerance (0 %, 5 %, 10 %, 20 % — audio quality MOS)
  • Bandwidth adaptation (passage 1 Mbps → 100 Kbps)
  • Multi-participants scale (3, 10, 50, 200)
  • Echo / feedback test (haut-parleur agent capté par mic, AEC suffit ?)
  • Sécurité : DTLS handshake validé, certificats valides, TURN auth correctement
  • DataChannel reliability sous load

Outils — TestRTC, KITE (Cosmo), webrtc-internals (Chrome DevTools), wireshark + SSL keys pour debug DTLS.


🔁 Quand utiliser / éviter

Utiliser WebRTC

  • Voicebot intégré à un site / app (e-commerce, banque, santé)
  • Visio avec agent IA participant (téléconsultation, formation)
  • Téléphonie moderne natale browser (call center cloud)
  • DataChannel + audio = synchronisation parfaite (live captions)

Utiliser SIP / téléphonie classique

  • Voicebot accessible par numéro 09xx / 08xx (audience non technophile)
  • Centre d'appels existant déjà sur Genesys / Avaya
  • Compatibilité large public (seniors, accessibilité)

Utiliser HTTP streaming audio (fallback)

  • Quand UDP est bloqué (corporate restrictif)
  • Quand qualité dégradée acceptable
  • Quand vous ne voulez pas dépendre d'un SFU externe

Éviter WebRTC manuel

  • Si vous n'avez pas 6+ mois de prod expérience : LiveKit / Daily / Twilio évitent 80 % des bugs
  • Pour POC rapide : LiveKit Cloud + Agents framework = 2 jours pour MVP
  • Pour environnements régulés sans expertise crypto : risque DTLS-SRTP mal configuré

🏋️ Exercices

Progressifs : du « ça marche sur mon Wi-Fi » au « défends le chiffre devant l'archi et le RSSI ». Faites-les dans l'ordre.

1. Établir un appel P2P chiffré sans framework

Objectif — Monter un RTCPeerConnection browser↔browser à la main : signaling via un WebSocket minimal, offer/answer SDP, trickle ICE, un seul STUN public, et vérifier dans chrome://webrtc-internals que le DTLS handshake aboutit et que la selected candidate pair est host/srflx. Indice/Solution — Reprenez le client.ts de la section « Code minimal ». Le piège classique : appeler setRemoteDescription avant que setLocalDescription ait résolu, ou envoyer les ICE candidates avant l'answer. Validez : pc.connectionState === "connected" et pc.getStats()transport.dtlsState === "connected".

2. Forcer le relay TURN et mesurer le surcoût

Objectif — Reproduire un NAT symétrique : configurez le RTCPeerConnection avec iceTransportPolicy: "relay" et un Coturn auto-hébergé (credentials HMAC éphémères, pas statiques). Mesurez le RTT et la latence first-audio en mode relay vs P2P direct, et chiffrez le surcoût bande passante côté serveur. Indice/SolutioniceTransportPolicy: "relay" force tout le média à transiter par TURN. Générez les credentials via l'API REST ephemeral de Coturn (timestamp:username + HMAC-SHA1 du secret). Attendez-vous à +20-60 ms de RTT et à 100 % du média qui transite par votre serveur (≈ 30-50 kbps/sens en mono Opus). C'est ce chiffre qui dimensionne vos nœuds TURN.

3. Survivre au packet loss et au handover réseau

Objectif — Sous contrainte réseau simulée (tc netem : 10 % loss, 80 ms RTT, puis bascule Wi-Fi→4G mid-call), garder l'audio intelligible. Activez Opus FEC + DTX, bornez le jitter buffer, et déclenchez un ICE restart au changement d'interface sans couper la session. Indice/Solution — FEC se négocie via useinbandfec=1 dans le SDP fmtp ; DTX via usedtx=1. Le handover requiert d'écouter pc.oniceconnectionstatechange === "disconnected" puis pc.restartIce() (ou createOffer({iceRestart: true})). Mesurez le MOS avant/après et le temps de re-établissement (cible < 2 s). Si l'audio glitche encore à 10 % loss, c'est que le jitter buffer max est trop court.

4. Brancher l'agent IA et défendre le budget de latence

Objectif — Insérer un agent IA participant (VAD → STT → LLM Sonnet 4.6 streaming → TTS flash) dans la room, instrumenter chaque span (vad.endpoint_ms, stt.final_ms, llm.ttft_ms, tts.ttfb_ms, e2e.first_audio_ms), et produire un budget de latence p95 < 1.2 s que vous pouvez défendre ligne par ligne. Indice/Solution — Le plus gros poste est presque toujours le VAD endpointing (minSilenceDurationMs) : descendre de 700 à 400 ms gagne 300 ms mais augmente les barge-in faux positifs. Streaming obligatoire sur LLM et TTS (sinon vous payez le temps de génération complet avant le 1er son). Activez le prompt caching sur le system prompt (vérifiez cache_read_input_tokens > 0). Défendez chaque ms : si on vous demande de gagner 200 ms, vous devez savoir lequel des 7 maillons couper et ce que ça casse.

5. Casser puis durcir pour la prod régulée (banque privée / HDS)

Objectif — Auditer l'archi end-to-end de la section « Exemple end-to-end » et lister tout ce qui ne passerait pas un audit ACPR/HDS, puis corriger : résidence du média (datacenter FR), enregistrement avec consentement vocal horodaté, isolation du destinationIdentities (les hints n'atteignent jamais le client), rotation des secrets, et E2EE Insertable Streams au-dessus de DTLS-SRTP. Indice/Solution — Failles à trouver : le TURN avec username:"u"/credential:"p" statique (relay public abusable) ; le JWT LiveKit à TTL 3600 s (trop long pour un token browser) ; le risque que publishData fuite côté client si destinationIdentities est omis ; l'absence de mention vocale d'enregistrement (art. 226-1 CP) ; le SFU qui voit le média en clair (d'où E2EE). Livrable : une matrice « risque → contrôle → preuve d'audit » exploitable par un RSSI.

6. Fallback quand UDP est mort (réseau corporate hostile)

Objectif — Sur un réseau qui bloque tout UDP (certains corporate FR), faire fonctionner le voicebot : TURN over TCP/TLS 443 d'abord, puis, si même ça échoue, un fallback HTTP audio streaming (qualité dégradée mais service maintenu). Mesurer la dégradation MOS et le delta de latence. Indice/Solution — Ajoutez turns:turn.example.fr:443?transport=tcp dans iceServers : le média s'encapsule dans TLS sur le port 443, indistinguable d'HTTPS pour un firewall. Le fallback HTTP n'est plus du WebRTC (pas de DTLS-SRTP, pas de jitter buffer adaptatif) — documentez ce que vous perdez. Le bon réflexe senior : turns:443 résout > 95 % des cas corporate ; le fallback HTTP est le dernier recours, pas le défaut.

7. Renégociation robuste et résolution du glare

Objectif — Implémenter le Perfect Negotiation pattern (rôles polite/impolite) pour qu'une renégociation (l'agent IA active sa caméra en cours d'appel, ou un track est remplacé) ne casse jamais la session, même si les deux pairs émettent une offer en même temps (glare). Provoquez délibérément le glare et prouvez que la session survit. Indice/Solution — Le piège : appeler setLocalDescription pendant que signalingState !== "stable". Implémentez le flag makingOffer, écoutez negotiationneeded, et sur réception d'une offer concurrente : le pair impolite ignore l'offer entrante, le pair polite fait un rollback (setLocalDescription({type:"rollback"})) puis accepte. Test de validation : déclenchez addTrack simultanément des deux côtés via un délai réseau injecté ; la session doit rester connected sans renégociation manuelle ni InvalidStateError. C'est exactement le bug qui fait « marcher en démo, casser en prod » sur tout voicebot qui ajoute la vidéo à chaud.


🎤 En entretien

  • « Différence STUN / TURN, et quel pourcentage de vos appels passe par TURN ? » — STUN découvre l'IP publique pour tenter du P2P direct ; TURN relaie tout le média quand le NAT symétrique l'interdit. En prod FR ~25-35 % des sessions finissent en relay (4G, corporate) — c'est ce ratio qui dimensionne le coût et la bande passante TURN.
  • « Pourquoi un SFU plutôt qu'un MCU pour un voicebot, et quand bascule-t-on ? » — Le SFU forwarde sans transcoder : latence basse, scale linéaire, idéal pour de l'audio temps réel. On ne passe au MCU (qui mixe côté serveur) que pour de très grandes conférences (> 50 participants) où le nombre de flux à recevoir devient le goulet — rarement pour un agent IA.
  • « Comment garantissez-vous le chiffrement et la confidentialité du média en banque privée ? » — DTLS-SRTP est obligatoire et automatique sur WebRTC (média jamais en clair sur le réseau), mais le SFU déchiffre pour forwarder ; pour que même l'opérateur SFU ne voie rien, on ajoute l'E2EE via Insertable Streams. Plus : TURN HMAC éphémère, JWT TTL court, secrets LLM côté serveur uniquement.
  • « Votre voicebot répond en 2,5 s, l'UX est jugée lente. Où coupez-vous ? » — Je décompose le budget par span : presque toujours le VAD endpointing (300-700 ms) et le LLM non-streamé sont les coupables. Je passe LLM et TTS en streaming (gain TTFT/TTFB), je baisse minSilenceDurationMs en surveillant les barge-in, et je vérifie que le prompt caching mord (cache_read_input_tokens > 0). Je défends chaque ms coupée par son risque associé.
  • « À quoi sert exactement le SDP, et que se passe-t-il en cas de "glare" ? » — Le SDP est le contrat de session négocié (codecs, ports, DTLS fingerprints, ICE ufrag/pwd) via offer/answer. Le glare survient quand les deux pairs émettent une offer simultanément : on le résout avec le rôle polite/impolite du Perfect Negotiation pattern (le pair poli rollback son offer et accepte celle de l'autre). C'est aussi pour ça qu'on ne mungle le SDP qu'en dernier recours — chaque modif manuelle casse la renégociation.

🔗 Liens

  • W3C WebRTC spec : w3.org/TR/webrtc/
  • WebRTC for the Curious (book) : webrtcforthecurious.com
  • LiveKit Agents docs : docs.livekit.io/agents
  • Daily.co docs : docs.daily.co
  • mediasoup docs : mediasoup.org/documentation/v3/
  • Pion (Go WebRTC) : github.com/pion/webrtc
  • Coturn server : github.com/coturn/coturn
  • Opus codec : opus-codec.org/docs/
  • RFC 8825 (WebRTC overview) : datatracker.ietf.org/doc/html/rfc8825
  • ANSSI recommandations VoIP 2025 : ssi.gouv.fr/guide/voix-sur-ip
  • HDS — certificat hébergeur santé : esante.gouv.fr/produits-services/hds
  • C2PA Insertable Streams (E2EE WebRTC) : w3c.github.io/webrtc-encoded-transform/

Bibliothèque tech perso — Achref