Files
ASPIC/micro.js
T

419 lines
19 KiB
JavaScript

/**
* ASPIC — micro.js
*
* Comportement du bouton micro (#bouton-micro), partagé par tous les
* écrans qui l'affichent — chargé via <script src="micro.js"></script> 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 =
'<div class="titre">Lilou vous écoute…</div><ul class="lignes"></ul>';
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 + ")."));
});
})();