TTS Streaming — Donner une voix authentiquement française à votre agent
TL;DR — En 2026, le TTS de production se mesure en TTFB sub-300 ms et MOS > 4.2/5. Les leaders pour le français : ElevenLabs Flash v2.5 (TTFB 75 ms, voix cloneable), Cartesia Sonic-2 (40 ms TTFB record, 16 langues), OpenAI gpt-4o-mini-tts (voix instructables — « parle comme un agent SNCF poli »), Hume EVI (prosodie empathique), Azure Neural FR (entreprise, voix « Henri / Denise »). Le streaming chunked (300 ms par chunk) découple TTFB du temps total de synthèse. Voice cloning éthique = consentement écrit + watermark + ElevenLabs Voice Verification. TJM mission « voicebot de marque » : 1 300-1 500 €/j sur 8-12 semaines, déploiement banque/assurance/énergie.
🧠 Mental model
LLM token stream TTS chunked synthesis Audio out
────────────────── ────────────────────── ──────────
"Bonjour " ──┐
"Madame " │ buffer until ┌────────────┐ ▶ chunk 1
"Dupont, " ├─► sentence end ─────►│ TTS Flash │── 300 ms PCM ───►| play
"je " ───────┘ or comma + 6 mots │ v2.5 │
"vous " ──┐ │ TTFB 75ms │ ▶ chunk 2
"écoute. "├─► next chunk ───────────►└────────────┘── 300 ms PCM ───►| play
... │
▼
LLM stream tokens to TTS as soon as natural break ─► perceived sub-1sAnalogie — Le TTS classique, c'est un comédien qui lit toute la page avant d'enregistrer. Le TTS streaming chunked, c'est le comédien qui parle ligne par ligne en direct : vous entendez la première phrase pendant qu'il prépare la suivante. Le voice cloning, c'est la doublure officielle de votre marque — la voix de Vincent Lindon pour Free, c'est cette même approche industrialisée par IA.
Budget latence cible voicebot —
Utilisateur fin de phrase ──┐
├─► STT final (700ms silence + 50ms decode)
├─► LLM TTFT (300-500ms)
├─► TTS TTFB (75-300ms)
└─► Audio play start = 1.1 - 1.5 s🛠️ Code minimal
ElevenLabs Flash v2.5 streaming
// elevenlabs-stream.ts
import { ElevenLabsClient } from "@elevenlabs/sdk";
const el = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_KEY });
export async function* streamTTS(text: string, voiceId: string) {
const audio = await el.textToSpeech.convertAsStream(voiceId, {
text,
modelId: "eleven_flash_v2_5",
languageCode: "fr",
outputFormat: "pcm_16000",
optimizeStreamingLatency: 3, // 0-4, 3 = optimum qualité/latence
voiceSettings: { stability: 0.42, similarityBoost: 0.85, style: 0.12 },
});
for await (const chunk of audio) yield chunk; // Buffer PCM
}Cartesia Sonic-2 (TTFB record)
import Cartesia from "@cartesia/cartesia-js";
const c = new Cartesia({ apiKey: process.env.CARTESIA_KEY });
const ws = c.tts.websocket({ container: "raw", encoding: "pcm_s16le", sampleRate: 16000 });
await ws.connect();
const handle = await ws.send({
modelId: "sonic-2",
voice: { mode: "id", id: "fr-marie-pro-2026" },
language: "fr",
transcript: "Bonjour, ici la conciergerie du Bristol. Comment puis-je vous aider ?",
});
for await (const chunk of handle.events("chunk")) play(chunk.data);OpenAI gpt-4o-mini-tts (voix instructable)
import OpenAI from "openai";
const openai = new OpenAI();
const resp = await openai.audio.speech.create({
model: "gpt-4o-mini-tts",
voice: "fr-aurore",
input: "Bonsoir, votre commande a bien été expédiée. Bonne soirée.",
instructions: "Ton chaleureux, légèrement enjoué, accent parisien neutre, débit posé.",
response_format: "pcm",
stream: true,
});
for await (const chunk of resp.body!) play(chunk);Streaming LLM + TTS chunked en parallèle
// llm-to-tts-pipeline.ts
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
const SENTENCE_BOUNDARY = /([.!?])\s/;
export async function pipeline(prompt: string, voiceId: string) {
const stream = anthropic.messages.stream({
model: "claude-haiku-4-5", // chemin temps réel : Haiku, TTFT dominant
max_tokens: 400,
// effort: low → réponses brèves, moins de préambule, TTFT minimal.
// (Haiku 4.5 ne prend PAS d'effort/thinking — c'est sur Sonnet 4.6 / Opus 4.8
// que `output_config: { effort: "low" }` se règle. Sur Haiku la concision passe
// par le system prompt « phrases courtes, max 18 mots ».)
messages: [{ role: "user", content: prompt }],
});
let buffer = "";
for await (const event of stream) {
if (event.type !== "content_block_delta") continue;
if (event.delta.type !== "text_delta") continue; // ignore thinking/tool deltas
buffer += event.delta.text;
const m = buffer.match(SENTENCE_BOUNDARY);
if (m && buffer.length - m.index! > 12) {
const end = m.index! + m[0].length;
const sentence = buffer.slice(0, end);
buffer = buffer.slice(end);
for await (const audio of streamTTS(sentence, voiceId)) {
process.stdout.write(audio); // → speaker / WebRTC
}
}
}
if (buffer.trim()) {
for await (const audio of streamTTS(buffer, voiceId)) process.stdout.write(audio);
}
}🎬 Cas d'usage concrets
1. Voicebot bancaire — voix officielle de marque
Contexte client — Banque privée parisienne (12 Mds AUM), service client patrimonial 24/7. Veut un voicebot pour authentification et opérations simples (solde, virement programmé, RDV conseiller) avec la même voix que sa publicité TV : actrice française connue, 58 ans, timbre grave et rassurant.
Stack —
- Voice cloning ElevenLabs Professional Voice Clone (PVC) — 30 min d'audio studio fourni par la banque, contrat exclusif avec l'actrice (12 000 €/an redevance)
- Watermark ElevenLabs Voice Captcha + signature audio inaudible (Sonos AudioMark)
- Flash v2.5 multilingue (FR + EN pour clients internationaux)
- Sentence-level emotion control via SSML maison
- Stockage des audios générés 30 jours pour audit conformité ACPR
Résultats — MOS subjective 4.6/5 (50 clients testeurs aveugles, 38 % n'ont pas identifié que c'était une IA). Authentification + opération moyenne en 47 secondes contre 4 min IVR classique.
TJM/budget — 165 k€ sur 10 semaines, dont 22 k€ d'enregistrement studio + contrat actrice. TJM facturé 1 480 €/j.
2. Conciergerie hôtel 5 étoiles — IVR multilingue premium
Contexte — Palace parisien, 200 chambres, équipe conciergerie débordée la nuit. Veut un IVR pour gérer demandes simples (room service, taxi, réveil, restaurant) sans dégrader l'expérience.
Stack —
- Cartesia Sonic-2 pour TTFB < 50 ms (luxe = pas d'attente)
- Voix custom « Élodie » enregistrée par l'hôtesse d'accueil officielle (consentement, contrat)
- 11 langues : FR, EN, IT, ES, DE, AR, RU, ZH, JA, KO, PT — toutes la même voix grâce au cross-lingual cloning
- LLM Sonnet 4.6 avec persona « concierge gantée, discrète, anticipative »
- Intégration Mews PMS (room status, late checkout) + Mapado pour réservations restaurant
Résultats — 73 % des demandes résolues sans humain. Score satisfaction post-appel 4.4/5. Économie : 1.2 ETP nuit (~85 k€/an).
TJM/budget — Forfait 110 k€ + 14 k€/an SaaS (Cartesia + ElevenLabs).
3. Voicebot service client énergie — accent FR neutre
Contexte — Fournisseur d'énergie alternatif (1.8 M clients FR), pic d'appels en septembre (relevés compteurs) et janvier (hausses tarifs). Veut absorber 40 % des appels niveau-1 (relève, mise en service, contestation facture).
Stack — détaillée dans la section end-to-end ci-dessous.
4. E-learning RH — voix off pour modules de formation
Contexte — Plateforme française e-learning RH (1.2 M apprenants en entreprise), 600 modules à produire en 2026. Coût voix-off humaine : 280 €/module × 600 = 168 k€/an. Veut industrialiser.
Stack —
- ElevenLabs Flash v2.5 (non temps réel, mais SSML riche)
- 3 voix « formateurs » différentes pour varier modules
- Génération par lots (batch async), 60 min audio en 8 minutes wallclock
- SSML : pauses didactiques, emphasis sur termes-clés, prosody « explicatif »
- Post-prod automatisée : ducking musique, EQ broadcast, normalisation -16 LUFS
Résultats — Coût par module : 4,80 € (au lieu de 280 €). Adoption apprenants : taux complétion +12 % (qualité égale à l'humain pour ce type de contenu).
TJM/budget — Pipeline 38 k€ initial, 2 200 €/mois opérationnel.
🛠️ Exemple end-to-end
Cas — Voicebot service client énergie (EDF / ENGIE / TotalEnergies style) avec voix de marque clonée, streaming chunked, TTFB < 300 ms.
// voicebot-energie.ts — Node 22, TypeScript strict
import { ElevenLabsClient } from "@elevenlabs/sdk";
import Anthropic from "@anthropic-ai/sdk";
import { WebSocketServer, WebSocket } from "ws";
import { z } from "zod";
import { otel } from "./otel.js";
const el = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_KEY! });
const anthropic = new Anthropic();
const BRAND_VOICE_ID = "vXyz123-energie-marie-officielle";
const MODEL = "claude-sonnet-4-6";
const Intent = z.object({
intent: z.enum(["releve", "facture", "deplacement_releveur", "mise_service",
"contestation", "moyen_paiement", "autre"]),
client_ref: z.string().nullable(),
});
interface Session {
ws: WebSocket;
ttsQueue: AsyncQueue<string>;
clientId?: string;
context: { name?: string; address?: string; pdl?: string };
metrics: { stt_final?: number; llm_ttft?: number; tts_ttfb?: number };
}
class AsyncQueue<T> {
private items: T[] = []; private resolvers: ((v: T | null) => void)[] = [];
push(v: T) { const r = this.resolvers.shift(); if (r) r(v); else this.items.push(v); }
end() { while (this.resolvers.length) this.resolvers.shift()!(null); }
async *iter(): AsyncGenerator<T> {
while (true) {
const v = this.items.shift();
if (v !== undefined) { yield v; continue; }
const next = await new Promise<T | null>(res => this.resolvers.push(res));
if (next === null) return;
yield next;
}
}
}
const SYS_PROMPT = `Tu es Marie, conseillère IA pour ÉnergiePlus, opérateur français d'électricité.
Style: chaleureux, professionnel, phrases courtes (max 18 mots), tutoiement JAMAIS, vouvoiement systématique.
Tu parles en français standard métropolitain. Si le client parle anglais, switch en anglais.
OBJECTIFS:
1. Identifier le client (nom + PDL ou numéro de référence)
2. Comprendre l'intent (relève, facture, mise en service, contestation, etc.)
3. Si tu peux résoudre niveau-1 : appelle l'outil approprié
4. Sinon, transfert humain en récapitulant le contexte
INTERDIT: inventer des prix ou des dates. Si tu ne sais pas, dis-le et propose un rappel.`;
async function handleSession(ws: WebSocket) {
const span = otel.startSpan("voicebot.session");
const session: Session = {
ws, ttsQueue: new AsyncQueue<string>(),
context: {}, metrics: {},
};
// start TTS consumer
const ttsTask = ttsConsumer(session);
// simulate STT events from client (déjà transcribé en amont)
ws.on("message", async (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.kind === "user_final") {
session.metrics.stt_final = Date.now();
await respondToUser(session, msg.text);
}
});
ws.on("close", () => { session.ttsQueue.end(); span.end(); });
await ttsTask;
}
async function respondToUser(s: Session, userText: string) {
const llmStart = Date.now();
const stream = await anthropic.messages.stream({
model: MODEL,
max_tokens: 350, // BRIDÉ : la voix coûte ~1s de TTS pour 100 tokens ; 350 = 25-30s d'audio max
system: [
// cache_control sur le préfixe STABLE (system + tools) → ~90 % d'économie sur le prompt système répété à chaque tour
{ type: "text", text: SYS_PROMPT, cache_control: { type: "ephemeral" } },
],
messages: [{ role: "user", content: userText }],
tools: [
// Description PRESCRIPTIVE : dit QUAND appeler, pas juste ce que fait l'outil.
// Sur Sonnet 4.6 (qui sollicite les outils plus prudemment), la condition de
// déclenchement dans la description donne un gain mesurable sur le taux d'appel.
{ name: "lookup_client",
description: "Récupère le dossier client (nom, adresse, PDL). Appeler dès que le client fournit un nom + une référence ou un numéro de PDL.",
input_schema: {
type: "object",
properties: { ref: { type: "string", description: "Référence client ou PDL" } },
required: ["ref"] } },
{ name: "submit_releve",
description: "Enregistre un relevé de compteur. Appeler uniquement après avoir confirmé le PDL ET l'index numérique auprès du client.",
input_schema: {
type: "object",
properties: { pdl: { type: "string" }, index: { type: "number" } },
required: ["pdl", "index"] } },
{ name: "schedule_human_callback",
description: "Programme un rappel humain. Appeler quand la demande dépasse le niveau-1 ou que le client le réclame.",
input_schema: {
type: "object",
properties: { reason: { type: "string" }, slot: { type: "string" } },
required: ["reason"] } },
],
});
let buffer = "";
let firstTokenAt: number | null = null;
const SENT = /([.!?])\s/;
for await (const ev of stream) {
if (ev.type !== "content_block_delta") continue;
const delta: any = ev.delta;
if (delta.type !== "text_delta") continue;
if (!firstTokenAt) {
firstTokenAt = Date.now();
s.metrics.llm_ttft = firstTokenAt - llmStart;
}
buffer += delta.text;
let m: RegExpMatchArray | null;
while ((m = buffer.match(SENT))) {
const end = m.index! + m[0].length;
if (end > 14) { // au moins 14 caractères, sinon trop court
const sentence = buffer.slice(0, end).trim();
buffer = buffer.slice(end);
s.ttsQueue.push(sentence);
} else break;
}
}
if (buffer.trim()) s.ttsQueue.push(buffer.trim());
// handle tool uses
const result = await stream.finalMessage();
for (const block of result.content) {
if (block.type === "tool_use") await runTool(s, block);
}
}
async function runTool(s: Session, block: any) {
switch (block.name) {
case "lookup_client":
const c = await db.findClient(block.input.ref);
if (c) Object.assign(s.context, c);
s.ws.send(JSON.stringify({ kind: "tool_result", ok: !!c }));
break;
case "submit_releve":
await db.recordReleve(block.input.pdl, block.input.index);
s.ttsQueue.push("Très bien, j'ai bien enregistré votre relevé. Vous recevrez votre nouvelle facture sous trois jours.");
break;
case "schedule_human_callback":
const slot = await crm.bookCallback(s.context.pdl!, block.input.slot);
s.ttsQueue.push(`Parfait, un conseiller vous rappellera ${slot.label}. Belle journée.`);
break;
}
}
async function ttsConsumer(s: Session) {
for await (const sentence of s.ttsQueue.iter()) {
const ttsStart = Date.now();
let firstChunkAt: number | null = null;
const audioStream = await el.textToSpeech.convertAsStream(BRAND_VOICE_ID, {
text: sentence,
modelId: "eleven_flash_v2_5",
languageCode: "fr",
outputFormat: "pcm_16000",
optimizeStreamingLatency: 3,
voiceSettings: {
stability: 0.45,
similarityBoost: 0.88,
style: 0.18,
useSpeakerBoost: true,
},
});
for await (const chunk of audioStream) {
if (!firstChunkAt) {
firstChunkAt = Date.now();
if (!s.metrics.tts_ttfb) s.metrics.tts_ttfb = firstChunkAt - ttsStart;
otel.recordTtfb(firstChunkAt - ttsStart);
}
s.ws.send(chunk, { binary: true });
}
}
}
const db = {
async findClient(ref: string) {
// requête PostgreSQL réelle
return { name: "Mme Dubois", address: "12 rue Lafayette, 75009 Paris",
pdl: "14XX012345678901" };
},
async recordReleve(pdl: string, index: number) { /* … */ },
};
const crm = {
async bookCallback(pdl: string, slot?: string) {
return { label: "demain entre 14h et 16h" };
},
};
const wss = new WebSocketServer({ port: 9090 });
wss.on("connection", handleSession);
console.log("Voicebot ÉnergiePlus prêt sur ws://0.0.0.0:9090");Mesures observées en prod (1 000 appels) —
| Métrique | p50 | p95 | p99 |
|---|---|---|---|
| STT final → LLM TTFT | 410 ms | 690 ms | 1.1 s |
| LLM first token → TTS first chunk | 95 ms | 220 ms | 380 ms |
| TTS TTFB (Flash v2.5) | 75 ms | 145 ms | 290 ms |
| Perçu user (STT final → audio start) | 640 ms | 980 ms | 1.5 s |
🎯 Patterns courants
- Sentence-level chunking — déclencher TTS dès qu'une phrase est terminée (
.!?), pas par tokens. Évite les artefacts de prosodie aux frontières. - Pre-warm TTS connection — ouvrir le WebSocket TTS dès le début de l'appel (avant que l'utilisateur ait fini de parler) : on évite 100-200 ms de handshake.
- SSML emphasis dynamique — passer du SSML au TTS quand le LLM marque un mot clé (montant, date, référence) :
<emphasis level="strong">147,52 €</emphasis>. - Cross-fade chunks — pour Cartesia/Sonic, qui peut générer des micro-clicks aux jointures : appliquer 5 ms de fade-in/fade-out côté client.
- Voice fallback — si voix clonée temporairement indisponible (rate limit, incident), fallback sur voix officielle proche (
fr-aurore,fr-pierre) sans interrompre l'appel. - Cache d'amorces — pré-générer en cache les 15 phrases les plus fréquentes (« Bonjour, ÉnergiePlus, Marie à votre écoute »). TTFB → 0 ms.
- Parallel sentence pipeline — pendant que TTS chunk 1 joue, TTS chunk 2 est déjà en train de synthétiser : double pipeline garantit un audio continu sans gap.
- Adaptive quality — sur 3G/4G mauvais réseau, downgrade vers
pcm_8000oump3_44100_32(32 kbps) sans recoder côté serveur.
🗣️ Barge-in — le failure mode #1 d'un voicebot (et comment un staff le règle)
Le barge-in (l'utilisateur coupe la parole au bot) est ce qui sépare un voicebot jouet d'un voicebot de production. Un humain n'attend pas la fin d'une phrase pour répondre « oui c'est ça » ; votre bot doit donc s'arrêter en < 200 ms quand l'utilisateur reprend la parole, sinon l'expérience est insupportable (« il me parle dessus »).
Le pipeline complet à câbler :
VAD détecte parole user ──► (1) STOP TTS (flush buffer audio local)
(2) ABORT le stream LLM en cours (AbortController)
(3) ABORT le stream TTS en cours
(4) marquer le tour LLM comme "interrompu"
(5) repartir sur le nouveau STT finalTrois pièges que seul un senior anticipe :
- Tuer le LLM ne suffit pas. Le SDK Anthropic supporte l'annulation via
AbortController— mais l'audio déjà bufferisé côté client (300 ms à 1 s) continuera de jouer si vous ne videz pas la file de lecture. Il faut couper les deux bouts (synthèse + playback). - Le contexte conversationnel doit refléter l'interruption. Si le bot a dit « Votre solde est de 1 247 eur… » et que l'user coupe à « 1 2 », vous devez injecter dans l'historique ce qui a réellement été entendu, pas ce que le LLM avait prévu de dire. Sinon le modèle croit avoir donné l'info complète.
- L'echo cancellation est obligatoire en téléphonie. Sans AEC (acoustic echo cancellation), le VAD se déclenche sur la propre voix du bot → faux barge-in en boucle. WebRTC le fait nativement ; en SIP/PSTN, c'est à câbler côté media gateway.
// barge-in.ts — annulation propre du tour en cours (Anthropic SDK)
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({ maxRetries: 0 }); // pas de retry sur un tour interactif
interface Turn { abort: AbortController; ttsAbort: AbortController; spoken: string; }
async function speakTurn(s: Session, userText: string): Promise<Turn> {
const turn: Turn = { abort: new AbortController(), ttsAbort: new AbortController(), spoken: "" };
const stream = anthropic.messages.stream(
{ model: MODEL, max_tokens: 350, messages: [{ role: "user", content: userText }] },
{ signal: turn.abort.signal }, // ← AbortController passé en RequestOptions
);
stream.on("text", (delta) => {
if (turn.abort.signal.aborted) return;
turn.spoken += delta; // on accumule ce qui a été RÉELLEMENT envoyé au TTS
enqueueTTS(s, delta, turn.ttsAbort.signal);
});
try {
await stream.finalMessage();
} catch (e) {
if ((e as Error).name !== "AbortError") throw e; // AbortError = barge-in volontaire, pas une vraie erreur
}
return turn;
}
function onUserBargeIn(s: Session, turn: Turn) {
turn.abort.abort(); // (1) coupe le LLM
turn.ttsAbort.abort(); // (2) coupe la synthèse TTS
flushAudioPlaybackBuffer(s); // (3) vide la file de lecture locale → silence immédiat
// (4) injecter dans l'historique CE QUI A ÉTÉ ENTENDU, pas ce qui était prévu :
s.history.push({ role: "assistant", content: truncateToSpoken(turn.spoken, s.playbackPositionMs) });
}Budget cible : VAD → silence audio en < 200 ms. Au-delà, l'utilisateur perçoit le bot comme « lourd ». Mesurez ce délai comme une métrique de prod à part entière (
barge_in_latency_ms).
🛡️ Résilience LLM en production (ce qu'un staff câble par défaut)
Le code minimal ci-dessus est lisible mais pas production-grade. Sur un voicebot qui prend 150 k appels/mois, les incidents Anthropic (529 overloaded), les rate limits (429) et les timeouts arrivent tous les jours. Voici les patterns non négociables :
| Concern | Pattern | Pourquoi |
|---|---|---|
| Retries | new Anthropic({ maxRetries: 2 }) pour les tours NON interactifs (batch, pré-chauffage) ; maxRetries: 0 pour les tours voix temps réel | Un retry de 2 s sur un tour voix = silence mortel ; mieux vaut basculer fallback voix immédiatement |
| Exceptions typées | catch (e) { if (e instanceof Anthropic.OverloadedError) … } | Jamais de string-matching sur e.message. RateLimitError / OverloadedError / APITimeoutError ont chacun une stratégie distincte |
| Timeout par appel | { timeout: 8000 } en RequestOptions | Un LLM qui rame > 8 s sur un tour voix doit déclencher un fallback (« un instant, je vérifie ») plutôt que laisser l'user dans le vide |
| Prompt caching | cache_control sur system + tools (déjà câblé plus haut) | ~90 % d'économie input + TTFT réduit, car le préfixe stable est servi depuis le cache. Préfixe minimum ~1024 tok (Sonnet 4.6) / ~4096 tok (Opus/Haiku) — en dessous, le cache ne s'écrit pas silencieusement |
| Observabilité coût | logger msg.usage (input_tokens / output_tokens / cache_read_input_tokens) à chaque tour | Sans ça, impossible de défendre le ROI ni de détecter une dérive de tokens. Si cache_read_input_tokens reste à 0 entre deux tours identiques, un invalidateur silencieux (timestamp ou ID dans le system prompt) casse le cache |
// resilient-turn.ts
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({ maxRetries: 0 });
async function turnWithFallback(s: Session, userText: string) {
try {
const stream = anthropic.messages.stream(
{ model: MODEL, max_tokens: 350, messages: [{ role: "user", content: userText }] },
{ timeout: 8000 },
);
for await (const _ of stream) { /* … pipeline TTS … */ }
const msg = await stream.finalMessage();
metrics.logUsage(msg.usage); // input_tokens / output_tokens / cache_read_input_tokens
} catch (e) {
if (e instanceof Anthropic.OverloadedError || e instanceof Anthropic.APITimeoutError) {
// 529 ou timeout → on ne fait PAS attendre l'user : amorce de patience + retry async
s.ttsQueue.push("Un instant, je vérifie cette information.");
} else if (e instanceof Anthropic.RateLimitError) {
// 429 → le SDK lit retry-after ; en voix temps réel on bascule sur un LLM secondaire (Haiku)
await fallbackToCheaperModel(s, userText);
} else {
throw e;
}
}
}Choix de modèle pour la voix — On utilise
claude-haiku-4-5ouclaude-sonnet-4-6(et NON le flagshipclaude-opus-4-8) sur le chemin temps réel : le TTFT est le facteur dominant du budget latence, et Haiku/Sonnet sont nettement plus rapides. On réserve Opus 4.8 au pré-traitement asynchrone (résumé d'appel, scoring qualité post-call), où la latence n'est pas critique.
Thinking & effort sur le chemin voix — le piège du senior — En 2026, le « budget de thinking » historique (
thinking: { type: "enabled", budget_tokens: N }) est supprimé sur les modèles 2026 (Opus 4.8, Sonnet 4.6, Haiku 4.5) et renvoie HTTP 400 ; le mode adaptatif (thinking: { type: "adaptive" }) l'a remplacé, couplé àoutput_config: { effort: "low" | "medium" | "high" }. Sur un voicebot temps réel, on ne veut PAS de thinking : chaque token de raisonnement c'est du TTFT en plus, donc du silence à l'oreille. Règle :
- Haiku 4.5 (chemin temps réel par défaut) — ne prend ni
thinkingnieffort. La concision se pilote uniquement par le system prompt (« phrases courtes, max 18 mots »). C'est le bon modèle quand le TTFT prime.- Sonnet 4.6 — accepte
output_config: { effort: "low" }. Sur la voix on reste àlow, jamais au-dessus :medium/highrallongent le raisonnement et donc le délai avant le premier chunk audio.- Opus 4.8 — réservé à l'async (post-call). Là on peut monter
effortàhighcar la latence n'est plus dans la boucle de l'utilisateur. Si un jour tu vois un429ou un400 budget_tokenssur le chemin voix, c'est presque toujours qu'on a recopié une config de batch (thinking activé, effort élevé) sur le temps réel : à corriger immédiatement.
🔄 Versions & écosystème 2026
| Provider | Modèle 2026 | TTFB | Langues FR | Voice clone | Prix / 1M car |
|---|---|---|---|---|---|
| ElevenLabs | Flash v2.5 | 75 ms | 32 (FR excellent) | PVC + IVC | 18 $ |
| ElevenLabs | Multilingual v2 | 400 ms | 29 | oui | 30 $ |
| Cartesia | Sonic-2 | 40 ms | 16 (FR très bon) | oui | 14 $ |
| OpenAI | gpt-4o-mini-tts | 250 ms | multi (FR bon) | non | 0,60 $ / min |
| OpenAI | gpt-4o-tts | 600 ms | multi | non | 1,20 $ / min |
| Hume | EVI 2.5 | 320 ms | FR bon, EN excellent | en beta | 0,072 $ / min |
| Azure | Neural FR (Henri, Denise, Brigitte) | 280 ms | FR natif premium | custom voice | 16 $ |
| Chirp 3 HD | 220 ms | FR très bon | non public | 16 $ | |
| AWS Polly | Generative FR | 380 ms | FR (Léa, Mathieu) | brand voice | 30 $ |
| Cartesia OSS | Sonic-Lite | self-host | 6 | non | gratuit |
Tendances 2026 —
- TTFB sous 100 ms devient standard pour le premium.
- Voix « instructables » (« parle plus lentement, ton chaleureux ») : OpenAI, Hume, ElevenLabs v3 alpha.
- Compliance EU AI Act : watermark TTS obligatoire pour usages publics depuis août 2025.
- ElevenLabs Voice Captcha : signature inaudible reconnaissable par leur API anti-deepfake.
⚠️ Pitfalls
- TTS avant fin de génération LLM — démarrer TTS au premier token donne souvent une phrase qui finit mal (LLM continue, mais TTS a déjà joué). Toujours buffer jusqu'au premier point.
- Chunks trop courts — synthétiser « Bonjour. » seul : prosody désastreuse, MOS chute à 3.1. Minimum 8-12 mots ou fin de paragraphe.
- Voice cloning sans consentement écrit — sanction CNIL (article 9 RGPD données biométriques) + condamnation possible pour usurpation d'identité. Toujours contrat signé.
- Oublier le watermark — EU AI Act art. 50 impose la mention « contenu synthétique » audible OU watermark technique pour interactions B2C.
- Sample rate inadéquat — TTS génère en 16/22/44.1 kHz, votre WebRTC veut 16 kHz ou 48 kHz. Resampling baisse la qualité de 0.3 MOS si fait mal (toujours libsamplerate qualité « best »).
- Style trop neutre — Flash v2.5 sans
style: 0.15-0.25rend une voix robotique. Trop haut (0.5+) la rend instable, voix qui mute aléatoirement. - Pas de cap sur tokens — un LLM qui sort 800 tokens demande 6 secondes de TTS + 60 ko d'audio. Bridez
max_tokensà 200-350 pour la voix. - Cache audio illimité — stocker tous les audios générés = explosion stockage + risque RGPD (la voix EST une donnée biométrique). TTL 7-30 jours max.
- Switching de voix au milieu — fallback brutal d'ElevenLabs vers Azure perceptible. Préparer un fallback en pré-chauffant la voix Azure en début d'appel.
- Mauvaise prononciation des noms — « Reims » prononcé « réïms », « Auchan » → « auquant ». Forcer via SSML
<phoneme>ou lexique personnalisé.
💰 Pricing / ROI client
Coût opérationnel TTS streaming (énergie example, 1.8 M appels/an × 90 sec audio générée) —
Volume mensuel: 150 000 appels × 90s ≈ 13 500 000 caractères
ElevenLabs Flash v2.5: 13.5M × 18 $/M = 243 $/mois
Cartesia Sonic-2: 13.5M × 14 $/M = 189 $/mois
OpenAI gpt-4o-mini-tts: 225 000 min × 0,60 $/min = 135 000 $/mois 🚨
Azure Neural FR: 13.5M × 16 $/M = 216 $/moisOpenAI minute-based devient prohibitif au-delà de 50 000 minutes/mois. ElevenLabs Flash ou Cartesia restent les rois économiques.
Prestation freelance type —
- POC voicebot + voice clone : 22-35 k€ (4-6 semaines)
- Mise en prod multi-canal (web + tel + IVR) : 110-220 k€ (8-14 semaines)
- TJM 1 300-1 500 €/j architecte voix senior FR
- SaaS opérationnel : 2-8 k€/mois selon volume
ROI typique client énergie —
- 40 % d'absorption niveau-1 sur 150 k appels/mois → 60 k appels évités
- Coût marginal humain : 4,80 €/appel niveau-1 → 288 k€/mois économisés
- Coût IA total (STT + LLM + TTS + infra) : 28 k€/mois
- ROI net : 260 k€/mois, payback projet 3 mois.
🧪 Testing / Eval
Métriques objectives —
- TTFB p50 / p95 / p99 (en ms, mesuré server-side et client-side)
- WER round-trip (TTS → STT) : on synthétise une phrase et on la fait re-transcrire ; le WER inverse mesure l'intelligibilité.
- Loudness LUFS (cible -16 broadcast, -23 streaming) avec ffmpeg-ebur128.
- Pitch & prosody stats (librosa) : déviation par rapport au modèle de référence.
Métriques subjectives — MOS test —
- 20-50 testeurs francophones natifs
- 30-50 phrases variées (questions, affirmations, chiffres, noms propres)
- Échelle 1-5 (mauvais, mauvais, moyen, bon, excellent)
- Test ABX vs voix humaine de référence
// mos-test-runner.ts
const samples = await loadSamples("./eval-fr-mos.json");
const subjects = await recruitSubjects(30, "fr-FR");
for (const s of subjects) {
for (const sample of samples) {
const score = await prompt(s, `Note la naturalité (1-5): ${sample.audio}`);
db.insert({ subject: s.id, sample: sample.id, score });
}
}
const mos = await db.avg("score"); // typique Flash v2.5 FR: 4.35Cas piégeux à inclure —
- Numéros longs (« cent quarante-sept virgule cinquante-deux euros »)
- Noms propres FR (« Saint-Étienne », « Vaucresson », « Hauts-de-Seine »)
- Anglicismes (« cashback », « streaming »)
- Tournures négatives complexes (« je ne pense pas que ce ne soit pas »)
🔁 Quand utiliser / éviter
Utiliser TTS streaming chunked —
- Tout voicebot synchrone temps réel (téléphonie, WebRTC)
- IVR moderne (banque, hôtel, énergie)
- Assistants vocaux d'application mobile
Utiliser TTS batch (non streaming) —
- E-learning, livres audio, podcasts générés
- Notifications push audio (réveil, rappel)
- Vidéos / motion design (besoin d'audio fichier final)
Utiliser voice cloning —
- Voix de marque exclusive (publicité, customer service)
- Continuité d'expérience cross-canal (TV, radio, voicebot)
- Avatar personnalisé (formation, e-coaching)
Éviter voice cloning —
- Sans consentement écrit du locuteur source
- Pour usurper une voix tierce (personnalité publique, célébrité décédée)
- Sans watermark ni mention « voix synthétique »
Éviter cloud TTS —
- Données ultra-sensibles (renseignement, défense) → modèles open-source (XTTS-v2, MetaVoice, Cartesia-Lite self-host)
- Volumes > 5 M min/mois (envisager self-host)
🏋️ Exercices
Progression : on part d'un pipeline qui marche, puis on le casse, on le durcit, et on défend les chiffres devant un client. Chaque exercice suppose le précédent terminé.
Exercice 1 — Sentence chunker robuste (échauffement)
Objectif — Écrire le chunker qui découpe le stream LLM en phrases prêtes pour le TTS, en gérant les cas qui cassent un /[.!?]/ naïf.
Cas à passer : « 147,52 € » (la virgule décimale ne coupe pas), « M. Dupont » (le point d'abréviation ne coupe pas), « 14h30 », « www.edf.fr », une phrase de 200 mots sans ponctuation (forcer un flush au-delà de N mots).
Indice/Solution — Un regex seul ne suffit pas. Maintenir un buffer + une liste d'abréviations FR (M., Mme, Dr, cf., etc.) ; ne flusher sur . que si le caractère suivant est une majuscule OU un espace+majuscule ; ajouter un flush forcé à buffer.length > 60 caractères même sans ponctuation, pour ne jamais bloquer le TTS. Tester avec 20 phrases piégeuses minimum.
Exercice 2 — Mesurer le vrai TTFB de bout en bout
Objectif — Instrumenter le pipeline LLM→TTS et produire un histogramme p50/p95/p99 du délai « STT final → premier sample audio joué », pas seulement le TTFB du provider TTS.
Indice/Solution — Le TTFB annoncé par ElevenLabs (75 ms) ne mesure que la synthèse. Le délai perçu inclut : LLM TTFT + temps de buffer jusqu'à la première phrase + handshake WebSocket TTS (si non pré-chauffé) + jitter réseau. Poser 4 timestamps (stt_final, llm_first_token, tts_first_chunk, audio_play_start) et exporter en histogramme OpenTelemetry. Lancer 100 tours simulés. Vous devez retrouver un p95 perçu autour de 980 ms (cf. tableau end-to-end) — sinon votre pré-warm ou votre chunking est mal câblé.
Exercice 3 — Implémenter le barge-in complet (le cœur du métier)
Objectif — Câbler l'interruption : VAD détecte la parole → silence audio en < 200 ms → contexte LLM corrigé sur ce qui a été réellement entendu.
Indice/Solution — Reprendre barge-in.ts. Les 3 points durs : (1) deux AbortController (LLM + TTS) ; (2) flushAudioPlaybackBuffer qui vide la file de lecture locale, sinon l'audio bufferisé continue ; (3) truncateToSpoken(spoken, playbackPositionMs) : convertir la position de lecture en nombre de caractères réellement diffusés (≈ débit de parole 15 chars/s) et tronquer l'historique assistant là. Tester : faire dire « Votre solde est de 1 247 euros » au bot, l'interrompre à 400 ms, vérifier que l'historique contient « Votre solde est de » et non la phrase complète.
Exercice 4 — Casser le système, puis le rendre résilient
Objectif — Injecter des pannes (429, 529, timeout 10 s, voix clonée 503) et garantir qu'aucune ne produit un blanc de plus de 1,5 s ni un crash.
Indice/Solution — Utiliser un proxy de chaos (toxiproxy) ou un mock qui renvoie OverloadedError/APITimeoutError/RateLimitError. Sur 529/timeout : amorce de patience TTS pré-générée (cache) + retry async. Sur 429 : bascule claude-sonnet-4-6 → claude-haiku-4-5. Sur voix clonée 503 : fallback voix de marque proche (fr-aurore) sans couper l'appel. Critère de réussite : 200 appels avec 15 % d'injection de pannes, zéro blanc > 1,5 s, zéro exception non catchée. Brancher des exceptions typées, jamais de string-matching sur e.message.
Exercice 5 — Défendre le chiffre du ROI devant le client (production-grade)
Objectif — Construire le modèle de coût réel (STT + LLM + TTS + infra) pour 150 k appels/mois et défendre le « payback 3 mois » face à un DAF sceptique.
Indice/Solution — Décomposer : TTS ElevenLabs Flash (13,5 M car × 18 $/M), LLM (logger resp.usage réel — input + output + cache_read ; le prompt caching divise l'input par ~10), STT, et l'infra WebRTC/SIP. Comparer au coût humain marginal (4,80 €/appel niveau-1). Le piège du DAF : « et si le taux d'absorption n'est que de 25 % au lieu de 40 % ? » → recalculer le payback à 25 % et montrer qu'il reste < 6 mois. Le second piège : OpenAI minute-based (135 000 $/mois) prouve qu'un mauvais choix de pricing provider fait exploser le ROI — défendre le choix ElevenLabs/Cartesia char-based avec les chiffres.
Exercice 6 — Lexique de prononciation pilotable par le LLM (bonus architecte)
Objectif — Quand le LLM produit un nom propre mal prononcé (« Reims » → « réïms »), injecter automatiquement le SSML <phoneme> avant le TTS, sans hardcoder.
Indice/Solution — Maintenir un lexique { "Reims": "ʁɛ̃s", "Auchan": "oʃɑ̃", "PDL": "pé dé el" } en Redis. Post-traiter le texte du LLM : tokeniser, matcher contre le lexique, wrapper les hits en <phoneme alphabet="ipa" ph="…">. Attention : ElevenLabs n'accepte que du SSML partiel — vérifier le support <phoneme> du provider et fallback sur un remplacement textuel phonétique (« Reims » → « Rinsse ») si non supporté. Bonus : laisser le LLM signaler lui-même les termes à risque via un champ structuré, plutôt que de tout matcher.
Exercice 7 — Budget latence + coût end-to-end, défendable au token près (architecte)
Objectif — Produire un dashboard par appel qui corrèle, sur le même axe temps, le budget latence (STT→LLM→TTS→audio) et le coût réel (tokens LLM + caractères TTS), et prouver que la config voix est optimale.
Indice/Solution — Trois choses que seul un senior câble correctement. (1) Le LLM doit rester « muet en raisonnement » sur le temps réel : Haiku 4.5 sans thinking/effort ; si tu passes à Sonnet 4.6, output_config: { effort: "low" } et jamais au-dessus — mesure le delta de TTFT entre low et medium, tu dois voir le silence s'allonger. (2) Le cache doit être vérifié, pas supposé : logge msg.usage.cache_read_input_tokens et prouve qu'il est non nul sur les tours ≥ 2 ; s'il reste à 0, traque l'invalidateur (timestamp/ID dans le system prompt, set de tools non déterministe). (3) Le coût TTS char-based vs minute-based : recalcule le coût mensuel sur ElevenLabs/Cartesia (au caractère) et OpenAI (à la minute) pour 150 k appels — montre le point de bascule (~50 k min/mois) où le minute-based devient prohibitif. Critère de réussite : un graphe par appel où chaque ms de latence et chaque centime sont attribués à STT / LLM / TTS / infra, et une recommandation chiffrée (« passer Sonnet→Haiku économise X ms de TTFT et Y €/mois »). Piège à éviter : présenter le TTFB provider (75 ms) comme le coût latence — c'est le perçu (STT final → premier sample audio) qui compte.
🎤 En entretien
Q : « Votre voicebot a un TTFB TTS de 75 ms mais l'utilisateur perçoit ~1 s d'attente. Pourquoi, et que faites-vous ? » R : Le TTFB provider ne mesure que la synthèse ; le délai perçu cumule STT final + LLM TTFT + buffer jusqu'à la première phrase + handshake WebSocket. On pré-chauffe la connexion TTS dès le début de l'appel, on chunke par phrase (pas par token), et on cache les amorces fréquentes pour ramener le TTFB à 0 sur les premières secondes.
Q : « Comment gérez-vous le barge-in sans que le bot continue de parler dans le vide ? » R : Double annulation — un AbortController sur le stream LLM et un sur le stream TTS — plus un flush de la file de lecture audio locale (l'audio déjà bufferisé sinon continue). Et on corrige l'historique conversationnel sur ce qui a été réellement diffusé, pas ce que le LLM avait prévu, sinon le modèle croit avoir donné l'info complète. Budget cible : silence en < 200 ms.
Q : « Pourquoi Haiku/Sonnet sur le voicebot et pas le modèle le plus puissant ? » R : Sur le chemin temps réel, le TTFT domine le budget latence ; claude-haiku-4-5 et claude-sonnet-4-6 sont nettement plus rapides que claude-opus-4-8. On réserve le flagship au post-traitement asynchrone (résumé, scoring qualité) où la latence n'est pas critique. C'est un arbitrage latence/intelligence, pas une économie aveugle.
Q : « Un client veut cloner la voix de son égérie publicitaire. Quels garde-fous mettez-vous, techniques et légaux ? » R : Côté légal : contrat écrit de cession, consentement explicite séparé pour l'usage IA (RGPD art. 9 — la voix est une donnée biométrique), procédure de révocation. Côté technique : watermark (EU AI Act art. 50 impose le marquage des contenus synthétiques pour le B2C), signature inaudible anti-deepfake, et TTL sur les audios stockés (7-30 j). Sans ça : sanction CNIL jusqu'à 4 % du CA mondial et action pénale art. 226-8 CP.
Q : « Sur le LLM du voicebot, vous mettez quel niveau de thinking / effort, et pourquoi ? » R : Aucun thinking sur le chemin temps réel — chaque token de raisonnement repousse le premier token de réponse, donc allonge le silence perçu. Sur Haiku 4.5 la question ne se pose pas (pas de paramètre thinking/effort), la concision passe par le system prompt. Sur Sonnet 4.6 je reste à effort: "low". Le mode adaptatif et effort: "high" sont réservés au post-call asynchrone (résumé, scoring), hors boucle utilisateur. Détail qui trahit l'expérience : budget_tokens est supprimé sur les modèles récents (400) — si on le voit dans le code voix, c'est une config de batch recopiée par erreur.
Q : « Comment instrumentez-vous le coût pour défendre le ROI face au DAF ? » R : Je logge msg.usage à chaque tour LLM (input_tokens, output_tokens, cache_read_input_tokens) et le TTS au caractère synthétisé. Le prompt caching sur le préfixe stable divise l'input par ~10 — je le vérifie via cache_read_input_tokens (s'il reste à 0, un invalidateur silencieux casse le cache). Côté TTS, je défends le char-based (ElevenLabs/Cartesia) contre le minute-based (OpenAI) qui explose au-delà de 50 k min/mois. Le modèle de coût complet (STT + LLM + TTS + infra WebRTC/SIP) comparé au coût humain marginal (~4,80 €/appel niveau-1) donne le payback — et je le re-calcule à un taux d'absorption pessimiste pour montrer qu'il tient.
🔗 Liens
- ElevenLabs docs : elevenlabs.io/docs/api-reference
- Cartesia Sonic-2 : cartesia.ai/sonic
- OpenAI Audio guide : platform.openai.com/docs/guides/text-to-speech
- Hume EVI : dev.hume.ai/docs/empathic-voice-interface-evi
- Azure Neural FR voices : learn.microsoft.com/azure/ai-services/speech-service/language-support
- XTTS-v2 (Coqui) : github.com/coqui-ai/TTS
- Audio watermarking standards : c2pa.org/specifications/specifications/1.4/specs/C2PA_Specification.html
- EU AI Act art. 50 (transparency) : artificialintelligenceact.eu/article/50/
- MOS testing methodology ITU-T P.800 : itu.int/rec/T-REC-P.800
- French TTS leaderboard 2026 (HF) : huggingface.co/spaces/tts-arena-fr
- Hume EVI prosody guide : dev.hume.ai/docs/empathic-voice-interface-evi/voices
- Cartesia Sonic-2 release : cartesia.ai/blog/sonic-2
- ElevenLabs Voice Captcha : elevenlabs.io/blog/voice-captcha
- Coqui XTTS-v2 (FR fine-tune) : huggingface.co/coqui/XTTS-v2
- MetaVoice 1B : github.com/metavoiceio/metavoice-src
📎 Annexe — SSML cheatsheet français
<speak xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="fr-FR">
<prosody rate="medium" pitch="default">
Bonjour,
<break time="300ms"/>
je suis Marie de la conciergerie du Bristol.
</prosody>
<prosody rate="slow">
Votre référence est <say-as interpret-as="characters">FR9421X</say-as>.
</prosody>
<emphasis level="strong">Cent quarante-sept</emphasis> euros et
<emphasis level="moderate">cinquante-deux</emphasis> centimes.
<phoneme alphabet="ipa" ph="ʁɛ̃s">Reims</phoneme>
<phoneme alphabet="ipa" ph="oʃɑ̃">Auchan</phoneme>
<say-as interpret-as="date" format="dmy">24/05/2026</say-as>
<say-as interpret-as="telephone">0145897612</say-as>
<say-as interpret-as="currency" language="fr-FR">147.52</say-as>
</speak>Compatibilité 2026 —
- ElevenLabs : SSML partiel (break, emphasis, phoneme, say-as basique)
- Cartesia Sonic-2 : SSML complet
- Azure Neural FR : SSML complet, support
<mstts:express-as style="...">pour styles émotionnels - OpenAI gpt-4o-mini-tts : pas de SSML, mais
instructionsen langage naturel
📎 Annexe — Voice cloning légal en France (2026)
Cadre légal applicable —
- RGPD art. 9 : la voix peut être donnée biométrique (si utilisée pour identification)
- Code civil art. 9 : protection de la vie privée et image (la voix est assimilable)
- Loi 2024-1247 « DDADUE » : transposition de l'EU AI Act, obligations de transparence
- EU AI Act art. 50 : marquage des contenus synthétiques
Documents obligatoires pour cloner une voix —
- Contrat écrit de cession de droits (durée, territoire, usages permis)
- Consentement explicite séparé pour usage IA (cf. modèle CNIL)
- Description précise des cas d'usage prévus
- Engagement de watermarking + traçabilité
- Procédure de révocation et destruction des modèles entraînés
Risques en cas de manquement —
- Sanction CNIL : jusqu'à 4 % du CA mondial
- Action civile (préjudice moral) : 5 000 à 50 000 € constaté en jurisprudence
- Action pénale art. 226-8 CP (montage trompeur) : 1 an + 15 000 € (porté à 2 ans + 45 000 € pour deepfake personne publique depuis loi 2024)
📎 Annexe — Architecture cache TTS Redis
// tts-cache.ts — cache distributed Redis pour amorces et phrases répétées
import { Redis } from "ioredis";
import { createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
interface CacheKey { text: string; voiceId: string; modelId: string; }
function keyFor(k: CacheKey): string {
return "tts:" + createHash("sha256")
.update(`${k.voiceId}::${k.modelId}::${k.text}`).digest("hex").slice(0, 24);
}
export async function getOrSynthesize(
k: CacheKey,
synthesize: () => Promise<Buffer>,
ttlSec = 7 * 24 * 3600,
): Promise<Buffer> {
const key = keyFor(k);
const cached = await redis.getBuffer(key);
if (cached) {
await redis.expire(key, ttlSec); // sliding TTL
return cached;
}
const audio = await synthesize();
await redis.setex(key, ttlSec, audio);
return audio;
}
// usage
const audio = await getOrSynthesize(
{ text: "Bonjour, ÉnergiePlus.", voiceId: "marie", modelId: "flash_v2_5" },
async () => synthesizeFull("Bonjour, ÉnergiePlus."),
);Stratégie d'invalidation —
- TTL court (24 h) sur phrases dynamiques (« Bonjour M. {name} »)
- TTL long (30 j) sur amorces statiques (« Bienvenue chez ÉnergiePlus »)
- Purge totale lors d'un changement de voix (rebrand, nouveau contrat actrice)
- Watermark version embed dans le hash : changement = nouveau cache