mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 16:07:10 +00:00
419 lines
19 KiB
JavaScript
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 + ")."));
|
|
});
|
|
})();
|