exemple d’architecture “MVP” en Node.js (endpoints webhook + intégration STT/LLM/TTS)
Super — voici un MVP Node.js (Express) prêt à copier-coller pour un IVR conversationnel avec :
- Entrée Twilio Voice → Webhook
- Tour-par-tour : on enregistre chaque tour de parole, on fait STT (Whisper) → LLM (intent + JSON) → TTS
- Réponse audio rejouée au téléphone
- Code clair, minimal, adaptable à Odoo/CRM via une fonction “callBusinessAPI()”.
1) Vue d’ensemble (MVP)
Flux : PSTN (appelant) → Numéro Twilio FR → /voice (TwiML) → <Record> → /stt → Whisper → LLM → TTS → <Play> → boucle
Pourquoi ce choix ?
- Évite la complexité du streaming temps réel pour commencer.
- Fonctionne partout, simple à déployer.
- Facile à “upgrader” vers Twilio Media Streams plus tard (barge-in, latence plus faible).
2) Variables d’environnement (exemple)
# .env PORT=3000 PUBLIC_BASE_URL=https://votre-domaine-public.tld OPENAI_API_KEY=sk-... # Choisissez un TTS – ci-dessous ElevenLabs par simplicité ELEVENLABS_API_KEY=eleven_... ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM # exemple "Rachel"
Remplacez par vos vraies clés. Vous pouvez aussi brancher Azure TTS si vous préférez.
3) package.json (minimal)
{ "name": "mvp-ivr-conversational", "version": "1.0.0", "type": "module", "dependencies": { "dotenv": "^16.4.5", "express": "^4.19.2", "twilio": "^5.3.3", "node-fetch": "^3.3.2", "multer": "^1.4.5-lts.1" } }
Installez : npm i
4) server.js (Webhook + STT + LLM + TTS)
Fichier unique pour démarrer rapidement. Servez un /static pour jouer les MP3 générés.
// server.js import 'dotenv/config'; import express from 'express'; import fetch from 'node-fetch'; import { twiml as Twiml } from 'twilio'; const app = express(); app.use(express.urlencoded({ extended: true })); app.use('/static', express.static('static')); const { PORT = 3000, PUBLIC_BASE_URL, OPENAI_API_KEY, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID } = process.env; // --- Helpers --- async function transcribeWithWhisper(audioUrl) { // Twilio envoie RecordingUrl (WAV/MP3). On le télécharge puis on envoie à Whisper. const audioRes = await fetch(`${audioUrl}.mp3`); // Twilio autorise .mp3 const audioBuf = Buffer.from(await audioRes.arrayBuffer()); const form = new FormData(); form.append('file', new Blob([audioBuf], { type: 'audio/mpeg' }), 'input.mp3'); form.append('model', 'whisper-1'); // ou "gpt-4o-transcribe" si dispo dans votre compte form.append('language', 'fr'); const resp = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { Authorization: `Bearer ${OPENAI_API_KEY}` }, body: form }); if (!resp.ok) throw new Error(await resp.text()); const data = await resp.json(); return data.text; // transcription } async function runLLM(userText, current = {}) { // Objectif : intention + extraction JSON const system = ` Vous êtes un agent IVR. Vous détectez l'intention et extrayez les variables utiles. Répondez AVEC UN JSON STRICT : {"intent":"<string>","fields":{"...": "..."},"answer":"<réponse courte, en français>"} Si une info manque, posez UNE question courte dans "answer". `; const messages = [ { role: 'system', content: system }, { role: 'user', content: `Texte: "${userText}"\nContexte courant: ${JSON.stringify(current)}` } ]; const resp = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${OPENAI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-4o-mini', // rapide/économe ; utilisez un modèle de votre choix messages, temperature: 0.2 }) }); if (!resp.ok) throw new Error(await resp.text()); const data = await resp.json(); const content = data.choices[0].message.content || '{}'; try { return JSON.parse(content); } catch { // garde-fou : si le modèle renvoie du texte, tente d'extraire un JSON const match = content.match(/\{[\s\S]*\}$/); return match ? JSON.parse(match[0]) : { intent: 'unknown', fields: {}, answer: "Je n'ai pas compris, pouvez-vous reformuler ?" }; } } async function callBusinessAPI(intent, fields) { // Branchez ici vos appels à Odoo/CRM/ERP (ex: suivi colis, solde client, prise RDV…) // Retournez un objet {answer, nextState} qui servira à la réponse vocale // Pour le MVP : juste reformuler. return { answer: null, // si LLM a déjà une "answer", on la garde ; sinon vous mettez la vôtre. nextState: { intent, fields } }; } async function ttsElevenLabs(text) { const url = `https://api.elevenlabs.io/v1/text-to-speech/${ELEVENLABS_VOICE_ID}`; const resp = await fetch(url, { method: 'POST', headers: { 'xi-api-key': ELEVENLABS_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ text, model_id: 'eleven_multilingual_v2', // FR OK voice_settings: { stability: 0.4, similarity_boost: 0.7 } }) }); if (!resp.ok) throw new Error(await resp.text()); const arrayBuf = await resp.arrayBuffer(); const fname = `static/reply_${Date.now()}.mp3`; await Bun.write(fname, new Uint8Array(arrayBuf)); // ⚠️ si vous n'utilisez pas Bun, remplacez par fs.writeFile return `${PUBLIC_BASE_URL}/${fname}`; } // ---- Twilio Webhooks ---- // Point d'entrée : accueil + enregistrement d'un tour de parole app.post('/voice', async (req, res) => { const twiml = new Twiml.VoiceResponse(); // Message d'accueil twiml.say({ language: 'fr-FR', voice: 'alice' }, "Bonjour, expliquez-moi votre demande après le bip. Puis faites une pause."); twiml.record({ playBeep: true, timeout: 2, // pause de 2s => fin d'énoncé maxLength: 8, // tours courts pour MVP action: '/stt', // callback pour traiter l'audio recordingStatusCallback: '/noop' // optionnel }); // Si rien n'est dit twiml.say({ language: 'fr-FR', voice: 'alice' }, "Je n'ai rien entendu. Au revoir."); twiml.hangup(); res.type('text/xml').send(twiml.toString()); }); // Traitement STT -> LLM -> (API) -> TTS -> réponse app.post('/stt', async (req, res) => { const { RecordingUrl } = req.body; // Twilio envoie l'URL sans extension const twiml = new Twiml.VoiceResponse(); try { const text = await transcribeWithWhisper(RecordingUrl); const llm = await runLLM(text); // {intent, fields, answer} const biz = await callBusinessAPI(llm.intent, llm.fields); const finalAnswer = biz.answer || llm.answer || "Très bien. Que puis-je faire d'autre pour vous ?"; // Générer audio via TTS (vous pouvez aussi laisser twiml.say pour MVP ultra-simple) // Pour aller vite, utilisez twiml.say d’abord : // twiml.say({ language: 'fr-FR', voice: 'alice' }, finalAnswer); // Version TTS MP3 hébergé const audioUrl = await ttsElevenLabs(finalAnswer); twiml.play(audioUrl); // Boucle : on ré-enchaîne un nouveau tour twiml.pause({ length: 1 }); twiml.say({ language: 'fr-FR', voice: 'alice' }, "Vous pouvez ajouter des précisions, je vous écoute."); twiml.record({ playBeep: true, timeout: 2, maxLength: 8, action: '/stt' }); res.type('text/xml').send(twiml.toString()); } catch (e) { console.error(e); twiml.say({ language: 'fr-FR', voice: 'alice' }, "Désolé, une erreur est survenue."); twiml.hangup(); res.type('text/xml').send(twiml.toString()); } }); // optionnel : endpoint vide pour le callback d'enregistrement app.post('/noop', (req, res) => res.sendStatus(204)); // Démarrage app.listen(PORT, () => { console.log(`IVR MVP up on : http://localhost:${PORT}`); });
Note 1 : ci-dessus j’utilise Bun.write pour aller vite. Si vous êtes sur Node “pur”, remplacez par import fs from 'fs/promises'; await fs.writeFile(fname, Buffer.from(arrayBuf)); et retirez Bun du code.
Note 2 : pour un MVP ultra-rapide, vous pouvez remplacer tout le TTS par twiml.say({ language:'fr-FR', voice:'alice' }, finalAnswer) (pas de MP3 à héberger).
5) Raccordement côté Twilio
- Achetez un numéro FR (local +33 1/… ou national +33 9).
- Dans la console Twilio → Phone Numbers → Voice & Fax → A CALL COMES IN = Webhook vers https://votre-domaine/voice (POST).
- Testez avec votre téléphone : vous entendez l’accueil, parlez, l’audio est transcrit → LLM → (API) → TTS → réponse jouée → nouveau tour.
6) Mise à niveau (quand vous serez prêts)
- Temps réel : utilisez Twilio Media Streams (WebSocket) et le Realtime API d’OpenAI pour STT+LLM+TTS en live (barge-in, latence ≈ 300–700 ms).
- Contexte & état : stockez l’intent/fields par CallSid (Redis/Postgres) pour un vrai dialogue multi-tours.
- Sécurité & RGPD : validez les webhooks Twilio, chiffrez, masquez PII, durées de rétention courtes.
- Odoo/CRM : implémentez callBusinessAPI() (ex. retrouver un client par numéro, créer ticket, etc.).
la variante Media Streams (temps réel) avec schéma + squelette de WebSocket serveur
Parfait — voici la variante temps réel (Twilio Media Streams) avec schéma + squelette de serveur WebSocket qui relaye l’audio PSTN ↔ Twilio ↔ votre serveur ↔ API Realtime (STT+LLM+TTS), et renvoie la synthèse en direct à l’appelant (barge-in possible).
1) Vue d’ensemble (temps réel)
flowchart LR A[📞 Appelant FR (PSTN +33)] --> B[(📟 Numéro FR Twilio)] B --> C{Twilio Voice} C -->|<Connect><Stream> (WebSocket)| D[🌐 WS Serveur Media] D -->|audio uplink| E[🧠 API Realtime (STT+NLP+TTS)] E -->|audio downlink (PCM)| D -->|media frames| C --> A D -->|🔗 APIs| F[(CRM/ERP/Odoo)]
Idée clé : on évite l’enregistrement tour-par-tour. On stream l’audio dès que l’utilisateur parle, on transcrit et comprend en continu, on synthétise la réponse et on la renvoie immédiatement dans le même flux au correspondant.
2) TwiML (activer Media Streams bidirectionnel)
Exposez un webhook /voice qui connecte l’appel au WS (wss) de votre serveur :
<?xml version="1.0" encoding="UTF-8"?> <Response> <Connect> <Stream url="wss://votre-domaine.tld/media" track="both_tracks" name="ivr-realtime-fr" statusCallback="https://votre-domaine.tld/stream-status" statusCallbackMethod="POST" /> </Connect> </Response>
- track="both_tracks" : audio entrant (caller → vous) et sortant (vous → caller).
- Twilio enverra des events JSON (start, media, mark, stop…) sur votre WS.
3) Squelette serveur WebSocket (Node.js)
Objectif :
- Recevoir l’audio PCMU 8kHz base64 depuis Twilio
- Le décoder / repacker en PCM (selon l’API Realtime)
- L’envoyer à votre API Realtime (STT+LLM+TTS)
- Récupérer l’audio TTS (PCM) et le ré-encoder en µ-law 8kHz
- Renvoyer vers Twilio via frames "media".
npm i ws express
js
Copier le code
// server.js (Node >= 18)
import express from 'express';
import { WebSocketServer } from 'ws';
import crypto from 'crypto';
// --- Helpers µ-law <-> PCM (simplifiés) ---
function pcm16ToMulawSample(sample) {
// sample: 16-bit signed PCM
// µ-law constants
const BIAS = 0x84, CLIP = 32635;
let sign = (sample >> 8) & 0x80;
if (sign) sample = -sample;
if (sample > CLIP) sample = CLIP;
sample = sample + BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--, expMask >>= 1) { /* scan */ }
let mantissa = (sample >> ((exponent === 0) ? 4 : (exponent + 3))) & 0x0f;
let ulaw = ~(sign | (exponent << 4) | mantissa);
return ulaw & 0xff;
}
function pcm16ToMulawBuffer(pcm16) {
const out = Buffer.alloc(pcm16.length / 2);
for (let i = 0, j = 0; i < pcm16.length; i += 2, j++) {
const s = pcm16.readInt16LE(i);
out[j] = pcm16ToMulawSample(s);
}
return out;
}
// (inverse mulaw->pcm16 si besoin)
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Webhook Twilio pour répondre le TwiML ci-dessus
app.post('/voice', (req, res) => {
res.type('text/xml').send(`
<Response>
<Connect>
<Stream url="wss://${req.headers.host}/media" track="both_tracks" name="ivr-realtime-fr"
statusCallback="https://${req.headers.host}/stream-status"/>
</Connect>
</Response>`);
});
app.post('/stream-status', (req, res) => {
console.log('Stream status:', req.body || req.query);
res.sendStatus(204);
});
// --- WebSocket Media Streams ---
const wss = new WebSocketServer({ noServer: true });
/**
* Idée : pour chaque appel Twilio (1 stream), on ouvre (dans la pratique) une session
* vers votre API Realtime (OpenAI Realtime, etc.), ici abstraite par un "RealtimeSession".
* Cette session reçoit l'audio entrant, renvoie de l'audio TTS en flux.
*/
class RealtimeSession {
constructor() {
this.id = crypto.randomUUID();
// TODO: ouvrir un WS vers votre API Realtime, config STT/TTS (fr-FR, 8k ou 16k), etc.
// this.rtc = new WebSocket('wss://api.realtime.provider/...',{headers:{Authorization:`Bearer ...`}});
// Brancher onmessage pour recevoir l'audio synthétisé en PCM
// et appeler this.onTTSFrame(pcm16Chunk)
}
onUserAudioMulaw(mulawB64) {
// 1) décoder base64
const mulaw = Buffer.from(mulawB64, 'base64');
// 2) si l’API Realtime attend du PCM16 16kHz, convertir:
// - µlaw 8k -> PCM16 8k -> (option) resample 8k -> 16k
// NOTE: pour MVP, envoyez tel quel si votre Realtime accepte µ-law 8k
// this.rtc.send(binaryFramePCM16orMulaw)
}
onTTSFrame(pcm16Chunk) {
// Reçu depuis l’API Realtime (PCM16 8k)
if (this.wsTwilio && this.wsTwilio.readyState === 1) {
const mulaw = pcm16ToMulawBuffer(pcm16Chunk); // PCM16 -> µ-law 8k
this.wsTwilio.send(JSON.stringify({
event: 'media',
streamSid: this.streamSid,
media: { payload: mulaw.toString('base64') }
}));
}
}
destroy() {
// Fermer la session Realtime si ouverte
// if (this.rtc) this.rtc.close();
}
}
wss.on('connection', (ws) => {
const session = new RealtimeSession();
session.wsTwilio = ws;
ws.on('message', (msg) => {
let data;
try { data = JSON.parse(msg); } catch { return; }
switch (data.event) {
case 'start':
session.streamSid = data.start.streamSid;
console.log('Stream start:', session.streamSid);
// Vous pouvez envoyer un prompt TTS initial (ex : “Bonjour, je vous écoute”)
break;
case 'media':
// Audio entrant de l’appelant en base64 (PCMU 8kHz)
session.onUserAudioMulaw(data.media.payload);
break;
case 'mark':
// marqueurs optionnels
break;
case 'stop':
console.log('Stream stop:', session.streamSid);
session.destroy();
ws.close(1000);
break;
default:
// autres events (clear, etc.)
break;
}
});
ws.on('close', () => session.destroy());
});
// Upgrade HTTP -> WS
const server = app.listen(3000, () => console.log('HTTP on :3000'));
server.on('upgrade', (req, socket, head) => {
if (req.url === '/media') {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} else {
socket.destroy();
}
})
✅ Ce squelette gère : connexion Twilio, réception des trames "media", et renvoi d’audio synthèse via "media" vers l’appelant (bidirectionnel).
🔧 À vous de brancher l’API Realtime (WebSocket) dans RealtimeSession :
- Envoyer l’uplink audio (mulaw→PCM16, resampling si besoin)
- Recevoir le downlink TTS en PCM (8 kHz de préférence)
- Appeler onTTSFrame(pcm16Chunk) pour diffuser vers Twilio.
4) Points clés d’implémentation
-
Codec Twilio Media Streams : PCMU (µ-law) 8 kHz mono.
- Si votre Realtime requiert PCM16 16 kHz, prévoyez un resampler (par ex. sox, node-web-audio-api, ou libs DSP).
- Barge-in : ne “bufferisez” pas trop, poussez l’audio dès réception pour baisser la latence.
- Session par appel : utilisez streamSid comme clé ; stockez l’état de dialogue (intent, slots) côté serveur.
- RGPD : chiffrez en transit, évitez de logger le contenu audio/texte en clair, définissez des rétentions courtes.
5) Orchestration dialogue (conseil rapide)
-
Configurez votre Realtime pour retourner :
- transcription partielle (interim) + finale,
- intent + entités (JSON),
- réponse TTS en flux PCM.
- Si une donnée manque (ex. numéro client), la réponse TTS doit poser une question et le tour suivant captera l’info.
6) Test pas à pas
- Achetez un numéro FR sur Twilio → Voice → A Call Comes In = /voice.
- Lancez votre serveur (port 3000) en HTTPS (reverse-proxy conseillé).
- Appelez le numéro → Twilio ouvre wss://…/media → parlez → vérifiez que vos frames "media" remontent → injectez une trame TTS de test (silence ou "Bonjour").
- Branchez la Realtime API → observez la boucle complète (parole → texte → réponse audio).
Si vous voulez, je peux vous fournir une implémentation complète de RealtimeSession avec :
- ouverture d’un WebSocket Realtime (prompt système + français),
- gestion uplink audio (µ-law → PCM → envoi),
- réception TTS (PCM) et renvoi vers Twilio,
- et un exemple d’extraction JSON (intent/slots) prêt à pousser vers Odoo.
🙌 On finalise la partie RealtimeSession pour brancher vraiment votre IVR conversationnel temps réel. Voici un exemple complet et commenté où on connecte Twilio Media Streams ↔ OpenAI Realtime API ↔ retour TTS.
1) Session temps réel : WebSocket OpenAI
OpenAI Realtime API accepte PCM16 16 kHz mono.
Twilio envoie µ-law 8 kHz → il faut décoder + resampler.
Ensuite on envoie les chunks à l’API Realtime, qui retourne du PCM (TTS), qu’on ré-encode en µ-law 8k et qu’on renvoie à Twilio.
2) Exemple RealtimeSession
import WebSocket from "ws"; import { spawn } from "child_process"; import fs from "fs"; // Resampler via sox (pratique pour 8k->16k) function resampleMulawToPcm16(mulawBuf, cb) { const sox = spawn("sox", [ "-t", "ul", "-", "-r", "16000", "-c", "1", "-b", "16", "-e", "signed", "-t", "raw", "-" ]); let chunks = []; sox.stdout.on("data", (c) => chunks.push(c)); sox.on("close", () => cb(Buffer.concat(chunks))); sox.stdin.end(mulawBuf); } export class RealtimeSession { constructor(wsTwilio) { this.wsTwilio = wsTwilio; this.streamSid = null; // --- ouvrir WS vers OpenAI Realtime --- this.rtc = new WebSocket("wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12", { headers: { "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`, "OpenAI-Beta": "realtime=v1" } } ); this.rtc.on("open", () => { console.log("Realtime API connected"); // Prompt initial (system message) this.rtc.send(JSON.stringify({ type: "response.create", response: { modalities: ["text","audio"], instructions: "Vous êtes un agent IVR en français. Répondez poliment et extrayez les infos nécessaires." } })); }); this.rtc.on("message", (msg) => { const data = JSON.parse(msg.toString()); if (data.type === "response.output_audio.delta") { // data.audio : base64 PCM16 16k const pcm16 = Buffer.from(data.audio, "base64"); this.sendTTSFrame(pcm16); } }); } onStart(streamSid) { this.streamSid = streamSid; } onUserAudioMulaw(b64) { const mulawBuf = Buffer.from(b64, "base64"); // Convert µ-law 8k -> PCM16 16k pour OpenAI resampleMulawToPcm16(mulawBuf, (pcm16) => { this.rtc.send(JSON.stringify({ type: "input_audio_buffer.append", audio: pcm16.toString("base64") })); this.rtc.send(JSON.stringify({ type: "input_audio_buffer.commit" })); this.rtc.send(JSON.stringify({ type: "response.create", response: { modalities: ["text","audio"] } })); }); } sendTTSFrame(pcm16) { if (!this.streamSid || this.wsTwilio.readyState !== 1) return; // Convertir PCM16 16k -> µ-law 8k (ici simplifié : on suppose resample déjà fait) // Pour MVP, vous pouvez utiliser sox inversement, ou lib DSP. // Ici on stocke simplement pour test : fs.writeFileSync("out.pcm", pcm16); // TODO: convertir vers µ-law 8k // const mulaw = pcm16ToMulawBuffer(pcm16); // this.wsTwilio.send(JSON.stringify({ // event: "media", // streamSid: this.streamSid, // media: { payload: mulaw.toString("base64") } // })); } destroy() { if (this.rtc) this.rtc.close(); } }
3) Intégration dans le WS Twilio
Dans votre handler WS (cf. squelette précédent) :
wss.on("connection", (ws) => { const session = new RealtimeSession(ws); ws.on("message", (msg) => { const data = JSON.parse(msg); switch (data.event) { case "start": session.onStart(data.start.streamSid); break; case "media": session.onUserAudioMulaw(data.media.payload); break; case "stop": session.destroy(); ws.close(); break; } }); });
4) Points pratiques
-
Codec conversion :
- Entrée : µ-law 8k → PCM16 16k (avec sox).
- Sortie : PCM16 16k → µ-law 8k (à implémenter pour sendTTSFrame).
- Latence : streaming ~300–700 ms possible.
- État dialogue : stockez dans la session (intent, slots) pour du multi-tours.
- RGPD : cryptez, purgez logs, anonymisez si données sensibles.
👉 Voulez-vous que je vous écrive aussi la fonction inverse PCM16→µ-law 8k (pour compléter sendTTSFrame et que la voix générée par OpenAI soit réellement réinjectée vers l’appelant) ?