mirror of
https://github.com/JMP-MS/ASPIC.git
synced 2026-10-27 16:07:10 +00:00
V1.005 Débuts de l'intégration de la voix
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 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 + ")."));
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user