Schéma (MVP sans Daily)
- Navigateur (mic) ⇄ WebSocket ⇄ Bridge Node
- Bridge Node → OpenAI ASR (Whisper / Realtime) → OpenAI LLM (tools) → OpenAI TTS
- Bridge Node ⇄ WebSocket ⇄ Navigateur (lecteur audio)
Exemple minimal (sans Daily)
1) Client React (capture micro, envoi des chunks, lecture TTS)
// VoiceAgent.tsx (React) import React, { useEffect, useRef, useState } from "react"; export default function VoiceAgent() { const wsRef = useRef<WebSocket | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null); const [status, setStatus] = useState<"idle"|"listening"|"thinking">("idle"); useEffect(() => { const ws = new WebSocket("wss://votre-bridge.exemple.com/ws"); ws.binaryType = "arraybuffer"; ws.onopen = () => setStatus("idle"); ws.onmessage = async (evt) => { // Le serveur renvoie des paquets audio (wav/pcm) à jouer const blob = new Blob([evt.data], { type: "audio/wav" }); const url = URL.createObjectURL(blob); const audio = new Audio(url); await audio.play(); }; ws.onerror = () => setStatus("idle"); ws.onclose = () => setStatus("idle"); wsRef.current = ws; return () => ws.close(); }, []); const start = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // MediaRecorder en PCM/16k mono (Chrome: "audio/webm;codecs=pcm" n’est pas standard — on reste en webm/opus et on convertit côté serveur) const mr = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 24000 }); mr.ondataavailable = (e) => { if (e.data.size && wsRef.current?.readyState === WebSocket.OPEN) { e.data.arrayBuffer().then(buf => wsRef.current!.send(buf)); } }; mr.start(250); // chunk toutes 250 ms mediaRecorderRef.current = mr; setStatus("listening"); }; const stop = () => { mediaRecorderRef.current?.stop(); mediaRecorderRef.current = null; setStatus("thinking"); // Optionnel: envoyer un marker de fin d’énoncé wsRef.current?.send(JSON.stringify({ type: "end_of_utterance" })); }; return ( <div className="p-4 space-x-2"> <button onClick={start} disabled={status==="listening"}>🎙️ Parler</button> <button onClick={stop} disabled={status!=="listening"}>⏹️ Stop</button> <span>State: {status}</span> </div> ); }
2) Bridge Node (WebSocket, ASR→LLM→TTS avec OpenAI)
Ce bridge reçoit des chunks Opus (webm), les décode en PCM, pousse en ASR, appelle le LLM (avec tools si besoin), puis génère du TTS et renvoie des blocs WAV au navigateur.
// server.ts (Node 18+, TypeScript conseillé) import http from "http"; import { WebSocketServer } from "ws"; import express from "express"; import { tmpdir } from "os"; import { writeFileSync, unlinkSync } from "fs"; import { randomUUID } from "crypto"; import { spawn } from "child_process"; // pour décoder opus -> wav via ffmpeg import OpenAI from "openai"; const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server, path: "/ws" }); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! }); type SessionState = { opusBuffers: Buffer[]; ended: boolean; }; wss.on("connection", (ws) => { const state: SessionState = { opusBuffers: [], ended: false }; ws.on("message", async (data) => { // Marker de fin d’énoncé if (typeof data === "string") { try { const msg = JSON.parse(data); if (msg.type === "end_of_utterance") { state.ended = true; // Traiter l’énoncé maintenant await handleUtterance(ws, state); // reset pour écoute suivante state.opusBuffers = []; state.ended = false; } } catch {} return; } // Binaire = chunk opus/webm state.opusBuffers.push(Buffer.from(data as ArrayBuffer)); }); ws.on("close", () => {}); }); async function handleUtterance(ws: any, state: SessionState) { if (!state.opusBuffers.length) return; // 1) Sauver l’opus temporaire const opusFile = `${tmpdir()}/${randomUUID()}.webm`; writeFileSync(opusFile, Buffer.concat(state.opusBuffers)); // 2) Convertir en wav PCM mono 16k via ffmpeg const wavFile = `${tmpdir()}/${randomUUID()}.wav`; await ffmpegConvert(opusFile, wavFile); // 3) ASR (Whisper) const transcript = await transcribeWav(wavFile); // 4) LLM (avec tools si vous devez “appeler des services extérieurs”) const answer = await callLLM(transcript.text); // 5) TTS → wav (OpenAI TTS) const wavBuffer = await synthesizeTTS(answer); // 6) Renvoi au client (il jouera le blob audio) ws.send(wavBuffer); // Cleanup try { unlinkSync(opusFile); unlinkSync(wavFile); } catch {} } function ffmpegConvert(inFile: string, outFile: string) { return new Promise<void>((resolve, reject) => { const ff = spawn("ffmpeg", ["-y", "-i", inFile, "-ac", "1", "-ar", "16000", "-f", "wav", outFile]); ff.on("error", reject); ff.on("close", (code) => code === 0 ? resolve() : reject(new Error("ffmpeg failed"))); }); } async function transcribeWav(wavPath: string) { // Whisper file transcription (non-streaming pour MVP) const resp = await openai.audio.transcriptions.create({ file: (await import("fs")).createReadStream(wavPath), model: "whisper-1", // remplacez par le modèle ASR actuel de votre compte // language: "fr" }); return resp; // { text: "..." } } async function callLLM(userText: string): Promise<string> { const sys = "Vous êtes un conseiller personnel utile, concis, en français."; const chat = await openai.chat.completions.create({ model: "gpt-4o-mini", // ou autre modèle dispo messages: [ { role: "system", content: sys }, { role: "user", content: userText } ], temperature: 0.2, }); return chat.choices[0]?.message?.content ?? "Je n’ai pas compris."; } async function synthesizeTTS(text: string): Promise<Buffer> { // TTS (voix FR) — adaptez au moteur TTS disponible sur votre compte const speech = await openai.audio.speech.create({ model: "gpt-4o-mini-tts", // exemple : remplacez par le modèle TTS actif voice: "alloy", // ou voix FR si disponible format: "wav", input: text, }); // L’SDK renvoie ArrayBuffer/stream selon version — normalisez en Buffer: const arrayBuffer = await speech.arrayBuffer(); return Buffer.from(arrayBuffer); } const PORT = process.env.PORT || 8080; server.listen(PORT, () => console.log(`Bridge listening on :${PORT}`));
Notes importantes
- Latence : chunks 250 ms côté client, ffmpeg pour convertir Opus→PCM 16 kHz, ASR non-streaming (MVP). Pour du quasi temps réel, passez à l’ASR streaming (OpenAI Realtime) et au TTS streaming (envoyez de petits bouts d’audio au fur et à mesure).
- Barge-in : pendant que le TTS joue, continuez à écouter le micro et coupez la synthèse si l’utilisateur reparle.
- Qualité audio : micro mono 16 kHz suffit pour l’ASR. Si vous ciblez une meilleure prosodie TTS, gardez 24 kHz côté synthèse.
Variante « WebRTC-first OpenAI Realtime » (ultra-low latency)
@Antonio, Intéressant aussi à évaluer car il faut pas oublier que openAI vient de récolter 40 000 M$ soit 26 fois la taille du CA de AXIAN pour juste cette année pour leurs R&D et leurs Infra
Si votre compte OpenAI Realtime est activé :
- Le navigateur établit une session WebRTC directement avec l’endpoint Realtime d’OpenAI (captation micro + réception audio TTS en retour).