F-08 Voix temps réel (duplex)
Brique F de la plateforme Enterprise AI. Technologie : LiveKit (SFU, framework agents) + WebRTC duplex, repli WebSocket /v1/realtime/ws/{session_id}. Version anglaise : ../../en/bricks/F-08-voice-live.md.
Rôle et responsabilités
- Conversations vocales en duplex intégral entre un utilisateur et un agent : interruption, détection de tour, faible latence.
- Pipeline : VAD (F-09) -> STT streaming (F-05) -> agent/LLM -> TTS streaming (F-07), ou modèles parole à parole natifs via des adaptateurs temps réel.
Position et interactions
- Chemin d'exécution : session temps réel duplex.
- POST /v1/realtime/sessions délivre un jeton LiveKit de courte durée limité à une salle et prend une réservation de quota pour la durée max.
- Les événements usage.update diffusent la consommation pendant l'appel ; POST /v1/realtime/sessions/{id}/end confirme la réservation.
Part open source et part développée
- Open source réutilisé : Serveur LiveKit, LiveKit agents, WebRTC.
- Développé dans la plateforme : Service de sessions, émission de jetons, protocole d'événements, contrôle budgétaire en cours d'appel, politique de conservation des transcriptions.
Modèle de données
Tables du Control Plane (schéma eai) : eai.realtime_sessions, eai.usage_events, eai.quota_reservations, eai.telemetry_spans.
Chaque table de tenant a tenant_id uuid NOT NULL et une politique de sécurité au niveau des lignes sur current_setting('app.tenant_id', true)::uuid. ClickHouse usage_events est la réplique analytique de eai.usage_events (ReplacingMergeTree par event_id).
Contrat : contracts/v1/multimodal-contract.schema.json (schema_version 1.0). L'usage est toujours remonté avec contracts/v1/usage-event.schema.json, l'audit avec contracts/v1/audit-event.schema.json.
Endpoints d'API
| Méthode | Chemin |
|---|---|
| POST | /v1/realtime/sessions |
| WebSocket | /v1/realtime/ws/{session_id} |
| POST | /v1/realtime/sessions/{id}/end |
POST /v1/realtime/sessions
Requête :
{
"room": "support-voice-42",
"pipeline": "stt",
"ttl_seconds": 900,
"config": {
"language": "fr",
"sample_rate": 16000,
"dlp_mode": "mask"
}
}
Réponse :
{
"session_id": "rts_3K",
"livekit_url": "wss://<livekit-host>",
"token": "eyJhbGciOi...",
"ws_url": "wss://<control-plane-host>/v1/realtime/ws/rts_3K",
"expires_at": "2026-10-29T10:15:00Z"
}
WebSocket /v1/realtime/ws/{session_id}
Requête :
{
"type": "input_audio.append",
"audio": "<base64 pcm16 20ms>"
}
Réponse :
{
"type": "transcript.delta",
"text": "Bonjour",
"final": false
}
POST /v1/realtime/sessions/{id}/end
Pas de corps de requête.
Réponse :
{
"session_id": "rts_3K",
"status": "ended",
"usage": {
"audio_seconds": 312.4,
"input_tokens": 2210,
"output_tokens": 1650,
"audio_tokens": 7800,
"sessions": 1
}
}
Exemples SDK
Python
from enterprise_ai import EnterpriseAI
client = EnterpriseAI(base_url="<control-plane-url>", api_key="eai_live_xxx", tenant="acme", locale="fr")
session = client.realtime.create_session("stt", config={"language": "fr"})
client.realtime.end_session(session["session_id"])
TypeScript
import { EnterpriseAI } from "@enterprise-ai/sdk";
const client = new EnterpriseAI({ baseUrl: "<control-plane-url>", apiKey: process.env.EAI_API_KEY, tenant: "acme", locale: "fr" });
const session = await client.realtime.createSession({ pipeline: "stt", config: { language: "fr" } });
await client.realtime.endSession(session.session_id as string);
Java
EnterpriseAIClient client = EnterpriseAIClient.builder()
.baseUrl("<control-plane-url>")
.apiKey(System.getenv("EAI_API_KEY"))
.tenant("acme")
.locale(Locale.FRENCH)
.build();
JsonNode session = client.realtime().createSession(Map.of("pipeline", "stt", "config", Map.of("language", "fr")));
client.realtime().endSession(session.get("session_id").asText());
Go
client, err := eai.NewClient(eai.WithBaseURL("<control-plane-url>"), eai.WithAPIKey(os.Getenv("EAI_API_KEY")), eai.WithTenant("acme"), eai.WithLocale("fr"))
if err != nil { log.Fatal(err) }
ctx := context.Background()
session, err := client.Realtime.CreateSession(ctx, map[string]any{"pipeline": "stt", "config": map[string]any{"language": "fr"}})
var s struct{ SessionID string `json:"session_id"` }
_ = json.Unmarshal(session, &s)
_, err = client.Realtime.EndSession(ctx, s.SessionID)
Client temps réel TypeScript (@enterprise-ai/realtime)
import { EnterpriseAI } from "@enterprise-ai/sdk";
import { RealtimeClient } from "@enterprise-ai/realtime";
const client = new EnterpriseAI({ baseUrl: "<control-plane-url>", apiKey: process.env.EAI_API_KEY, tenant: "acme", locale: "fr" });
const rtClient = new RealtimeClient({ baseUrl: "<control-plane-url>", apiKey: process.env.EAI_API_KEY, tenant: "acme", locale: "fr" });
const s = await rtClient.createSession({ pipeline: "stt" });
const rt = await rtClient.connect(s, { language: "fr", turn_detection: { type: "server_vad" } });
rt.on("transcript.delta", (e) => render(e.delta));
rt.on("usage.update", (e) => showBudget(e.usage));
rt.on("error", (e) => console.error(e.error.code, e.error.message));
rt.appendAudio(pcm20ms, 16000);
rt.close();
await rtClient.endSession(s.session_id);
Package Go realtime
raw, err := client.Realtime.CreateSession(ctx, map[string]any{"pipeline": "stt"})
if err != nil { return err }
var s struct{ SessionID string `json:"session_id"` }
if err := json.Unmarshal(raw, &s); err != nil { return err }
conn, err := realtime.Dial(ctx, client, s.SessionID)
if err != nil { return err }
defer conn.Close()
_ = conn.UpdateSession(map[string]any{"language": "fr"})
_ = conn.AppendAudio(pcm20ms)
for {
ev, err := conn.Recv()
if err != nil { return err }
if ev.Type == realtime.TranscriptDelta {
var d struct{ Delta string `json:"delta"` }
_ = ev.Decode(&d)
log.Println(d.Delta)
}
}
Configuration
Variables d'environnement : EAI_LIVEKIT_URL, EAI_LIVEKIT_API_KEY.
Clés de valeurs Helm (deploy/helm/enterprise-ai/values.yaml) : controlPlane.config.EAI_LIVEKIT_URL, controlPlane.secrets, global.networkPolicy.
Sécurité
- Les jetons expirent vite et n'autorisent que publish/subscribe dans la salle de la session ; SRTP/DTLS pour les médias.
- L'enregistrement est désactivé par défaut ; une demande de consentement est exigée par politique si activé.
- Tenant déduit de l'identifiant d'accès, jamais du corps ; sécurité au niveau des lignes PostgreSQL sur chaque table de tenant.
- Chaque mutation et décision de politique écrit un événement d'audit chaîné par hachage (G-11).
- Les erreurs utilisent l'enveloppe standard avec message_i18n en et fr.
Observabilité
- Latence bouche à oreille, gigue, perte de paquets et nombre d'interruptions par session.
- traceparent W3C propagé, X-Request-Id renvoyé, les spans portent eai.tenant_id, eai.project, eai.agent_id, eai.run_id.
Unités FinOps
- usage_type voice_live : audio_seconds, audio_tokens, tokens texte entrée/sortie, sessions=1, duration_ms.
Modes de défaillance et résilience
- Quota atteint pendant l'appel -> événement d'avertissement puis fin propre avec un message vocal.
- WebRTC bloqué par le réseau -> TURN sur TLS 443, puis repli WebSocket.
Adoption autonome
Un client de client intègre un agent vocal avec @enterprise-ai/realtime dans son application web en n'utilisant que les briques temps réel et FinOps.
Tests d'acceptation
- p95 entre la fin de parole de l'utilisateur et le premier audio de l'agent sous 800 ms sur le réseau de référence.
- Une interruption arrête la lecture TTS en moins de 200 ms.
- La fin d'une session confirme exactement un Usage Event avec sessions=1.
Sécurité du WebSocket
ws_tokenest un jeton court (EAI_REALTIME_WS_TOKEN_TTL_SECONDS, 300 s par défaut), à usage unique et lié au tenant, à la session et à l'identité. Sa signature et son expiration sont vérifiées avant toute lecture en base, puis la session est lue avec le principal, sous RLS. Jeton invalide, expiré, rejoué ou d'un autre tenant : fermeture 4401.POST /v1/realtime/sessions/{id}/tokendélivre un nouveau jeton pour une reconnexion (409session_endedsi la session est terminée). Le SDK Realtime TypeScript l'appelle avant chaque reconnexion automatique.- Limites : taille par message
EAI_REALTIME_WS_MAX_MESSAGE_BYTES(1 Mio, égalementws_max_sized'uvicorn, fermeture 1009) ; débitEAI_REALTIME_WS_MAX_EVENTS_PER_SECOND(50) avec rafaleEAI_REALTIME_WS_BURST(100), fermeture 4429 ; duréeEAI_REALTIME_WS_MAX_SESSION_SECONDS(3600) et inactivitéEAI_REALTIME_WS_IDLE_TIMEOUT_SECONDS(120), fermeture 4408. - La connexion réserve le coût de la durée maximale sur les quotas (4429
quota_exceededsinon) ;session.endconfirme le montant mesuré, une déconnexion libère la réservation.
Feuille de route d'implémentation
Preuves vocales du kit L4
Consentement par participant, identité (participant, utilisateur, agent, trace) dans chaque tour, interruption, vrai serveur LiveKit 1.13.11 : jeton validé, salle rejointe par la signalisation, ListParticipants (MULT-001, MULT-002 côté serveur, MULT-008, MULT-012, RELE-011). Média WebRTC non testé. Voir le guide du runtime vocal.