/** * ASPIC — micro.js * * Comportement du bouton micro (#bouton-micro), partagé par tous les * écrans qui l'affichent — chargé via en * toute fin de page, après le script propre à chaque écran. * * Pourquoi un fichier séparé plutôt que dupliqué dans le gabarit comme le * badge société ou l'avatar : ceux-là ne sont que de la présentation, alors * qu'ici il s'agit de comportement (reconnaissance vocale, puis * interprétation de commandes) qui doit rester rigoureusement identique * partout, sur un nombre d'écrans appelé à grandir — une correction ne * doit se faire qu'à un seul endroit. Nommé "micro.js" plutôt que "voix.js" * car appelé à porter d'autres fonctionnalités liées à la voix au fil des * prochains chantiers, pas seulement la reconnaissance. * * Chaque page hôte doit déjà contenir la structure suivante dans son * bandeau (voir liste_contacts.html / liste_todolist.html) : * #bouton-micro > #icone-micro (svg, icône micro barré) + #barres-ecoute * et peut définir une fonction globale afficherToast(message) pour le * retour visuel — micro.js s'en sert si elle existe, sinon se contente * d'un console.log (utile en test avant d'avoir une page complète). * * Étape 1 (1er septembre 2026) : reconnaissance vocale réelle branchée sur * le bouton, texte transcrit simplement affiché en retour pour valider le * circuit micro -> texte. Aucune commande interprétée ni action * déclenchée à ce stade — ce sera l'étape 2 (ex. ajout de tâche à la * ToDoList), une fois ces premiers tests concluants. Le stockage des * textes entendus dans une table dédiée, évoqué par Jean-Marc pour la * suite, n'est pas non plus branché ici — à ajouter une fois la structure * de table définie avec lui. * * Mode utilisé : reconnaissance "cloud" du navigateur (SpeechRecognition / * webkitSpeechRecognition) — mature et gratuite, au prix d'un envoi de * l'audio à un service externe (voir réflexions stratégiques, section 3). * Bascule vers le mode local ("on-device", encore expérimental côté Edge à * ce jour) envisageable plus tard sans changer la structure de ce fichier. * * Mot déclencheur "Lilou" (1er septembre 2026) : en écoute continue, * chaque phrase captée est transcrite — y compris une conversation de fond * non destinée à ASPIC. Plutôt que de réagir à tout, seules les phrases * commençant par "Lilou" (le prénom donné à l'assistante vocale d'ASPIC, * clin d'œil au Cinquième Élément — Viktor s'occupant déjà des contacts * côté commercial) sont prises en compte ; le reste est ignoré en * silence. Nuance à garder en tête : ce filtre s'applique sur le texte * déjà transcrit, pas avant l'envoi au service de reconnaissance — le son * continue d'être transmis tant que le micro est ouvert, qu'on y trouve * "Lilou" ou non (voir réflexions stratégiques pour la discussion sur le * mode local, seule vraie façon d'éviter cet envoi). * * Mode "notation" — capture d'une note sans interprétation (1er septembre * 2026, demande explicite de Jean-Marc) : une phrase d'ouverture ("Lilou, * ajoute une tâche...", "Lilou, un truc à faire...", "Lilou, note ça dans * ma todolist...") fait basculer dans un mode où le "Lilou" en tête n'est * plus exigé pour chaque ligne suivante — tout ce qui est capté est ajouté * tel quel au fichier, jusqu'à une phrase de clôture ("c'est tout Lilou") * ou d'annulation ("Lilou annule"), qui produisent exactement le même * comportement (on enregistre ce qui a été dit dans les deux cas — pas de * traitement différencié pour l'annulation, sur demande explicite de * Jean-Marc : lui analysera ses propres essais après coup). "ensuite" en * fin de phrase sert de marqueur oral de fin de ligne, retiré du texte * conservé. Volontairement SANS interprétation à ce stade — le texte part * tel quel dans notes_lilou/dictees.txt (via lilou_notes.php) pour * constituer un corpus de vrais essais, avant de définir des règles * d'interprétation dans un prochain chantier (étape 2, envisagée via n8n). * Les phrases déclenchantes ci-dessous sont volontairement en dur et * simples (pas de grammaire compliquée) — à ajuster une fois les premiers * essais de Jean-Marc faits. * * Écoute "tant qu'on ne l'a pas coupée soi-même" (1er septembre 2026, * ajusté après un premier essai de Jean-Marc) : `continuous = true` ne * suffit pas à lui seul — de façon documentée (y compris sur Chrome * desktop, pas seulement mobile), le moteur du navigateur peut mettre fin * à l'écoute de lui-même après un silence prolongé ou au bout d'un certain * temps, sans que ce soit une erreur ni une action de l'utilisateur. On * distingue donc "l'utilisateur veut que le micro soit ouvert" * (ecouteVoulue) de l'état réel du moteur : si celui-ci s'arrête tout seul * alors que l'écoute est toujours voulue, on le relance aussitôt — de façon * invisible pour l'utilisateur, qui n'a donc à cliquer qu'une fois pour * ouvrir le micro et une fois pour le refermer. Exception : une erreur * bloquante (micro refusé, pas de micro détecté) ne redémarre pas * automatiquement, pour ne pas boucler sans fin sur un blocage qui ne se * résoudra pas tout seul. */ (function(){ "use strict"; const bouton = document.getElementById("bouton-micro"); if(!bouton) return; // écran sans bouton micro dans son bandeau function notifier(message){ if(typeof window.afficherToast === "function"){ window.afficherToast(message); } else { console.log("[micro.js]", message); } } const ReconnaissanceVocale = window.SpeechRecognition || window.webkitSpeechRecognition; if(!ReconnaissanceVocale){ // Navigateur sans support de l'API : bouton laissé visible (cohérence // du bandeau) mais explicite sur l'indisponibilité au clic. bouton.title = "Reconnaissance vocale non disponible sur ce navigateur"; bouton.addEventListener("click", function(){ notifier("La reconnaissance vocale n'est pas disponible sur ce navigateur."); }); return; } const reconnaissance = new ReconnaissanceVocale(); reconnaissance.lang = "fr-FR"; reconnaissance.continuous = true; reconnaissance.interimResults = false; reconnaissance.maxAlternatives = 1; // ecouteVoulue : ce que l'utilisateur a demandé (vrai entre un clic // d'ouverture et le clic de fermeture, quels que soient les redémarrages // silencieux du moteur entre les deux). let ecouteVoulue = false; // erreurBloquante : mise à vrai par une erreur qui ne se résoudra pas en // relançant tout de suite (micro refusé, pas de micro détecté) — empêche // le redémarrage automatique sur l'événement "end" qui suit. let erreurBloquante = false; function passerEnEcoute(){ bouton.classList.add("ecoute"); bouton.title = "Le micro est ouvert"; } function revenirAuRepos(){ ecouteVoulue = false; bouton.classList.remove("ecoute"); bouton.title = "Le micro est inactif"; } bouton.addEventListener("click", function(){ if(ecouteVoulue){ // Fermeture demandée par l'utilisateur : le retour au repos se fait // via l'événement "end" qui va suivre, sans redémarrage puisque // ecouteVoulue passe à faux avant l'appel à stop(). ecouteVoulue = false; reconnaissance.stop(); return; } ecouteVoulue = true; erreurBloquante = false; try{ reconnaissance.start(); }catch(erreur){ // start() peut lever une exception synchrone : soit un cycle déjà en // cours côté navigateur (double-clic très rapide, sans conséquence), // soit un vrai blocage (page pas en contexte sécurisé, permission // micro refusée avant même le début d'écoute selon le navigateur). // Dans le doute on prévient plutôt que de rester silencieux : mieux // vaut un message superflu sur un double-clic qu'un bouton muet qui // ne fait jamais rien sans explication. ecouteVoulue = false; notifier("Impossible de démarrer le micro (" + erreur.name + ")."); } }); // -------- Panneau de notation (affichage seul, construit par ce script // -------- pour être appelable depuis n'importe quel écran sans avoir à // -------- modifier chaque page hôte — voir commentaire d'en-tête. let panneauNotation = null; let listeNotation = null; function construirePanneauNotation(){ if(panneauNotation) return; const style = document.createElement("style"); style.textContent = "#panneau-notation-lilou{position:fixed;bottom:20px;right:20px;" + "max-width:280px;background:#FFFFFF;border:1px solid rgba(0,0,0,0.15);" + "border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.25);" + "padding:12px 16px;z-index:900;font-size:13px;display:none;}" + "#panneau-notation-lilou.visible{display:block;}" + "#panneau-notation-lilou .titre{font-weight:700;" + "color:var(--rouge-micro,#D9364B);margin-bottom:8px;}" + "#panneau-notation-lilou .lignes{list-style:none;margin:0;padding:0;}" + "#panneau-notation-lilou .lignes li{padding:2px 0 2px 14px;color:#2a2a2a;}"; document.head.appendChild(style); panneauNotation = document.createElement("div"); panneauNotation.id = "panneau-notation-lilou"; panneauNotation.innerHTML = '
Lilou vous écoute…
'; document.body.appendChild(panneauNotation); listeNotation = panneauNotation.querySelector(".lignes"); } function ouvrirPanneauNotation(){ construirePanneauNotation(); listeNotation.innerHTML = ""; panneauNotation.classList.add("visible"); } function afficherLigneDansPanneau(ligne){ if(!listeNotation) return; const li = document.createElement("li"); li.textContent = ligne; listeNotation.appendChild(li); } function fermerPanneauNotation(){ if(panneauNotation) panneauNotation.classList.remove("visible"); } // -------- État "notation" -------- let modeNotation = false; let bufferNotation = []; // Fenêtre de tolérance après un "Lilou" entendu seul (retour de Jean-Marc, // 2 septembre 2026 : "ajoute une tâche" arrivé sans "Lilou" en tête dans // la console). En écoute continue, le moteur découpe parfois la phrase // dite d'une traite ("Lilou, ajoute une tâche") en deux résultats séparés // dès qu'il y a la moindre pause après la virgule — le second morceau // n'a alors plus "Lilou" en tête et était jusqu'ici ignoré en silence. // Si un résultat ne contient QUE le mot déclencheur (rien d'exploitable // derrière), on ouvre une petite fenêtre pendant laquelle le résultat // suivant est accepté même sans "Lilou", comme s'il l'avait précédé. let reveilEnAttente = false; let expirationReveil = 0; const DUREE_REVEIL_MS = 6000; // Le moteur de reconnaissance confond parfois "Lilou" avec "Milou" (même // sonorité, et "Milou" est un mot bien plus courant en français...) — // tous les motifs qui doivent repérer "Lilou" ci-dessous acceptent aussi // "Milou", pour ne pas bloquer tout le circuit sur un simple aléa de // transcription (retour de Jean-Marc, 2 septembre 2026 : "il a cru que je // disais c'est tout Milou"). const MOT_LILOU = "(?:li|mi)lou"; // Phrases d'ouverture : testées sur le texte après retrait de "Lilou". const PHRASES_OUVERTURE = [/tâche/i, /truc à faire/i, /note.*todolist/i]; // Phrases de clôture / annulation : recherchées n'importe où dans la // phrase entendue, pas seulement en tête (ajusté le 1er septembre 2026 // suite au retour de Jean-Marc : dire "..., ensuite" juste avant "c'est // tout Lilou" pour clore sur du contenu tout juste dicté n'est pas // naturel à l'oral — "date de réalisation le 12/09 c'est tout Lilou" doit // fonctionner dit d'une traite). Ce qui précède la phrase repérée dans la // même phrase entendue est conservé comme dernière ligne de contenu avant // de clore — voir traiterLigneNotation. const PHRASES_CLOTURE = [new RegExp("c'?est tout " + MOT_LILOU, "i")]; const PHRASES_ANNULATION = [new RegExp(MOT_LILOU + "\\s+annule", "i")]; // Marqueur oral de fin de ligne ("..., ensuite") retiré du texte conservé. const MARQUEUR_FIN_LIGNE = /\s*,?\s*ensuite\s*[.,!]?\s*$/i; // Repère la première occurrence (n'importe où dans le texte) d'une phrase // de la liste donnée, et renvoie sa position — ou null si aucune ne // correspond. function trouverPhraseDeControle(texte, motifs){ for(let i = 0; i < motifs.length; i++){ const correspondance = texte.match(motifs[i]); if(correspondance) return {index: correspondance.index}; } return null; } // Nettoie une ligne de contenu : retire le marqueur oral "ensuite" en fin // de ligne s'il est présent, puis toute ponctuation de fin isolée // restante (cas d'une ligne coupée juste avant une phrase de clôture/ // annulation repérée en cours de phrase, sans "ensuite" prononcé). function nettoyerLigne(texte){ return texte.replace(MARQUEUR_FIN_LIGNE, "").replace(/[\s,;:]+$/, "").trim(); } function envoyerNotation(lignes){ fetch("lilou_notes.php", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({lignes: lignes}) }).then(function(reponse){ if(!reponse.ok) throw new Error("HTTP " + reponse.status); notifier("Note enregistrée."); }).catch(function(){ notifier("Erreur : la note n'a pas pu être enregistrée."); }); } function demarrerNotation(){ modeNotation = true; bufferNotation = []; ouvrirPanneauNotation(); notifier('Lilou note votre demande — dites "c\'est tout Lilou" pour terminer, ou "Lilou annule" pour annuler.'); } function terminerNotation(){ modeNotation = false; fermerPanneauNotation(); if(bufferNotation.length > 0){ envoyerNotation(bufferNotation); } bufferNotation = []; } function traiterLigneNotation(texte){ const cloture = trouverPhraseDeControle(texte, PHRASES_CLOTURE); const annulation = cloture ? null : trouverPhraseDeControle(texte, PHRASES_ANNULATION); const repere = cloture || annulation; if(repere){ // Même comportement dans les deux cas : on enregistre ce qui a été // dit, sans traitement différencié (demande explicite de Jean-Marc). // Tout ce qui précède la phrase de clôture/annulation dans cette même // phrase entendue est la dernière ligne de contenu ("date de // réalisation le 12/09 c'est tout Lilou" dit d'une traite) : on la // capture avant de clore. const avant = nettoyerLigne(texte.slice(0, repere.index)); if(avant){ bufferNotation.push(avant); afficherLigneDansPanneau(avant); } terminerNotation(); return; } const ligne = nettoyerLigne(texte); if(ligne){ bufferNotation.push(ligne); afficherLigneDansPanneau(ligne); } } reconnaissance.addEventListener("start", passerEnEcoute); reconnaissance.addEventListener("end", function(){ if(ecouteVoulue && !erreurBloquante){ // Le moteur s'est arrêté tout seul (silence prolongé, limite interne // du navigateur...) alors que l'utilisateur n'a rien demandé de tel // : on relance aussitôt, sans que ça se voie côté bouton. try{ reconnaissance.start(); }catch(erreur){ revenirAuRepos(); notifier("Le micro s'est arrêté et n'a pas pu redémarrer automatiquement."); } return; } revenirAuRepos(); }); // Mot déclencheur en tête de phrase, avec ou sans virgule/deux-points // juste après ("Lilou, ajoute une tâche" ou "Lilou ajoute une tâche") — // "Milou" toléré au même titre que les autres motifs, voir MOT_LILOU. const motifDeclencheur = new RegExp("^" + MOT_LILOU + "\\s*[,:]?\\s*", "i"); reconnaissance.addEventListener("result", function(evenement){ const dernier = evenement.results[evenement.results.length - 1]; const texte = dernier[0].transcript.trim(); if(modeNotation){ // En notation, plus besoin de "Lilou" en tête sur chaque ligne — on // ne teste que la clôture/annulation, tout le reste est du contenu. traiterLigneNotation(texte); return; } let commande; if(motifDeclencheur.test(texte)){ commande = texte.replace(motifDeclencheur, "").trim(); } else if(reveilEnAttente && Date.now() <= expirationReveil){ // "Lilou" venait d'être entendu seul : on accepte ce résultat-ci // comme s'il avait été précédé du mot déclencheur (voir commentaire // sur reveilEnAttente plus haut). commande = texte; } else { // Pas adressé à Lilou (conversation de fond, etc.) : ignoré en // silence, sans notification — c'est tout l'intérêt du mot // déclencheur en écoute continue. Laissé en console pour le debug. console.log('[micro.js] ignoré (pas de "Lilou" en tête) :', texte); return; } reveilEnAttente = false; if(!commande){ // "Lilou" entendu seul, rien d'exploitable derrière dans ce même // résultat — probablement coupé par le moteur juste après la // virgule. On laisse une petite fenêtre pour que la suite, dite // juste après, soit acceptée même sans "Lilou" en tête. reveilEnAttente = true; expirationReveil = Date.now() + DUREE_REVEIL_MS; notifier("Lilou vous écoute."); return; } if(PHRASES_OUVERTURE.some(function(m){ return m.test(commande); })){ demarrerNotation(); return; } // Étape 1 (hors notation) : simple retour visuel du texte reconnu // après le mot déclencheur, pour valider le circuit — aucune autre // commande interprétée à ce stade. notifier('Lilou a entendu : "' + commande + '"'); }); reconnaissance.addEventListener("error", function(evenement){ const messagesBloquants = { "not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.", "service-not-allowed": "Micro refusé — vérifie l'autorisation dans le navigateur.", "audio-capture": "Aucun micro détecté." }; if(messagesBloquants[evenement.error]){ erreurBloquante = true; notifier(messagesBloquants[evenement.error]); return; } // Erreurs considérées transitoires (rien entendu, réseau...) : pas de // erreurBloquante, l'événement "end" qui suit relancera l'écoute tant // que ecouteVoulue reste vrai. const messagesTransitoires = { "no-speech": "Rien entendu, le micro reste ouvert.", "network": "Problème réseau pendant la reconnaissance, nouvelle tentative..." }; notifier(messagesTransitoires[evenement.error] || ("Erreur micro (" + evenement.error + ").")); }); })();