Skip to content

VAD & Streaming STT — Détecter la parole et la transcrire en temps réel

TL;DR — Un voicebot français qui fonctionne dépend à 60 % de la qualité de la couche VAD + STT. Silero VAD (CPU, 1 ms par frame) détecte la parole, l'endpointing décide quand l'utilisateur a fini (silence > 700 ms + analyse sémantique). Pour le streaming STT en 2026 : Deepgram Nova-3 (~150 ms latence, 70+ langues), AssemblyAI Universal-2 (diarization native), gpt-4o-transcribe (résistant au bruit), WhisperX (open-source, diarization pyannote). Pour le français avec accents (Québec, Marseille, Maghreb, créole), Whisper large-v3 reste imbattable, Nova-3 gagne sur la latence. Budget : 0,003-0,012 $ / minute. TJM mission « voicebot service client FR » : 1 300-1 500 €/j sur 6-10 semaines.


🧠 Mental model

Pipeline voix entrant pour un agent vocal :

┌──────────────────────────────────────────────────────────────────────┐
│  Micro (16 kHz mono PCM) — WebRTC track ou téléphonie SIP/PSTN       │
└────────────┬─────────────────────────────────────────────────────────┘
             │ frames 20-30 ms

   ┌─────────────────────┐
   │  Noise suppression  │  RNNoise / Krisp / Deepgram NSS
   │  (optionnel)        │  -10 à -25 dB sur bruit constant
   └────────┬────────────┘

   ┌─────────────────────┐    speech? non → drop (économise STT $$$)
   │  VAD                │ ─────────────────────────────┐
   │  Silero / WebRTC    │    speech? oui → bufferise   │
   └────────┬────────────┘                              │
            ▼                                           │
   ┌─────────────────────┐                              │
   │  Endpointing        │  silence > 700 ms + LLM      │
   │  (turn detection)   │  « est-ce une phrase complète ? »
   └────────┬────────────┘                              │
            ▼                                           │
   ┌─────────────────────┐                              │
   │  Streaming STT      │  partial transcripts toutes  │
   │  (WebSocket)        │  les 100-300 ms              │
   └────────┬────────────┘                              │
            ▼                                           │
       texte final ─────────► LLM (Sonnet 4.6 / Haiku)  │

   Métriques OTel : VAD lag, STT TTFT, WER, no-speech ──┘

Analogie — Le VAD est un standardiste qui décroche dès qu'on entend une voix, l'endpointing est l'oreille qui sait quand l'interlocuteur a fini de parler (pas juste une pause pour respirer), et le STT est la sténodactylo qui retranscrit. Si le standardiste est sourd, vous payez la sténo pour transcrire du silence. Si l'oreille est trop pressée, vous coupez la parole du client.

Comment un staff engineer raisonne sur cette couche

Trois budgets se disputent les mêmes 700-1000 ms entre la fin de la parole et le début de la réponse TTS. Un senior les pose explicitement avant d'écrire une ligne de code :

BudgetCible p95Qui le consommeLevier si ça déborde
Latence perçue (fin de parole → 1er phonème TTS)< 800 msendpointing + STT final + 1er token LLM + TTFB TTSendpointing 2-stage, streaming partout, préfetch RAG sur partial
Coût / minute0,003-0,012 $STT (90 %), endpointing LLM (négligeable), noise suppressionVAD pour ne pas streamer le silence, self-host si volume stable
Qualité (WER, DER)WER < 8 %, DER < 12 %modèle STT + diarization + bruit + accentdual-channel, hot-words, fine-tune LoRA, noise gate

La règle d'arbitrage : la latence est le budget rigide (un client qui attend > 1,2 s croit que la ligne a coupé), le coût est élastique (on optimise après la prod), la qualité a un plancher contractuel (le seuil d'acceptation du client). Quand les trois entrent en conflit — typiquement « je veux du dual-channel diarizé ET sub-800 ms ET à 0,003 $/min » — on défend le compromis avec des chiffres, pas avec des opinions (voir exercices 3 et 6).

Pourquoi 60 % de la qualité dépend de cette couche : tout ce qui se passe en aval (NLU, RAG, génération) opère sur le texte produit ici. Un mot mal transcrit (« résilier » → « régler ») ou un tour coupé trop tôt empoisonne l'intent, et aucun prompt en aval ne le rattrape. C'est le principe garbage-in appliqué au vocal : on investit le budget d'ingénierie sur le VAD/STT avant de peaufiner le LLM.

