commande vocale pour des soins aux personnes âgées (ou pour les infirmières pressées)
Absolument, c'est un projet tout à fait réalisable et très pertinent pour ce cas d'usage. L'ajout d'une commande vocale pour des soins aux personnes âgées (ou pour les infirmières pressées) est une excellente idée d'accessibilité et d'efficacité.
Voici comment nous allons procéder :
- Analyse de l'architecture technique : Je vais confirmer la faisabilité de votre stack (Odoo + JSON-RPC + OpenAI/N8N).
- Refonte de l'interface (Code) : Je vais générer le code HTML/CSS (avec Tailwind CSS pour la rapidité et le style moderne) reproduisant fidèlement votre capture d'écran, en y intégrant le nouveau bouton "Commande Vocale".
1. Faisabilité Technique & Architecture
Votre approche est solide. Voici pourquoi c'est "jouable" et comment structurer le flux :
- Frontend (L'écran) : Une application mobile (Flutter/React Native) ou une WebApp (PWA). Elle affiche les données via un GET JSON-RPC depuis Odoo.
- Le Bouton Audio (Nouveau) :
- L'utilisateur appuie sur le bouton "Microphone".
- L'audio est capturé par le téléphone.
- Traitement IA : Le fichier audio est envoyé à une API (OpenAI Whisper pour la transcription speech-to-text + GPT-4 pour extraire les intentions "Commander 2 boîtes de gants").
- Orchestration (n8n) : C'est là que la magie opère. Au lieu de coder toute la logique dans l'app, un workflow n8n reçoit le texte transcrit, interroge Odoo pour vérifier les stocks/IDs produits, et crée le bon de commande (Sale Order) dans Odoo via l'API.
- Backend (Odoo) : Il reste la source de vérité pour les produits, les prix et l'historique (ce qui permet la personnalisation dont vous parliez).
2. Code de l'Interface (Reproduction + Bouton Audio)
Absolument, c'est un projet tout à fait réalisable et très pertinent pour ce cas d'usage. L'ajout d'une commande vocale pour des soins aux personnes âgées (ou pour les infirmières pressées) est une excellente idée d'accessibilité et d'efficacité.
Voici comment nous allons procéder :
- Analyse de l'architecture technique : Je vais confirmer la faisabilité de votre stack (Odoo + JSON-RPC + OpenAI/N8N).
- Refonte de l'interface (Code) : Je vais générer le code HTML/CSS (avec Tailwind CSS pour la rapidité et le style moderne) reproduisant fidèlement votre capture d'écran, en y intégrant le nouveau bouton "Commande Vocale".
1. Faisabilité Technique & Architecture
Votre approche est solide. Voici pourquoi c'est "jouable" et comment structurer le flux :
- Frontend (L'écran) : Une application mobile (Flutter/React Native) ou une WebApp (PWA). Elle affiche les données via un GET JSON-RPC depuis Odoo.
- Le Bouton Audio (Nouveau) :
- L'utilisateur appuie sur le bouton "Microphone".
- L'audio est capturé par le téléphone.
- Traitement IA : Le fichier audio est envoyé à une API (OpenAI Whisper pour la transcription speech-to-text + GPT-4 pour extraire les intentions "Commander 2 boîtes de gants").
- Orchestration (n8n) : C'est là que la magie opère. Au lieu de coder toute la logique dans l'app, un workflow n8n reçoit le texte transcrit, interroge Odoo pour vérifier les stocks/IDs produits, et crée le bon de commande (Sale Order) dans Odoo via l'API.
- Backend (Odoo) : Il reste la source de vérité pour les produits, les prix et l'historique (ce qui permet la personnalisation dont vous parliez).
2. Code de l'Interface (Reproduction + Bouton Audio)
Voici le code complet pour recréer cet écran. J'ai utilisé HTML et Tailwind CSS pour que vous puissiez le visualiser immédiatement. J'ai ajouté le bouton micro en bas, bien visible, à côté du bouton "Voir panier".
Points clés du design :
- Reproduction de la grille de produits.
- Sélecteurs de quantité (+/-).
- Ajout du bouton "Commande Vocale" (Microphone) avec une animation de pulsation pour indiquer l'état "écoute"
Voici la version convertie en React JS (compatible Next.js ou Vite), découpée en composants propres : Composant React Complet (App.jsx ou OrderPage.js
J'ai intégré les modifications demandées :
- Personnalisation de l'en-tête : J'ai remplacé le titre générique par les informations de "Mme Ursula LAURET" (Nom + Adresse).
- Préparation pour l'interfaçage Odoo : J'ai ajouté des attributs data-odoo-field et id
Voici la version convertie en React JS (compatible Next.js ou Vite), découpée en composants propres : Composant React Complet (App.jsx ou OrderPage.js)
import React, { useState, useRef } from 'react';
import { Mic, Search, User, MapPin, Plus, Minus, ShoppingCart } from 'lucide-react';
// Simulation des données venant d'Odoo (GET)
const INITIAL_PRODUCTS = [
{ id: 101, name: "Pansements non-tissés absorbants", price: 4.99, unit: "Boîte de 50", img: "https://placehold.co/150x150/png?text=Pansements", qty: 1 },
{ id: 102, name: "Bréfes pour adulte", price: 16.99, unit: "Paquet de 20", img: "https://placehold.co/150x150/png?text=Couches", qty: 1 },
{ id: 103, name: "Crème hydratante épaisse", price: 7.50, unit: "Flacon de 250 ml", img: "https://placehold.co/150x150/png?text=Crème", qty: 1 },
{ id: 104, name: "Gants en nitrile sans poudre", price: 9.49, unit: "Boîte de 100", img: "https://placehold.co/150x150/png?text=Gants", qty: 1 },
{ id: 105, name: "Compresses de gaze stériles", price: 5.99, unit: "Paquet de 25", img: "https://placehold.co/150x150/png?text=Compresses", qty: 5 },
{ id: 106, name: "Alèses jetables", price: 11.99, unit: "Paquet de 30", img: "https://placehold.co/150x150/png?text=Alèses", qty: 1 },
];
const PATIENT_INFO = {
id: 12345,
name: "Mme Ursula LAURET",
address: "137 rue d'Alésia, 75014 Paris"
};
export default function OrderPage() {
// État du panier (produits)
const [products, setProducts] = useState(INITIAL_PRODUCTS);
// État de l'enregistrement audio
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); // Pour l'état "jaune"
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
// --- LOGIQUE AUDIO ---
const handleMicClick = async () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
audioChunksRef.current = [];
mediaRecorderRef.current.ondataavailable = (event) => {
audioChunksRef.current.push(event.data);
};
mediaRecorderRef.current.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
await sendAudioToN8N(audioBlob);
};
mediaRecorderRef.current.start();
setIsRecording(true);
} catch (err) {
console.error("Erreur micro:", err);
alert("Accès micro refusé");
}
};
const stopRecording = () => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
// Arrêt des tracks pour éteindre le témoin micro
mediaRecorderRef.current.stream.getTracks().forEach(t => t.stop());
}
};
const sendAudioToN8N = async (audioBlob) => {
setIsProcessing(true); // Passe en jaune/attente
const formData = new FormData();
formData.append('audio_file', audioBlob, 'commande.webm');
formData.append('patient_id', PATIENT_INFO.id);
try {
// Remplacer par votre URL n8n réelle
const response = await fetch('https://votre-n8n.com/webhook/audio', {
method: 'POST',
body: formData
});
if(response.ok) {
alert("Commande vocale envoyée avec succès !");
// Ici, vous pourriez parser la réponse JSON pour mettre à jour le state 'products'
}
} catch (error) {
console.error("Erreur envoi", error);
} finally {
setIsProcessing(false);
}
};
// --- LOGIQUE UI ---
const updateQty = (id, delta) => {
setProducts(products.map(p =>
p.id === id ? { ...p, qty: Math.max(0, p.qty + delta) } : p
));
};
const totalItems = products.reduce((acc, curr) => acc + curr.qty, 0);
return (
<div className="bg-gray-50 text-gray-900 min-h-screen pb-28 font-sans">
{/* HEADER */}
<div className="bg-white sticky top-0 z-20 shadow-sm">
<div className="p-4 pb-2">
<h1 className="text-3xl font-extrabold mb-4 tracking-tight">Commandes</h1>
<div className="relative">
<Search className="absolute left-3 top-3.5 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Rechercher des produits"
className="w-full pl-10 pr-4 py-3 bg-gray-100 rounded-xl border-none focus:ring-2 focus:ring-blue-500 outline-none text-gray-600 font-medium"
/>
</div>
</div>
{/* INFO PATIENT */}
<div className="px-4 py-3 border-b border-gray-100 bg-blue-50/50 flex items-start gap-3">
<div className="bg-blue-100 p-2 rounded-full text-blue-600 mt-1">
<User size={16} />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900 leading-tight">{PATIENT_INFO.name}</h2>
<div className="flex items-center text-gray-500 text-sm mt-0.5 gap-1">
<MapPin size={12} />
<span>{PATIENT_INFO.address}</span>
</div>
</div>
</div>
</div>
{/* GRILLE PRODUITS */}
<div className="p-4 grid grid-cols-2 gap-4">
{products.map((product) => (
<div key={product.id} className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm flex flex-col justify-between transition-transform active:scale-[0.98]">
<div className="flex justify-center mb-2">
<img src={product.img} alt={product.name} className="h-24 object-contain" />
</div>
<div>
<h3 className="font-semibold text-sm leading-tight mb-1 text-gray-800 line-clamp-2 h-10">
{product.name}
</h3>
<p className="text-gray-900 font-bold text-lg">€{product.price.toFixed(2)}</p>
<p className="text-xs text-gray-400 mb-3 font-medium">{product.unit}</p>
<div className="flex items-center justify-between h-10 gap-2">
<div className="flex border border-gray-200 rounded-lg h-full items-center bg-white flex-1 justify-between px-1">
<button onClick={() => updateQty(product.id, -1)} className="px-2 text-gray-400 hover:bg-gray-50 h-full">-</button>
<span className="text-sm font-semibold">{product.qty}</span>
<button onClick={() => updateQty(product.id, 1)} className="px-2 text-gray-400 hover:bg-gray-50 h-full">+</button>
</div>
<button className="bg-blue-600 text-white h-10 w-10 flex items-center justify-center rounded-lg shadow-md active:bg-blue-700">
<Plus size={20} />
</button>
</div>
</div>
</div>
))}
</div>
{/* FOOTER FLOTTANT */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-white/95 backdrop-blur-md border-t border-gray-200 flex gap-4 items-center shadow-[0_-8px_30px_rgba(0,0,0,0.08)] z-30 pb-6">
{/* BOUTON MICRO */}
<button
onClick={handleMicClick}
className={`w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all active:scale-90 flex-shrink-0 text-white
${isRecording ? 'bg-red-600 animate-pulse ring-4 ring-red-200' : ''}
${isProcessing ? 'bg-yellow-500' : ''}
${!isRecording && !isProcessing ? 'bg-red-500 hover:bg-red-600' : ''}
`}
>
<Mic size={28} />
</button>
{/* BOUTON PANIER */}
<button className="bg-blue-600 hover:bg-blue-700 text-white py-3.5 px-6 rounded-xl font-bold text-lg flex-1 shadow-lg shadow-blue-600/30 active:scale-[0.98] transition-transform flex items-center justify-center gap-2">
<span>Voir panier</span>
{totalItems > 0 && (
<span className="bg-blue-500 px-2 py-0.5 rounded text-sm">({totalItems})</span>
)}
</button>
</div>
</div>
);
}
État (useState) : Les données produits ne sont plus "en dur" dans le HTML, elles sont stockées dans une variable d'état. Cela signifie que si n8n renvoie une réponse, vous pouvez mettre à jour l'interface instantanément.
Icônes : Utilisation de la bibliothèque lucide-react (standard React) au lieu des balises <i>.
Boucle (map) : Au lieu de copier-coller 6 fois le code HTML de la carte produit, on utilise une boucle .map() sur la liste des produits, ce qui rend le code beaucoup plus propre et dynamique.
Version code pour du NATIVE REACT ( App. )
Pour une application mobile native (téléchargeable sur l'App Store et le Play Store) qui utilise le micro, React Native est la solution standard de l'industrie.
Voici le code complet adapté pour React Native (via Expo).
.
Logique Backend suggérée pour l'Audio (n8n + Odoo)
Pour que la commande "Expédier au Back Office Odoo" fonctionne, voici le flux logique que je recommande dans n8n :
- Webhook (Trigger) : Reçoit le fichier audio depuis l'application.
- OpenAI (Whisper) : Transcrit l'audio en texte (ex: "Rajoute 3 paquets d'alèses et une crème hydratante").
- OpenAI (Completion/GPT-4) : Analyse le texte pour extraire des données structurées JSON.
- Prompt système : "Tu es un assistant de commande médicale. Voici la liste des produits disponibles dans Odoo : [ID: 12, Name: Alèses], [ID: 14, Name: Crème]. Extrais la commande en JSON avec 'product_id' et 'quantity'."
- Odoo Node (n8n) :
- Utilise l'opération create sur le modèle sale.order.line.
- Injecte les product_id et quantity trouvés par l'IA.
- Réponse : Renvoie un JSON à l'app mobile pour confirmer : "Commande mise à jour : 3 Alèses, 1 Crème".
3- Le schéma JSON que l'IA devrait retourner pour s'interfacer proprement avec votre module Odoo >
C'est parti. Pour que l'intégration entre la voix (OpenAI) et l'ERP (Odoo) soit fluide, nous devons imposer une structure stricte à la réponse de l'IA.
L'objectif est de transformer une phrase naturelle comme "Rajoute deux boîtes de gants et enlève la crème" en une donnée structurée que votre workflow n8n pourra lire sans erreur.
Voici le schéma technique détaillé.
1. L'Architecture du flux de données (n8n)
Pour visualiser comment cela s'orchestre, voici à quoi ressemble généralement ce type d'automatisation dans un outil comme n8n :
Le flux logique dans n8n sera :
- Webhook : Reçoit l'audio.
- Whisper (OpenAI) : Audio → Texte ("Rajoute deux boîtes de gants...").
- Odoo (Get) : Récupère la liste actuelle des produits et leurs IDs (pour donner le contexte à l'IA).
- GPT-4 (OpenAI) : Texte + Liste Produits → JSON Structuré (C'est l'étape clé ci-dessous).
- Split in Batches (n8n) : Sépare chaque article commandé.
- Odoo (Create/Write) : Met à jour le panier.
2. Le Schéma JSON Cible
C'est parti. Pour que l'intégration entre la voix (OpenAI) et l'ERP (Odoo) soit fluide, nous devons imposer une structure stricte à la réponse de l'IA.
L'objectif est de transformer une phrase naturelle comme "Rajoute deux boîtes de gants et enlève la crème" en une donnée structurée que votre workflow n8n pourra lire sans erreur.
Voici le schéma technique détaillé.
1. L'Architecture du flux de données (n8n)
Pour visualiser comment cela s'orchestre, voici à quoi ressemble généralement ce type d'automatisation dans un outil comme n8n :
Le flux logique dans n8n sera :
- Webhook : Reçoit l'audio.
- Whisper (OpenAI) : Audio → Texte ("Rajoute deux boîtes de gants...").
- Odoo (Get) : Récupère la liste actuelle des produits et leurs IDs (pour donner le contexte à l'IA).
- GPT-4 (OpenAI) : Texte + Liste Produits → JSON Structuré (C'est l'étape clé ci-dessous).
- Split in Batches (n8n) : Sépare chaque article commandé.
- Odoo (Create/Write) : Met à jour le panier.
2. Le Schéma JSON Cible
Voici le format JSON exact que vous devez demander à l'IA de générer. C'est ce payload que n8n va parser.
JSON
{
"operation_type": "update_cart",
"items": [
{
"odoo_product_id": 42,
"product_name": "Gants en nitrile sans poudre",
"quantity": 2,
"action": "add"
},
{
"odoo_product_id": 15,
"product_name": "Crème hydratante épaisse",
"quantity": 1,
"action": "remove"
}
]
}
Pourquoi ce schéma ?
- odoo_product_id : Crucial. L'IA doit choisir l'ID correspondant dans la liste que vous lui fournissez, pour éviter une recherche floue ("fuzzy match") risquée dans Odoo plus tard.
- action ("add", "set", "remove") : Permet de gérer la nuance entre "Rajoute 2 gants" (panier + 2) et "Je veux 2 gants au total" (panier = 2).
3. Le "System Prompt" pour OpenAI (dans n8n)
Dans votre nœud OpenAI sur n8n, utilisez ce prompt système. C'est ici que l'intelligence opère.
Prompt Système :
"Tu es un assistant API pour une application de commande médicale.
Voici le catalogue de produits disponibles (Format : ID - Nom) :
- 101 : Pansements non-tissés
- 102 : Bréfes pour adulte
- 103 : Crème hydratante
- 104 : Gants en nitrile
- 105 : Compresses stériles
- 106 : Alèses jetables
Ta tâche : Analyse la demande de l'utilisateur et retourne UNIQUEMENT un objet JSON valide. Ne parle pas, ne dis pas bonjour. Juste du JSON.
Règles :
- Identifie le 'odoo_product_id' correct pour chaque produit mentionné.
- Si l'utilisateur dit 'enlève', mets l'action à 'remove'.
- Si l'utilisateur ajoute, mets l'action à 'add'.
- Si le produit n'est pas dans la liste, ignore-le."
Message Utilisateur (venant de Whisper) :
"Mets-moi deux boîtes de gants et annule la crème hydratante stp."
4-. L'Appel API Odoo (JSON-RPC)
Une fois que n8n a ce JSON, il va boucler sur chaque "item". Voici comment configurer l'appel vers Odoo pour créer/modifier la ligne de commande (sale.order.line).
Endpoint : /jsonrpc (ou via le nœud Odoo natif de n8n) Méthode Odoo : execute_kw Modèle : sale.order.line
Exemple de payload pour l'ajout (action "add") :
JSON
{
"jsonrpc": "2.0",
"method": "call",
"params": {
"service": "object",
"method": "create",
"args": [
"database_name",
"user_id",
"password",
"sale.order.line",
{
"order_id": 12345, // ID du panier en cours (venant de l'app)
"product_id": 104, // ID identifié par l'IA
"product_uom_qty": 2 // Quantité identifiée par l'IA
}
]
},
"id": 1
}
Résumé du plan d'action
- Frontend : Intégrer le code HTML fourni précédemment.
- Middleware (n8n) : Créer le workflow (Webhook -> Whisper -> GPT -> Odoo).
- Backend (Odoo) : S'assurer que l'utilisateur API a les droits d'écriture sur sale.order.
Génèration du code JavaScript (fetch) côté client pour envoyer le fichier audio au Webhook n8n
Absolument. C'est la brique finale pour rendre l'interface fonctionnelle.
Nous allons utiliser l'API native du navigateur MediaRecorder pour capturer la voix, créer un fichier audio (Blob), et l'envoyer via une requête POST à votre Webhook n8n.
Voici le script JavaScript complet, commenté et prêt à être intégré dans votre page.
Le Code JavaScript Client (Frontend)
Remplacez la section <script> de l'exemple HTML précédent par celle-ci :
JavaScript
<script>
// Initialisation des icônes Lucide
lucide.createIcons();
// --- CONFIGURATION ---
// Remplacez ceci par l'URL de votre Webhook n8n "Production"
const N8N_WEBHOOK_URL = 'https://votre-instance-n8n.com/webhook/commande-vocale';
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
/**
* Fonction principale déclenchée au clic sur le bouton micro
*/
async function toggleRecording(btn) {
const icon = btn.querySelector('i');
if (!isRecording) {
// DÉMARRAGE DE L'ENREGISTREMENT
try {
// Demande l'accès au micro
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
// À chaque fois que des données audio sont disponibles
mediaRecorder.ondataavailable = event => {
audioChunks.push(event.data);
};
// Quand l'enregistrement s'arrête (logique d'envoi)
mediaRecorder.onstop = async () => {
// Création du fichier audio (Blob)
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); // ou audio/mp4 selon compatibilité
// Feedback visuel : Chargement...
btn.classList.remove('bg-red-600', 'mic-active');
btn.classList.add('bg-yellow-500'); // Jaune = en cours de traitement
// Envoi vers n8n
await sendAudioToN8N(audioBlob, btn);
};
mediaRecorder.start();
isRecording = true;
// Mise à jour UI : Mode "Enregistrement" (Rouge pulsant)
btn.classList.remove('bg-red-500');
btn.classList.add('bg-red-600', 'mic-active');
} catch (err) {
console.error("Erreur d'accès au micro:", err);
alert("Impossible d'accéder au micro. Veuillez vérifier les permissions.");
}
} else {
// ARRÊT DE L'ENREGISTREMENT
mediaRecorder.stop(); // Déclenche l'événement 'onstop' défini plus haut
isRecording = false;
// Arrêt des pistes du micro pour éteindre le témoin d'enregistrement du navigateur
mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
}
/**
* Envoie le fichier audio vers n8n
*/
async function sendAudioToN8N(audioBlob, btn) {
// Préparation des données formulaire
const formData = new FormData();
formData.append('audio_file', audioBlob, 'commande_vocale.webm');
// On peut aussi envoyer l'ID du client ou du panier actuel
formData.append('cart_id', '12345');
try {
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
console.log("Réponse n8n:", result);
// Feedback : Succès
btn.classList.remove('bg-yellow-500');
btn.classList.add('bg-green-500');
// Exemple : Mise à jour dynamique de l'interface basée sur le retour JSON
// updateInterface(result);
alert(`Commande reçue ! \nMessage IA: "${result.message || 'Panier mis à jour'}"`);
// Retour à la normale après 2 secondes
setTimeout(() => {
btn.classList.remove('bg-green-500');
btn.classList.add('bg-red-500');
}, 2000);
} else {
throw new Error('Erreur serveur');
}
} catch (error) {
console.error("Erreur d'envoi:", error);
btn.classList.remove('bg-yellow-500');
btn.classList.add('bg-gray-500'); // Gris = Erreur
alert("Erreur lors de l'envoi de la commande vocale.");
}
}
</script>
Absolument. C'est la brique finale pour rendre l'interface fonctionnelle.
Nous allons utiliser l'API native du navigateur MediaRecorder pour capturer la voix, créer un fichier audio (Blob), et l'envoyer via une requête POST à votre Webhook n8n.
Voici le script JavaScript complet, commenté et prêt à être intégré dans votre page.
Le Code JavaScript Client (Frontend)
Remplacez la section <script> de l'exemple HTML précédent par celle-ci :
JavaScript
<script>
// Initialisation des icônes Lucide
lucide.createIcons();
// --- CONFIGURATION ---
// Remplacez ceci par l'URL de votre Webhook n8n "Production"
const N8N_WEBHOOK_URL = 'https://votre-instance-n8n.com/webhook/commande-vocale';
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
/**
* Fonction principale déclenchée au clic sur le bouton micro
*/
async function toggleRecording(btn) {
const icon = btn.querySelector('i');
if (!isRecording) {
// DÉMARRAGE DE L'ENREGISTREMENT
try {
// Demande l'accès au micro
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
// À chaque fois que des données audio sont disponibles
mediaRecorder.ondataavailable = event => {
audioChunks.push(event.data);
};
// Quand l'enregistrement s'arrête (logique d'envoi)
mediaRecorder.onstop = async () => {
// Création du fichier audio (Blob)
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); // ou audio/mp4 selon compatibilité
// Feedback visuel : Chargement...
btn.classList.remove('bg-red-600', 'mic-active');
btn.classList.add('bg-yellow-500'); // Jaune = en cours de traitement
// Envoi vers n8n
await sendAudioToN8N(audioBlob, btn);
};
mediaRecorder.start();
isRecording = true;
// Mise à jour UI : Mode "Enregistrement" (Rouge pulsant)
btn.classList.remove('bg-red-500');
btn.classList.add('bg-red-600', 'mic-active');
} catch (err) {
console.error("Erreur d'accès au micro:", err);
alert("Impossible d'accéder au micro. Veuillez vérifier les permissions.");
}
} else {
// ARRÊT DE L'ENREGISTREMENT
mediaRecorder.stop(); // Déclenche l'événement 'onstop' défini plus haut
isRecording = false;
// Arrêt des pistes du micro pour éteindre le témoin d'enregistrement du navigateur
mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
}
/**
* Envoie le fichier audio vers n8n
*/
async function sendAudioToN8N(audioBlob, btn) {
// Préparation des données formulaire
const formData = new FormData();
formData.append('audio_file', audioBlob, 'commande_vocale.webm');
// On peut aussi envoyer l'ID du client ou du panier actuel
formData.append('cart_id', '12345');
try {
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
console.log("Réponse n8n:", result);
// Feedback : Succès
btn.classList.remove('bg-yellow-500');
btn.classList.add('bg-green-500');
// Exemple : Mise à jour dynamique de l'interface basée sur le retour JSON
// updateInterface(result);
alert(`Commande reçue ! \nMessage IA: "${result.message || 'Panier mis à jour'}"`);
// Retour à la normale après 2 secondes
setTimeout(() => {
btn.classList.remove('bg-green-500');
btn.classList.add('bg-red-500');
}, 2000);
} else {
throw new Error('Erreur serveur');
}
} catch (error) {
console.error("Erreur d'envoi:", error);
btn.classList.remove('bg-yellow-500');
btn.classList.add('bg-gray-500'); // Gris = Erreur
alert("Erreur lors de l'envoi de la commande vocale.");
}
}
</script>
Points importants techniques :
- navigator.mediaDevices.getUserMedia : C'est la méthode standard moderne. Sur mobile (iOS Safari / Android Chrome), cela demandera la permission à l'utilisateur la première fois. Note importante : Cela ne fonctionne que si votre site est servi en HTTPS (ou sur localhost).
- FormData : C'est crucial. Nous n'envoyons pas juste du texte (JSON), mais un fichier binaire. En utilisant FormData, n8n recevra l'entrée comme un fichier binaire standard, prêt à être passé au nœud "OpenAI Whisper".
- Format Audio (audio/webm) : C'est le format le plus compatible pour le web. Whisper (OpenAI) le gère parfaitement.
- Feedback Utilisateur : J'ai ajouté des états de couleur pour le bouton afin que l'utilisateur sache ce qui se passe :
- 🔴 Rouge fixe : Prêt / Repos.
- 🔴 Rouge pulsant : Enregistrement en cours.
- 🟡 Jaune : Envoi et traitement par l'IA (Attente).
- 🟢 Vert : Succès (Commande validée).
Prochaine étape : Le test
Pour tester cela sans déployer tout de suite n8n,
Pour tester cela sans déployer tout de suite n8n, vous pouvez utiliser un site comme Webhook.site.
https://webhook.site/#!/view/8aa2f7af-2c43-4c81-928f-ea7d399af78e
- Allez sur Webhook.site.
- Copiez l'URL unique qu'ils vous donnent.
- Remplacez const N8N_WEBHOOK_URL dans mon script par cette URL.
- Ouvrez votre fichier HTML, cliquez sur le micro, parlez, et arrêtez.
- Vous verrez apparaître le fichier audio directement sur Webhook.site. Cela validera que votre frontend fonctionne parfaitement.