Observabilité minimale (sinon vous pilotez à l'aveugle) — quatre métriques OTel à instrumenter dès le POC, par appel et par condition de bruit :

  • vad_lag_ms : délai entre l'attaque réelle du son et le premier frame isSpeech (détecte le pré-roll manquant).
  • stt_ttft_ms : time-to-first-partial-transcript (santé du WebSocket STT).
  • endpoint_latency_ms : distribution bimodale attendue (cache hit Haiku ~40 ms / miss ~150 ms) — alerter sur la queue, pas sur la moyenne.
  • no_speech_rate et wer : qualité ; à corréler avec un tag accent et snr_db pour savoir quelle population se dégrade.

🛠️ Code minimal

Silero VAD côté serveur (ONNX, CPU, ~1 ms/frame)

ts
// silero-vad.ts — pipeline VAD streaming Node.js
import { InferenceSession, Tensor } from "onnxruntime-node";

export class SileroVAD {
  private session!: InferenceSession;
  private state = new Float32Array(2 * 1 * 128);
  private sr = 16000;

  async init(modelPath = "./silero_vad.onnx") {
    this.session = await InferenceSession.create(modelPath);
  }

  /** frame: Float32Array of 512 samples @ 16 kHz (32 ms) */
  async isSpeech(frame: Float32Array): Promise<number> {
    const input = new Tensor("float32", frame, [1, frame.length]);
    const sr = new Tensor("int64", BigInt64Array.from([BigInt(this.sr)]), []);
    const state = new Tensor("float32", this.state, [2, 1, 128]);
    const out = await this.session.run({ input, sr, state });
    this.state = out.stateN.data as Float32Array;
    return (out.output.data as Float32Array)[0]; // proba 0..1
  }
}

Endpointing : silence + LLM sémantique

ts
// endpoint-detector.ts
export class EndpointDetector {
  private silenceMs = 0;
  private lastSpeechAt = Date.now();
  constructor(
    private minSilence = 700,           // ms de silence dur
    private maxSilenceSentence = 1400,  // ms après phrase a priori complète
    private isSentenceFinal: (text: string) => Promise<boolean>,
  ) {}

  onFrame(isSpeech: boolean) {
    if (isSpeech) { this.silenceMs = 0; this.lastSpeechAt = Date.now(); }
    else this.silenceMs = Date.now() - this.lastSpeechAt;
  }

  async shouldEndpoint(partialTranscript: string): Promise<boolean> {
    if (this.silenceMs >= this.maxSilenceSentence) return true;
    if (this.silenceMs >= this.minSilence) {
      // appel léger Haiku 4.5 — « phrase finie ? oui/non »
      return await this.isSentenceFinal(partialTranscript);
    }
    return false;
  }
}

Le check sémantique : Haiku 4.5 + structured outputs

Sur le chemin chaud d'un voicebot, l'endpointing sémantique doit coûter < 60 ms p95 et être déterministe (jamais de préambule, jamais de phrase). On force donc un schéma de sortie au lieu de parser du texte libre, et on cache le prompt système stable (cache_control) car il est identique à chaque appel. On utilise messages.parse() (structured outputs natifs) plutôt que messages.create() + JSON.parse() à la main : le SDK valide la réponse contre le schéma Zod et renvoie un objet typé, on ne parse plus de texte libre :

ts
// is-sentence-final.ts — Haiku 4.5, structured output via messages.parse(), prompt caché
import { Anthropic } from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";

const anthropic = new Anthropic({ maxRetries: 2 }); // backoff SDK sur 429/5xx

// Schéma minimal : un seul booléen, pas de marge d'interprétation.
const TurnEnd = z.object({ complete: z.boolean() });

const SYSTEM = `Tu décides si un tour de parole est terminé dans une conversation
téléphonique française. Réponds UNIQUEMENT via le schéma. "complete" = l'utilisateur
a fini son idée ; false s'il hésite, énumère, ou laisse une phrase en suspens
(« je voudrais... », « c'est le numéro... »).`;

export async function isSentenceFinal(partial: string): Promise<boolean> {
  const resp = await anthropic.messages.parse({
    model: "claude-haiku-4-5", // 1 $/5 $ par M tok — tient la latence sur le chemin chaud
    max_tokens: 16,
    system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
    output_config: { format: zodOutputFormat(TurnEnd) },
    messages: [{ role: "user", content: partial }],
  });
  // resp.parsed_output est typé { complete: boolean } | null ; logguer resp.usage
  // (input / output / cache_read_input_tokens) pour suivre le coût réel par check.
  return resp.parsed_output?.complete === true;
}

Note SDK 2026 — Haiku 4.5 (comme Opus 4.8 / Sonnet 4.6) ne prend pas de thinking/budget_tokens : c'est un classifieur binaire, pas une tâche de raisonnement. Inutile (et inefficace) d'activer l'adaptive thinking ici — on veut une latence plate et un coût de sortie minimal. Le thinking: { type: "enabled", budget_tokens } historique renverrait de toute façon une 400 sur les modèles 4.7/4.8.

Pourquoi pas Sonnet/Opus ici ? Haiku 4.5 (1 $/5 $ par M tok) suffit pour un oui/non binaire et tient la latence ; réserver Sonnet 4.6 au post-traitement (résumé, CR) et Opus 4.8 aux raisonnements lourds hors temps réel. Coût : ~20 tokens entrée + 5 sortie par check, soit ~0,00003 $/check — négligeable devant le STT. Avec cache_control sur le SYSTEM, le préfixe stable est facturé à ~0,1× en cache read (vérifier usage.cache_read_input_tokens > 0 sur les appels successifs, sinon un invalidant silencieux casse le cache). Garde-fou obligatoire : si l'appel dépasse un timeout serré (AbortSignal.timeout(120)), on retombe sur le seuil de silence dur (maxSilenceSentence) plutôt que de geler le tour. Ne jamais bloquer l'endpointing sur le réseau — typer les erreurs SDK (RateLimitError, APITimeoutError, OverloadedError) et traiter tout échec comme « pas de signal sémantique », jamais comme « tour fini ».

Streaming Deepgram Nova-3 (WebSocket)

ts
import { createClient, LiveTranscriptionEvents } from "@deepgram/sdk";
const dg = createClient(process.env.DEEPGRAM_KEY!);

const conn = dg.listen.live({
  model: "nova-3",
  language: "fr",
  smart_format: true,
  interim_results: true,
  punctuate: true,
  diarize: true,
  endpointing: 300, // ms côté Deepgram, doublé avec endpointing applicatif
  filler_words: false,
});

conn.on(LiveTranscriptionEvents.Transcript, (data) => {
  const alt = data.channel.alternatives[0];
  if (data.is_final) onFinal(alt.transcript, alt.words);
  else onPartial(alt.transcript);
});

// micro → WebRTC → on push raw PCM 16 kHz
function pushAudio(chunk: Buffer) { conn.send(chunk); }

🎬 Cas d'usage concrets

1. Centre d'appels assurance — transcription + sentiment temps réel

Contexte client — Une mutuelle santé (15 000 appels/jour, plateau de 80 conseillers à Lille et Tunis) veut afficher au conseiller en temps réel : transcription dual-channel (client / conseiller), score de stress du client, suggestion de scripts conformes ACPR.

Stack

  • SIP trunk OVH Telecom → media gateway FreeSWITCH → flux RTP dual-channel
  • Silero VAD par channel (séparation conseiller / assuré dès la source)
  • Deepgram Nova-3 streaming FR (1 connexion par canal, multichannel: true non utilisé car latence)
  • Modèle sentiment custom (distilcamembert fine-tuné sur 12 000 verbatims annotés)
  • Affichage Vue.js dans le CRM Salesforce via Lightning Web Component

Résultats — Latence affichage transcript : 280 ms p95. Détection « client en colère » avec rappel 92 %, précision 87 %. Conseillers gagnent 40 secondes par appel (pas besoin de prendre des notes), -18 % de DMT.

TJM/budget — Mission 14 semaines, équipe 2 freelances (architecte AI + ingé voix). Forfait 180 k€, TJM moyen 1 450 €/j.

2. Médecin libéral — dictée de consultations en mobilité

Contexte — Cabinet de cardiologie à Lyon, 4 praticiens, 30 patients/jour. Aujourd'hui dictée Dragon Medical Direct (licence 1 200 €/an/poste, peu intégré au LGC).

Stack

  • App iPad / iPhone (Capacitor + Web Audio API)
  • VAD côté client (Silero WASM, 5 Mo) pour ne pas streamer du silence pendant l'examen
  • WhisperX (large-v3 + médecine FR fine-tune) auto-hébergé sur GPU L4 OVH Roubaix (RGPD HDS)
  • Diarization pyannote-3.1 pour séparer médecin / patient (utile pour compte-rendu)
  • Post-traitement Sonnet 4.6 : génération du compte-rendu structuré (motif, antécédents, examen, conclusion) + injection dans Doctolib API

Résultats — Médecin économise 12 minutes par consultation. Sur 30 patients/jour, 6 heures gagnées par semaine. ROI projet : 4 mois.

TJM/budget — Forfait MVP 65 k€ (10 semaines), maintenance 1 800 €/mois.

3. Juriste / tribunal — transcription d'audiences pénales

Contexte — Greffe d'un tribunal judiciaire pilote 2026, 6 salles. Aujourd'hui la rédaction du PV mobilise un greffier à plein temps par audience.

Stack

  • Captation 4 micros table (procureur, président, prévenu, avocat) — Shure MXA710
  • Diarization WhisperX + pyannote, ré-attribution via meta-données (qui parle quand) supervisée par le greffier
  • Modèle Whisper large-v3 + LoRA juridique entraîné sur 800 h d'audiences anciennes (Ministère Justice)
  • Horodatage chaque mot, export RTF + PDF signé electroniquement
  • Mode « confidentiel » : tout reste sur serveur ministériel, pas de cloud

Résultats — Greffier valide en 25 minutes ce qu'il aurait écrit en 3 h. Conforme attentes du décret 2025-1247 sur la modernisation des PV d'audience.

TJM/budget — Marché public 480 k€ sur 18 mois, équipe 3 ingés. TJM facturé via SSII : 1 100 €/j net intervenant.


🛠️ Exemple end-to-end

Cas — Transcription temps réel d'une audience correctionnelle, 4 locuteurs, export PV horodaté.

ts
// audience-transcriber.ts — Node 22, TypeScript strict
import { spawn } from "node:child_process";
import { WebSocketServer } from "ws";
import { SileroVAD } from "./silero-vad.js";
import OpenAI from "openai"; // pour fallback gpt-4o-transcribe
import { Anthropic } from "@anthropic-ai/sdk";
import { writeFile } from "node:fs/promises";

type Word = { text: string; start: number; end: number; speaker: string };
type Turn = { speaker: string; text: string; start: number; end: number };

interface SpeakerRegistry {
  president: string;        // p-id pyannote
  procureur: string;
  prevenu: string;
  avocat: string;
}

const anthropic = new Anthropic();

async function startSession(audienceId: string, registry: SpeakerRegistry) {
  const wss = new WebSocketServer({ port: 8080 });
  const vad = new SileroVAD();
  await vad.init();

  // pipe vers WhisperX local via socket Unix
  const whisper = spawn("python", [
    "-u", "whisperx_server.py",
    "--model", "large-v3",
    "--lora", "fr-juridique-v2.bin",
    "--diarize", "true",
    "--language", "fr",
    "--compute_type", "float16",
  ]);

  const turns: Turn[] = [];
  let buffer: Float32Array[] = [];
  let bufferedMs = 0;
  let silenceMs = 0;
  let speechStart: number | null = null;

  wss.on("connection", (ws) => {
    ws.on("message", async (data: Buffer) => {
      // chunk = 512 samples @ 16 kHz (32 ms)
      const frame = new Float32Array(data.buffer, data.byteOffset, 512);
      const p = await vad.isSpeech(frame);

      const isSpeech = p > 0.55;
      if (isSpeech) {
        if (!speechStart) speechStart = Date.now();
        silenceMs = 0;
        buffer.push(frame);
        bufferedMs += 32;
      } else {
        silenceMs += 32;
        if (speechStart) buffer.push(frame); // queue un peu de silence
      }

      // endpointing : 800 ms de silence après parole détectée
      if (speechStart && silenceMs >= 800 && bufferedMs >= 500) {
        const audio = mergeFloat32(buffer);
        buffer = [];
        const segStart = speechStart;
        speechStart = null;
        bufferedMs = 0;

        const result = await transcribeWithDiarization(whisper, audio);
        for (const w of result.words) {
          w.start += segStart / 1000;
          w.end += segStart / 1000;
        }
        const grouped = groupBySpeaker(result.words, registry);
        for (const t of grouped) {
          turns.push(t);
          ws.send(JSON.stringify({ kind: "turn", turn: t }));
        }
      }
    });

    ws.on("close", async () => {
      const pv = await renderPV(audienceId, turns);
      await writeFile(`./pv/${audienceId}.md`, pv, "utf8");
    });
  });
}

function mergeFloat32(chunks: Float32Array[]): Float32Array {
  const total = chunks.reduce((s, c) => s + c.length, 0);
  const out = new Float32Array(total);
  let o = 0;
  for (const c of chunks) { out.set(c, o); o += c.length; }
  return out;
}

async function transcribeWithDiarization(
  proc: any, audio: Float32Array,
): Promise<{ words: Word[] }> {
  return new Promise((res, rej) => {
    const header = Buffer.alloc(4); header.writeUInt32LE(audio.length, 0);
    proc.stdin.write(header);
    proc.stdin.write(Buffer.from(audio.buffer));
    let chunks = "";
    proc.stdout.once("data", (d: Buffer) => {
      chunks += d.toString();
      try { res(JSON.parse(chunks)); } catch (e) { rej(e); }
    });
  });
}

function groupBySpeaker(words: Word[], reg: SpeakerRegistry): Turn[] {
  const turns: Turn[] = [];
  const speakerName = (id: string) => {
    if (id === reg.president) return "Le Président";
    if (id === reg.procureur) return "Le Procureur";
    if (id === reg.prevenu) return "Le Prévenu";
    if (id === reg.avocat) return "Maître " + reg.avocat;
    return "Inconnu";
  };
  for (const w of words) {
    const name = speakerName(w.speaker);
    const last = turns[turns.length - 1];
    if (last && last.speaker === name && w.start - last.end < 1.5) {
      last.text += " " + w.text; last.end = w.end;
    } else {
      turns.push({ speaker: name, text: w.text, start: w.start, end: w.end });
    }
  }
  return turns;
}

async function renderPV(audienceId: string, turns: Turn[]): Promise<string> {
  const txt = turns.map(t =>
    `**[${fmt(t.start)}] ${t.speaker}** — ${t.text}`).join("\n\n");

  // résumé exécutif via Sonnet 4.6 (adaptive thinking — pas de budget_tokens)
  // NB: sur 4.7/4.8, `thinking: { type: "enabled", budget_tokens }` renvoie HTTP 400 (supprimé).
  // On pilote la profondeur de réflexion via output_config.effort, pas un budget de tokens.
  const summary = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 4000,
    thinking: { type: "adaptive" },
    output_config: { effort: "medium" }, // low | medium | high
    system: "Tu rédiges l'en-tête synthétique d'un PV d'audience correctionnelle française. Reste neutre, factuel, juridique.",
    messages: [{ role: "user", content:
      `Voici la transcription brute. Rédige : (1) résumé en 8 lignes, (2) liste des chefs d'accusation évoqués, (3) liste des pièces citées.\n\n${turns.map(t=>`${t.speaker}: ${t.text}`).join("\n")}` }],
  });
  // content peut contenir des blocs `thinking` (vides par défaut) avant le bloc `text` :
  // ne jamais lire content[0] en aveugle, filtrer sur le type.
  const header = (summary.content.find((b: any) => b.type === "text") as any).text;

  return `# PV d'audience ${audienceId}\n\n## Résumé\n\n${header}\n\n## Transcription horodatée\n\n${txt}\n`;
}

function fmt(s: number): string {
  const h = Math.floor(s / 3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
  return `${pad(h)}:${pad(m)}:${pad(sec)}`;
}
const pad = (n: number) => String(n).padStart(2, "0");

startSession("CORR-2026-0524-S03", {
  president: "SPEAKER_00",
  procureur: "SPEAKER_01",
  prevenu: "SPEAKER_02",
  avocat: "SPEAKER_03",
});

Côté Python (extrait du process WhisperX) :

python
# whisperx_server.py
import sys, struct, json, numpy as np, whisperx, torch

device = "cuda"
model = whisperx.load_model("large-v3", device, language="fr",
                            asr_options={"suppress_numerals": False})
diarize_model = whisperx.DiarizationPipeline(
    use_auth_token=os.environ["HF_TOKEN"], device=device)
align_model, metadata = whisperx.load_align_model(language_code="fr", device=device)

while True:
    n = struct.unpack("<I", sys.stdin.buffer.read(4))[0]
    raw = sys.stdin.buffer.read(n * 4)
    audio = np.frombuffer(raw, dtype=np.float32)
    result = model.transcribe(audio, batch_size=16)
    result = whisperx.align(result["segments"], align_model, metadata, audio, device)
    diarize = diarize_model(audio, min_speakers=2, max_speakers=6)
    result = whisperx.assign_word_speakers(diarize, result)
    words = [{"text": w["word"].strip(), "start": w["start"],
              "end": w["end"], "speaker": w.get("speaker","UNK")}
             for seg in result["segments"] for w in seg["words"]]
    sys.stdout.write(json.dumps({"words": words}) + "\n")
    sys.stdout.flush()

🧰 Variantes provider — code prêt à coller

AssemblyAI Universal-2 (diarization native, robuste bruit)

ts
import { AssemblyAI } from "assemblyai";
const client = new AssemblyAI({ apiKey: process.env.ASSEMBLYAI_KEY! });

const tx = client.realtime.transcriber({
  sampleRate: 16000,
  wordBoost: ["Marie Curie", "Quai d'Orsay", "Bercy"],
  encoding: "pcm_s16le",
});

tx.on("transcript", (m) => {
  if (m.message_type === "FinalTranscript") {
    console.log(`[${m.speaker}] ${m.text}`); // diarization native
  }
});

await tx.connect();
// pousser des frames raw PCM
function push(frame: Buffer) { tx.sendAudio(frame); }
await tx.close();

OpenAI gpt-4o-transcribe streaming

ts
import OpenAI from "openai";
import fs from "node:fs";

const openai = new OpenAI();

// streaming via WebSocket Realtime API (préféré pour temps réel voicebot)
const rt = await openai.beta.realtime.connect({ model: "gpt-realtime-v3" });
rt.on("response.audio_transcript.delta", e => onPartial(e.delta));
rt.on("response.audio_transcript.done", e => onFinal(e.transcript));

// PCM 24 kHz mono, base64 encodé
function pushPCM(chunk: Buffer) {
  rt.send({
    type: "input_audio_buffer.append",
    audio: chunk.toString("base64"),
  });
}

Faster-Whisper local (GPU L4, ~70 ms par chunk 2 s)

python
# faster-whisper-stream.py
from faster_whisper import WhisperModel
import numpy as np
import sounddevice as sd

model = WhisperModel("large-v3", device="cuda", compute_type="float16")

def stream_transcribe():
    with sd.InputStream(samplerate=16000, channels=1, dtype="float32") as stream:
        while True:
            audio, _ = stream.read(16000 * 2)  # 2 s chunks
            segments, _ = model.transcribe(
                audio.flatten(), language="fr", vad_filter=True,
                vad_parameters=dict(min_silence_duration_ms=500),
                beam_size=5, word_timestamps=True,
            )
            for seg in segments:
                print(f"{seg.start:.2f}-{seg.end:.2f}: {seg.text}")

Distil-Whisper FR (rapide, qualité standard)

python
# distil-whisper-fr.py — 6x plus rapide, WER 9 % en standard
from transformers import pipeline
pipe = pipeline(
    "automatic-speech-recognition",
    model="bofenghuang/distil-whisper-large-v3-fr",
    chunk_length_s=15, batch_size=8, device="cuda:0",
)
result = pipe("appel-client-12345.wav", return_timestamps="word")

🎯 Patterns courants

  • Two-stage endpointing — silence 700 ms + check sémantique Haiku (« phrase finie ? ») évite de couper la parole au client qui réfléchit.
  • Dual-channel séparé à la source — en téléphonie, ne jamais mixer agent + client : un canal STT chacun, diarization gratuite et parfaite.
  • VAD client + VAD serveur — VAD WASM côté navigateur économise 60-80 % de bande passante (n'envoie pas le silence) ; VAD serveur sécurise (le client peut mentir).
  • Adaptive endpointing — silence court (400 ms) après une question fermée (« oui/non ? »), long (1 200 ms) après une question ouverte (« décrivez votre problème »).
  • Hot-word boost — passer un vocabulaire métier à Deepgram (keywords) ou à Whisper (initial_prompt) : noms de médicaments, noms de produits, marques. Gain WER 4-12 %.
  • Stream parallèle STT + LLM — dès qu'on a 80 % de confiance sur les premiers mots, on commence à préfetcher le RAG en parallèle.
  • Fallback multi-providers — Deepgram primaire, AssemblyAI secondaire, WhisperX local en DRP : circuit breaker basé sur la latence p95 sur les 30 derniers appels.
  • Noise gate avant VAD — RNNoise (-25 dB sur ventilateur, climatisation) évite que le VAD se déclenche sur bruit constant.

🔄 Versions & écosystème 2026

OutilVersion 2026LatenceFRDiarizationPrix / min
Deepgram Nova-3GA mai 2025150 msexcellentoui (8 spk)0,0043 $
AssemblyAI Universal-2GA fin 2024250 mstrès bonoui (10 spk)0,0065 $
OpenAI gpt-4o-transcribeGA mars 2025400 msexcellentnon native0,006 $
ElevenLabs ScribeGA jan 2025350 mstrès bonoui0,005 $
Whisper large-v3-turboopen-source80 ms (L4)excellentvia WhisperXself-host
Distil-Whisper FRcommunity40 ms (T4)bonnonself-host
Speechmatics Ursa-2GA 2025200 msexcellentoui0,008 $
Silero VADv5.11 ms CPUn/an/agratuit
pyannote-3.1stableofflinen/aréférenceself-host

Tendances 2026

  • Tous les providers majeurs supportent WebSocket et HTTP/2 streaming standard.
  • Mode « multichannel + diarization simultanée » natif chez Deepgram et AssemblyAI.
  • Distil-whisper-v3-fr (300 M params) tient la route sur RTX 4090 avec WER 8 % sur français standard.
  • gpt-4o-transcribe excellent en bruyant (rue, voiture, café) mais reste cher.

⚠️ Pitfalls

  1. Streamer du silence — sans VAD, vous payez le STT pour transcrire 60 % de blanc. Sur 100 k appels/mois × 5 min, c'est 500 € jetés.
  2. Endpointing trop agressif — couper après 300 ms de silence frustre les seniors qui hésitent ; passer à 800-1 000 ms minimum sur senior care, médical, juridique.
  3. Diarization sur enregistrement mono mixé — fonctionne mal sur PSTN car bande passante 8 kHz + écho. Préférer dual-channel quand disponible.
  4. Accents oubliés — WER sur Whisper Quebec : 14 % vs 6 % standard ; sur arabe-français mixé (Maghreb), 22 %. Tester avec corpus représentatif AVANT prod.
  5. Casser sur les mots français composés — « bouilloire » devient « bouilloir » sans punctuation et casse votre extraction d'intent.
  6. Mauvais sample rate — 8 kHz téléphonique vs 16 kHz Whisper : ne pas upsampler bêtement, utiliser modèle entraîné pour 8 kHz ou downsampler côté client.
  7. Ignorer la latence WebSocket — TLS handshake = 200 ms supplémentaires si vous ouvrez une connexion par tour de parole. Maintenir une connexion persistante.
  8. Bypass RGPD — envoyer la voix d'un mineur à OpenAI sans accord parental = sanction CNIL (cf décision 2024-SAN-0019).
  9. Pas de fallback — Deepgram down pendant 11 min en mars 2025 = 100 % de vos appels morts. Toujours circuit breaker + provider secondaire.
  10. Sur-confiance dans les partial transcripts — les interim_results peuvent changer 4-5 fois ; ne jamais déclencher d'action irréversible (paiement, envoi mail) avant is_final: true.

💰 Pricing / ROI client

Coûts réels (mai 2026)

ComposantCoût / minute audio
Deepgram Nova-30,0043 $
AssemblyAI U-2 + diar0,0065 $
WhisperX self-host (L4 OVH)0,0012 $ (amorti)
RNNoise / Krisp SDK0,0006 $ ou licence
Silero VADgratuit (CPU)
Bande passante OVH FRnégligeable

Exemple devis client (mutuelle 15 000 appels/jour × 5 min)

  • Volume : 2,25 M minutes/mois
  • STT Deepgram : 9 675 $/mois
  • Avec WhisperX self-host (3× L4) : 2 700 $/mois (mais coût ingé +)
  • Économie annuelle : ~84 k€

Prestation freelance type

  • Audit + POC voicebot : 18-30 k€ (3-5 semaines)
  • Mise en prod centre d'appels : 90-220 k€ (10-16 semaines)
  • TJM 1 200-1 500 €/j architecte voix freelance senior FR
  • Maintenance / amélioration : 12-24 k€/an forfait

🧪 Testing / Eval

ts
// eval-stt.ts — WER + diarization error rate sur jeu FR annoté
import { wer } from "word-error-rate";
import fs from "node:fs/promises";

const dataset = JSON.parse(await fs.readFile("./eval-fr-300.json","utf8"));
// chaque entrée: { audio: "path.wav", reference: "...", speakers: [...] }

let totalWer = 0, totalDer = 0;
for (const ex of dataset) {
  const hyp = await transcribeViaProvider(ex.audio, "deepgram-nova-3");
  totalWer += wer(ex.reference.toLowerCase(), hyp.text.toLowerCase());
  totalDer += diarizationErrorRate(ex.speakers, hyp.speakers);
}
console.log(`WER moyen: ${(totalWer/dataset.length*100).toFixed(2)}%`);
console.log(`DER moyen: ${(totalDer/dataset.length*100).toFixed(2)}%`);

Jeu de test recommandé pour le français

  • Common Voice 17 FR (35 h, accents variés)
  • MLS FR (1 100 h livres audio)
  • AVAB-DEMoS (Québec, 12 h)
  • Custom : 200 enregistrements clients réels annotés (RGPD : consentement explicite)

Seuils d'acceptation

  • WER < 8 % standard, < 14 % bruyant
  • Latence p95 STT < 350 ms (partial), < 700 ms (final)
  • DER < 12 % avec 4 locuteurs
  • No-speech rate (VAD échoue à détecter parole réelle) < 2 %

🔁 Quand utiliser / éviter

Utiliser streaming STT cloud (Deepgram, AssemblyAI)

  • Voicebot temps réel sub-1s
  • Volume variable, pas d'envie de gérer GPU
  • Multi-langue spontanée (clients étrangers)

Utiliser WhisperX self-host

  • Contrainte RGPD/HDS/SecNumCloud forte (santé, défense, justice)
  • Volume stable et fort (> 500 h/mois)
  • Diarization haute qualité critique
  • Domaine spécialisé (médical, juridique) → fine-tune LoRA possible

Éviter

  • gpt-4o-transcribe pour très haute volumétrie (cher)
  • WebRTC VAD seul pour endpointing senior care (trop sensible aux clics)
  • Whisper sans alignment word-level si vous avez besoin de timestamps fins
  • Diarization sur appels mono téléphoniques mixés (résultat médiocre)

🏋️ Exercices

Progression : on part d'un pipeline qui marche, puis on le casse, on le mesure, et on défend les chiffres devant un client. Chronométrer chaque exercice, garder les métriques.

1. Le pipeline VAD → endpointing → STT qui ne perd pas un mot

Objectif — Brancher Silero VAD + l'EndpointDetector + Deepgram Nova-3 (ou WhisperX) sur un flux micro 16 kHz et transcrire un dialogue de 2 min sans couper la parole ni streamer le silence.

Indice/Solution — Pré-buffer de 300 ms avant le premier frame isSpeech (sinon la première syllabe est mangée : le VAD se déclenche après l'attaque du son). Pousser ce pré-buffer au STT au moment du endpoint. Vérifier que no-speech rate < 2 % sur un corpus annoté. Piège classique : le ring buffer qui n'inclut pas le pré-roll → « onjour » au lieu de « bonjour ».

2. Endpointing adaptatif + check sémantique sous budget latence

Objectif — Câbler isSentenceFinal (Haiku 4.5, structured output) dans la boucle, et garantir un endpointing p95 < 800 ms avec le aller-retour LLM, sinon fallback sur le silence dur.

Indice/Solution — Lancer le check Haiku en parallèle dès silenceMs >= minSilence, avec AbortSignal.timeout(120). Si le LLM répond après le maxSilenceSentence, on a déjà endpointé : le résultat arrive trop tard, on l'ignore. Mesurer la distribution réelle de la latence Haiku (souvent bimodale : cache hit ~40 ms, cache miss ~150 ms) et caler minSilence pour absorber la queue. Défendre : pourquoi pas tool_use au lieu de output_config.format ? (Réponse : structured output garantit la forme sans round-trip d'exécution.)

3. Diarization dual-channel vs mono mixé — prouver l'écart

Objectif — Sur le même appel (agent + client), comparer DER en (a) dual-channel séparé à la source et (b) mono mixé PSTN 8 kHz, puis défendre le choix d'archi devant un client qui veut « juste un micro ».

Indice/Solution — Construire un jeu de 30 appels avec ground-truth speaker. Attendre DER < 5 % en dual-channel, > 25 % en mono 8 kHz (écho + bande passante). Le livrable n'est pas le code mais le tableau DER + le coût marginal du dual-channel (≈ 2× connexions STT). La défense : « -20 points de DER pour +X €/mois, voici le seuil de volume où ça se rentabilise ».

4. Casser le pipeline avec du bruit, puis le réparer

Objectif — Injecter du bruit (rue, clim, babble multi-locuteurs) jusqu'à faire exploser le WER, identifier quelle couche lâche (VAD ? STT ?), puis restaurer le WER cible.

Indice/Solution — Le babble fait halluciner le VAD (il « entend » de la parole partout) bien avant que le STT ne décroche. Ajouter un noise gate RNNoise avant le VAD et remonter le seuil de proba (p > 0.550.7). Si c'est le STT qui hallucine sur le silence résiduel, activer vad_filter côté Whisper. Mesurer WER avant/après par condition de bruit. Piège : remonter le seuil VAD trop haut coupe les voix faibles (seniors) → re-tester avec un corpus de voix faibles.

5. Production-grade : fallback multi-provider sous panne réelle

Objectif — Rendre le STT résilient : Deepgram primaire, AssemblyAI secondaire, WhisperX local en DRP, avec un circuit breaker basé sur la latence p95 glissante. Simuler une panne Deepgram de 11 min (cf. mars 2025) et garantir 0 appel mort.

Indice/Solution — Circuit breaker sur fenêtre glissante des 30 derniers appels : si p95 > seuil ou taux d'erreur > 5 %, basculer. Le piège n'est pas le failover mais le rebasculement : ne pas re-router vers Deepgram dès le premier appel OK (flapping) — exiger N succès consécutifs. Conserver une connexion WebSocket persistante par provider chaud (le TLS handshake coûte 200 ms par tour). Défendre le coût : le secondaire chaud coûte des connexions idle, mais une heure de panne sans fallback = 100 % des appels perdus.

6. Défendre le chiffre : auto-héberger WhisperX ou rester cloud ?

Objectif — Pour la mutuelle (2,25 M min/mois), produire le devis comparatif Deepgram cloud vs WhisperX self-host (3× L4) et défendre la recommandation sous contrainte RGPD/HDS.

Indice/Solution — Cloud : ~9 675 $/mois, zéro ops, mais transfert des verbatims hors HDS → DPIA lourde. Self-host : ~2 700 $/mois compute mais +1 ingé MLOps (~8-10 k€/mois chargé) → le self-host ne gagne que si le volume est stable ET que la contrainte HDS rend le cloud non-viable. Le vrai livrable est le seuil de volume où le self-host devient rentable, et la phrase qui tue : « à votre volume, l'économie compute est mangée par l'OPEX ops sauf si le HDS vous l'impose de toute façon ».


🎤 En entretien

  • « Comment décidez-vous qu'un utilisateur a fini de parler ? » — Two-stage : seuil de silence dur (filet de sécurité, ~700-1500 ms selon le contexte) + check sémantique LLM léger (Haiku) qui distingue une pause de respiration d'une vraie fin de tour ; le LLM ne bloque jamais le chemin chaud, on fallback sur le silence si timeout.
  • « VAD côté client ou côté serveur ? » — Les deux : client (WASM) pour économiser 60-80 % de bande passante en ne streamant pas le silence, serveur pour la sécurité (le client peut mentir ou être compromis) — le VAD serveur fait foi pour la facturation STT et la détection de parole réelle.
  • « Pourquoi Haiku et pas Sonnet/Opus pour l'endpointing ? » — C'est un oui/non binaire sur le chemin chaud : Haiku 4.5 (1 $/5 $) tient la latence et le coût (~0,00003 $/check), avec output_config.format pour garantir la forme sans préambule ; Sonnet 4.6 est réservé au post-traitement (résumé/CR) et Opus 4.8 au raisonnement lourd hors temps réel. Pas de budget_tokens (supprimé, HTTP 400 sur 4.7/4.8) — on pilote via effort.
  • « Diarization sur un appel téléphonique mono : ça marche ? » — Mal : PSTN 8 kHz + écho + canaux mixés font monter le DER au-delà de 25 %. Si on contrôle la captation, on sépare les canaux à la source (dual-channel) pour une diarization quasi-parfaite et gratuite ; sinon on prévient le client que la qualité diarization sera dégradée et on pose un seuil d'acceptation réaliste.
  • « À 2,25 M minutes/mois, cloud STT ou WhisperX self-host ? Défendez le chiffre. » — Pas de réponse dogmatique : Deepgram ~9 700 $/mois zéro ops vs WhisperX ~2 700 $/mois compute mais +1 ingé MLOps chargé (~8-10 k€/mois). Le self-host ne gagne que si (a) le volume est stable et élevé ET (b) la contrainte HDS/SecNumCloud rend le cloud non-viable de toute façon. La phrase qui ferme le sujet : « à votre volume l'économie compute est mangée par l'OPEX ops, sauf si le HDS vous l'impose ». On défend un seuil de volume, pas une préférence.
  • « Comment garantissez-vous 0 appel mort si votre provider STT tombe ? » — Circuit breaker sur fenêtre glissante (p95 latence / taux d'erreur sur les 30 derniers appels) + provider secondaire chaud (connexion WebSocket persistante, sinon 200 ms de TLS handshake par tour) + WhisperX local en DRP. Le piège n'est pas le failover mais le rebasculement : exiger N succès consécutifs avant de re-router vers le primaire, sinon flapping. Deepgram a eu 11 min de panne en mars 2025 — sans fallback c'est 100 % des appels perdus.

🔗 Liens

  • Silero VAD : github.com/snakers4/silero-vad
  • WhisperX : github.com/m-bain/whisperX
  • pyannote-audio : github.com/pyannote/pyannote-audio
  • Deepgram Nova-3 docs : developers.deepgram.com/docs/models-languages-overview
  • AssemblyAI Universal-2 : assemblyai.com/blog/universal-2
  • LiveKit Agents (intègre VAD + STT) : docs.livekit.io/agents
  • Common Voice FR : commonvoice.mozilla.org/fr
  • Paper « WhisperX » (Bain et al. 2023) : arxiv.org/abs/2303.00747
  • CNIL — voix biométrique : cnil.fr/fr/biometrie
  • Bofenghuang Distil-Whisper FR : huggingface.co/bofenghuang/distil-whisper-large-v3-fr
  • OpenAI gpt-4o-transcribe : platform.openai.com/docs/guides/speech-to-text
  • ANSSI référentiel SecNumCloud : ssi.gouv.fr/secnumcloud
  • Décret 2025-1247 (PV d'audience) : legifrance.gouv.fr
  • Common Voice 17 release notes : commonvoice.mozilla.org/datasets

📎 Annexe — Endpointing adaptatif (production)

Tableau de référence des seuils de silence par contexte :

ContexteSilence minSilence maxPourquoi
Drive-thru300 ms600 msclient pressé, scripts courts
Voicebot e-commerce500 ms900 msclient peut hésiter sur taille / couleur
Service client énergie700 ms1 200 msseniors fréquents, vocabulaire technique
Médical urgence900 ms1 500 mspatient stressé, peut chercher mots
Médical dictée1 200 ms2 500 mspraticien réfléchit
Juridique audience1 500 ms4 000 msdélibération, citation textuelle
ts
// adaptive-endpoint.ts
type Context = "drive_thru" | "ecom" | "energie" | "med_urg" | "med_dict" | "legal";
const POLICY: Record<Context, { min: number; max: number }> = {
  drive_thru:  { min: 300,  max: 600 },
  ecom:        { min: 500,  max: 900 },
  energie:     { min: 700,  max: 1200 },
  med_urg:     { min: 900,  max: 1500 },
  med_dict:    { min: 1200, max: 2500 },
  legal:       { min: 1500, max: 4000 },
};

export function endpointConfig(ctx: Context, isFinalSentence: boolean) {
  const p = POLICY[ctx];
  return isFinalSentence ? p.min : p.max;
}

📎 Annexe — RGPD biométrie voix

Depuis l'avis CNIL 2024-002 du 8 février 2024, la voix utilisée à des fins d'identification (voice biometrics, anti-fraude) entre dans le champ de l'article 9 RGPD (donnée biométrique sensible). Recommandations :

  • Consentement écrit explicite, séparé du contrat principal
  • Mention obligatoire au début de l'enregistrement (« cet appel est enregistré et votre voix peut servir à votre identification »)
  • Droit d'opposition immédiat, sans dégradation du service
  • DPIA (Data Protection Impact Assessment) obligatoire
  • Pas de transfert hors UE (sauf clauses contractuelles + analyse de risque)
  • Suppression effective au plus 6 mois après dernier usage (sauf obligation légale)

La voix transcrite (texte de la conversation) n'est pas biométrique mais reste donnée personnelle classique : conservation justifiée + minimisation + droit d'effacement.

Bibliothèque tech perso — Achref