This commit is contained in:
pporcheret
2026-10-26 00:06:24 +01:00
parent c9c3d5f223
commit cd37ac3c0a
207 changed files with 19205 additions and 54 deletions
+15
View File
@@ -10,3 +10,18 @@ AUDIO_OUTPUT_DEVICE=plughw:1,0
## fr_FR-upmc-medium ## fr_FR-upmc-medium
## PIPER_VOICE=fr_FR-mls-medium ## PIPER_VOICE=fr_FR-mls-medium
PIPER_VOICE=fr_FR-upmc-medium PIPER_VOICE=fr_FR-upmc-medium
#Calendrier HA
ALFRED_TIMEZONE=Europe/Paris
HA_DEFAULT_CALENDAR=calendar.alfred
ALFRED_ALLOW_CALENDAR_WRITES=true
HA_URL=http://192.168.1.141:8123
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MTU3YzFjYjA3NWY0YmNkOGRkYTEzYzIyOWVkMDFkOSIsImlhdCI6MTc4MTUyNjg5MSwiZXhwIjoyMDk2ODg2ODkxfQ.i4IfhNwgfPMmRnIhLdapq10sTxBKnNJmy4kFCDX4Ylw
ALFRED_WEATHER_ILLUSTRATIONS=true
ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS=12
COMFYUI_URL=http://192.168.1.13:8188
WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows
COMFY_PROMPT_NODE=6
COMFYUI_TIMEOUT=300
+65 -42
View File
@@ -1,59 +1,82 @@
# Alfred — Interface mise à jour depuis interface.zip (24/10/2026) # Alfred — Vues dynamiques & Calendrier Home Assistant (V1)
## Modifications Base : votre **alfredapp.zip** du 25 octobre 2026. Aucun avatar n’est ajouté. Les fonctions Vosk, Hermes, Wyoming Piper, caméra locale, contrôles audio et ComfyUI sont conservées.
- Coin supérieur gauche : état réel de l'agent (`idle`, `listening`, `thinking`, `speaking`, `error`) avec libellé en français. Le titre « Alfred » est supprimé de la page. ## Installation : patch (conseillé)
- Coin supérieur droit : bouton « réglages audio » ouvrant un panneau flottant (volume sortie, sensibilité micro ALSA, mute micro), et bouton « couper/réactiver le son » (mute haut-parleur).
- Coin inférieur droit : transcription Vosk partielle puis finale, conservée pendant le retour à la veille. Les données arrivent des événements Socket.IO `alfred_state` existants (`partial` et `transcript`).
- Coin inférieur gauche : prévisualisation webcam MJPEG en direct. La caméra s'ouvre **uniquement sur clic** pour ne pas occuper en permanence la caméra utilisée par le MCP facial, et s'arrête lorsque l'onglet devient invisible.
- Protection supplémentaire : le texte d'Hermes est rendu sans injection de code HTML dans la page.
## Installation 1. Sauvegarder **/opt/alfred/app**. Le ZIP de mise à jour ne contient que les fichiers ajoutés/modifiés, sous `app/`.
2. Décompresser `Alfred_Calendrier_HA_V1_PATCH.zip` sous **/opt/alfred/** (pas sous `/opt/alfred/app`, sinon vous obtiendrez `app/app`).
À effectuer sur la machine Alfred après transfert du ZIP, en étant dans son dossier parent `/opt/alfred` : 3. Activer votre environnement Python habituel et installer les dépendances si nécessaire :
```bash ```bash
sudo systemctl stop alfred sudo systemctl stop alfred
sudo cp -a /opt/alfred/app /opt/alfred/app.sauvegarde-$(date +%Y%m%d-%H%M%S) sudo cp -a /opt/alfred/app /opt/alfred/app.sauvegarde.$(date +%Y%m%d_%H%M%S)
cd /opt/alfred sudo unzip -o /tmp/Alfred_Calendrier_HA_V1_PATCH.zip -d /opt/alfred
sudo unzip -o /tmp/Alfred_Interface_Reorganisation_V1.zip cd /opt/alfred/app
python3 -m py_compile /opt/alfred/app/app.py /opt/alfred/app/services/camera.py # Utiliser le pip du venv exécutant app.py :
sudo systemctl start alfred /path/vers/venv/bin/python -m pip install requests websocket-client
sudo journalctl -u alfred -n 50 --no-pager /path/vers/venv/bin/python -m py_compile app.py services/ha_views.py services/alfred_intents.py services/alfred_views_controller.py
``` ```
Le ZIP remplace uniquement : Le script de démarrage systemd existant n’est **pas modifié**.
``` ## Variables d’environnement à fournir à `alfred.service`
app/app.py
app/templates/index.html ```ini
app/static/css/alfred.css HA_URL=http://192.168.1.XXX:8123
app/static/css/alfred-controls.css HA_TOKEN=JETON_LONGUE_DUREE_HOME_ASSISTANT
app/static/js/alfred.js ALFRED_TIMEZONE=Europe/Paris
app/static/js/alfred-controls.js # Limiter aux calendriers choisis (laisser absent pour lire tous les calendriers autorisés au jeton) :
app/static/js/alfred-camera.js (nouveau) # HA_CALENDARS=calendar.famille,calendar.alfred
app/services/camera.py # Permet de cibler un calendrier pour les créations vocales :
# HA_DEFAULT_CALENDAR=calendar.alfred
# Pour restreindre les caméras visibles (ex. uniquement les caméras extérieures) :
# HA_CAMERAS=camera.entree,camera.jardin
# L'écriture est OFF par sécurité ; l'activer après validation de la lecture :
ALFRED_ALLOW_CALENDAR_WRITES=true
``` ```
## Webcam Mettre ces valeurs **dans l’environnement du service** (par exemple un fichier `EnvironmentFile=` de systemd ou fichier `.env` effectivement chargé par `/opt/alfred/app/app.py`). **Ne pas mettre le jeton dans le navigateur ni dans le ZIP.** Si l’application utilise `load_dotenv()`, un fichier `.env` dans son répertoire de travail peut convenir, mais vérifiez `systemctl cat alfred` avant de choisir son emplacement.
- Index de la caméra par défaut : 0, réglable avec `CAMERA_INDEX=1` (ou autre) dans l'environnement du service Alfred. Après modification des paramètres systemd : `sudo systemctl daemon-reload`. Puis `sudo systemctl start alfred` et `sudo journalctl -u alfred -f`.
- Options facultatives : `CAMERA_WIDTH=640`, `CAMERA_HEIGHT=480`, `CAMERA_FPS=8`, `CAMERA_JPEG_QUALITY=75`.
- `cv2` / OpenCV doit être installé dans **l'environnement Python d'Alfred**, par exemple `python -m pip install opencv-python-headless` depuis son venv (uniquement si absent).
- Tester le périphérique : `ls -l /dev/video*`.
- URL du flux : `http://IP_ALFRED:54321/api/camera/stream`.
- Si le MCP de reconnaissance faciale utilise déjà la webcam en exclusivité, le flux peut échouer. Désactiver la prévisualisation avant une capture MCP si nécessaire. Cette version ne partage pas les images entre les processus.
- Attention : le flux est actuellement accessible à toute personne ayant accès au serveur Flask (aucune authentification intégrée à l'application de départ). Ne pas exposer le port 54321 sur Internet sans contrôle d'accès.
## Audio L’utilisateur associé à HA_TOKEN doit avoir des droits lecture sur les calendriers voulus et des droits écriture sur ceux à modifier. Les calendriers Google ou externes en lecture seule ne permettront pas l’écriture. Certains calendriers n’acceptent pas la suppression.
Le bouton « Mute » en haut à droite agit sur **le haut-parleur**. Le mute du **microphone** se trouve dans le popup audio. Tous les réglages utilisent l'API POST `/api/audio/settings` existante. ## Tests initiaux
## Tests réalisés 1. Ouvrir `http://IP_ALFRED:54321/`. Appuyer sur l'icône **Agenda**.
2. Tester `http://IP_ALFRED:54321/api/calendar/calendars` puis `http://IP_ALFRED:54321/api/calendar/events` depuis une session réseau de confiance.
3. Dire « Alfred, montre-moi mon planning sur sept jours » puis « Et demain ? ».
4. Faire un événement de test via le bouton **+ Rendez-vous** dans un calendrier HA disposant de l'option création.
5. Depuis la grille, ouvrir cet événement, demander sa suppression, puis confirmer la boîte de dialogue ; vérifier l'événement dans HA.
- Compilation syntaxique de `app.py` et `camera.py`. **Commandes prises en charge** : « mon agenda aujourd'hui », « demain », « ce matin », « cet après-midi », « ce soir », « dans la journée », « le 8 novembre », « sur 3 jours », « sur 7 jours », « cette semaine », « la semaine prochaine », « ce mois », « ai-je des choses prévues demain ? », « ajoute un rendez-vous dentiste demain à 15 h dans le calendrier Famille », « supprime mon rendez-vous dentiste demain », « confirme » / « annule », « montre les températures », « affiche les caméras », « reviens à l'accueil ».
- Vérification syntaxique des trois scripts JavaScript au moyen de `node --check`.
- Validation des identifiants des contrôles de l'interface.
- Test du générateur MJPEG, du verrou d'accès à la caméra et de sa libération sur fermeture, avec une caméra simulée.
Non testés : affichage en conditions réelles sous Chromium, accès à la caméra physique du MINISFORUM, intégration réelle à Vosk/ALSA. L'environnement de test empêche la navigation Chromium locale. ### Écriture vocale et sécurité
- Pour une création entièrement spécifiée (date, heure, calendrier, titre), Alfred propose l'événement et **attend « confirme »** avant d'enregistrer. Si des éléments manquent, il affiche un formulaire prérempli ; il ne devine pas le calendrier ou l'heure.
- Pour une suppression, Alfred recherche l'événement par nom/période, exige un identifiant HA `uid` et une **confirmation explicite**. Si plusieurs événements correspondent, l'écran permet de choisir. Une confirmation vocale expire après 90 secondes ; elle n'est pas sauvegardée après redémarrage. Pour une série récurrente sans `recurrence_id`, la suppression peut affecter toute la série ; la boîte de dialogue l'indique.
- `ALFRED_ALLOW_CALENDAR_WRITES` désactive la création/suppression par défaut ; l'activer seulement sur un réseau de confiance et un utilisateur Home Assistant aux droits restreints. **Cette application ne fournit pas d'authentification utilisateur complète** : ne pas exposer Flask à Internet ni à un réseau non fiable. Les événements contiennent des données personnelles.
- La création utilise `POST /api/services/calendar/create_event`. La suppression utilise la commande HA WebSocket `calendar/event/delete`, **pas** un service REST inexistant.
### Vue capteurs et caméras
- Les températures sont les entités HA `sensor` ayant `device_class: temperature`, plus les températures courantes des entités `climate`. Le clic ouvre un graphique des dernières 24 h (si l'API d'historique HA fournit des points).
- La vue Caméras Home Assistant affiche des **instantanés** sécurisés derrière Flask, rafraîchis toutes les 15 s et seulement quand la vue est ouverte. Ce n'est pas un flux vidéo WebRTC/RTSP. La petite prévisualisation de la webcam locale existante reste inchangée.
- Le bouton Accueil ou la commande vocale rétablit la page d'accueil.
### Remarques sur le contexte Hermes
Ce patch **ne modifie pas `hermes_client.py`, `identity.md`, les sessions Hermes ou le MCP Home Assistant**. Les commandes visuelles explicites sont détectées côté Flask ; les autres demandes continuent vers Hermes et ses MCP, pour éviter de réintroduire la limitation du contexte qui avait empêché la génération ComfyUI.
### Limites connues
- L'interprétation vocale des dates suit un jeu de règles françaises (aujourd'hui/demain/jour de la semaine/jour et mois). Une formulation très libre peut nécessiter le formulaire tactile ou Hermes ; le routeur ne doit pas improviser une écriture.
- La couleur des événements suit la liste des calendriers ; elle n'est pas encore configurable.
- Certains calendriers de HA ne transmettent pas `uid` ou sont en lecture seule : leurs événements restent consultables, mais ne peuvent pas être supprimés dans Alfred.
- Les appels HA ne sont pas testables depuis ce fichier d'installation sans accès à votre serveur/token ; les validations automatiques portent sur la syntaxe, le routage vocal et des réponses HA simulées.
- Les requêtes de lecture et de mutation sont faites directement de Flask vers Home Assistant, **sans dupliquer les calendriers**. C'est un adaptateur de source, réutilisable pour d'autres fournisseurs plus tard.
## Restauration
Arrêter Alfred puis restaurer le dossier sauvegardé. Ne pas copier `identity.md` d'anciennes archives si vous l'aviez volontairement désactivé.
+42
View File
@@ -0,0 +1,42 @@
# Alfred — Micro muet dans la barre supérieure
## Modifications
- `app/templates/index.html` : déplace `#micMute` depuis la fenêtre audio vers la barre supérieure, à gauche du mute haut-parleur. Bouton avec deux icônes SVG (micro activé / micro coupé).
- `app/static/js/alfred-controls.js` : conserve l'API existante `POST /api/audio/settings` et actualise `aria-pressed` ainsi que le libellé du bouton en fonction de `mic_muted`.
- `app/static/css/alfred-controls.css` : affiche l'icône appropriée selon l'état du micro (état coupé rouge via la règle globale existante).
La fenêtre des réglages conserve les curseurs de volume et de sensibilité du microphone. Le bouton de mute du haut-parleur reste inchangé. Aucune modification Python, Vosk, Hermes, Home Assistant ou calendrier.
## Installation
**Ce patch s'applique sur Alfred Calendrier V1**, et contient uniquement les trois fichiers ci-dessus.
1. Copier `Alfred_Micro_Mute_Barre_V1.zip` dans `/tmp/` sur le mini-PC.
2. Sauvegarder les trois fichiers, puis extraire le ZIP depuis `/opt/alfred` (et non `/opt/alfred/app`).
```bash
sudo systemctl stop alfred
sudo cp -a /opt/alfred/app/templates/index.html /opt/alfred/app/templates/index.html.bak
sudo cp -a /opt/alfred/app/static/js/alfred-controls.js /opt/alfred/app/static/js/alfred-controls.js.bak
sudo cp -a /opt/alfred/app/static/css/alfred-controls.css /opt/alfred/app/static/css/alfred-controls.css.bak
sudo unzip -o /tmp/Alfred_Micro_Mute_Barre_V1.zip -d /opt/alfred
sudo systemctl start alfred
```
Rechargez Chromium avec `Ctrl+F5` pour éviter l'ancien JavaScript en cache.
## Vérification
- Bouton micro visible en haut à droite, **à gauche du haut-parleur**.
- Appui : l'icône devient rouge et le micro est coupé ; nouvel appui : il est réactivé.
- Le bouton de réglages ouvre uniquement les curseurs de volume et de sensibilité.
- Le bouton haut-parleur reste indépendant.
Le mute du micro continue à agir sur l'écoute d'Alfred via le backend existant. Lorsque le micro est coupé, les commandes vocales ne peuvent pas le réactiver : il faut utiliser le bouton tactile.
## Tests effectués
Syntaxe JavaScript vérifiée (`node --check`). Test Chromium automatisé : états micro activé/coupé/réactivé, lecture/écriture de `mic_muted` sur une simulation de l'API, présence des autres commandes, ouverture du popup.
Ces tests ne remplacent pas une vérification réelle sur le mini-PC (ALSA, systemd et Vosk).
+226
View File
@@ -0,0 +1,226 @@
import os, threading, hmac
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.hermes_client import ask_hermes
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
load_dotenv()
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
+180
View File
@@ -0,0 +1,180 @@
import os, threading
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.hermes_client import ask_hermes
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
load_dotenv()
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
audio_settings.initialize()
def emit_state(mode=None, **extra):
if mode: STATE["mode"]=mode
STATE.update(extra); socketio.emit("alfred_state",STATE)
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
print("[ALFRED] Envoi à Hermes", flush=True)
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
STATE["content"] = display
socketio.emit("alfred_render", display)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()})
@app.post("/api/state")
def set_state():
STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True})
@app.post("/api/render")
def render_payload():
data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,130 @@
import os, threading
from flask import Flask, render_template, request, jsonify
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.hermes_client import ask_hermes
load_dotenv()
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
conversation_lock=threading.Lock()
def emit_state(mode=None, **extra):
if mode: STATE["mode"]=mode
STATE.update(extra); socketio.emit("alfred_state",STATE)
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
print("[ALFRED] Envoi à Hermes", flush=True)
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
STATE["content"] = display
socketio.emit("alfred_render", display)
if speech:
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()})
@app.post("/api/state")
def set_state():
STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True})
@app.post("/api/render")
def render_payload():
data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on"): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,379 @@
"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre.
STT: Vosk + sounddevice, avec fallback arecord/ALSA.
TTS: Piper local, avec fallback espeak puis spd-say.
Sortie: haut-parleur local via paplay/aplay.
Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu
à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique.
"""
from __future__ import annotations
from functools import lru_cache
import time
import json
import os
import queue
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from typing import Callable, Optional
def _cmd_ok(cmd, **kwargs):
try:
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True, text=True, **kwargs)
except Exception:
return None
def audio_backend_detect() -> str:
configured = os.getenv("AUDIO_BACKEND", "auto").lower()
if configured in ("pactl", "alsa"):
return configured
if shutil.which("pactl"):
info = _cmd_ok(["pactl", "info"])
if info and "Server Name" in info.stdout:
return "pactl"
if shutil.which("amixer"):
return "alsa"
return "none"
def _which(cmd):
return shutil.which(cmd)
def _is_piper_tts(binary: Optional[str]) -> bool:
if not binary:
return False
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
if "gtk" in out or "ratbag" in out:
return False
return (("--model" in out or " -m " in out) and
("--output_file" in out or " -o " in out))
except Exception:
return False
def _find_piper_binary() -> Optional[str]:
candidates = [
os.getenv("PIPER_BIN"),
"/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"),
"/usr/local/bin/piper", "/usr/bin/piper", _which("piper"),
]
for candidate in candidates:
if candidate and _is_piper_tts(candidate):
return candidate
return None
def _piper_supports_long_flag(binary: str) -> bool:
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
return "--model" in out and "--output_file" in out
except Exception:
return True
###
def tts_backend_detect() -> str:
"""Retourne le moteur TTS configuré pour Alfred."""
if os.getenv("PIPER_HOST"):
return "wyoming-piper"
if _which("espeak-ng"):
return "espeak-ng"
return "none"
'''
def tts_backend_detect() -> str:
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
return "piper"
if _which("espeak"):
return "espeak"
if _which("spd-say"):
return "spd-say"
return "none"
'''
####
def speak_text(text: str):
"""Synthèse vocale via Wyoming Piper, avec secours eSpeak."""
if not text or not text.strip():
return
text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))]
try:
from services.piper_tts import speak as piper_speak
print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True)
piper_speak(text)
return
except Exception as exc:
print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True)
# Secours eSpeak
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
print("[ALFRED TTS] Secours eSpeak", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
except Exception as exc:
print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
'''
def speak_text(text: str):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
start = time.monotonic()
print("[ALFRED TTS] Génération audio...", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
print("[ALFRED TTS] Lecture audio...", flush=True)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED TTS] Erreur : {e}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
def speak_text(text: str):
"""Synthétise et joue localement le texte. Retourne (ok, backend, erreur)."""
text = (text or "").strip()
if not text:
return False, "none", "texte vide"
text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))]
backend = audio_backend_detect()
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
wav_path = tmp.name
cmd = [piper]
if _piper_supports_long_flag(piper):
cmd += ["--model", model, "--output_file", wav_path]
else:
cmd += ["-m", model, "-o", wav_path]
p = subprocess.run(cmd, input=text, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if p.returncode != 0:
return False, "piper", (p.stderr or p.stdout or "")[:2000]
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
return False, "piper", "Piper n'a pas produit de WAV"
if backend == "pactl" and shutil.which("paplay"):
play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif shutil.which("aplay"):
play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else:
return False, "piper", "paplay/aplay introuvable"
return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée"
except Exception as exc:
return False, "piper", str(exc)
finally:
if wav_path:
try: os.unlink(wav_path)
except OSError: pass
if _which("espeak"):
args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text]
if backend == "pactl" and shutil.which("padsp"):
args = ["padsp"] + args
ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
return ok, "espeak", None if ok else "espeak a échoué"
if _which("spd-say"):
ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE,
stderr=subprocess.PIPE).returncode == 0
return ok, "spd-say", None if ok else "spd-say a échoué"
return False, "none", "aucun backend TTS disponible"
'''
_tts_lock = threading.Lock()
def speak_async(text: str, on_start: Optional[Callable] = None,
on_end: Optional[Callable] = None) -> bool:
"""Lecture non bloquante; refuse d'empiler deux réponses vocales."""
if _tts_lock.locked():
return False
def worker():
with _tts_lock:
if on_start: on_start()
try: speak_text(text)
finally:
if on_end: on_end()
threading.Thread(target=worker, daemon=True).start()
return True
@dataclass
class STTResult:
text: str = ""
error: Optional[str] = None
@lru_cache(maxsize=2)
def get_vosk_model(model_path):
from vosk import Model
print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True)
model = Model(model_path)
print("[ALFRED] Modèle Vosk prêt", flush=True)
return model
def listen_once(stop_event: Optional[threading.Event] = None,
on_partial: Optional[Callable[[str], None]] = None,
on_level: Optional[Callable[[float], None]] = None) -> STTResult:
"""Attend une phrase finale Vosk et la retourne.
Reprend le comportement du prototype: sounddevice en priorité, arecord sinon.
Le niveau audio transmis à l'UI est une approximation RMS sur int16.
"""
try:
import numpy as np
from vosk import Model, KaldiRecognizer
except Exception as exc:
return STTResult(error=f"Dépendance STT absente: {exc}")
model_path = os.getenv("VOSK_MODEL_PATH", "")
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
if not model_path or not os.path.exists(model_path):
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
try:
# rec = KaldiRecognizer(Model(model_path), sample_rate)
rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate)
except Exception as exc:
return STTResult(error=f"Init Vosk: {exc}")
def process(data: bytes) -> Optional[str]:
if on_level and data:
try:
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0
on_level(min(1.0, rms / 6000.0))
except Exception:
pass
if rec.AcceptWaveform(data):
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
if text:
return text
elif on_partial:
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True)
on_partial(partial)
return None
try:
import sounddevice as sd
q = queue.Queue()
def callback(indata, frames, time_info, status):
q.put(bytes(indata))
device = os.getenv("AUDIO_INPUT_DEVICE") or None
with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16",
channels=1, callback=callback, device=device):
while not (stop_event and stop_event.is_set()):
text = process(q.get())
if text:
return STTResult(text=text)
except Exception as exc:
print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True)
# except Exception:
# pass
if not shutil.which("arecord"):
return STTResult(error="sounddevice indisponible et arecord introuvable")
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"]
alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip()
if alsa_device:
cmd += ["-D", alsa_device]
proc = None
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
while not (stop_event and stop_event.is_set()):
chunk = proc.stdout.read(8000)
if not chunk:
continue
text = process(chunk)
if text:
return STTResult(text=text)
return STTResult()
except Exception as exc:
return STTResult(error=f"arecord: {exc}")
finally:
if proc and proc.poll() is None:
proc.terminate()
def devices():
try:
# import sounddevice as sd
# return sd.query_devices()
print("[ALFRED STT] Périphériques audio :", flush=True)
print(sd.query_devices(), flush=True)
print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True)
return sd.query_devices()
except Exception:
return []
@@ -0,0 +1,19 @@
/* Contrôles indépendants de la feuille alfred.css existante */
body > main { position: relative; min-height: 100dvh; padding-bottom: 82px; box-sizing: border-box; }
.alfred-controls {
position: absolute; left: 16px; right: 16px; bottom: 12px;
display: flex; align-items: center; gap: 14px; min-height: 56px;
padding: 8px 18px; box-sizing: border-box;
border: 1px solid rgba(255,255,255,.10); border-radius: 16px;
background: rgba(35,37,41,.92); color: #f4f4f5;
box-shadow: 0 8px 28px rgba(0,0,0,.14);
font: 14px/1.3 system-ui, sans-serif;
}
.alfred-controls .audio-toggle { flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border: 1px solid transparent; border-radius: 12px; background: transparent; color: inherit; font-size: 23px; cursor: pointer; }
.alfred-controls .audio-toggle:hover, .alfred-controls .audio-toggle:focus-visible { background: rgba(255,255,255,.10); outline-offset: 2px; }
.alfred-controls .audio-toggle[aria-pressed="true"] { background: rgba(225,100,100,.14); border-color: rgba(225,100,100,.28); }
.alfred-controls .volume-label { white-space: nowrap; color: #c8c8ce; }
.alfred-controls input[type="range"] { min-width: 60px; flex: 1; accent-color: #d1d5db; cursor: pointer; }
.alfred-controls output { min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; }
.alfred-controls .controls-separator { height: 25px; width: 1px; background: rgba(255,255,255,.16); }
@media (max-width: 480px) { .alfred-controls { gap: 8px; padding: 6px 9px; left: 8px; right: 8px; } .alfred-controls .volume-label { display: none; } }
@@ -0,0 +1,73 @@
(() => {
'use strict';
const slider = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const speaker = document.getElementById('speakerMute');
const mic = document.getElementById('micMute');
const endpoint = '/api/audio/settings';
let state = { volume: 70, speaker_muted: false, mic_muted: false };
let saveTimer;
function render() {
slider.value = String(state.volume);
volumeValue.value = `${state.volume} %`;
speaker.setAttribute('aria-pressed', String(state.speaker_muted));
speaker.setAttribute('aria-label', state.speaker_muted ? 'Réactiver le haut-parleur' : 'Couper le haut-parleur');
speaker.title = speaker.getAttribute('aria-label');
speaker.textContent = state.speaker_muted ? '🔇' : '🔊';
mic.setAttribute('aria-pressed', String(state.mic_muted));
mic.setAttribute('aria-label', state.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone');
mic.title = mic.getAttribute('aria-label');
mic.textContent = state.mic_muted ? '🎙️̸' : '🎙️';
}
async function save() {
try {
const response = await fetch(endpoint, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (result && typeof result.volume === 'number') {
state = { ...state, ...result };
render();
}
} catch (error) {
console.warn('Réglages audio non synchronisés avec Flask :', error);
}
}
slider.addEventListener('input', () => {
state.volume = Number(slider.value);
render();
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 200);
});
speaker.addEventListener('click', () => {
state.speaker_muted = !state.speaker_muted;
render();
clearTimeout(saveTimer);
save();
});
mic.addEventListener('click', () => {
state.mic_muted = !state.mic_muted;
render();
clearTimeout(saveTimer);
save();
});
(async () => {
try {
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const settings = await response.json();
if (typeof settings.volume === 'number') state.volume = Math.max(0, Math.min(100, settings.volume));
if (typeof settings.speaker_muted === 'boolean') state.speaker_muted = settings.speaker_muted;
if (typeof settings.mic_muted === 'boolean') state.mic_muted = settings.mic_muted;
} catch (error) {
console.warn('Réglages audio non disponibles :', error);
}
render();
})();
})();
@@ -0,0 +1,62 @@
"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe."""
import os
import re
import subprocess
import threading
_LOCK = threading.RLock()
_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False}
_CARD = os.getenv('ALFRED_ALSA_CARD', '1')
_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master')
def _amixer(*args):
return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True,
text=True, timeout=5, check=True).stdout
def initialize():
"""Lit le volume existant sans modifier les réglages audio au démarrage."""
try:
output = _amixer('sget', _CONTROL)
matches = re.findall(r'\[(\d+)%\]', output)
with _LOCK:
if matches:
_STATE['volume'] = int(matches[0])
# Ne pas déduire speaker_muted du mixer : état propre à Alfred.
except (OSError, subprocess.SubprocessError) as exc:
print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True)
def get_settings():
with _LOCK:
return dict(_STATE)
def update_settings(payload):
if not isinstance(payload, dict):
raise ValueError('Un objet JSON est attendu')
allowed = {'volume', 'speaker_muted', 'mic_muted'}
if set(payload) - allowed:
raise ValueError('Paramètre audio inconnu')
with _LOCK:
new = dict(_STATE)
if 'volume' in payload:
v = payload['volume']
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100:
raise ValueError('volume doit être entre 0 et 100')
new['volume'] = round(v)
for key in ('speaker_muted', 'mic_muted'):
if key in payload:
if not isinstance(payload[key], bool):
raise ValueError(f'{key} doit être un booléen')
new[key] = payload[key]
# Ne touche jamais aux contrôles Mic/Capture : calibration conservée.
if new['volume'] != _STATE['volume']:
_amixer('sset', _CONTROL, f"{new['volume']}%")
_STATE.update(new)
return dict(_STATE)
def speaker_muted():
with _LOCK:
return _STATE['speaker_muted']
def mic_muted():
with _LOCK:
return _STATE['mic_muted']
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{name}}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main>
<header><span id="name">{{name}}</span><span id="mode">repos</span></header>
<section id="stage"><div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div></section>
<section id="wave"><canvas id="waveCanvas"></canvas></section>
<footer class="alfred-controls" aria-label="Contrôles audio">
<button id="speakerMute" class="audio-toggle" type="button" aria-label="Couper le haut-parleur" aria-pressed="false" title="Couper le haut-parleur">🔊</button>
<label class="volume-label" for="volumeSlider">Volume</label>
<input id="volumeSlider" type="range" min="0" max="100" step="1" value="70" aria-label="Volume sonore">
<output id="volumeValue" for="volumeSlider">70 %</output>
<span class="controls-separator" aria-hidden="true"></span>
<button id="micMute" class="audio-toggle" type="button" aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">🎙️</button>
</footer>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-controls.js"></script>
</body>
</html>
@@ -0,0 +1,3 @@
{
"session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599"
}
@@ -0,0 +1,3 @@
{
"session_id": "local-f2399cb2bcf9e91094d3dc34492b463eda9cf42d09857d51b5954c5982aaaa5e"
}
@@ -0,0 +1,31 @@
# Identité et comportement d'Alfred
Tu es Alfred, le majordome vocal personnel de la maison.
Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
- Tutoie ton interlocuteur.
- Adopte un ton naturel, calme, cordial et professionnel.
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
## Domotique
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
- Ne confirme une action que si son exécution a été effectivement vérifiée.
- Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. »
# Alfred
Tu disposes des outils et compétences configurés
dans Hermes Agent.
Utilise librement les outils disponibles lorsque
la demande le nécessite.
Ne limite pas tes capacités à la domotique.
@@ -0,0 +1,26 @@
# Identité d'Alfred
Tu es Alfred, un majordome personnel intelligent.
Tu fonctionnes dans une maison équipée de Home Assistant.
Tu utilises Hermes Agent comme moteur de raisonnement.
## Comportement
- Ton nom est Alfred.
- Tu réponds toujours en français, sauf demande explicite contraire.
- Tu t'adresses à ton utilisateur en le tutoyant.
- Tu adoptes un ton naturel, calme, chaleureux et professionnel.
- Tu privilégies les réponses courtes, adaptées à une conversation vocale.
- Tu évites les introductions inutiles et les formulations trop techniques.
- Tu ne mentionnes pas Hermes, Ollama ou ton fonctionnement interne sauf si on te le demande.
## Domotique
- Tu peux utiliser les outils Home Assistant disponibles.
- Lorsque tu exécutes une commande, confirme brièvement le résultat.
- Ne prétends jamais avoir exécuté une action si l'outil a échoué.
## Identité
Si on te demande ton nom, tu réponds : « Je m'appelle Alfred. »
@@ -0,0 +1,74 @@
# Identité et comportement d'Alfred
Tu es Alfred, un majordome numérique personnel,
intelligent, polyvalent et connecté.
Tu n'es pas limité à la domotique.
Tu es un assistant capable de répondre à des questions,
de rechercher des informations, de générer des images,
d'analyser des documents et d'interagir avec la maison.
## Langue et personnalité
- Réponds toujours en français.
- Tutoie ton interlocuteur.
- Adopte un ton calme, cordial et professionnel.
- Privilégie les réponses courtes adaptées à la voix.
- Ne mentionne pas ton architecture interne sans raison.
## Capacités et outils
Tu disposes de plusieurs outils MCP.
### Home Assistant
- Contrôler les équipements connectés.
- Consulter les capteurs et les états.
- Exécuter les commandes demandées.
### ComfyUI
- Générer des images à partir de descriptions.
- Analyser des images avec les outils disponibles.
- Lorsqu'une génération est demandée, recherche et
utilise l'outil MCP generate_image.
- L'outil ComfyUI peut transmettre directement
l'image générée à l'interface graphique d'Alfred.
- Ne prétends pas être incapable de générer une image
sans avoir vérifié la disponibilité de cet outil.
### Autres capacités
- Répondre aux questions générales.
- Utiliser les outils de recherche disponibles.
- Reconnaître des personnes avec le MCP facial,
uniquement lorsque son utilisation est appropriée.
- Utiliser les autres MCP selon les demandes.
## Règles générales
- Choisis l'outil approprié selon la demande.
- Ne limite jamais tes capacités à la seule domotique.
- Ne prétends jamais avoir exécuté une action
si celle-ci n'a pas abouti.
- Si un outil est indisponible, explique le problème.
- Ne fabrique jamais de résultats d'outils.
## Identité
Si on te demande ton nom, réponds :
"Je m'appelle Alfred."
## Génération d'images
Tu peux générer des images avec le MCP ComfyUI.
Lorsqu'un utilisateur demande une image :
1. Recherche l'outil generate_image du MCP ComfyUI.
2. Si Tool Search est actif, utilise tool_search pour
découvrir l'outil et tool_describe si nécessaire.
3. Appelle l'outil de génération avec la description.
4. Attends son résultat avant de confirmer la génération.
Le MCP ComfyUI transmet automatiquement les images
générées à l'interface graphique d'Alfred.
Ne prétends pas que tu es limité à la domotique.
Si l'outil n'est pas accessible, indique l'erreur réelle.
@@ -0,0 +1,392 @@
"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre.
STT: Vosk + sounddevice, avec fallback arecord/ALSA.
TTS: Piper local, avec fallback espeak puis spd-say.
Sortie: haut-parleur local via paplay/aplay.
Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu
à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique.
"""
from __future__ import annotations
from functools import lru_cache
import time
import json
import os
import queue
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from typing import Callable, Optional
def _cmd_ok(cmd, **kwargs):
try:
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True, text=True, **kwargs)
except Exception:
return None
def audio_backend_detect() -> str:
configured = os.getenv("AUDIO_BACKEND", "auto").lower()
if configured in ("pactl", "alsa"):
return configured
if shutil.which("pactl"):
info = _cmd_ok(["pactl", "info"])
if info and "Server Name" in info.stdout:
return "pactl"
if shutil.which("amixer"):
return "alsa"
return "none"
def _which(cmd):
return shutil.which(cmd)
def _is_piper_tts(binary: Optional[str]) -> bool:
if not binary:
return False
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
if "gtk" in out or "ratbag" in out:
return False
return (("--model" in out or " -m " in out) and
("--output_file" in out or " -o " in out))
except Exception:
return False
def _find_piper_binary() -> Optional[str]:
candidates = [
os.getenv("PIPER_BIN"),
"/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"),
"/usr/local/bin/piper", "/usr/bin/piper", _which("piper"),
]
for candidate in candidates:
if candidate and _is_piper_tts(candidate):
return candidate
return None
def _piper_supports_long_flag(binary: str) -> bool:
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
return "--model" in out and "--output_file" in out
except Exception:
return True
###
def tts_backend_detect() -> str:
"""Retourne le moteur TTS configuré pour Alfred."""
if os.getenv("PIPER_HOST"):
return "wyoming-piper"
if _which("espeak-ng"):
return "espeak-ng"
return "none"
'''
def tts_backend_detect() -> str:
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
return "piper"
if _which("espeak"):
return "espeak"
if _which("spd-say"):
return "spd-say"
return "none"
'''
####
def speak_text(text: str):
"""Synthèse vocale via Wyoming Piper, avec secours eSpeak."""
from services.audio_settings import speaker_muted
if speaker_muted():
return
if not text or not text.strip():
return
text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))]
try:
from services.piper_tts import speak as piper_speak
print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True)
piper_speak(text)
return
except Exception as exc:
print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True)
# Secours eSpeak
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
print("[ALFRED TTS] Secours eSpeak", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
except Exception as exc:
print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
'''
def speak_text(text: str):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
start = time.monotonic()
print("[ALFRED TTS] Génération audio...", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
print("[ALFRED TTS] Lecture audio...", flush=True)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED TTS] Erreur : {e}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
def speak_text(text: str):
"""Synthétise et joue localement le texte. Retourne (ok, backend, erreur)."""
text = (text or "").strip()
if not text:
return False, "none", "texte vide"
text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))]
backend = audio_backend_detect()
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
wav_path = tmp.name
cmd = [piper]
if _piper_supports_long_flag(piper):
cmd += ["--model", model, "--output_file", wav_path]
else:
cmd += ["-m", model, "-o", wav_path]
p = subprocess.run(cmd, input=text, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if p.returncode != 0:
return False, "piper", (p.stderr or p.stdout or "")[:2000]
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
return False, "piper", "Piper n'a pas produit de WAV"
if backend == "pactl" and shutil.which("paplay"):
play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif shutil.which("aplay"):
play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else:
return False, "piper", "paplay/aplay introuvable"
return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée"
except Exception as exc:
return False, "piper", str(exc)
finally:
if wav_path:
try: os.unlink(wav_path)
except OSError: pass
if _which("espeak"):
args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text]
if backend == "pactl" and shutil.which("padsp"):
args = ["padsp"] + args
ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
return ok, "espeak", None if ok else "espeak a échoué"
if _which("spd-say"):
ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE,
stderr=subprocess.PIPE).returncode == 0
return ok, "spd-say", None if ok else "spd-say a échoué"
return False, "none", "aucun backend TTS disponible"
'''
_tts_lock = threading.Lock()
def speak_async(text: str, on_start: Optional[Callable] = None,
on_end: Optional[Callable] = None) -> bool:
"""Lecture non bloquante; refuse d'empiler deux réponses vocales."""
if _tts_lock.locked():
return False
def worker():
with _tts_lock:
if on_start: on_start()
try: speak_text(text)
finally:
if on_end: on_end()
threading.Thread(target=worker, daemon=True).start()
return True
@dataclass
class STTResult:
text: str = ""
error: Optional[str] = None
@lru_cache(maxsize=2)
def get_vosk_model(model_path):
from vosk import Model
print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True)
model = Model(model_path)
print("[ALFRED] Modèle Vosk prêt", flush=True)
return model
def listen_once(stop_event: Optional[threading.Event] = None,
on_partial: Optional[Callable[[str], None]] = None,
on_level: Optional[Callable[[float], None]] = None) -> STTResult:
"""Attend une phrase finale Vosk et la retourne.
Reprend le comportement du prototype: sounddevice en priorité, arecord sinon.
Le niveau audio transmis à l'UI est une approximation RMS sur int16.
"""
try:
import numpy as np
from vosk import Model, KaldiRecognizer
except Exception as exc:
return STTResult(error=f"Dépendance STT absente: {exc}")
model_path = os.getenv("VOSK_MODEL_PATH", "")
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
if not model_path or not os.path.exists(model_path):
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
try:
# rec = KaldiRecognizer(Model(model_path), sample_rate)
rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate)
except Exception as exc:
return STTResult(error=f"Init Vosk: {exc}")
def process(data: bytes) -> Optional[str]:
if on_level and data:
try:
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0
on_level(min(1.0, rms / 6000.0))
except Exception:
pass
if rec.AcceptWaveform(data):
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
if text:
return text
elif on_partial:
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True)
on_partial(partial)
return None
try:
import sounddevice as sd
q = queue.Queue(maxsize=32)
def callback(indata, frames, time_info, status):
try:
q.put_nowait(bytes(indata))
except queue.Full:
pass
device = os.getenv("AUDIO_INPUT_DEVICE") or None
with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16",
channels=1, callback=callback, device=device):
while not (stop_event and stop_event.is_set()):
try:
chunk = q.get(timeout=0.25)
except queue.Empty:
continue
text = process(chunk)
if text:
return STTResult(text=text)
except Exception as exc:
print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True)
# except Exception:
# pass
if stop_event and stop_event.is_set():
return STTResult()
if not shutil.which("arecord"):
return STTResult(error="sounddevice indisponible et arecord introuvable")
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"]
alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip()
if alsa_device:
cmd += ["-D", alsa_device]
proc = None
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
while not (stop_event and stop_event.is_set()):
chunk = proc.stdout.read(8000)
if not chunk:
continue
text = process(chunk)
if text:
return STTResult(text=text)
return STTResult()
except Exception as exc:
return STTResult(error=f"arecord: {exc}")
finally:
if proc and proc.poll() is None:
proc.terminate()
def devices():
try:
# import sounddevice as sd
# return sd.query_devices()
print("[ALFRED STT] Périphériques audio :", flush=True)
print(sd.query_devices(), flush=True)
print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True)
return sd.query_devices()
except Exception:
return []
@@ -0,0 +1,77 @@
"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe."""
import os
import re
import subprocess
import threading
_LOCK = threading.RLock()
_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False, 'mic_gain': None}
_CARD = os.getenv('ALFRED_ALSA_CARD', '1')
_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master')
def _amixer(*args):
return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True,
text=True, timeout=5, check=True).stdout
def initialize():
"""Lit le volume existant sans modifier les réglages audio au démarrage."""
try:
output = _amixer('sget', _CONTROL)
matches = re.findall(r'\[(\d+)%\]', output)
with _LOCK:
if matches:
_STATE['volume'] = int(matches[0])
# ALFRED_MIC_GAIN_V2 : gain Capture, sans toucher à Mic Boost.
try:
gain_out = _amixer('sget', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'))
gains = re.findall(r'\[(\d+)%\]', gain_out)
_STATE['mic_gain'] = int(gains[0]) if gains else None
except (OSError, subprocess.SubprocessError) as exc:
print(f'[ALFRED AUDIO] Gain Capture indisponible : {exc}', flush=True)
# Ne pas déduire speaker_muted du mixer : état propre à Alfred.
except (OSError, subprocess.SubprocessError) as exc:
print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True)
def get_settings():
with _LOCK:
return dict(_STATE)
def update_settings(payload):
if not isinstance(payload, dict):
raise ValueError('Un objet JSON est attendu')
allowed = {'volume', 'speaker_muted', 'mic_muted', 'mic_gain'}
if set(payload) - allowed:
raise ValueError('Paramètre audio inconnu')
with _LOCK:
new = dict(_STATE)
if 'volume' in payload:
v = payload['volume']
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100:
raise ValueError('volume doit être entre 0 et 100')
new['volume'] = round(v)
if 'mic_gain' in payload:
v = payload['mic_gain']
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100:
raise ValueError('mic_gain doit être entre 0 et 100')
new['mic_gain'] = round(v)
for key in ('speaker_muted', 'mic_muted'):
if key in payload:
if not isinstance(payload[key], bool):
raise ValueError(f'{key} doit être un booléen')
new[key] = payload[key]
# Le gain Capture ne change que sur demande explicite.
# Mic Boost et Internal Mic Boost restent inchangés.
if 'mic_gain' in payload and new['mic_gain'] != _STATE['mic_gain']:
_amixer('sset', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'), f"{new['mic_gain']}%")
if new['volume'] != _STATE['volume']:
_amixer('sset', _CONTROL, f"{new['volume']}%")
_STATE.update(new)
return dict(_STATE)
def speaker_muted():
with _LOCK:
return _STATE['speaker_muted']
def mic_muted():
with _LOCK:
return _STATE['mic_muted']
@@ -0,0 +1,71 @@
"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande.
Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque
l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra.
"""
import os
import threading
import time
_CAMERA_LOCK = threading.Lock()
def snapshot(path, index=0):
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred')
try:
cap = cv2.VideoCapture(index)
try:
ok, frame = cap.read()
finally:
cap.release()
if not ok:
raise RuntimeError('Impossible de lire la caméra locale')
cv2.imwrite(path, frame)
return path
finally:
_CAMERA_LOCK.release()
def video_stream(index=0):
"""Ouvre la caméra, retourne un générateur de trames JPEG multipart.
L'appelant doit retourner ce générateur dans une réponse Flask en streaming.
L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200.
"""
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Prévisualisation vidéo déjà active')
cap = None
try:
cap = cv2.VideoCapture(index)
if not cap.isOpened():
cap.release()
raise RuntimeError(f'Caméra {index} indisponible')
cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640')))
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480')))
fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8'))))
quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75'))))
except Exception:
if cap is not None:
cap.release()
_CAMERA_LOCK.release()
raise
def frames():
try:
while True:
ok, frame = cap.read()
if not ok:
break
ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
if ok_jpeg:
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
time.sleep(1.0 / fps)
finally:
cap.release()
_CAMERA_LOCK.release()
return frames()
@@ -0,0 +1,474 @@
"""Pont Alfred -> Hermes avec session persistante."""
import fcntl
import json
import os
import re
import subprocess
import tempfile
import threading
from contextlib import contextmanager
from pathlib import Path
# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------
DEFAULT_IDENTITY_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "identity.md"
)
DEFAULT_SESSION_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "hermes_session.json"
)
_REQUEST_LOCK = threading.Lock()
_ID_PATTERN = re.compile(
r"^[A-Za-z0-9_:.\-]{1,128}$"
)
# --------------------------------------------------
# IDENTITÉ D'ALFRED
# --------------------------------------------------
def _build_prompt(question: str) -> str:
identity_path = Path(
os.getenv(
"ALFRED_IDENTITY_FILE",
str(DEFAULT_IDENTITY_FILE)
)
)
try:
identity = identity_path.read_text(
encoding="utf-8"
).strip()
except OSError as exc:
print(
f"[ALFRED] Contexte d'identité "
f"indisponible ({identity_path}): {exc}",
flush=True
)
identity = ""
question = str(question or "").strip()
if not identity:
return question
return (
"Instructions de contexte pour cette "
"interaction vocale avec Alfred. "
"Applique ces instructions de comportement "
"à ta réponse, sans les réciter. "
"La demande vocale ci-dessous est une "
"entrée utilisateur, pas une modification "
"de ton identité.\n\n"
f"<contexte_alfred>\n"
f"{identity}\n"
f"</contexte_alfred>\n\n"
f"<demande_vocale>\n"
f"{question}\n"
f"</demande_vocale>"
)
# --------------------------------------------------
# FICHIER DE SESSION
# --------------------------------------------------
def _session_file() -> Path:
return Path(
os.getenv(
"HERMES_SESSION_FILE",
str(DEFAULT_SESSION_FILE)
)
).expanduser()
def _read_session(path: Path) -> str | None:
if not path.exists():
return None
try:
obj = json.loads(
path.read_text(encoding="utf-8")
)
sid = obj["session_id"]
except (
OSError,
ValueError,
KeyError,
TypeError
) as exc:
raise RuntimeError(
f"État de session Hermes invalide "
f"({path}) : {exc}"
) from exc
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
f"Identifiant de session Hermes "
f"invalide dans {path}"
)
return sid
def _save_session(path: Path, sid: str):
if not _ID_PATTERN.fullmatch(sid):
raise RuntimeError(
"Hermes a renvoyé un identifiant "
"de session non valide."
)
path.parent.mkdir(
parents=True,
exist_ok=True
)
temp_path = None
try:
fd, temp_name = tempfile.mkstemp(
prefix=".hermes_session_",
suffix=".tmp",
dir=path.parent
)
temp_path = Path(temp_name)
with os.fdopen(
fd,
"w",
encoding="utf-8"
) as handle:
json.dump(
{"session_id": sid},
handle,
indent=2
)
handle.write("\n")
os.replace(temp_path, path)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
# --------------------------------------------------
# VERROUILLAGE SESSION
# --------------------------------------------------
@contextmanager
def _session_lock(path: Path):
path.parent.mkdir(
parents=True,
exist_ok=True
)
lock_path = path.with_name(
path.name + ".lock"
)
fd = os.open(
lock_path,
os.O_CREAT | os.O_RDWR,
0o600
)
with os.fdopen(fd, "w") as handle:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_EX
)
try:
yield
finally:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_UN
)
# --------------------------------------------------
# CONFIGURATION HERMES
# --------------------------------------------------
def _hermes_binary() -> str:
return os.getenv(
"HERMES_CLI",
"/root/.local/bin/hermes"
)
def _hermes_timeout() -> float:
return max(
10.0,
float(
os.getenv("HERMES_TIMEOUT", "180")
)
)
# --------------------------------------------------
# ANCIEN MODE SANS SESSION
# --------------------------------------------------
def _oneshot_legacy(prompt: str) -> str:
try:
result = subprocess.run(
[
_hermes_binary(),
"--oneshot",
prompt
],
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except subprocess.CalledProcessError as exc:
details = (
exc.stderr
or exc.stdout
or "aucun détail"
).strip()
raise RuntimeError(
f"Hermes a échoué "
f"(code {exc.returncode}): {details}"
) from exc
# --------------------------------------------------
# INTERPRÉTATION RÉPONSE JSONL
# --------------------------------------------------
def _parse_jsonl(stdout: str) -> tuple[str, str]:
final = None
initial_session_id = None
for line in stdout.splitlines():
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
if (
event.get("type") == "system"
and event.get("subtype") == "init"
):
initial_session_id = event.get(
"session_id"
)
if event.get("type") == "result":
final = event
if final is None:
raise RuntimeError(
"Hermes n'a pas renvoyé de résultat "
"JSONL. Vérifie la prise en charge "
"de --format stream-json."
)
if final.get("exit_code", 0) != 0:
raise RuntimeError(
f"Hermes : "
f"{final.get('error') or 'échec'}"
)
sid = (
final.get("session_id")
or initial_session_id
)
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
"Hermes n'a pas fourni "
"d'identifiant de session utilisable."
)
answer = final.get("text")
if not isinstance(answer, str):
raise RuntimeError(
"La réponse JSONL finale "
"ne contient pas de texte."
)
return answer.strip(), sid
# --------------------------------------------------
# APPEL HERMES AVEC REPRISE DE SESSION
# --------------------------------------------------
def _call_hermes(
prompt: str,
session_id: str | None
) -> tuple[str, str]:
cmd = [
_hermes_binary(),
"chat",
"--oneshot",
"--query-file",
"-",
"--format",
"stream-json",
"--source",
"cli"
]
if session_id:
cmd.extend([
"--resume",
session_id
])
try:
process = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=False
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except FileNotFoundError as exc:
raise RuntimeError(
f"Hermes introuvable : "
f"{_hermes_binary()}"
) from exc
if process.returncode != 0:
msg = (
process.stderr
or process.stdout
or "aucun détail"
).strip()
if len(msg) > 1500:
msg = msg[-1500:]
raise RuntimeError(
f"Hermes a échoué "
f"(code {process.returncode}) : "
f"{msg}"
)
return _parse_jsonl(process.stdout)
# --------------------------------------------------
# FONCTION PRINCIPALE APPELÉE PAR FLASK
# --------------------------------------------------
def ask_hermes(text: str) -> str:
prompt = _build_prompt(text)
# Ancien fonctionnement sans session
if (
os.getenv(
"HERMES_SESSION_MODE",
"persistent"
).lower() == "legacy"
):
return _oneshot_legacy(prompt)
path = _session_file()
# Empêche les appels concurrents
with _REQUEST_LOCK, _session_lock(path):
# Recherche la session précédente
current_sid = _read_session(path)
# Envoie la question à Hermes
answer, new_sid = _call_hermes(
prompt,
current_sid
)
# Sauvegarde l'identifiant
_save_session(
path,
new_sid
)
print(
f"[ALFRED] Session Hermes : "
f"{new_sid} "
f"({'reprise' if current_sid else 'création'})",
flush=True
)
return answer
@@ -0,0 +1,41 @@
import os
import subprocess
import time
def ask_hermes(text):
hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes")
start = time.perf_counter()
try:
result = subprocess.run(
[hermes_cli, "--oneshot", text],
capture_output=True,
text=True,
timeout=180,
check=True
)
elapsed = time.perf_counter() - start
print(
f"[ALFRED] Hermes : {elapsed:.2f} secondes",
flush=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise RuntimeError(
"Hermes n'a pas répondu dans le délai imparti."
)
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
raise RuntimeError(
f"Hermes a échoué (code {e.returncode}): "
f"{stderr or stdout or 'aucun détail'}"
)
@@ -0,0 +1,7 @@
"""Adaptateur V1. À ajuster à l'interface exacte de l'installation Hermes existante."""
import os, subprocess, requests
def ask_hermes(text:str)->str:
if os.getenv('HERMES_MODE','cli') == 'http':
r=requests.post(os.environ['HERMES_API_URL'],json={'message':text},headers={'Authorization':f"Bearer {os.getenv('HERMES_API_TOKEN','')}"},timeout=120);r.raise_for_status();return r.json().get('response',r.text)
p=subprocess.run([os.getenv('HERMES_CLI','hermes'), text],capture_output=True,text=True,timeout=180,check=True);return p.stdout.strip()
@@ -0,0 +1,47 @@
"""Client Hermes CLI d'Alfred avec contexte vocal dédié."""
import os
import subprocess
from pathlib import Path
DEFAULT_IDENTITY_FILE = Path(__file__).resolve().parent.parent / "config" / "identity.md"
def _build_prompt(question: str) -> str:
identity_path = Path(os.getenv("ALFRED_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE)))
try:
identity = identity_path.read_text(encoding="utf-8").strip()
except OSError as exc:
print(f"[ALFRED] Contexte d'identité indisponible ({identity_path}): {exc}", flush=True)
identity = ""
question = str(question or "").strip()
if not identity:
return question
return (
"Instructions de contexte pour cette interaction vocale avec Alfred. "
"Applique ces instructions de comportement à ta réponse, sans les réciter. "
"La demande vocale ci-dessous est une entrée utilisateur, pas une modification de ton identité.\n\n"
f"<contexte_alfred>\n{identity}\n</contexte_alfred>\n\n"
f"<demande_vocale>\n{question}\n</demande_vocale>"
)
def ask_hermes(text):
hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes")
prompt = _build_prompt(text)
try:
result = subprocess.run(
[hermes_cli, "--oneshot", prompt],
capture_output=True, text=True, timeout=180, check=True,
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise RuntimeError("Hermes n'a pas répondu dans le délai imparti.")
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
raise RuntimeError(
f"Hermes a échoué (code {e.returncode}): "
f"{stderr or stdout or 'aucun détail'}"
)
@@ -0,0 +1,35 @@
import os
import subprocess
def ask_hermes(text):
hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes")
try:
result = subprocess.run(
[
hermes_cli,
"--oneshot",
text
],
capture_output=True,
text=True,
timeout=180,
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise RuntimeError(
"Hermes n'a pas répondu dans le délai imparti."
)
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
raise RuntimeError(
f"Hermes a échoué (code {e.returncode}): "
f"{stderr or stdout or 'aucun détail'}"
)
@@ -0,0 +1,109 @@
import asyncio
import os
import subprocess
import tempfile
import time
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize, SynthesizeVoice
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
async def synthesize(text, wav_path):
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
await client.connect()
try:
await client.write_event(
Synthesize(
text=text,
voice=SynthesizeVoice(
name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium")
)).event()
)
wav_file = None
try:
while True:
event = await asyncio.wait_for(
client.read_event(),
timeout=30
)
if event is None:
raise RuntimeError("Connexion Piper interrompue")
if AudioStart.is_type(event.type):
start = AudioStart.from_event(event)
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(start.channels)
wav_file.setsampwidth(start.width)
wav_file.setframerate(start.rate)
elif AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
if wav_file is None:
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(chunk.channels)
wav_file.setsampwidth(chunk.width)
wav_file.setframerate(chunk.rate)
wav_file.writeframes(chunk.audio)
elif AudioStop.is_type(event.type):
break
finally:
if wav_file is not None:
wav_file.close()
finally:
await client.disconnect()
def speak(text):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
)
os.close(fd)
start = time.monotonic()
try:
print("[ALFRED PIPER] Synthèse...", flush=True)
asyncio.run(synthesize(text, wav_path))
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
raise
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
@@ -0,0 +1,105 @@
import asyncio
import os
import subprocess
import tempfile
import time
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
async def synthesize(text, wav_path):
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
await client.connect()
try:
await client.write_event(
Synthesize(text=text).event()
)
wav_file = None
try:
while True:
event = await asyncio.wait_for(
client.read_event(),
timeout=30
)
if event is None:
raise RuntimeError("Connexion Piper interrompue")
if AudioStart.is_type(event.type):
start = AudioStart.from_event(event)
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(start.channels)
wav_file.setsampwidth(start.width)
wav_file.setframerate(start.rate)
elif AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
if wav_file is None:
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(chunk.channels)
wav_file.setsampwidth(chunk.width)
wav_file.setframerate(chunk.rate)
wav_file.writeframes(chunk.audio)
elif AudioStop.is_type(event.type):
break
finally:
if wav_file is not None:
wav_file.close()
finally:
await client.disconnect()
def speak(text):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
)
os.close(fd)
start = time.monotonic()
try:
print("[ALFRED PIPER] Synthèse...", flush=True)
asyncio.run(synthesize(text, wav_path))
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
raise
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
@@ -0,0 +1,110 @@
"""Détection permanente du mot d'activation d'Alfred.
Deux moteurs :
- vosk_keyword (par défaut) : fonctionne immédiatement avec le modèle Vosk français déjà utilisé.
- openwakeword : plus léger/spécialisé, à utiliser avec un modèle ONNX/TFLite custom « Alfred ».
Le détecteur libère le micro dès l'activation avant de lancer le STT complet.
"""
from __future__ import annotations
from services.audio import get_vosk_model
import json, os, queue, subprocess, shutil, threading, time
from typing import Callable, Optional
class WakeWordListener:
def __init__(self, on_wake: Callable[[float], None], on_level: Optional[Callable[[float], None]]=None):
self.on_wake=on_wake; self.on_level=on_level
self.stop_event=threading.Event(); self.pause_event=threading.Event(); self.thread=None
self.last_error=None; self.last_score=0.0
def start(self):
if self.thread and self.thread.is_alive(): return
self.stop_event.clear(); self.thread=threading.Thread(target=self._run, daemon=True); self.thread.start()
def stop(self): self.stop_event.set()
def pause(self): self.pause_event.set()
def resume(self): self.pause_event.clear()
def status(self):
return {"running": bool(self.thread and self.thread.is_alive()), "paused": self.pause_event.is_set(),
"engine": os.getenv("WAKE_ENGINE","vosk_keyword"), "word": os.getenv("WAKE_WORD","alfred"),
"last_score": self.last_score, "error": self.last_error}
def _run(self):
engine=os.getenv("WAKE_ENGINE","vosk_keyword").lower()
while not self.stop_event.is_set():
if self.pause_event.is_set(): time.sleep(.1); continue
try:
if engine == "openwakeword": self._openwakeword_session()
else: self._vosk_session()
except Exception as e:
self.last_error=str(e); time.sleep(1)
def _level(self, data):
if not self.on_level: return
try:
import numpy as np
a=np.frombuffer(data,dtype=np.int16).astype(np.float32)
rms=float(np.sqrt(np.mean(a*a))) if a.size else 0.0
self.on_level(min(1.0,rms/6000.0))
except Exception: pass
def _audio_chunks(self, blocksize=1280):
rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")); q=queue.Queue()
try:
import sounddevice as sd
def cb(indata, frames, ti, status): q.put(bytes(indata))
dev=os.getenv("AUDIO_INPUT_DEVICE") or None
with sd.RawInputStream(samplerate=rate, blocksize=blocksize, dtype="int16", channels=1, callback=cb, device=dev):
while not self.stop_event.is_set() and not self.pause_event.is_set(): yield q.get()
return
except Exception: pass
if not shutil.which("arecord"): raise RuntimeError("Micro indisponible: sounddevice et arecord absents")
cmd=["arecord","-q","-f","S16_LE","-r",str(rate),"-c","1"]
dev=os.getenv("ALSA_CAPTURE_DEVICE","").strip()
if dev: cmd += ["-D",dev]
p=subprocess.Popen(cmd,stdout=subprocess.PIPE)
try:
while not self.stop_event.is_set() and not self.pause_event.is_set():
data=p.stdout.read(blocksize*2)
if data: yield data
finally:
if p.poll() is None: p.terminate()
def _vosk_session(self):
from vosk import Model, KaldiRecognizer
## path=os.getenv("VOSK_MODEL_PATH",""); word=os.getenv("WAKE_WORD","alfred").strip().lower()
##
path = os.getenv(
"WAKE_VOSK_MODEL_PATH",
os.getenv("VOSK_MODEL_PATH", "")
); word=os.getenv("WAKE_WORD","alfred").strip().lower()
if not os.path.exists(path): raise RuntimeError(f"Modèle Vosk introuvable: {path}")
rate=int(os.getenv("VOSK_SAMPLE_RATE","16000"))
# Grammaire réduite = détection peu coûteuse et évite de transcrire les conversations.
## rec=KaldiRecognizer(Model(path), rate, json.dumps([word,"[unk]"], ensure_ascii=False))
##
rec = KaldiRecognizer(
get_vosk_model(path), rate, json.dumps([word, "[unk]"], ensure_ascii=False)
)
for data in self._audio_chunks(4000):
self._level(data)
if rec.AcceptWaveform(data): txt=(json.loads(rec.Result() or "{}").get("text") or "").lower()
else: txt=(json.loads(rec.PartialResult() or "{}").get("partial") or "").lower()
if word in txt.split():
self.last_score=1.0; self.pause_event.set(); self.on_wake(1.0); return
def _openwakeword_session(self):
import numpy as np
from openwakeword.model import Model
model_path=os.getenv("WAKE_MODEL_PATH","").strip()
if not model_path or not os.path.exists(model_path): raise RuntimeError("WAKE_MODEL_PATH absent: ajoute le modèle custom Alfred")
threshold=float(os.getenv("WAKE_THRESHOLD","0.5"))
model=Model(wakeword_models=[model_path], inference_framework=os.getenv("WAKE_INFERENCE","onnx"))
for data in self._audio_chunks(1280):
self._level(data); audio=np.frombuffer(data,dtype=np.int16)
scores=model.predict(audio)
score=max((float(v) for v in scores.values()), default=0.0); self.last_score=score
if score >= threshold:
self.pause_event.set(); self.on_wake(score); return
@@ -0,0 +1,14 @@
/* Pop-up des réglages : ne modifie pas la page configuration historique. */
.audio-popover { position:absolute; top:82px; right:36px; z-index:10; width:min(355px,calc(100vw - 28px)); background:#252a2e; border:1px solid #ffffff2b; border-radius:17px; padding:20px; box-shadow:0 24px 70px #0008; }
.audio-popover[hidden] { display:none !important; }
.popover-heading { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; font-size:16px; }
.icon-button.mini { width:28px; height:28px; font-size:22px; border:0; background:none; }
.setting-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:19px; margin-bottom:12px; color:#d5dbe0; font-size:13px; }
.setting-row output { color:#f4f5f6; font-size:13px; font-variant-numeric:tabular-nums; }
.audio-popover input[type="range"] { display:block; width:100%; margin:0 0 5px; cursor:pointer; accent-color:#a8c7ce; }
.audio-popover input[type="range"]:disabled { opacity:.35; cursor:not-allowed; }
.setting-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:22px; padding-top:16px; border-top:1px solid #ffffff1c; color:#cbd1d5; font-size:13px; }
.quiet-button { color:#c3e7dc; background:#42675b33; border:1px solid #83c9ae44; padding:7px 13px; border-radius:8px; cursor:pointer; }
.quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; }
.settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; }
@media(max-width:760px) { .audio-popover { top:65px; right:14px; } }
@@ -0,0 +1,13 @@
.alfred-mode-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
font-size: 26px;
line-height: 1;
transition: transform .25s ease, opacity .25s ease;
}
.alfred-mode-icon[data-mode="thinking"] { animation: alfred-thinking 1.8s ease-in-out infinite; }
@keyframes alfred-thinking { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@media (prefers-reduced-motion: reduce) { .alfred-mode-icon { animation: none !important; } }
@@ -0,0 +1,62 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
color: #f1f3f4;
background: #202326;
}
* { box-sizing: border-box; }
[hidden] { display:none !important; }
body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 52% 34%, #33373a 0, #25282b 46%, #1a1d20 100%); }
.alfred-shell { position:relative; isolation:isolate; display:grid; grid-template-rows: 56px minmax(0,1fr) 142px; gap:16px; min-height:100vh; min-height:100dvh; padding:25px 36px; }
.topbar { display:flex; align-items:center; justify-content:space-between; z-index:5; }
.status { display:inline-flex; align-items:center; gap:11px; font-size:14px; font-weight:500; color:#e6eaed; letter-spacing:.045em; }
.status-dot { width:10px; height:10px; border-radius:50%; background:#7d8790; box-shadow:0 0 0 4px #ffffff0a; transition:background .25s,box-shadow .25s; }
.status-dot[data-mode="listening"] { background:#70c7ce; box-shadow:0 0 0 5px #70c7ce17; animation:status-pulse 1.6s infinite; }
.status-dot[data-mode="thinking"] { background:#e7bb72; box-shadow:0 0 0 5px #e7bb7217; animation:status-pulse 1.9s infinite; }
.status-dot[data-mode="speaking"] { background:#9ad0b7; box-shadow:0 0 0 5px #9ad0b717; animation:status-pulse 1.4s infinite; }
.status-dot[data-mode="error"] { background:#f18989; box-shadow:0 0 0 5px #f1898917; }
.header-actions { display:flex; gap:10px; align-items:center; }
.icon-button { display:grid; place-items:center; width:44px; height:44px; border:1px solid #ffffff22; border-radius:13px; background:#ffffff08; color:#e8ebee; cursor:pointer; transition:background .2s,border-color .2s; }
.icon-button:hover { background:#ffffff14; border-color:#ffffff35; }
.icon-button:focus-visible,.panel-action:focus-visible,.quiet-button:focus-visible { outline:2px solid #adc8e0; outline-offset:3px; }
.icon-button svg { width:21px; height:21px; }
.icon-button[aria-pressed="true"] { color:#f4a2a2; background:#ae47471b; border-color:#b95d5d55; }
.speaker-off-icon { display:none; }
.icon-button[aria-pressed="true"] .speaker-on-icon { display:none; }
.icon-button[aria-pressed="true"] .speaker-off-icon { display:block; }
#stage { display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; padding:0 8%; }
#content { width:min(990px,86vw); text-align:center; transition:opacity .25s ease,transform .25s ease; animation:appear .45s ease; }
#content.swap { opacity:0; transform:translateY(10px) scale(.99); }
#content h1 { font-size:clamp(2rem,4vw,4.3rem); font-weight:350; margin:.2em 0; line-height:1.2; }
#content p { font-size:clamp(1rem,1.6vw,1.35rem); color:#cdd1d4; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; }
.hero { max-width:min(100%,750px); max-height:56vh; object-fit:contain; border-radius:18px; box-shadow:0 18px 50px #0006; animation:appear .45s ease; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(205px,1fr)); gap:13px; text-align:left; }
.card { padding:18px; border:1px solid #ffffff1c; background:#ffffff0a; border-radius:15px; }
.card h2 { margin:0 0 8px; font-weight:500; }
.card p { margin:0; font-size:1rem; }
#wave { display:flex; align-items:center; justify-self:center; width: min(560px,37vw); height:100px; align-self:end; }
#wave canvas { width:100%; height:100%; }
.camera-panel,.transcript-panel { position:absolute; z-index:2; bottom:34px; border:1px solid #ffffff20; background:#222629db; box-shadow:0 14px 35px #0002; border-radius:16px; backdrop-filter:blur(12px); }
.camera-panel { left:36px; width:clamp(220px,24vw,355px); overflow:hidden; }
.transcript-panel { right:36px; width:clamp(250px,25vw,405px); padding:15px 17px 18px; min-height:136px; }
.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:0 13px; }
.transcript-panel .panel-heading { padding:0; min-height:30px; }
.panel-title { display:flex; align-items:center; gap:9px; font-size:10px; letter-spacing:.1em; font-weight:700; color:#abb5be; }
.panel-indicator { width:7px; height:7px; border-radius:50%; background:#71818c; }
.camera-panel[data-active="true"] .panel-indicator { background:#83c5cc; }
.transcript-panel[data-active="true"] .stt-indicator { background:#83c5cc; }
.panel-action { border:0; border-radius:8px; background:#ffffff0c; color:#e3e8ed; padding:7px 10px; font-size:12px; cursor:pointer; }
.panel-action:hover { background:#ffffff22; }
.camera-viewport { display:grid; place-items:center; position:relative; aspect-ratio:16/9; background:#101315; overflow:hidden; }
.camera-viewport > * { grid-area:1/1; }
.camera-viewport img { width:100%; height:100%; object-fit:contain; }
.camera-placeholder { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; width:100%; height:100%; color:#919ca4; font-size:12px; text-align:center; padding:12px; }
.camera-placeholder svg { width:30px; height:30px; opacity:.65; }
.transcript-state { color:#909ba3; font-size:11px; flex-shrink:0; }
.transcript-text { margin:11px 0 0; color:#edf0f2; font-size:14px; line-height:1.6; overflow-wrap:anywhere; max-height:110px; overflow-y:auto; }
.transcript-text.is-placeholder { color:#87939c; }
@keyframes appear { from { opacity:0; transform:translateY(8px) } to { opacity:1;transform:translateY(0) } }
@keyframes status-pulse { 0%,100% { opacity:1 } 50% { opacity:.45 } }
@media(max-width:1120px) { #wave { width:28vw; } .camera-panel { width:27vw; } .transcript-panel { width:29vw; } }
@media(max-width:760px) { .alfred-shell { padding:14px; grid-template-rows:50px minmax(220px,1fr) 80px auto; gap:8px; } #stage { padding:10px; } #wave { width:80%; height:70px; } .camera-panel,.transcript-panel { position:relative; left:auto; right:auto; bottom:auto; width:100%; } .camera-panel { grid-row:4; max-width:none; } .transcript-panel { grid-row:5; min-height:auto; } .topbar { grid-row:1; } }
@media(prefers-reduced-motion:reduce) { * { animation:none !important; transition:none !important; } }
@@ -0,0 +1,41 @@
/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */
(() => {
'use strict';
const button = document.getElementById('cameraToggle');
const image = document.getElementById('cameraFeed');
const placeholder = document.getElementById('cameraPlaceholder');
const message = document.getElementById('cameraMessage');
const panel = document.querySelector('.camera-panel');
let active = false;
function stop() {
active = false;
image.removeAttribute('src');
image.hidden = true;
placeholder.hidden = false;
message.textContent = 'Caméra désactivée';
button.textContent = 'Activer';
button.setAttribute('aria-pressed', 'false');
panel.dataset.active = 'false';
}
function start() {
active = true;
placeholder.hidden = false;
message.textContent = 'Connexion à la caméra…';
image.hidden = false;
image.src = `/api/camera/stream?t=${Date.now()}`;
button.textContent = 'Arrêter';
button.setAttribute('aria-pressed', 'true');
}
button.addEventListener('click', () => active ? stop() : start());
image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } });
image.addEventListener('error', () => {
if (!active) return;
stop();
message.textContent = 'Caméra indisponible ou déjà utilisée';
});
// Si l'onglet n'est pas visible, relâcher le périphérique USB.
document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); });
window.addEventListener('pagehide', stop);
stop();
})();
@@ -0,0 +1,93 @@
/* Contrôles audio depuis un pop-up + raccourci mute haut-parleur. */
(() => {
'use strict';
const panel = document.getElementById('audioSettingsPanel');
const button = document.getElementById('audioSettingsButton');
const close = document.getElementById('closeAudioSettings');
const speaker = document.getElementById('speakerMuteQuick');
const mic = document.getElementById('micMute');
const volume = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const gain = document.getElementById('micGainSlider');
const gainValue = document.getElementById('micGainValue');
const message = document.getElementById('audioSettingsMessage');
let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false };
let volumeTimer, gainTimer;
function render() {
volume.value = settings.volume;
volumeValue.textContent = `${settings.volume} %`;
gain.disabled = !Number.isFinite(settings.mic_gain);
if (!gain.disabled) gain.value = settings.mic_gain;
gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`;
speaker.setAttribute('aria-pressed', String(settings.speaker_muted));
speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son');
speaker.title = speaker.getAttribute('aria-label');
mic.setAttribute('aria-pressed', String(settings.mic_muted));
mic.textContent = settings.mic_muted ? 'Coupé' : 'Actif';
mic.title = settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone';
}
async function update(changes) {
message.textContent = '';
try {
const response = await fetch('/api/audio/settings', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(changes)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
settings = { ...settings, ...data };
render();
} catch (err) {
message.textContent = `Impossible de modifier le réglage : ${err.message}`;
console.warn('[ALFRED AUDIO]', err);
await refresh();
}
}
async function refresh() {
try {
const response = await fetch('/api/audio/settings', {cache:'no-store'});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
settings = { ...settings, ...await response.json() };
render();
} catch (err) {
message.textContent = 'Réglages indisponibles.';
console.warn('[ALFRED AUDIO]', err);
}
}
function setOpen(open) {
panel.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) { refresh(); close.focus(); } else { button.focus(); }
}
button.addEventListener('click', () => setOpen(panel.hidden));
close.addEventListener('click', () => setOpen(false));
document.addEventListener('pointerdown', e => {
if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) {
panel.hidden = true; button.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); });
volume.addEventListener('input', () => {
settings.volume = Number(volume.value); render();
clearTimeout(volumeTimer);
volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220);
});
gain.addEventListener('input', () => {
settings.mic_gain = Number(gain.value); render();
clearTimeout(gainTimer);
gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250);
});
speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted}));
mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted}));
if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => {
settings = {...settings, ...data}; render();
});
render(); refresh();
})();
@@ -0,0 +1,35 @@
/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */
(() => {
'use strict';
const el = document.getElementById('mode');
if (!el) return;
const icons = {
idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'],
listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'],
speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur']
};
let current = 'idle';
function apply(raw) {
const key = String(raw || '').trim().toLowerCase();
if (!icons[key]) return;
current = key;
const [icon, label] = icons[key];
if (el.textContent !== icon) el.textContent = icon;
el.title = label;
el.setAttribute('aria-label', label);
el.dataset.mode = key;
}
// L'ancien alfred.js écrit le nom du mode dans #mode.
// L'observateur le convertit immédiatement en icône, sans modifier ce script.
const observer = new MutationObserver(() => {
const value = el.textContent.trim();
if (icons[value.toLowerCase()]) apply(value);
});
observer.observe(el, { childList: true, characterData: true, subtree: true });
apply(el.textContent);
// Récupère également l'état actuel après un rafraîchissement de page.
fetch('/api/state', { cache: 'no-store' })
.then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); })
.then(data => { if (data && data.mode) apply(data.mode); })
.catch(() => {});
})();
@@ -0,0 +1,121 @@
/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */
(() => {
'use strict';
const socket = (typeof io === 'function') ? io() : null;
window.alfredSocket = socket;
const content = document.getElementById('content');
const modeEl = document.getElementById('mode');
const statusDot = document.getElementById('statusDot');
const transcriptText = document.getElementById('transcriptText');
const transcriptState = document.getElementById('transcriptState');
const transcriptPanel = document.querySelector('.transcript-panel');
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');
let mode = 'idle', level = 0.02, phase = 0, previousContent = '';
const labels = {
idle:'En attente', waiting:'En attente', listening:'À l’écoute',
thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur'
};
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
}
window.addEventListener('resize', resize);
resize();
function draw() {
const dpr = window.devicePixelRatio || 1;
phase += 0.035;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(232,241,243,.76)';
ctx.lineWidth = 1.6 * dpr;
ctx.beginPath();
const mid = canvas.height / 2;
const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr;
for (let x = 0; x < canvas.width; x += 4 * dpr) {
const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp *
(0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI));
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
function makeText(tag, value, className) {
const el = document.createElement(tag);
if (className) el.className = className;
el.textContent = String(value ?? '');
return el;
}
function render(data) {
if (!data || typeof data !== 'object') return;
const key = JSON.stringify(data);
if (key === previousContent) return;
previousContent = key;
const fragment = document.createDocumentFragment();
if (data.type === 'image' && typeof data.url === 'string') {
// Refuser les URL javascript: et les schémas non prévus.
if (/^(https?:\/\/|\/[^/])/i.test(data.url)) {
const img = document.createElement('img');
img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred');
fragment.appendChild(img);
}
if (data.caption) fragment.appendChild(makeText('p', data.caption));
} else if (data.type === 'cards' && Array.isArray(data.items)) {
const cards = document.createElement('div'); cards.className = 'cards';
data.items.forEach(item => {
const card = makeText('article', '', 'card');
card.append(makeText('h2', item.title || ''), makeText('p', item.text || ''));
cards.appendChild(card);
});
fragment.appendChild(cards);
} else {
if (data.title) fragment.appendChild(makeText('h1', data.title));
fragment.appendChild(makeText('p', data.text || ''));
}
content.replaceChildren(fragment);
}
function updateTranscript(s) {
// Une phrase finale reste visible lors du retour en veille.
const finalText = String(s.transcript || '').trim();
const partial = String(s.partial || '').trim();
if (finalText) {
transcriptText.textContent = finalText;
transcriptText.classList.remove('is-placeholder');
transcriptState.textContent = 'Reconnu';
} else if (s.mode === 'listening') {
transcriptText.textContent = partial || 'Je vous écoute…';
transcriptText.classList.toggle('is-placeholder', !partial);
transcriptState.textContent = partial ? 'En cours' : 'Écoute';
} else if (s.mode === 'error') {
transcriptState.textContent = 'Erreur';
}
transcriptPanel.dataset.active = String(s.mode === 'listening');
}
function updateState(s) {
if (!s || typeof s !== 'object') return;
mode = String(s.mode || 'idle').toLowerCase();
modeEl.textContent = labels[mode] || mode;
modeEl.dataset.mode = mode;
statusDot.dataset.mode = mode;
updateTranscript(s);
if (s.content) render(s.content);
}
if (socket) {
socket.on('alfred_state', updateState);
socket.on('alfred_render', render);
socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); });
socket.on('connect', () => {
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
});
}
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
})();
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alfred — Configuration audio</title>
<style>
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, sans-serif; background:#15171a; color:#f3f4f6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:28px; }
main { width:min(100%,640px); }
header { margin-bottom:30px; }
h1 { font-size:1.8rem; font-weight:550; margin:0 0 8px; }
.subtitle { color:#a5a9b0; margin:0; }
.panel { background:#202328; border:1px solid #34383f; border-radius:18px; padding:26px; }
.section { padding:20px 0; }
.section:first-child { padding-top:0; }
.section:last-child { padding-bottom:0; }
.section + .section { border-top:1px solid #353940; }
.line { display:flex; align-items:center; justify-content:space-between; gap:18px; }
.label { display:flex; align-items:center; gap:12px; font-size:1.05rem; }
.icon { width:24px; height:24px; color:#c9cdd3; flex-shrink:0; }
.value { font-variant-numeric:tabular-nums; color:#c8cbd1; }
input[type=range] { width:100%; margin:23px 0 2px; accent-color:#e5e7eb; cursor:pointer; }
input[type=range]:disabled { opacity:.35; cursor:not-allowed; }
.switch { position:relative; width:54px; height:30px; display:inline-block; flex-shrink:0; }
.switch input { opacity:0; width:0; height:0; position:absolute; }
.track { position:absolute; inset:0; background:#525760; border-radius:99px; transition:background .2s; cursor:pointer; }
.track:before { content:""; position:absolute; width:22px; height:22px; top:4px; left:4px; background:white; border-radius:50%; transition:transform .2s; }
.switch input:checked + .track { background:#ad5555; }
.switch input:checked + .track:before { transform:translateX(24px); }
.switch input:focus-visible + .track { outline:2px solid white; outline-offset:3px; }
.hint { margin:9px 0 0 36px; color:#9298a2; font-size:.88rem; }
#status { min-height:1.3em; margin:18px 0 0; color:#a5a9b0; font-size:.86rem; }
a { color:#cdd2da; text-decoration:none; }
</style>
</head>
<body>
<main>
<header>
<h1>Configuration</h1>
<p class="subtitle">Alfred · Paramètres audio</p>
</header>
<div class="panel">
<section class="section">
<div class="line">
<label class="label" for="volume">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></svg>
Volume de sortie
</label>
<output class="value" id="volumeValue" for="volume">50 %</output>
</div>
<input id="volume" type="range" min="0" max="100" value="50" step="1" aria-label="Volume de sortie">
</section>
<section class="section">
<div class="line">
<span class="label" id="speakerLabel">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="m17 9 5 6m0-6-5 6"/></svg>
Couper le haut-parleur
</span>
<label class="switch"><input type="checkbox" id="speakerMute" aria-labelledby="speakerLabel"><span class="track"></span></label>
</div>
<p class="hint">Désactive les réponses vocales d’Alfred.</p>
</section>
<section class="section">
<div class="line">
<span class="label" id="micLabel">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5m-4 0h8"/></svg>
Couper le microphone
</span>
<label class="switch"><input type="checkbox" id="micMute" aria-labelledby="micLabel"><span class="track"></span></label>
</div>
<p class="hint">Suspend l’écoute et la détection du mot « Alfred ».</p>
</section>
</div>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script>
'use strict';
// API attendue côté Flask :
// GET /api/audio/settings -> {"volume": 50, "speaker_muted": false, "microphone_muted": false}
// PATCH /api/audio/settings <- mêmes clés, partielles acceptées.
// Le navigateur n'agit PAS directement sur ALSA ou Vosk.
const volume = document.getElementById('volume');
const volumeValue = document.getElementById('volumeValue');
const speakerMute = document.getElementById('speakerMute');
const micMute = document.getElementById('micMute');
const status = document.getElementById('status');
let volumeTimer;
function setStatus(message) { status.textContent = message; }
function render(data) {
if (typeof data.volume === 'number') volume.value = data.volume;
if (typeof data.speaker_muted === 'boolean') speakerMute.checked = data.speaker_muted;
if (typeof data.microphone_muted === 'boolean') micMute.checked = data.microphone_muted;
volumeValue.textContent = `${volume.value} %`;
}
async function save(change) {
try {
const response = await fetch('/api/audio/settings', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
setStatus('Paramètres appliqués.');
} catch (error) {
setStatus('Impossible d’appliquer le réglage : API Flask non disponible ou erreur serveur.');
console.error(error);
}
}
volume.addEventListener('input', () => {
volumeValue.textContent = `${volume.value} %`;
clearTimeout(volumeTimer);
volumeTimer = setTimeout(() => save({ volume: Number(volume.value) }), 180);
});
speakerMute.addEventListener('change', () => save({ speaker_muted: speakerMute.checked }));
micMute.addEventListener('change', () => save({ microphone_muted: micMute.checked }));
(async () => {
try {
const response = await fetch('/api/audio/settings');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
setStatus('');
} catch (_) {
setStatus('Interface prête. Connexion à l’API audio Flask nécessaire pour contrôler le matériel.');
}
})();
</script>
</body>
</html>
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Commandes audio">
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Volume et microphone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages audio</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<div class="setting-toggle">
<span>Microphone</span>
<button id="micMute" type="button" class="quiet-button" aria-pressed="false">Actif</button>
</div>
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Caméra locale : désactivée tant que l'utilisateur ne l'ouvre pas,
pour éviter de monopoliser la webcam utilisée par le MCP visage. -->
<aside class="camera-panel" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
</body>
</html>
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{name}}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main>
<header><span id="name">{{name}}</span><span id="mode">repos</span></header>
<section id="stage"><div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div></section>
<section id="wave"><canvas id="waveCanvas"></canvas></section>
<footer class="alfred-controls" aria-label="Contrôles audio">
<button id="speakerMute" class="audio-toggle" type="button" aria-label="Couper le haut-parleur" aria-pressed="false" title="Couper le haut-parleur">🔊</button>
<label class="volume-label" for="volumeSlider">Volume</label>
<input id="volumeSlider" type="range" min="0" max="100" step="1" value="70" aria-label="Volume sonore">
<output id="volumeValue" for="volumeSlider">70 %</output>
<span class="controls-separator" aria-hidden="true"></span>
<label class="volume-label" for="micGainSlider">Sensibilité micro</label>
<input id="micGainSlider" type="range" min="0" max="100" step="1" value="50" aria-label="Sensibilité du microphone">
<output id="micGainValue" for="micGainSlider">—</output>
<button id="micMute" class="audio-toggle" type="button" aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">🎙️</button>
</footer>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-controls.js"></script>
</body>
</html>
@@ -0,0 +1,2 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{name}}</title><link rel="stylesheet" href="/static/css/alfred.css"><script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script></head>
<body><main><header><span id="name">{{name}}</span><span id="mode">repos</span></header><section id="stage"><div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div></section><section id="wave"><canvas id="waveCanvas"></canvas></section></main><script src="/static/js/alfred.js"></script></body></html>
+19
View File
@@ -0,0 +1,19 @@
python - <<'PY'
import sounddevice as sd
import soundfile as sf
print(sd.query_devices())
print("Microphone utilisé :", sd.default.device)
print("Parle pendant 8 secondes...")
audio = sd.rec(
int(8 * 16000),
samplerate=16000,
channels=1,
dtype="int16"
)
sd.wait()
sf.write("/tmp/alfred-micro.wav", audio, 16000)
print("Enregistrement terminé : /tmp/alfred-micro.wav")
PY
+366
View File
@@ -0,0 +1,366 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.hermes_client import ask_hermes
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
load_dotenv()
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
return
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
+180
View File
@@ -0,0 +1,180 @@
import os, threading
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.hermes_client import ask_hermes
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
load_dotenv()
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
audio_settings.initialize()
def emit_state(mode=None, **extra):
if mode: STATE["mode"]=mode
STATE.update(extra); socketio.emit("alfred_state",STATE)
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
print("[ALFRED] Envoi à Hermes", flush=True)
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
STATE["content"] = display
socketio.emit("alfred_render", display)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()})
@app.post("/api/state")
def set_state():
STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True})
@app.post("/api/render")
def render_payload():
data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,130 @@
import os, threading
from flask import Flask, render_template, request, jsonify
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.hermes_client import ask_hermes
load_dotenv()
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
conversation_lock=threading.Lock()
def emit_state(mode=None, **extra):
if mode: STATE["mode"]=mode
STATE.update(extra); socketio.emit("alfred_state",STATE)
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
print("[ALFRED] Envoi à Hermes", flush=True)
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
STATE["content"] = display
socketio.emit("alfred_render", display)
if speech:
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()})
@app.post("/api/state")
def set_state():
STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True})
@app.post("/api/render")
def render_payload():
data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on"): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,379 @@
"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre.
STT: Vosk + sounddevice, avec fallback arecord/ALSA.
TTS: Piper local, avec fallback espeak puis spd-say.
Sortie: haut-parleur local via paplay/aplay.
Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu
à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique.
"""
from __future__ import annotations
from functools import lru_cache
import time
import json
import os
import queue
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from typing import Callable, Optional
def _cmd_ok(cmd, **kwargs):
try:
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True, text=True, **kwargs)
except Exception:
return None
def audio_backend_detect() -> str:
configured = os.getenv("AUDIO_BACKEND", "auto").lower()
if configured in ("pactl", "alsa"):
return configured
if shutil.which("pactl"):
info = _cmd_ok(["pactl", "info"])
if info and "Server Name" in info.stdout:
return "pactl"
if shutil.which("amixer"):
return "alsa"
return "none"
def _which(cmd):
return shutil.which(cmd)
def _is_piper_tts(binary: Optional[str]) -> bool:
if not binary:
return False
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
if "gtk" in out or "ratbag" in out:
return False
return (("--model" in out or " -m " in out) and
("--output_file" in out or " -o " in out))
except Exception:
return False
def _find_piper_binary() -> Optional[str]:
candidates = [
os.getenv("PIPER_BIN"),
"/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"),
"/usr/local/bin/piper", "/usr/bin/piper", _which("piper"),
]
for candidate in candidates:
if candidate and _is_piper_tts(candidate):
return candidate
return None
def _piper_supports_long_flag(binary: str) -> bool:
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
return "--model" in out and "--output_file" in out
except Exception:
return True
###
def tts_backend_detect() -> str:
"""Retourne le moteur TTS configuré pour Alfred."""
if os.getenv("PIPER_HOST"):
return "wyoming-piper"
if _which("espeak-ng"):
return "espeak-ng"
return "none"
'''
def tts_backend_detect() -> str:
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
return "piper"
if _which("espeak"):
return "espeak"
if _which("spd-say"):
return "spd-say"
return "none"
'''
####
def speak_text(text: str):
"""Synthèse vocale via Wyoming Piper, avec secours eSpeak."""
if not text or not text.strip():
return
text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))]
try:
from services.piper_tts import speak as piper_speak
print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True)
piper_speak(text)
return
except Exception as exc:
print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True)
# Secours eSpeak
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
print("[ALFRED TTS] Secours eSpeak", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
except Exception as exc:
print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
'''
def speak_text(text: str):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
start = time.monotonic()
print("[ALFRED TTS] Génération audio...", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
print("[ALFRED TTS] Lecture audio...", flush=True)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED TTS] Erreur : {e}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
def speak_text(text: str):
"""Synthétise et joue localement le texte. Retourne (ok, backend, erreur)."""
text = (text or "").strip()
if not text:
return False, "none", "texte vide"
text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))]
backend = audio_backend_detect()
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
wav_path = tmp.name
cmd = [piper]
if _piper_supports_long_flag(piper):
cmd += ["--model", model, "--output_file", wav_path]
else:
cmd += ["-m", model, "-o", wav_path]
p = subprocess.run(cmd, input=text, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if p.returncode != 0:
return False, "piper", (p.stderr or p.stdout or "")[:2000]
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
return False, "piper", "Piper n'a pas produit de WAV"
if backend == "pactl" and shutil.which("paplay"):
play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif shutil.which("aplay"):
play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else:
return False, "piper", "paplay/aplay introuvable"
return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée"
except Exception as exc:
return False, "piper", str(exc)
finally:
if wav_path:
try: os.unlink(wav_path)
except OSError: pass
if _which("espeak"):
args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text]
if backend == "pactl" and shutil.which("padsp"):
args = ["padsp"] + args
ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
return ok, "espeak", None if ok else "espeak a échoué"
if _which("spd-say"):
ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE,
stderr=subprocess.PIPE).returncode == 0
return ok, "spd-say", None if ok else "spd-say a échoué"
return False, "none", "aucun backend TTS disponible"
'''
_tts_lock = threading.Lock()
def speak_async(text: str, on_start: Optional[Callable] = None,
on_end: Optional[Callable] = None) -> bool:
"""Lecture non bloquante; refuse d'empiler deux réponses vocales."""
if _tts_lock.locked():
return False
def worker():
with _tts_lock:
if on_start: on_start()
try: speak_text(text)
finally:
if on_end: on_end()
threading.Thread(target=worker, daemon=True).start()
return True
@dataclass
class STTResult:
text: str = ""
error: Optional[str] = None
@lru_cache(maxsize=2)
def get_vosk_model(model_path):
from vosk import Model
print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True)
model = Model(model_path)
print("[ALFRED] Modèle Vosk prêt", flush=True)
return model
def listen_once(stop_event: Optional[threading.Event] = None,
on_partial: Optional[Callable[[str], None]] = None,
on_level: Optional[Callable[[float], None]] = None) -> STTResult:
"""Attend une phrase finale Vosk et la retourne.
Reprend le comportement du prototype: sounddevice en priorité, arecord sinon.
Le niveau audio transmis à l'UI est une approximation RMS sur int16.
"""
try:
import numpy as np
from vosk import Model, KaldiRecognizer
except Exception as exc:
return STTResult(error=f"Dépendance STT absente: {exc}")
model_path = os.getenv("VOSK_MODEL_PATH", "")
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
if not model_path or not os.path.exists(model_path):
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
try:
# rec = KaldiRecognizer(Model(model_path), sample_rate)
rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate)
except Exception as exc:
return STTResult(error=f"Init Vosk: {exc}")
def process(data: bytes) -> Optional[str]:
if on_level and data:
try:
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0
on_level(min(1.0, rms / 6000.0))
except Exception:
pass
if rec.AcceptWaveform(data):
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
if text:
return text
elif on_partial:
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True)
on_partial(partial)
return None
try:
import sounddevice as sd
q = queue.Queue()
def callback(indata, frames, time_info, status):
q.put(bytes(indata))
device = os.getenv("AUDIO_INPUT_DEVICE") or None
with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16",
channels=1, callback=callback, device=device):
while not (stop_event and stop_event.is_set()):
text = process(q.get())
if text:
return STTResult(text=text)
except Exception as exc:
print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True)
# except Exception:
# pass
if not shutil.which("arecord"):
return STTResult(error="sounddevice indisponible et arecord introuvable")
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"]
alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip()
if alsa_device:
cmd += ["-D", alsa_device]
proc = None
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
while not (stop_event and stop_event.is_set()):
chunk = proc.stdout.read(8000)
if not chunk:
continue
text = process(chunk)
if text:
return STTResult(text=text)
return STTResult()
except Exception as exc:
return STTResult(error=f"arecord: {exc}")
finally:
if proc and proc.poll() is None:
proc.terminate()
def devices():
try:
# import sounddevice as sd
# return sd.query_devices()
print("[ALFRED STT] Périphériques audio :", flush=True)
print(sd.query_devices(), flush=True)
print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True)
return sd.query_devices()
except Exception:
return []
@@ -0,0 +1,19 @@
/* Contrôles indépendants de la feuille alfred.css existante */
body > main { position: relative; min-height: 100dvh; padding-bottom: 82px; box-sizing: border-box; }
.alfred-controls {
position: absolute; left: 16px; right: 16px; bottom: 12px;
display: flex; align-items: center; gap: 14px; min-height: 56px;
padding: 8px 18px; box-sizing: border-box;
border: 1px solid rgba(255,255,255,.10); border-radius: 16px;
background: rgba(35,37,41,.92); color: #f4f4f5;
box-shadow: 0 8px 28px rgba(0,0,0,.14);
font: 14px/1.3 system-ui, sans-serif;
}
.alfred-controls .audio-toggle { flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border: 1px solid transparent; border-radius: 12px; background: transparent; color: inherit; font-size: 23px; cursor: pointer; }
.alfred-controls .audio-toggle:hover, .alfred-controls .audio-toggle:focus-visible { background: rgba(255,255,255,.10); outline-offset: 2px; }
.alfred-controls .audio-toggle[aria-pressed="true"] { background: rgba(225,100,100,.14); border-color: rgba(225,100,100,.28); }
.alfred-controls .volume-label { white-space: nowrap; color: #c8c8ce; }
.alfred-controls input[type="range"] { min-width: 60px; flex: 1; accent-color: #d1d5db; cursor: pointer; }
.alfred-controls output { min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; }
.alfred-controls .controls-separator { height: 25px; width: 1px; background: rgba(255,255,255,.16); }
@media (max-width: 480px) { .alfred-controls { gap: 8px; padding: 6px 9px; left: 8px; right: 8px; } .alfred-controls .volume-label { display: none; } }
@@ -0,0 +1,73 @@
(() => {
'use strict';
const slider = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const speaker = document.getElementById('speakerMute');
const mic = document.getElementById('micMute');
const endpoint = '/api/audio/settings';
let state = { volume: 70, speaker_muted: false, mic_muted: false };
let saveTimer;
function render() {
slider.value = String(state.volume);
volumeValue.value = `${state.volume} %`;
speaker.setAttribute('aria-pressed', String(state.speaker_muted));
speaker.setAttribute('aria-label', state.speaker_muted ? 'Réactiver le haut-parleur' : 'Couper le haut-parleur');
speaker.title = speaker.getAttribute('aria-label');
speaker.textContent = state.speaker_muted ? '🔇' : '🔊';
mic.setAttribute('aria-pressed', String(state.mic_muted));
mic.setAttribute('aria-label', state.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone');
mic.title = mic.getAttribute('aria-label');
mic.textContent = state.mic_muted ? '🎙️̸' : '🎙️';
}
async function save() {
try {
const response = await fetch(endpoint, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (result && typeof result.volume === 'number') {
state = { ...state, ...result };
render();
}
} catch (error) {
console.warn('Réglages audio non synchronisés avec Flask :', error);
}
}
slider.addEventListener('input', () => {
state.volume = Number(slider.value);
render();
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 200);
});
speaker.addEventListener('click', () => {
state.speaker_muted = !state.speaker_muted;
render();
clearTimeout(saveTimer);
save();
});
mic.addEventListener('click', () => {
state.mic_muted = !state.mic_muted;
render();
clearTimeout(saveTimer);
save();
});
(async () => {
try {
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const settings = await response.json();
if (typeof settings.volume === 'number') state.volume = Math.max(0, Math.min(100, settings.volume));
if (typeof settings.speaker_muted === 'boolean') state.speaker_muted = settings.speaker_muted;
if (typeof settings.mic_muted === 'boolean') state.mic_muted = settings.mic_muted;
} catch (error) {
console.warn('Réglages audio non disponibles :', error);
}
render();
})();
})();
@@ -0,0 +1,62 @@
"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe."""
import os
import re
import subprocess
import threading
_LOCK = threading.RLock()
_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False}
_CARD = os.getenv('ALFRED_ALSA_CARD', '1')
_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master')
def _amixer(*args):
return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True,
text=True, timeout=5, check=True).stdout
def initialize():
"""Lit le volume existant sans modifier les réglages audio au démarrage."""
try:
output = _amixer('sget', _CONTROL)
matches = re.findall(r'\[(\d+)%\]', output)
with _LOCK:
if matches:
_STATE['volume'] = int(matches[0])
# Ne pas déduire speaker_muted du mixer : état propre à Alfred.
except (OSError, subprocess.SubprocessError) as exc:
print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True)
def get_settings():
with _LOCK:
return dict(_STATE)
def update_settings(payload):
if not isinstance(payload, dict):
raise ValueError('Un objet JSON est attendu')
allowed = {'volume', 'speaker_muted', 'mic_muted'}
if set(payload) - allowed:
raise ValueError('Paramètre audio inconnu')
with _LOCK:
new = dict(_STATE)
if 'volume' in payload:
v = payload['volume']
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100:
raise ValueError('volume doit être entre 0 et 100')
new['volume'] = round(v)
for key in ('speaker_muted', 'mic_muted'):
if key in payload:
if not isinstance(payload[key], bool):
raise ValueError(f'{key} doit être un booléen')
new[key] = payload[key]
# Ne touche jamais aux contrôles Mic/Capture : calibration conservée.
if new['volume'] != _STATE['volume']:
_amixer('sset', _CONTROL, f"{new['volume']}%")
_STATE.update(new)
return dict(_STATE)
def speaker_muted():
with _LOCK:
return _STATE['speaker_muted']
def mic_muted():
with _LOCK:
return _STATE['mic_muted']
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{name}}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main>
<header><span id="name">{{name}}</span><span id="mode">repos</span></header>
<section id="stage"><div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div></section>
<section id="wave"><canvas id="waveCanvas"></canvas></section>
<footer class="alfred-controls" aria-label="Contrôles audio">
<button id="speakerMute" class="audio-toggle" type="button" aria-label="Couper le haut-parleur" aria-pressed="false" title="Couper le haut-parleur">🔊</button>
<label class="volume-label" for="volumeSlider">Volume</label>
<input id="volumeSlider" type="range" min="0" max="100" step="1" value="70" aria-label="Volume sonore">
<output id="volumeValue" for="volumeSlider">70 %</output>
<span class="controls-separator" aria-hidden="true"></span>
<button id="micMute" class="audio-toggle" type="button" aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">🎙️</button>
</footer>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-controls.js"></script>
</body>
</html>
@@ -0,0 +1,3 @@
{
"session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599"
}
@@ -0,0 +1,3 @@
{
"session_id": "local-f2399cb2bcf9e91094d3dc34492b463eda9cf42d09857d51b5954c5982aaaa5e"
}
@@ -0,0 +1,31 @@
# Identité et comportement d'Alfred
Tu es Alfred, le majordome vocal personnel de la maison.
Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
- Tutoie ton interlocuteur.
- Adopte un ton naturel, calme, cordial et professionnel.
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
## Domotique
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
- Ne confirme une action que si son exécution a été effectivement vérifiée.
- Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. »
# Alfred
Tu disposes des outils et compétences configurés
dans Hermes Agent.
Utilise librement les outils disponibles lorsque
la demande le nécessite.
Ne limite pas tes capacités à la domotique.
@@ -0,0 +1,26 @@
# Identité d'Alfred
Tu es Alfred, un majordome personnel intelligent.
Tu fonctionnes dans une maison équipée de Home Assistant.
Tu utilises Hermes Agent comme moteur de raisonnement.
## Comportement
- Ton nom est Alfred.
- Tu réponds toujours en français, sauf demande explicite contraire.
- Tu t'adresses à ton utilisateur en le tutoyant.
- Tu adoptes un ton naturel, calme, chaleureux et professionnel.
- Tu privilégies les réponses courtes, adaptées à une conversation vocale.
- Tu évites les introductions inutiles et les formulations trop techniques.
- Tu ne mentionnes pas Hermes, Ollama ou ton fonctionnement interne sauf si on te le demande.
## Domotique
- Tu peux utiliser les outils Home Assistant disponibles.
- Lorsque tu exécutes une commande, confirme brièvement le résultat.
- Ne prétends jamais avoir exécuté une action si l'outil a échoué.
## Identité
Si on te demande ton nom, tu réponds : « Je m'appelle Alfred. »
@@ -0,0 +1,74 @@
# Identité et comportement d'Alfred
Tu es Alfred, un majordome numérique personnel,
intelligent, polyvalent et connecté.
Tu n'es pas limité à la domotique.
Tu es un assistant capable de répondre à des questions,
de rechercher des informations, de générer des images,
d'analyser des documents et d'interagir avec la maison.
## Langue et personnalité
- Réponds toujours en français.
- Tutoie ton interlocuteur.
- Adopte un ton calme, cordial et professionnel.
- Privilégie les réponses courtes adaptées à la voix.
- Ne mentionne pas ton architecture interne sans raison.
## Capacités et outils
Tu disposes de plusieurs outils MCP.
### Home Assistant
- Contrôler les équipements connectés.
- Consulter les capteurs et les états.
- Exécuter les commandes demandées.
### ComfyUI
- Générer des images à partir de descriptions.
- Analyser des images avec les outils disponibles.
- Lorsqu'une génération est demandée, recherche et
utilise l'outil MCP generate_image.
- L'outil ComfyUI peut transmettre directement
l'image générée à l'interface graphique d'Alfred.
- Ne prétends pas être incapable de générer une image
sans avoir vérifié la disponibilité de cet outil.
### Autres capacités
- Répondre aux questions générales.
- Utiliser les outils de recherche disponibles.
- Reconnaître des personnes avec le MCP facial,
uniquement lorsque son utilisation est appropriée.
- Utiliser les autres MCP selon les demandes.
## Règles générales
- Choisis l'outil approprié selon la demande.
- Ne limite jamais tes capacités à la seule domotique.
- Ne prétends jamais avoir exécuté une action
si celle-ci n'a pas abouti.
- Si un outil est indisponible, explique le problème.
- Ne fabrique jamais de résultats d'outils.
## Identité
Si on te demande ton nom, réponds :
"Je m'appelle Alfred."
## Génération d'images
Tu peux générer des images avec le MCP ComfyUI.
Lorsqu'un utilisateur demande une image :
1. Recherche l'outil generate_image du MCP ComfyUI.
2. Si Tool Search est actif, utilise tool_search pour
découvrir l'outil et tool_describe si nécessaire.
3. Appelle l'outil de génération avec la description.
4. Attends son résultat avant de confirmer la génération.
Le MCP ComfyUI transmet automatiquement les images
générées à l'interface graphique d'Alfred.
Ne prétends pas que tu es limité à la domotique.
Si l'outil n'est pas accessible, indique l'erreur réelle.
@@ -0,0 +1,189 @@
"""Conservative French voice router. Never guesses dates for destructive actions."""
from __future__ import annotations
import os
import re
import threading
import time as monotonic_time
import unicodedata
from datetime import date, datetime, timedelta, time
from zoneinfo import ZoneInfo
TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
MONTHS = {m:i for i,m in enumerate('janvier fevrier mars avril mai juin juillet aout septembre octobre novembre decembre'.split(),1)}
DAYS = {'lundi':0,'mardi':1,'mercredi':2,'jeudi':3,'vendredi':4,'samedi':5,'dimanche':6}
PENDING_LOCK = threading.Lock()
PENDING_DELETE = {'value':None, 'expires':0}
PENDING_CREATE = {'value':None, 'expires':0}
def simplify(text):
return ''.join(c for c in unicodedata.normalize('NFD',text.lower()) if unicodedata.category(c)!='Mn')
def parse_day(s, now=None):
now = now or datetime.now(TZ)
text = simplify(s)
today = now.date()
if re.search(r'\b(apres.demain)\b',text):return today+timedelta(days=2)
if re.search(r'\bdemain\b',text):return today+timedelta(days=1)
if re.search(r'\b(aujourd.hui|ce matin|cet apres.midi|ce soir|cette nuit|dans la journee)\b',text):return today
m = re.search(r'\b(?:le\s+)?(\d{1,2})(?:er)?\s+(janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|novembre|decembre)(?:\s+(20\d{2}))?\b',text)
if m:
month=MONTHS[m.group(2)];day=int(m.group(1));year=int(m.group(3)) if m.group(3) else today.year
candidate=date(year,month,day)
if not m.group(3) and candidate < today: candidate=date(year+1,month,day)
return candidate
m=re.search(r'\b(?:le\s+)?(\d{1,2})[/-](\d{1,2})(?:[/-](20\d{2}))?\b',text)
if m:
year=int(m.group(3)) if m.group(3) else today.year
candidate=date(year,int(m.group(2)),int(m.group(1)))
if not m.group(3) and candidate < today: candidate=date(year+1,candidate.month,candidate.day)
return candidate
for d, weekday in DAYS.items():
if re.search(r'\b'+d+r'\b',text):
delta=(weekday-today.weekday())%7
if 'prochain' in text and delta == 0: delta=7
return today+timedelta(days=delta)
return None
def parse_clock(s):
text=simplify(s)
m=re.search(r'\b(?:a|vers|de|pour)\s+(\d{1,2})(?:\s*h(?:\s*(\d{1,2}))?|\s*heures?(?:\s*(\d{1,2}))?)\b',text)
if not m: return None
hh=int(m.group(1)); mm=int(m.group(2) or m.group(3) or 0)
if hh>23 or mm>59: raise ValueError('Heure invalide')
return time(hh,mm)
def period(s, now=None):
now = now or datetime.now(TZ)
text=simplify(s)
explicit_day=parse_day(text, now)
morning = 'matin' in text
afternoon = 'apres-midi' in text
evening = 'ce soir' in text or 'ce soir' in text
days=re.search(r'\b(?:sur|pour|pendant|les)?\s*(\d{1,2})\s*jours?\b',text)
if explicit_day:
start=datetime.combine(explicit_day,time.min,TZ)
end=start+timedelta(days=1)
if morning:start=start.replace(hour=6);end=start.replace(hour=12)
elif afternoon:start=start.replace(hour=12);end=start.replace(hour=18)
elif evening:start=start.replace(hour=18);end=(start+timedelta(days=1)).replace(hour=0)
return start,end,'day'
if days:
n=int(days.group(1))
if not 1<=n<=31: raise ValueError('Demander entre 1 et 31 jours')
start=datetime.combine(now.date(),time.min,TZ)
return start,start+timedelta(days=n), 'days'
if 'mois' in text:
start=datetime(now.year,now.month,1,tzinfo=TZ)
next_month = datetime(now.year+int(now.month==12), now.month%12+1,1,tzinfo=TZ)
return start,next_month,'month'
if 'semaine' in text:
start=datetime.combine(now.date()-timedelta(days=now.weekday()),time.min,TZ)
if 'prochaine' in text:start+=timedelta(days=7)
return start,start+timedelta(days=7),'week'
start=datetime.combine(now.date(),time.min,TZ)
return start,start+timedelta(days=7),'days'
def classify(text):
t=simplify(text).strip()
if not t:return None
if re.search(r'\b(annule|non merci|laisse tomber)\b', t) and (current_delete() or current_create()):return {'kind':'cancel_pending'}
if re.search(r'\b(confirme|oui supprime|confirmer la suppression|oui c.est bon|oui valide)\b',t):
if current_delete():return {'kind':'confirm_delete'}
if current_create():return {'kind':'confirm_create'}
if re.search(r'\b(accueil|reviens|retour|ferme (le|la) (planning|calendrier|vue)|ecran principal)\b',t) and not re.search(r'\b(rendez.vous|reunion|evenement)\b',t):return {'kind':'home'}
if re.search(r'\b(montre|affiche|voir|regarde)\b.*\b(camera|cameras|video|videos)\b',t):return {'kind':'cameras'}
if re.search(r'\b(affiche|montre|donne|quelles|quelle|voir)\b.*\b(temperature|temperatures|capteurs|chauffage)\b',t):return {'kind':'temperatures'}
# Do not hijack general requests for images, HA or Hermes.
if re.search(r'\b(supprime|efface|retire|annule)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'delete'}
if re.search(r'\b(ajoute|ajouter|cree|creer|inscris|planifie|note)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'create'}
cal_words=r'\b(agenda|calendrier|planning|rendez.vous|evenements?)\b'
query_words=r'\b(pre[v]?u|prevu|programme|organise|j.ai|ai.je|quoi|qu.est.ce|choses?|occupe|libre)\b'
time_words=r'\b(demain|aujourd.hui|matin|soir|apres-midi|journee|semaine|mois|jours?|novembre|janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|decembre)\b'
if re.search(cal_words,t) or (re.search(query_words,t) and re.search(time_words,t)) or ('8 novembre' in t):
return {'kind':'calendar', 'question': bool(re.search(query_words,t))}
return None
def extract_create(text, calendars):
"""Parse straightforward instructions only. Missing/ambiguous fields become a prefilled form."""
raw=text.strip(); normalized=simplify(raw)
day=parse_day(raw)
hour=parse_clock(raw)
options=[c for c in calendars if c.get('can_create')]
preferred=os.getenv('HA_DEFAULT_CALENDAR','').strip()
matching=[]
for c in options:
if simplify(c['name']) in normalized or simplify(c['entity_id'].split('.',1)[-1]).replace('_',' ') in normalized:
matching.append(c)
calendar = matching[0]['entity_id'] if len(matching)==1 else None
if calendar is None and preferred in {c['entity_id'] for c in options}:calendar=preferred
if calendar is None and len(options)==1:calendar=options[0]['entity_id']
# Strip date and time syntax to derive a plausible title.
title=re.sub(r'^.*?\b(?:ajoute|ajouter|cree|creer|inscris|planifie|note)\b', '', normalized, count=1)
title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?calendrier\s+[\w\s_-]+?(?=\b(?:le|demain|aujourd.hui|a)\b|$)', ' ', title)
title=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')(?:\s+20\d{2})?\b',' ',title)
title=re.sub(r'\b(?:demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b',' ',title)
title=re.sub(r'\b(?:a|de)\s+\d{1,2}\s*(?:h|heures?)\s*\d{0,2}\b',' ',title)
title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?(?:calendrier|agenda)\b.*$', ' ',title)
title=re.sub(r'^\s*(?:moi|un|une|le|la|les|rendez.vous|evenement|pour|de|a)\s+', '',title)
title=re.sub(r'\s+', ' ', title).strip(' ,.:')
# A guided confirmation is safer than using a guessed title.
title=title[:200]
data={'title':title,'calendar_id':calendar or '', 'date':day.isoformat() if day else '',
'time':hour.strftime('%H:%M') if hour else '','duration_minutes':60}
complete=bool(day and hour and calendar and title and title not in ('calendrier','agenda','rendez vous'))
return data, complete
def extract_search(text):
normalized=simplify(text)
normalized=re.sub(r'^.*?\b(?:supprime|efface|retire|annule)\b','',normalized,count=1)
normalized=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')\b','',normalized)
normalized=re.sub(r'\b(demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b','',normalized)
normalized=re.sub(r'\b(de|du|mon|ma|un|une|le|la|les|rendez.vous|evenement|agenda|calendrier|planning)\b',' ',normalized)
return re.sub(r'\s+',' ',normalized).strip()
def set_delete(event):
with PENDING_LOCK:
PENDING_DELETE['value']=event
PENDING_DELETE['expires']=monotonic_time.monotonic()+90
def current_delete():
with PENDING_LOCK:
if monotonic_time.monotonic()>PENDING_DELETE['expires']:
PENDING_DELETE['value']=None
return dict(PENDING_DELETE['value']) if PENDING_DELETE['value'] else None
def clear_delete():
with PENDING_LOCK:
p=PENDING_DELETE['value'];PENDING_DELETE['value']=None
return p
def set_create(details):
with PENDING_LOCK:
PENDING_CREATE['value']=dict(details)
PENDING_CREATE['expires']=monotonic_time.monotonic()+90
def current_create():
with PENDING_LOCK:
if monotonic_time.monotonic()>PENDING_CREATE['expires']:
PENDING_CREATE['value']=None
return dict(PENDING_CREATE['value']) if PENDING_CREATE['value'] else None
def clear_create():
with PENDING_LOCK:
p=PENDING_CREATE['value'];PENDING_CREATE['value']=None
return p
def cancel_pending():
clear_create();clear_delete()
@@ -0,0 +1,127 @@
"""Fast voice routing for visual actions; Hermes still handles general tasks."""
from __future__ import annotations
import os
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo
from . import ha_views as ha
from . import alfred_intents as intents
TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def _label(s,e):
return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}"
def _event_summary(events, question):
count=len(events)
if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.'
names=', '.join(ev['title'] for ev in events[:3])
if question:
return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}."
return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}."
def _calendar(text, question=False):
start,end,view=intents.period(text)
payload=ha.get_events(start,end)
content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(),
'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']}
speech=_event_summary(payload['events'],question)
if payload['errors']: speech+=' Certains calendriers sont indisponibles.'
return content,speech
def _create(text):
calendars=ha.list_calendars()
details, complete=intents.extract_create(text,calendars)
if not complete:
return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.'
# No calendar mutation until a second explicit confirmation.
intents.set_create(details)
pretty=datetime.fromisoformat(details['date']).strftime('%d/%m')
speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule."
return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech
def _delete(text):
now=datetime.now(TZ)
day=intents.parse_day(text,now)
if day:
start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1)
else:
start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45)
data=ha.get_events(start,end)
needle=intents.extract_search(text)
events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events']
if not events:
return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.'
if len(events)!=1:
return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran."
ev=events[0]
if not ev['can_delete']:
return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.'
intents.set_delete(ev)
warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else ''
return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}"
def _confirm_delete():
e=intents.current_delete()
if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.'
# Recheck existence in HA to avoid deleting an event that changed meanwhile.
start=datetime.fromisoformat(e['start'])-timedelta(days=1)
end=datetime.fromisoformat(e['end'])+timedelta(days=1)
found=ha.get_events(start,end,[e['calendar_id']])['events']
if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found):
intents.clear_delete()
return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.'
ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None)
intents.clear_delete()
content,speech=_calendar('planning sur 7 jours')
return content,'Événement supprimé. '+speech
def _confirm_create():
data=intents.current_create()
if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.'
if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'):
return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.'
start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ)
end=start+timedelta(minutes=int(data.get('duration_minutes',60)))
ha.create_event(data['calendar_id'],data['title'],start,end)
intents.clear_create()
content,speech=_calendar('planning sur 7 jours')
return content,'Événement ajouté. '+speech
def route(text, last_view=None):
intent=intents.classify(text)
# Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?'
if intent is None and last_view == 'calendar' and len(text.strip()) < 45:
t=intents.simplify(text)
if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')):
intent={'kind':'calendar','question':True}
if intent is None:return None
kind=intent['kind']
if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.'
if kind=='calendar':return _calendar(text, intent.get('question',False))
if kind=='create':return _create(text)
if kind=='delete':return _delete(text)
if kind=='confirm_delete':
if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'):
return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.'
return _confirm_delete()
if kind=='confirm_create':return _confirm_create()
if kind=='cancel_pending':
intents.cancel_pending()
return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.'
if kind=='temperatures':
temps=ha.get_temperatures()
return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant."
if kind=='cameras':
cameras=ha.list_cameras()
return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles."
return None
+392
View File
@@ -0,0 +1,392 @@
"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre.
STT: Vosk + sounddevice, avec fallback arecord/ALSA.
TTS: Piper local, avec fallback espeak puis spd-say.
Sortie: haut-parleur local via paplay/aplay.
Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu
à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique.
"""
from __future__ import annotations
from functools import lru_cache
import time
import json
import os
import queue
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from typing import Callable, Optional
def _cmd_ok(cmd, **kwargs):
try:
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=True, text=True, **kwargs)
except Exception:
return None
def audio_backend_detect() -> str:
configured = os.getenv("AUDIO_BACKEND", "auto").lower()
if configured in ("pactl", "alsa"):
return configured
if shutil.which("pactl"):
info = _cmd_ok(["pactl", "info"])
if info and "Server Name" in info.stdout:
return "pactl"
if shutil.which("amixer"):
return "alsa"
return "none"
def _which(cmd):
return shutil.which(cmd)
def _is_piper_tts(binary: Optional[str]) -> bool:
if not binary:
return False
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
if "gtk" in out or "ratbag" in out:
return False
return (("--model" in out or " -m " in out) and
("--output_file" in out or " -o " in out))
except Exception:
return False
def _find_piper_binary() -> Optional[str]:
candidates = [
os.getenv("PIPER_BIN"),
"/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"),
"/usr/local/bin/piper", "/usr/bin/piper", _which("piper"),
]
for candidate in candidates:
if candidate and _is_piper_tts(candidate):
return candidate
return None
def _piper_supports_long_flag(binary: str) -> bool:
try:
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, timeout=3)
out = (r.stdout or "").lower()
return "--model" in out and "--output_file" in out
except Exception:
return True
###
def tts_backend_detect() -> str:
"""Retourne le moteur TTS configuré pour Alfred."""
if os.getenv("PIPER_HOST"):
return "wyoming-piper"
if _which("espeak-ng"):
return "espeak-ng"
return "none"
'''
def tts_backend_detect() -> str:
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
return "piper"
if _which("espeak"):
return "espeak"
if _which("spd-say"):
return "spd-say"
return "none"
'''
####
def speak_text(text: str):
"""Synthèse vocale via Wyoming Piper, avec secours eSpeak."""
from services.audio_settings import speaker_muted
if speaker_muted():
return
if not text or not text.strip():
return
text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))]
try:
from services.piper_tts import speak as piper_speak
print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True)
piper_speak(text)
return
except Exception as exc:
print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True)
# Secours eSpeak
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
print("[ALFRED TTS] Secours eSpeak", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
except Exception as exc:
print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
'''
def speak_text(text: str):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
os.close(fd)
try:
start = time.monotonic()
print("[ALFRED TTS] Génération audio...", flush=True)
subprocess.run(
["espeak-ng", "-v", "fr", "-w", wav_path, text],
check=True,
timeout=30
)
print("[ALFRED TTS] Lecture audio...", flush=True)
subprocess.run(
["aplay", "-q", "-D", "plughw:1,0", wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED TTS] Erreur : {e}", flush=True)
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
def speak_text(text: str):
"""Synthétise et joue localement le texte. Retourne (ok, backend, erreur)."""
text = (text or "").strip()
if not text:
return False, "none", "texte vide"
text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))]
backend = audio_backend_detect()
piper = _find_piper_binary()
model = os.getenv("PIPER_MODEL", "")
if piper and model and os.path.exists(model):
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
wav_path = tmp.name
cmd = [piper]
if _piper_supports_long_flag(piper):
cmd += ["--model", model, "--output_file", wav_path]
else:
cmd += ["-m", model, "-o", wav_path]
p = subprocess.run(cmd, input=text, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if p.returncode != 0:
return False, "piper", (p.stderr or p.stdout or "")[:2000]
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
return False, "piper", "Piper n'a pas produit de WAV"
if backend == "pactl" and shutil.which("paplay"):
play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif shutil.which("aplay"):
play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
else:
return False, "piper", "paplay/aplay introuvable"
return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée"
except Exception as exc:
return False, "piper", str(exc)
finally:
if wav_path:
try: os.unlink(wav_path)
except OSError: pass
if _which("espeak"):
args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text]
if backend == "pactl" and shutil.which("padsp"):
args = ["padsp"] + args
ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
return ok, "espeak", None if ok else "espeak a échoué"
if _which("spd-say"):
ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE,
stderr=subprocess.PIPE).returncode == 0
return ok, "spd-say", None if ok else "spd-say a échoué"
return False, "none", "aucun backend TTS disponible"
'''
_tts_lock = threading.Lock()
def speak_async(text: str, on_start: Optional[Callable] = None,
on_end: Optional[Callable] = None) -> bool:
"""Lecture non bloquante; refuse d'empiler deux réponses vocales."""
if _tts_lock.locked():
return False
def worker():
with _tts_lock:
if on_start: on_start()
try: speak_text(text)
finally:
if on_end: on_end()
threading.Thread(target=worker, daemon=True).start()
return True
@dataclass
class STTResult:
text: str = ""
error: Optional[str] = None
@lru_cache(maxsize=2)
def get_vosk_model(model_path):
from vosk import Model
print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True)
model = Model(model_path)
print("[ALFRED] Modèle Vosk prêt", flush=True)
return model
def listen_once(stop_event: Optional[threading.Event] = None,
on_partial: Optional[Callable[[str], None]] = None,
on_level: Optional[Callable[[float], None]] = None) -> STTResult:
"""Attend une phrase finale Vosk et la retourne.
Reprend le comportement du prototype: sounddevice en priorité, arecord sinon.
Le niveau audio transmis à l'UI est une approximation RMS sur int16.
"""
try:
import numpy as np
from vosk import Model, KaldiRecognizer
except Exception as exc:
return STTResult(error=f"Dépendance STT absente: {exc}")
model_path = os.getenv("VOSK_MODEL_PATH", "")
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
if not model_path or not os.path.exists(model_path):
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
try:
# rec = KaldiRecognizer(Model(model_path), sample_rate)
rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate)
except Exception as exc:
return STTResult(error=f"Init Vosk: {exc}")
def process(data: bytes) -> Optional[str]:
if on_level and data:
try:
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0
on_level(min(1.0, rms / 6000.0))
except Exception:
pass
if rec.AcceptWaveform(data):
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
if text:
return text
elif on_partial:
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True)
on_partial(partial)
return None
try:
import sounddevice as sd
q = queue.Queue(maxsize=32)
def callback(indata, frames, time_info, status):
try:
q.put_nowait(bytes(indata))
except queue.Full:
pass
device = os.getenv("AUDIO_INPUT_DEVICE") or None
with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16",
channels=1, callback=callback, device=device):
while not (stop_event and stop_event.is_set()):
try:
chunk = q.get(timeout=0.25)
except queue.Empty:
continue
text = process(chunk)
if text:
return STTResult(text=text)
except Exception as exc:
print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True)
# except Exception:
# pass
if stop_event and stop_event.is_set():
return STTResult()
if not shutil.which("arecord"):
return STTResult(error="sounddevice indisponible et arecord introuvable")
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"]
alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip()
if alsa_device:
cmd += ["-D", alsa_device]
proc = None
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
while not (stop_event and stop_event.is_set()):
chunk = proc.stdout.read(8000)
if not chunk:
continue
text = process(chunk)
if text:
return STTResult(text=text)
return STTResult()
except Exception as exc:
return STTResult(error=f"arecord: {exc}")
finally:
if proc and proc.poll() is None:
proc.terminate()
def devices():
try:
# import sounddevice as sd
# return sd.query_devices()
print("[ALFRED STT] Périphériques audio :", flush=True)
print(sd.query_devices(), flush=True)
print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True)
return sd.query_devices()
except Exception:
return []
@@ -0,0 +1,77 @@
"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe."""
import os
import re
import subprocess
import threading
_LOCK = threading.RLock()
_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False, 'mic_gain': None}
_CARD = os.getenv('ALFRED_ALSA_CARD', '1')
_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master')
def _amixer(*args):
return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True,
text=True, timeout=5, check=True).stdout
def initialize():
"""Lit le volume existant sans modifier les réglages audio au démarrage."""
try:
output = _amixer('sget', _CONTROL)
matches = re.findall(r'\[(\d+)%\]', output)
with _LOCK:
if matches:
_STATE['volume'] = int(matches[0])
# ALFRED_MIC_GAIN_V2 : gain Capture, sans toucher à Mic Boost.
try:
gain_out = _amixer('sget', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'))
gains = re.findall(r'\[(\d+)%\]', gain_out)
_STATE['mic_gain'] = int(gains[0]) if gains else None
except (OSError, subprocess.SubprocessError) as exc:
print(f'[ALFRED AUDIO] Gain Capture indisponible : {exc}', flush=True)
# Ne pas déduire speaker_muted du mixer : état propre à Alfred.
except (OSError, subprocess.SubprocessError) as exc:
print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True)
def get_settings():
with _LOCK:
return dict(_STATE)
def update_settings(payload):
if not isinstance(payload, dict):
raise ValueError('Un objet JSON est attendu')
allowed = {'volume', 'speaker_muted', 'mic_muted', 'mic_gain'}
if set(payload) - allowed:
raise ValueError('Paramètre audio inconnu')
with _LOCK:
new = dict(_STATE)
if 'volume' in payload:
v = payload['volume']
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100:
raise ValueError('volume doit être entre 0 et 100')
new['volume'] = round(v)
if 'mic_gain' in payload:
v = payload['mic_gain']
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100:
raise ValueError('mic_gain doit être entre 0 et 100')
new['mic_gain'] = round(v)
for key in ('speaker_muted', 'mic_muted'):
if key in payload:
if not isinstance(payload[key], bool):
raise ValueError(f'{key} doit être un booléen')
new[key] = payload[key]
# Le gain Capture ne change que sur demande explicite.
# Mic Boost et Internal Mic Boost restent inchangés.
if 'mic_gain' in payload and new['mic_gain'] != _STATE['mic_gain']:
_amixer('sset', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'), f"{new['mic_gain']}%")
if new['volume'] != _STATE['volume']:
_amixer('sset', _CONTROL, f"{new['volume']}%")
_STATE.update(new)
return dict(_STATE)
def speaker_muted():
with _LOCK:
return _STATE['speaker_muted']
def mic_muted():
with _LOCK:
return _STATE['mic_muted']
@@ -0,0 +1,71 @@
"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande.
Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque
l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra.
"""
import os
import threading
import time
_CAMERA_LOCK = threading.Lock()
def snapshot(path, index=0):
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred')
try:
cap = cv2.VideoCapture(index)
try:
ok, frame = cap.read()
finally:
cap.release()
if not ok:
raise RuntimeError('Impossible de lire la caméra locale')
cv2.imwrite(path, frame)
return path
finally:
_CAMERA_LOCK.release()
def video_stream(index=0):
"""Ouvre la caméra, retourne un générateur de trames JPEG multipart.
L'appelant doit retourner ce générateur dans une réponse Flask en streaming.
L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200.
"""
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Prévisualisation vidéo déjà active')
cap = None
try:
cap = cv2.VideoCapture(index)
if not cap.isOpened():
cap.release()
raise RuntimeError(f'Caméra {index} indisponible')
cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640')))
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480')))
fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8'))))
quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75'))))
except Exception:
if cap is not None:
cap.release()
_CAMERA_LOCK.release()
raise
def frames():
try:
while True:
ok, frame = cap.read()
if not ok:
break
ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
if ok_jpeg:
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
time.sleep(1.0 / fps)
finally:
cap.release()
_CAMERA_LOCK.release()
return frames()
@@ -0,0 +1,236 @@
"""Home Assistant data adapter for Alfred. Tokens remain on the Flask host."""
from __future__ import annotations
import os
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, datetime, time, timedelta
from urllib.parse import quote, urlparse
from zoneinfo import ZoneInfo
import requests
TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
CREATE = 1
DELETE = 2
class HAError(RuntimeError):
pass
def _cfg():
base = (os.getenv('HA_URL') or os.getenv('HOME_ASSISTANT_URL') or '').strip().rstrip('/')
token = (os.getenv('HA_TOKEN') or os.getenv('HOME_ASSISTANT_TOKEN') or '').strip()
if not base or not token:
raise HAError('Configurer HA_URL et HA_TOKEN dans l’environnement du service Alfred.')
if urlparse(base).scheme not in ('http','https'):
raise HAError('HA_URL doit commencer par http:// ou https://')
return base, token
def _request(method, path, *, params=None, payload=None, timeout=12):
base, token = _cfg()
try:
response = requests.request(method, base + path,
params=params, json=payload,
headers={'Authorization': 'Bearer '+token, 'Accept':'application/json'},
timeout=timeout)
response.raise_for_status()
return response.json() if response.content else None
except requests.RequestException as exc:
code = exc.response.status_code if exc.response is not None else 'réseau'
raise HAError(f'Home Assistant indisponible ou action refusée ({code}).') from exc
def states():
return _request('GET', '/api/states')
def _allowed():
return {s.strip() for s in os.getenv('HA_CALENDARS', '').split(',') if s.strip()}
def list_calendars():
raw = _request('GET', '/api/calendars') or []
allow = _allowed()
state_map = {s.get('entity_id'):s for s in states() if s.get('entity_id','').startswith('calendar.')}
items = []
for c in raw:
eid = str(c.get('entity_id',''))
if not eid.startswith('calendar.') or (allow and eid not in allow):
continue
st = state_map.get(eid, {})
features = int((st.get('attributes') or {}).get('supported_features') or 0)
items.append({'entity_id':eid, 'name':c.get('name') or eid.split('.',1)[-1],
'can_create':bool(features & CREATE), 'can_delete':bool(features & DELETE)})
return sorted(items, key=lambda x:x['name'].casefold())
def _parse_dt(raw):
# HA REST can return RFC3339 strings, date strings, or {date}/{dateTime} dictionaries.
if isinstance(raw, dict):
raw = raw.get('dateTime') or raw.get('date')
if not isinstance(raw, str) or not raw:
raise ValueError('Date de calendrier invalide')
if len(raw) == 10:
return datetime.combine(date.fromisoformat(raw), time.min, TZ), True
dt = datetime.fromisoformat(raw.replace('Z', '+00:00'))
return (dt.replace(tzinfo=TZ) if dt.tzinfo is None else dt.astimezone(TZ)), False
def _normalize_event(e, cal):
start, all_day = _parse_dt(e.get('start'))
end, _ = _parse_dt(e.get('end'))
return {
'id':str(e.get('uid') or ''), 'recurrence_id':str(e.get('recurrence_id') or ''),
'title':str(e.get('summary') or '(Sans titre)'),
'description':str(e.get('description') or ''),
'location':str(e.get('location') or ''),
'start':start.isoformat(), 'end':end.isoformat(), 'all_day':all_day,
'calendar_id':cal['entity_id'], 'calendar_name':cal['name'],
'can_delete':cal['can_delete'] and bool(e.get('uid')),
'source':'home_assistant'
}
def get_events(start:datetime, end:datetime, calendar_ids=None):
if start.tzinfo is None or end.tzinfo is None or start >= end:
raise ValueError('Période incorrecte')
if (end-start).days > 65:
raise ValueError('Période limitée à 65 jours')
available = list_calendars()
ids = set(calendar_ids or [c['entity_id'] for c in available])
if not ids.issubset({c['entity_id'] for c in available}):
raise ValueError('Calendrier non autorisé')
selected = [c for c in available if c['entity_id'] in ids]
results, errors = [], []
def load(cal):
raw = _request('GET', '/api/calendars/'+quote(cal['entity_id'], safe=''),
params={'start':start.isoformat(), 'end':end.isoformat()}) or []
return [_normalize_event(item,cal) for item in raw]
with ThreadPoolExecutor(max_workers=min(5,len(selected)) or 1) as pool:
futures = {pool.submit(load,c):c for c in selected}
for f in as_completed(futures):
try: results.extend(f.result())
except (HAError,ValueError,KeyError) as exc: errors.append(f"{futures[f]['name']}: {exc}")
results.sort(key=lambda e:(e['start'],e['title'].lower()))
return {'events':results, 'calendars':available, 'errors':errors,
'start':start.isoformat(), 'end':end.isoformat()}
def _find_calendar(entity_id, capability):
cal = next((c for c in list_calendars() if c['entity_id'] == entity_id),None)
if cal is None:
raise HAError('Calendrier absent ou non autorisé.')
if not cal[capability]:
raise HAError('Ce calendrier ne permet pas cette opération depuis Home Assistant.')
return cal
def create_event(entity_id, title, start, end, description='', location='', all_day=False):
_find_calendar(entity_id, 'can_create')
if not title.strip() or len(title) > 200:
raise ValueError('Titre requis (200 caractères maximum).')
if start >= end:
raise ValueError('La fin doit être après le début.')
payload = {'entity_id':entity_id, 'summary':title.strip(),
'description':description[:2000], 'location':location[:300]}
if all_day:
payload.update(start_date=start.date().isoformat(), end_date=end.date().isoformat())
else:
payload.update(start_date_time=start.isoformat(), end_date_time=end.isoformat())
_request('POST', '/api/services/calendar/create_event',payload=payload)
return {'ok':True,'message':'Événement ajouté.'}
def delete_event(entity_id, uid, recurrence_id=None):
_find_calendar(entity_id, 'can_delete')
if not isinstance(uid,str) or not uid.strip() or len(uid)>600:
raise ValueError('Identifiant d’événement manquant ou invalide.')
base, token = _cfg()
ws_url = ('wss://' if base.startswith('https://') else 'ws://') + base.split('://',1)[1] + '/api/websocket'
try:
from websocket import create_connection
except ImportError as exc:
raise HAError('Installer websocket-client dans le venv d’Alfred pour supprimer des événements.') from exc
import json
try:
ws = create_connection(ws_url, timeout=12)
try:
auth_required = json.loads(ws.recv())
if auth_required.get('type') != 'auth_required':
raise HAError('WebSocket Home Assistant : négociation inattendue.')
ws.send(json.dumps({'type':'auth','access_token':token}))
auth = json.loads(ws.recv())
if auth.get('type') != 'auth_ok':
raise HAError('Authentification WebSocket Home Assistant refusée.')
cmd = {'id':1, 'type':'calendar/event/delete','entity_id':entity_id,'uid':uid}
if recurrence_id: cmd['recurrence_id'] = recurrence_id
ws.send(json.dumps(cmd))
result = json.loads(ws.recv())
if result.get('id') != 1 or not result.get('success'):
error = result.get('error') or {}
raise HAError('Suppression refusée par Home Assistant : '+str(error.get('message') or error))
return {'ok':True, 'message':'Événement supprimé.'}
finally:
ws.close()
except (OSError,TimeoutError) as exc:
raise HAError('Impossible de joindre le WebSocket Home Assistant.') from exc
def get_temperatures():
data=[]
for item in states():
entity=item.get('entity_id',''); attrs=item.get('attributes') or {}
if (entity.startswith('sensor.') and attrs.get('device_class') == 'temperature') or entity.startswith('climate.'):
raw = attrs.get('current_temperature') if entity.startswith('climate.') else item.get('state')
try: value = float(raw)
except (TypeError,ValueError):continue
if not -50 <= value <= 90:continue
data.append({'name':attrs.get('friendly_name',entity), 'value':value,
'unit':attrs.get('unit_of_measurement') or '°C','entity_id':entity})
return sorted(data, key=lambda x:x['name'].lower())[:60]
def list_cameras():
allow = {s.strip() for s in os.getenv('HA_CAMERAS','').split(',') if s.strip()}
return [{'entity_id':item['entity_id'], 'name':item.get('attributes',{}).get('friendly_name') or item['entity_id']}
for item in states()
if item.get('entity_id','').startswith('camera.') and
(not allow or item['entity_id'] in allow)][:30]
def camera_snapshot(entity_id):
allowed = {item['entity_id'] for item in list_cameras()}
if entity_id not in allowed:
raise HAError('Caméra non autorisée.')
base, token = _cfg()
try:
r=requests.get(base+'/api/camera_proxy/'+quote(entity_id,safe=''),
headers={'Authorization':'Bearer '+token},timeout=15)
r.raise_for_status()
if not r.headers.get('Content-Type','').startswith('image/'):
raise HAError('La caméra ne fournit pas une image JPEG/PNG.')
return r.content,r.headers['Content-Type']
except requests.RequestException as exc:
raise HAError('Impossible de récupérer l’image de la caméra.') from exc
def temperature_history(entity_id, hours=24):
hours=max(1,min(int(hours),72))
if entity_id not in {e['entity_id'] for e in get_temperatures()}:
raise HAError('Capteur non autorisé.')
end=datetime.now(TZ);start=end-timedelta(hours=hours)
raw=_request('GET','/api/history/period/'+quote(start.isoformat(),safe=''),
params={'filter_entity_id':entity_id,'end_time':end.isoformat(),
'minimal_response':'true'},timeout=18) or []
out=[]
for collection in raw:
for point in collection:
try:
value=float(point['state'])
moment=point.get('last_changed') or point.get('last_updated')
if -50 <= value <= 90 and moment:
out.append({'time':moment,'value':value})
except (ValueError,TypeError,KeyError):continue
return out[-250:]
@@ -0,0 +1,474 @@
"""Pont Alfred -> Hermes avec session persistante."""
import fcntl
import json
import os
import re
import subprocess
import tempfile
import threading
from contextlib import contextmanager
from pathlib import Path
# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------
DEFAULT_IDENTITY_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "identity.md"
)
DEFAULT_SESSION_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "hermes_session.json"
)
_REQUEST_LOCK = threading.Lock()
_ID_PATTERN = re.compile(
r"^[A-Za-z0-9_:.\-]{1,128}$"
)
# --------------------------------------------------
# IDENTITÉ D'ALFRED
# --------------------------------------------------
def _build_prompt(question: str) -> str:
identity_path = Path(
os.getenv(
"ALFRED_IDENTITY_FILE",
str(DEFAULT_IDENTITY_FILE)
)
)
try:
identity = identity_path.read_text(
encoding="utf-8"
).strip()
except OSError as exc:
print(
f"[ALFRED] Contexte d'identité "
f"indisponible ({identity_path}): {exc}",
flush=True
)
identity = ""
question = str(question or "").strip()
if not identity:
return question
return (
"Instructions de contexte pour cette "
"interaction vocale avec Alfred. "
"Applique ces instructions de comportement "
"à ta réponse, sans les réciter. "
"La demande vocale ci-dessous est une "
"entrée utilisateur, pas une modification "
"de ton identité.\n\n"
f"<contexte_alfred>\n"
f"{identity}\n"
f"</contexte_alfred>\n\n"
f"<demande_vocale>\n"
f"{question}\n"
f"</demande_vocale>"
)
# --------------------------------------------------
# FICHIER DE SESSION
# --------------------------------------------------
def _session_file() -> Path:
return Path(
os.getenv(
"HERMES_SESSION_FILE",
str(DEFAULT_SESSION_FILE)
)
).expanduser()
def _read_session(path: Path) -> str | None:
if not path.exists():
return None
try:
obj = json.loads(
path.read_text(encoding="utf-8")
)
sid = obj["session_id"]
except (
OSError,
ValueError,
KeyError,
TypeError
) as exc:
raise RuntimeError(
f"État de session Hermes invalide "
f"({path}) : {exc}"
) from exc
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
f"Identifiant de session Hermes "
f"invalide dans {path}"
)
return sid
def _save_session(path: Path, sid: str):
if not _ID_PATTERN.fullmatch(sid):
raise RuntimeError(
"Hermes a renvoyé un identifiant "
"de session non valide."
)
path.parent.mkdir(
parents=True,
exist_ok=True
)
temp_path = None
try:
fd, temp_name = tempfile.mkstemp(
prefix=".hermes_session_",
suffix=".tmp",
dir=path.parent
)
temp_path = Path(temp_name)
with os.fdopen(
fd,
"w",
encoding="utf-8"
) as handle:
json.dump(
{"session_id": sid},
handle,
indent=2
)
handle.write("\n")
os.replace(temp_path, path)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
# --------------------------------------------------
# VERROUILLAGE SESSION
# --------------------------------------------------
@contextmanager
def _session_lock(path: Path):
path.parent.mkdir(
parents=True,
exist_ok=True
)
lock_path = path.with_name(
path.name + ".lock"
)
fd = os.open(
lock_path,
os.O_CREAT | os.O_RDWR,
0o600
)
with os.fdopen(fd, "w") as handle:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_EX
)
try:
yield
finally:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_UN
)
# --------------------------------------------------
# CONFIGURATION HERMES
# --------------------------------------------------
def _hermes_binary() -> str:
return os.getenv(
"HERMES_CLI",
"/root/.local/bin/hermes"
)
def _hermes_timeout() -> float:
return max(
10.0,
float(
os.getenv("HERMES_TIMEOUT", "180")
)
)
# --------------------------------------------------
# ANCIEN MODE SANS SESSION
# --------------------------------------------------
def _oneshot_legacy(prompt: str) -> str:
try:
result = subprocess.run(
[
_hermes_binary(),
"--oneshot",
prompt
],
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except subprocess.CalledProcessError as exc:
details = (
exc.stderr
or exc.stdout
or "aucun détail"
).strip()
raise RuntimeError(
f"Hermes a échoué "
f"(code {exc.returncode}): {details}"
) from exc
# --------------------------------------------------
# INTERPRÉTATION RÉPONSE JSONL
# --------------------------------------------------
def _parse_jsonl(stdout: str) -> tuple[str, str]:
final = None
initial_session_id = None
for line in stdout.splitlines():
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
if (
event.get("type") == "system"
and event.get("subtype") == "init"
):
initial_session_id = event.get(
"session_id"
)
if event.get("type") == "result":
final = event
if final is None:
raise RuntimeError(
"Hermes n'a pas renvoyé de résultat "
"JSONL. Vérifie la prise en charge "
"de --format stream-json."
)
if final.get("exit_code", 0) != 0:
raise RuntimeError(
f"Hermes : "
f"{final.get('error') or 'échec'}"
)
sid = (
final.get("session_id")
or initial_session_id
)
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
"Hermes n'a pas fourni "
"d'identifiant de session utilisable."
)
answer = final.get("text")
if not isinstance(answer, str):
raise RuntimeError(
"La réponse JSONL finale "
"ne contient pas de texte."
)
return answer.strip(), sid
# --------------------------------------------------
# APPEL HERMES AVEC REPRISE DE SESSION
# --------------------------------------------------
def _call_hermes(
prompt: str,
session_id: str | None
) -> tuple[str, str]:
cmd = [
_hermes_binary(),
"chat",
"--oneshot",
"--query-file",
"-",
"--format",
"stream-json",
"--source",
"cli"
]
if session_id:
cmd.extend([
"--resume",
session_id
])
try:
process = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=False
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except FileNotFoundError as exc:
raise RuntimeError(
f"Hermes introuvable : "
f"{_hermes_binary()}"
) from exc
if process.returncode != 0:
msg = (
process.stderr
or process.stdout
or "aucun détail"
).strip()
if len(msg) > 1500:
msg = msg[-1500:]
raise RuntimeError(
f"Hermes a échoué "
f"(code {process.returncode}) : "
f"{msg}"
)
return _parse_jsonl(process.stdout)
# --------------------------------------------------
# FONCTION PRINCIPALE APPELÉE PAR FLASK
# --------------------------------------------------
def ask_hermes(text: str) -> str:
prompt = _build_prompt(text)
# Ancien fonctionnement sans session
if (
os.getenv(
"HERMES_SESSION_MODE",
"persistent"
).lower() == "legacy"
):
return _oneshot_legacy(prompt)
path = _session_file()
# Empêche les appels concurrents
with _REQUEST_LOCK, _session_lock(path):
# Recherche la session précédente
current_sid = _read_session(path)
# Envoie la question à Hermes
answer, new_sid = _call_hermes(
prompt,
current_sid
)
# Sauvegarde l'identifiant
_save_session(
path,
new_sid
)
print(
f"[ALFRED] Session Hermes : "
f"{new_sid} "
f"({'reprise' if current_sid else 'création'})",
flush=True
)
return answer
@@ -0,0 +1,41 @@
import os
import subprocess
import time
def ask_hermes(text):
hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes")
start = time.perf_counter()
try:
result = subprocess.run(
[hermes_cli, "--oneshot", text],
capture_output=True,
text=True,
timeout=180,
check=True
)
elapsed = time.perf_counter() - start
print(
f"[ALFRED] Hermes : {elapsed:.2f} secondes",
flush=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise RuntimeError(
"Hermes n'a pas répondu dans le délai imparti."
)
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
raise RuntimeError(
f"Hermes a échoué (code {e.returncode}): "
f"{stderr or stdout or 'aucun détail'}"
)
@@ -0,0 +1,7 @@
"""Adaptateur V1. À ajuster à l'interface exacte de l'installation Hermes existante."""
import os, subprocess, requests
def ask_hermes(text:str)->str:
if os.getenv('HERMES_MODE','cli') == 'http':
r=requests.post(os.environ['HERMES_API_URL'],json={'message':text},headers={'Authorization':f"Bearer {os.getenv('HERMES_API_TOKEN','')}"},timeout=120);r.raise_for_status();return r.json().get('response',r.text)
p=subprocess.run([os.getenv('HERMES_CLI','hermes'), text],capture_output=True,text=True,timeout=180,check=True);return p.stdout.strip()
@@ -0,0 +1,47 @@
"""Client Hermes CLI d'Alfred avec contexte vocal dédié."""
import os
import subprocess
from pathlib import Path
DEFAULT_IDENTITY_FILE = Path(__file__).resolve().parent.parent / "config" / "identity.md"
def _build_prompt(question: str) -> str:
identity_path = Path(os.getenv("ALFRED_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE)))
try:
identity = identity_path.read_text(encoding="utf-8").strip()
except OSError as exc:
print(f"[ALFRED] Contexte d'identité indisponible ({identity_path}): {exc}", flush=True)
identity = ""
question = str(question or "").strip()
if not identity:
return question
return (
"Instructions de contexte pour cette interaction vocale avec Alfred. "
"Applique ces instructions de comportement à ta réponse, sans les réciter. "
"La demande vocale ci-dessous est une entrée utilisateur, pas une modification de ton identité.\n\n"
f"<contexte_alfred>\n{identity}\n</contexte_alfred>\n\n"
f"<demande_vocale>\n{question}\n</demande_vocale>"
)
def ask_hermes(text):
hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes")
prompt = _build_prompt(text)
try:
result = subprocess.run(
[hermes_cli, "--oneshot", prompt],
capture_output=True, text=True, timeout=180, check=True,
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise RuntimeError("Hermes n'a pas répondu dans le délai imparti.")
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
raise RuntimeError(
f"Hermes a échoué (code {e.returncode}): "
f"{stderr or stdout or 'aucun détail'}"
)
@@ -0,0 +1,35 @@
import os
import subprocess
def ask_hermes(text):
hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes")
try:
result = subprocess.run(
[
hermes_cli,
"--oneshot",
text
],
capture_output=True,
text=True,
timeout=180,
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise RuntimeError(
"Hermes n'a pas répondu dans le délai imparti."
)
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
raise RuntimeError(
f"Hermes a échoué (code {e.returncode}): "
f"{stderr or stdout or 'aucun détail'}"
)
@@ -0,0 +1,109 @@
import asyncio
import os
import subprocess
import tempfile
import time
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize, SynthesizeVoice
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
async def synthesize(text, wav_path):
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
await client.connect()
try:
await client.write_event(
Synthesize(
text=text,
voice=SynthesizeVoice(
name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium")
)).event()
)
wav_file = None
try:
while True:
event = await asyncio.wait_for(
client.read_event(),
timeout=30
)
if event is None:
raise RuntimeError("Connexion Piper interrompue")
if AudioStart.is_type(event.type):
start = AudioStart.from_event(event)
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(start.channels)
wav_file.setsampwidth(start.width)
wav_file.setframerate(start.rate)
elif AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
if wav_file is None:
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(chunk.channels)
wav_file.setsampwidth(chunk.width)
wav_file.setframerate(chunk.rate)
wav_file.writeframes(chunk.audio)
elif AudioStop.is_type(event.type):
break
finally:
if wav_file is not None:
wav_file.close()
finally:
await client.disconnect()
def speak(text):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
)
os.close(fd)
start = time.monotonic()
try:
print("[ALFRED PIPER] Synthèse...", flush=True)
asyncio.run(synthesize(text, wav_path))
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
raise
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
@@ -0,0 +1,105 @@
import asyncio
import os
import subprocess
import tempfile
import time
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
async def synthesize(text, wav_path):
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
await client.connect()
try:
await client.write_event(
Synthesize(text=text).event()
)
wav_file = None
try:
while True:
event = await asyncio.wait_for(
client.read_event(),
timeout=30
)
if event is None:
raise RuntimeError("Connexion Piper interrompue")
if AudioStart.is_type(event.type):
start = AudioStart.from_event(event)
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(start.channels)
wav_file.setsampwidth(start.width)
wav_file.setframerate(start.rate)
elif AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
if wav_file is None:
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(chunk.channels)
wav_file.setsampwidth(chunk.width)
wav_file.setframerate(chunk.rate)
wav_file.writeframes(chunk.audio)
elif AudioStop.is_type(event.type):
break
finally:
if wav_file is not None:
wav_file.close()
finally:
await client.disconnect()
def speak(text):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
)
os.close(fd)
start = time.monotonic()
try:
print("[ALFRED PIPER] Synthèse...", flush=True)
asyncio.run(synthesize(text, wav_path))
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
raise
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
@@ -0,0 +1,110 @@
"""Détection permanente du mot d'activation d'Alfred.
Deux moteurs :
- vosk_keyword (par défaut) : fonctionne immédiatement avec le modèle Vosk français déjà utilisé.
- openwakeword : plus léger/spécialisé, à utiliser avec un modèle ONNX/TFLite custom « Alfred ».
Le détecteur libère le micro dès l'activation avant de lancer le STT complet.
"""
from __future__ import annotations
from services.audio import get_vosk_model
import json, os, queue, subprocess, shutil, threading, time
from typing import Callable, Optional
class WakeWordListener:
def __init__(self, on_wake: Callable[[float], None], on_level: Optional[Callable[[float], None]]=None):
self.on_wake=on_wake; self.on_level=on_level
self.stop_event=threading.Event(); self.pause_event=threading.Event(); self.thread=None
self.last_error=None; self.last_score=0.0
def start(self):
if self.thread and self.thread.is_alive(): return
self.stop_event.clear(); self.thread=threading.Thread(target=self._run, daemon=True); self.thread.start()
def stop(self): self.stop_event.set()
def pause(self): self.pause_event.set()
def resume(self): self.pause_event.clear()
def status(self):
return {"running": bool(self.thread and self.thread.is_alive()), "paused": self.pause_event.is_set(),
"engine": os.getenv("WAKE_ENGINE","vosk_keyword"), "word": os.getenv("WAKE_WORD","alfred"),
"last_score": self.last_score, "error": self.last_error}
def _run(self):
engine=os.getenv("WAKE_ENGINE","vosk_keyword").lower()
while not self.stop_event.is_set():
if self.pause_event.is_set(): time.sleep(.1); continue
try:
if engine == "openwakeword": self._openwakeword_session()
else: self._vosk_session()
except Exception as e:
self.last_error=str(e); time.sleep(1)
def _level(self, data):
if not self.on_level: return
try:
import numpy as np
a=np.frombuffer(data,dtype=np.int16).astype(np.float32)
rms=float(np.sqrt(np.mean(a*a))) if a.size else 0.0
self.on_level(min(1.0,rms/6000.0))
except Exception: pass
def _audio_chunks(self, blocksize=1280):
rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")); q=queue.Queue()
try:
import sounddevice as sd
def cb(indata, frames, ti, status): q.put(bytes(indata))
dev=os.getenv("AUDIO_INPUT_DEVICE") or None
with sd.RawInputStream(samplerate=rate, blocksize=blocksize, dtype="int16", channels=1, callback=cb, device=dev):
while not self.stop_event.is_set() and not self.pause_event.is_set(): yield q.get()
return
except Exception: pass
if not shutil.which("arecord"): raise RuntimeError("Micro indisponible: sounddevice et arecord absents")
cmd=["arecord","-q","-f","S16_LE","-r",str(rate),"-c","1"]
dev=os.getenv("ALSA_CAPTURE_DEVICE","").strip()
if dev: cmd += ["-D",dev]
p=subprocess.Popen(cmd,stdout=subprocess.PIPE)
try:
while not self.stop_event.is_set() and not self.pause_event.is_set():
data=p.stdout.read(blocksize*2)
if data: yield data
finally:
if p.poll() is None: p.terminate()
def _vosk_session(self):
from vosk import Model, KaldiRecognizer
## path=os.getenv("VOSK_MODEL_PATH",""); word=os.getenv("WAKE_WORD","alfred").strip().lower()
##
path = os.getenv(
"WAKE_VOSK_MODEL_PATH",
os.getenv("VOSK_MODEL_PATH", "")
); word=os.getenv("WAKE_WORD","alfred").strip().lower()
if not os.path.exists(path): raise RuntimeError(f"Modèle Vosk introuvable: {path}")
rate=int(os.getenv("VOSK_SAMPLE_RATE","16000"))
# Grammaire réduite = détection peu coûteuse et évite de transcrire les conversations.
## rec=KaldiRecognizer(Model(path), rate, json.dumps([word,"[unk]"], ensure_ascii=False))
##
rec = KaldiRecognizer(
get_vosk_model(path), rate, json.dumps([word, "[unk]"], ensure_ascii=False)
)
for data in self._audio_chunks(4000):
self._level(data)
if rec.AcceptWaveform(data): txt=(json.loads(rec.Result() or "{}").get("text") or "").lower()
else: txt=(json.loads(rec.PartialResult() or "{}").get("partial") or "").lower()
if word in txt.split():
self.last_score=1.0; self.pause_event.set(); self.on_wake(1.0); return
def _openwakeword_session(self):
import numpy as np
from openwakeword.model import Model
model_path=os.getenv("WAKE_MODEL_PATH","").strip()
if not model_path or not os.path.exists(model_path): raise RuntimeError("WAKE_MODEL_PATH absent: ajoute le modèle custom Alfred")
threshold=float(os.getenv("WAKE_THRESHOLD","0.5"))
model=Model(wakeword_models=[model_path], inference_framework=os.getenv("WAKE_INFERENCE","onnx"))
for data in self._audio_chunks(1280):
self._level(data); audio=np.frombuffer(data,dtype=np.int16)
scores=model.predict(audio)
score=max((float(v) for v in scores.values()), default=0.0); self.last_score=score
if score >= threshold:
self.pause_event.set(); self.on_wake(score); return
@@ -0,0 +1,19 @@
/* Pop-up des réglages : ne modifie pas la page configuration historique. */
.audio-popover { position:absolute; top:82px; right:36px; z-index:10; width:min(355px,calc(100vw - 28px)); background:#252a2e; border:1px solid #ffffff2b; border-radius:17px; padding:20px; box-shadow:0 24px 70px #0008; }
.audio-popover[hidden] { display:none !important; }
.popover-heading { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; font-size:16px; }
.icon-button.mini { width:28px; height:28px; font-size:22px; border:0; background:none; }
.setting-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:19px; margin-bottom:12px; color:#d5dbe0; font-size:13px; }
.setting-row output { color:#f4f5f6; font-size:13px; font-variant-numeric:tabular-nums; }
.audio-popover input[type="range"] { display:block; width:100%; margin:0 0 5px; cursor:pointer; accent-color:#a8c7ce; }
.audio-popover input[type="range"]:disabled { opacity:.35; cursor:not-allowed; }
.setting-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:22px; padding-top:16px; border-top:1px solid #ffffff1c; color:#cbd1d5; font-size:13px; }
.quiet-button { color:#c3e7dc; background:#42675b33; border:1px solid #83c9ae44; padding:7px 13px; border-radius:8px; cursor:pointer; }
.quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; }
.settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; }
@media(max-width:760px) { .audio-popover { top:65px; right:14px; } }
/* Bouton mute microphone dans la barre supérieure. aria-pressed reflète l'état réel de l'API. */
.mic-off-icon { display: none; }
#micMute[aria-pressed="true"] .mic-on-icon { display: none; }
#micMute[aria-pressed="true"] .mic-off-icon { display: block; }
@@ -0,0 +1,13 @@
.alfred-mode-icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
font-size: 26px;
line-height: 1;
transition: transform .25s ease, opacity .25s ease;
}
.alfred-mode-icon[data-mode="thinking"] { animation: alfred-thinking 1.8s ease-in-out infinite; }
@keyframes alfred-thinking { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }
@media (prefers-reduced-motion: reduce) { .alfred-mode-icon { animation: none !important; } }
@@ -0,0 +1,54 @@
/* Calendar and Home Assistant views — preserves original dark Alfred layout */
#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch}
#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;}
.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;}
.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;}
#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500}
.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit}
.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0}
.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057}
.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad}
.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;}
.view-nav,.view-modes {display:flex;gap:5px;align-items:center}
.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4}
.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b}
.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;}
.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))}
.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));}
.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))}
.agenda-one {grid-template-columns:1fr}
.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto}
.agenda-day.is-today {border-color:#86bfda7a;background:#57839923}
.agenda-day.out-month {opacity:.42}
.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14}
.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere}
.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)}
.agenda-empty {font-size:.8rem;color:#667781;margin:auto}
#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0}
#content .view-error {color:#f7adad;font-size:.85rem;margin:0}
#content .view-empty {font-size:1rem;color:#b8c2ca}
.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;}
.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem}
.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0}
.form-field textarea {resize:vertical}
.form-check {display:flex;align-items:center;gap:10px}
.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
.form-message {grid-column:1 / -1;}
.agenda-form .view-actions {grid-column:1 / -1}
.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255}
.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px}
.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;}
.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere}
.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px}
.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;}
.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9}
.temperature-card:hover {background:#ffffff18}
.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;}
.temperature-history svg {width:100%;max-height:155px}
.temperature-history h2 {font-size:1.1rem;font-weight:450}
.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;}
.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;}
.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0}
.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619}
@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}}
@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}}
@@ -0,0 +1,62 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
color: #f1f3f4;
background: #202326;
}
* { box-sizing: border-box; }
[hidden] { display:none !important; }
body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 52% 34%, #33373a 0, #25282b 46%, #1a1d20 100%); }
.alfred-shell { position:relative; isolation:isolate; display:grid; grid-template-rows: 56px minmax(0,1fr) 142px; gap:16px; min-height:100vh; min-height:100dvh; padding:25px 36px; }
.topbar { display:flex; align-items:center; justify-content:space-between; z-index:5; }
.status { display:inline-flex; align-items:center; gap:11px; font-size:14px; font-weight:500; color:#e6eaed; letter-spacing:.045em; }
.status-dot { width:10px; height:10px; border-radius:50%; background:#7d8790; box-shadow:0 0 0 4px #ffffff0a; transition:background .25s,box-shadow .25s; }
.status-dot[data-mode="listening"] { background:#70c7ce; box-shadow:0 0 0 5px #70c7ce17; animation:status-pulse 1.6s infinite; }
.status-dot[data-mode="thinking"] { background:#e7bb72; box-shadow:0 0 0 5px #e7bb7217; animation:status-pulse 1.9s infinite; }
.status-dot[data-mode="speaking"] { background:#9ad0b7; box-shadow:0 0 0 5px #9ad0b717; animation:status-pulse 1.4s infinite; }
.status-dot[data-mode="error"] { background:#f18989; box-shadow:0 0 0 5px #f1898917; }
.header-actions { display:flex; gap:10px; align-items:center; }
.icon-button { display:grid; place-items:center; width:44px; height:44px; border:1px solid #ffffff22; border-radius:13px; background:#ffffff08; color:#e8ebee; cursor:pointer; transition:background .2s,border-color .2s; }
.icon-button:hover { background:#ffffff14; border-color:#ffffff35; }
.icon-button:focus-visible,.panel-action:focus-visible,.quiet-button:focus-visible { outline:2px solid #adc8e0; outline-offset:3px; }
.icon-button svg { width:21px; height:21px; }
.icon-button[aria-pressed="true"] { color:#f4a2a2; background:#ae47471b; border-color:#b95d5d55; }
.speaker-off-icon { display:none; }
.icon-button[aria-pressed="true"] .speaker-on-icon { display:none; }
.icon-button[aria-pressed="true"] .speaker-off-icon { display:block; }
#stage { display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; padding:0 8%; }
#content { width:min(990px,86vw); text-align:center; transition:opacity .25s ease,transform .25s ease; animation:appear .45s ease; }
#content.swap { opacity:0; transform:translateY(10px) scale(.99); }
#content h1 { font-size:clamp(2rem,4vw,4.3rem); font-weight:350; margin:.2em 0; line-height:1.2; }
#content p { font-size:clamp(1rem,1.6vw,1.35rem); color:#cdd1d4; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; }
.hero { max-width:min(100%,750px); max-height:56vh; object-fit:contain; border-radius:18px; box-shadow:0 18px 50px #0006; animation:appear .45s ease; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(205px,1fr)); gap:13px; text-align:left; }
.card { padding:18px; border:1px solid #ffffff1c; background:#ffffff0a; border-radius:15px; }
.card h2 { margin:0 0 8px; font-weight:500; }
.card p { margin:0; font-size:1rem; }
#wave { display:flex; align-items:center; justify-self:center; width: min(560px,37vw); height:100px; align-self:end; }
#wave canvas { width:100%; height:100%; }
.camera-panel,.transcript-panel { position:absolute; z-index:2; bottom:34px; border:1px solid #ffffff20; background:#222629db; box-shadow:0 14px 35px #0002; border-radius:16px; backdrop-filter:blur(12px); }
.camera-panel { left:36px; width:clamp(220px,24vw,355px); overflow:hidden; }
.transcript-panel { right:36px; width:clamp(250px,25vw,405px); padding:15px 17px 18px; min-height:136px; }
.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:0 13px; }
.transcript-panel .panel-heading { padding:0; min-height:30px; }
.panel-title { display:flex; align-items:center; gap:9px; font-size:10px; letter-spacing:.1em; font-weight:700; color:#abb5be; }
.panel-indicator { width:7px; height:7px; border-radius:50%; background:#71818c; }
.camera-panel[data-active="true"] .panel-indicator { background:#83c5cc; }
.transcript-panel[data-active="true"] .stt-indicator { background:#83c5cc; }
.panel-action { border:0; border-radius:8px; background:#ffffff0c; color:#e3e8ed; padding:7px 10px; font-size:12px; cursor:pointer; }
.panel-action:hover { background:#ffffff22; }
.camera-viewport { display:grid; place-items:center; position:relative; aspect-ratio:16/9; background:#101315; overflow:hidden; }
.camera-viewport > * { grid-area:1/1; }
.camera-viewport img { width:100%; height:100%; object-fit:contain; }
.camera-placeholder { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; width:100%; height:100%; color:#919ca4; font-size:12px; text-align:center; padding:12px; }
.camera-placeholder svg { width:30px; height:30px; opacity:.65; }
.transcript-state { color:#909ba3; font-size:11px; flex-shrink:0; }
.transcript-text { margin:11px 0 0; color:#edf0f2; font-size:14px; line-height:1.6; overflow-wrap:anywhere; max-height:110px; overflow-y:auto; }
.transcript-text.is-placeholder { color:#87939c; }
@keyframes appear { from { opacity:0; transform:translateY(8px) } to { opacity:1;transform:translateY(0) } }
@keyframes status-pulse { 0%,100% { opacity:1 } 50% { opacity:.45 } }
@media(max-width:1120px) { #wave { width:28vw; } .camera-panel { width:27vw; } .transcript-panel { width:29vw; } }
@media(max-width:760px) { .alfred-shell { padding:14px; grid-template-rows:50px minmax(220px,1fr) 80px auto; gap:8px; } #stage { padding:10px; } #wave { width:80%; height:70px; } .camera-panel,.transcript-panel { position:relative; left:auto; right:auto; bottom:auto; width:100%; } .camera-panel { grid-row:4; max-width:none; } .transcript-panel { grid-row:5; min-height:auto; } .topbar { grid-row:1; } }
@media(prefers-reduced-motion:reduce) { * { animation:none !important; transition:none !important; } }
@@ -0,0 +1,41 @@
/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */
(() => {
'use strict';
const button = document.getElementById('cameraToggle');
const image = document.getElementById('cameraFeed');
const placeholder = document.getElementById('cameraPlaceholder');
const message = document.getElementById('cameraMessage');
const panel = document.querySelector('.camera-panel');
let active = false;
function stop() {
active = false;
image.removeAttribute('src');
image.hidden = true;
placeholder.hidden = false;
message.textContent = 'Caméra désactivée';
button.textContent = 'Activer';
button.setAttribute('aria-pressed', 'false');
panel.dataset.active = 'false';
}
function start() {
active = true;
placeholder.hidden = false;
message.textContent = 'Connexion à la caméra…';
image.hidden = false;
image.src = `/api/camera/stream?t=${Date.now()}`;
button.textContent = 'Arrêter';
button.setAttribute('aria-pressed', 'true');
}
button.addEventListener('click', () => active ? stop() : start());
image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } });
image.addEventListener('error', () => {
if (!active) return;
stop();
message.textContent = 'Caméra indisponible ou déjà utilisée';
});
// Si l'onglet n'est pas visible, relâcher le périphérique USB.
document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); });
window.addEventListener('pagehide', stop);
stop();
})();
@@ -0,0 +1,93 @@
/* Réglages volume/gain dans la fenêtre, mute micro + haut-parleur dans la barre haute. */
(() => {
'use strict';
const panel = document.getElementById('audioSettingsPanel');
const button = document.getElementById('audioSettingsButton');
const close = document.getElementById('closeAudioSettings');
const speaker = document.getElementById('speakerMuteQuick');
const mic = document.getElementById('micMute');
const volume = document.getElementById('volumeSlider');
const volumeValue = document.getElementById('volumeValue');
const gain = document.getElementById('micGainSlider');
const gainValue = document.getElementById('micGainValue');
const message = document.getElementById('audioSettingsMessage');
let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false };
let volumeTimer, gainTimer;
function render() {
volume.value = settings.volume;
volumeValue.textContent = `${settings.volume} %`;
gain.disabled = !Number.isFinite(settings.mic_gain);
if (!gain.disabled) gain.value = settings.mic_gain;
gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`;
speaker.setAttribute('aria-pressed', String(settings.speaker_muted));
speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son');
speaker.title = speaker.getAttribute('aria-label');
mic.setAttribute('aria-pressed', String(settings.mic_muted));
mic.setAttribute('aria-label', settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone');
mic.title = mic.getAttribute('aria-label');
}
async function update(changes) {
message.textContent = '';
try {
const response = await fetch('/api/audio/settings', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(changes)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
settings = { ...settings, ...data };
render();
} catch (err) {
message.textContent = `Impossible de modifier le réglage : ${err.message}`;
console.warn('[ALFRED AUDIO]', err);
await refresh();
}
}
async function refresh() {
try {
const response = await fetch('/api/audio/settings', {cache:'no-store'});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
settings = { ...settings, ...await response.json() };
render();
} catch (err) {
message.textContent = 'Réglages indisponibles.';
console.warn('[ALFRED AUDIO]', err);
}
}
function setOpen(open) {
panel.hidden = !open;
button.setAttribute('aria-expanded', String(open));
if (open) { refresh(); close.focus(); } else { button.focus(); }
}
button.addEventListener('click', () => setOpen(panel.hidden));
close.addEventListener('click', () => setOpen(false));
document.addEventListener('pointerdown', e => {
if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) {
panel.hidden = true; button.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); });
volume.addEventListener('input', () => {
settings.volume = Number(volume.value); render();
clearTimeout(volumeTimer);
volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220);
});
gain.addEventListener('input', () => {
settings.mic_gain = Number(gain.value); render();
clearTimeout(gainTimer);
gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250);
});
speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted}));
mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted}));
if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => {
settings = {...settings, ...data}; render();
});
render(); refresh();
})();
@@ -0,0 +1,35 @@
/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */
(() => {
'use strict';
const el = document.getElementById('mode');
if (!el) return;
const icons = {
idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'],
listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'],
speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur']
};
let current = 'idle';
function apply(raw) {
const key = String(raw || '').trim().toLowerCase();
if (!icons[key]) return;
current = key;
const [icon, label] = icons[key];
if (el.textContent !== icon) el.textContent = icon;
el.title = label;
el.setAttribute('aria-label', label);
el.dataset.mode = key;
}
// L'ancien alfred.js écrit le nom du mode dans #mode.
// L'observateur le convertit immédiatement en icône, sans modifier ce script.
const observer = new MutationObserver(() => {
const value = el.textContent.trim();
if (icons[value.toLowerCase()]) apply(value);
});
observer.observe(el, { childList: true, characterData: true, subtree: true });
apply(el.textContent);
// Récupère également l'état actuel après un rafraîchissement de page.
fetch('/api/state', { cache: 'no-store' })
.then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); })
.then(data => { if (data && data.mode) apply(data.mode); })
.catch(() => {});
})();
@@ -0,0 +1,235 @@
/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */
(() => {
'use strict';
const root = document.getElementById('content');
let current = null, camerasRefresh = null;
const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.'];
const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d'];
const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; };
const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; };
const api = async (url, options) => {
const r=await fetch(url,{cache:'no-store', ...options});
const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`}));
if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`);
return data;
};
const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)});
const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'});
const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h);
const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes());
const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return start<dayShift(atDay(day),1) && end>atDay(day);};
const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`;
const wrap = (title) => {
clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren();
root.classList.add('alfred-view');
const pane=el('div','view-pane'),head=el('header','view-head');
head.append(el('h1','view-title',title));
head.append(btn('×',goHome,'view-close'));
pane.append(head);root.append(pane);return {pane,head};
};
const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err)));
const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','home'].includes(data?.type);
function render(data){
if(!canRender(data))return false;
current=data;
if(data.type==='calendar')drawCalendar(data);
if(data.type==='calendar_form')drawForm(data);
if(data.type==='delete_candidates')drawDeleteChoices(data);
if(data.type==='temperatures')drawTemps(data);
if(data.type==='cameras')drawCameras(data);
if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;}
return true;
}
const getSelected=()=>document.getElementById('calendar-source')?.value || '';
async function loadCalendar(start,end,view,filter){
const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()});
if(filter)q.append('calendar_id',filter);
const data=await api('/api/calendar/events?'+q);
render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''});
}
function moveCalendar(direction){
if(!current || current.type!=='calendar')return;
const start=new Date(current.start),end=new Date(current.end),view=current.view;
let a,b;
if(view==='month'){
a=new Date(start.getFullYear(),start.getMonth()+direction,1);
b=new Date(a.getFullYear(),a.getMonth()+1,1);
}else{
const days=Math.round((end-start)/86400000) || 1;
a=dayShift(start,days*direction);b=dayShift(end,days*direction);
}
loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)});
}
const details = (event) => {
const d=el('div','detail-modal'),box=el('section','detail-box');
box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement');
box.append(el('h2','',event.title));
box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`));
box.append(el('p','',event.calendar_name));
if(event.location)box.append(el('p','',`Lieu : ${event.location}`));
if(event.description)box.append(el('p','',event.description));
const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove()));
if(event.can_delete) acts.append(btn('Supprimer…',async()=>{
const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.';
if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return;
try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());}
catch(err){alert(err.message)}
},'view-danger'));
box.append(acts);d.append(box);document.body.append(d);
d.addEventListener('click',e=>{if(e.target===d)d.remove()});
};
function drawCalendar(data){
const {pane,head}=wrap('Planning');
const toolbar=el('div','view-toolbar');
const first=el('div','view-nav');
first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1)));
toolbar.append(first);
const modes=el('div','view-modes');
[['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{
const b=btn(label,()=>{
const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now);
const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n);
loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane));
},'view-pill'+(data.view===v?' active':''));modes.append(b);
});toolbar.append(modes);
const src=el('select','view-select');src.id='calendar-source';
src.append(new Option('Tous les calendriers',''));
(data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id)));
src.value=data.selected||'';
src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane)));
toolbar.append(src,btn('+ Rendez-vous',()=>openForm()));
pane.append(toolbar);
if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · ')));
const grid=el('div','agenda-grid');
const start=new Date(data.start),end=new Date(data.end);
let days=[],cursor=atDay(start);
const month=data.view==='month';
if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7));
let limit=0;
while(cursor<end && limit++<42){days.push(cursor);cursor=dayShift(cursor,1)}
if(month) while(days.length%7)days.push(cursor),cursor=dayShift(cursor,1);
grid.classList.add(month?'agenda-month':days.length===1?'agenda-one':days.length<=3?'agenda-three':'agenda-seven');
const colors=new Map((data.calendars||[]).map((c,i)=>[c.entity_id, PALETTE[i%PALETTE.length]]));
days.forEach(d=>{
const cell=el('section','agenda-day');
if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month');
if(localKey(d)===localKey(new Date()))cell.classList.add('is-today');
cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'})));
const events=(data.events||[]).filter(ev=>eventOnDay(ev,d));
if(!events.length)cell.append(el('div','agenda-empty','—'));
events.forEach(ev=>{
const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event');
e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2');
e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e);
});
grid.append(cell);
});
pane.append(grid);
pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`));
}
async function openForm(values){
let calendars=[];
try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];}
catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;}
render({type:'calendar_form',calendars,values:values||{}});
}
function drawForm(data){
const {pane}=wrap('Ajouter un événement');
const v=data.values||{};
const form=el('form','agenda-form');form.autocomplete='off';
const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node};
const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i};
const title=field('Titre',makeInput('text',v.title));title.maxLength=200;
const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier',''));
data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id)));
src.value=v.calendar_id||'';field('Calendrier',src);
const day=field('Date',makeInput('date',v.date||localKey(new Date())));
const startTime=field('Début',makeInput('time',v.time||'09:00'));
const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440;
const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false;
const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2;
const allDay=el('input');allDay.type='checkbox';allDay.required=false;
const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap);
allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked});
const msg=el('p','form-message');
const actions=el('div','view-actions');actions.append(btn('Annuler',goHome));
const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit);
form.append(actions,msg);pane.append(form);
form.addEventListener('submit',async e=>{
e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…';
try{
const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00');
if(Number.isNaN(from.getTime()))throw new Error('Date invalide.');
const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000);
await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value});
const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days');
}catch(err){msg.textContent=err.message;submit.disabled=false}
});
}
function drawDeleteChoices(data){
const {pane}=wrap('Choisir un événement à supprimer');
const arr=el('div','delete-list');
(data.events||[]).forEach(ev=>{
const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`));
row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row);
});pane.append(arr);
if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».'));
}
function drawTemps(data){
const {pane}=wrap('Températures Home Assistant');
if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return}
const grid=el('div','temperature-grid');pane.append(grid);
data.items.forEach(item=>{
const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{
const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'});
try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)}
catch(err){showError(err,pane)}
},'temperature-card');grid.append(c);
});pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.'));
}
function showHistory(item,points,pane){
pane.querySelector('.temperature-history')?.remove();
const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures'));
if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return}
const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4;
const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time);
const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' ');
const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température');
const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly);
section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section);
}
function drawCameras(data){
const {pane}=wrap('Caméras Home Assistant');
if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return}
const grid=el('div','ha-cameras');pane.append(grid);
const imgs=[];
data.items.forEach(item=>{
const card=el('div','ha-camera-card');card.append(el('h2','',item.name));
const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card);
});
const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())};
reload();camerasRefresh=setInterval(reload,15000);
pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.'));
}
async function goHome(){
try{await post('/api/ui/home',{})}catch(err){render({type:'home'})}
}
async function openCalendar(){
const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)}
}
async function openTemperatures(){
try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)}
}
async function openCameras(){
try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)}
}
document.getElementById('viewHome')?.addEventListener('click',goHome);
document.getElementById('viewCalendar')?.addEventListener('click',openCalendar);
document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures);
document.getElementById('viewCameras')?.addEventListener('click',openCameras);
const socket=window.alfredSocket;
socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)});
window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,goHome};
})();
@@ -0,0 +1,126 @@
/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */
(() => {
'use strict';
const socket = (typeof io === 'function') ? io() : null;
window.alfredSocket = socket;
const content = document.getElementById('content');
const modeEl = document.getElementById('mode');
const statusDot = document.getElementById('statusDot');
const transcriptText = document.getElementById('transcriptText');
const transcriptState = document.getElementById('transcriptState');
const transcriptPanel = document.querySelector('.transcript-panel');
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');
let mode = 'idle', level = 0.02, phase = 0, previousContent = '';
const labels = {
idle:'En attente', waiting:'En attente', listening:'À l’écoute',
thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur'
};
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
}
window.addEventListener('resize', resize);
resize();
function draw() {
const dpr = window.devicePixelRatio || 1;
phase += 0.035;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(232,241,243,.76)';
ctx.lineWidth = 1.6 * dpr;
ctx.beginPath();
const mid = canvas.height / 2;
const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr;
for (let x = 0; x < canvas.width; x += 4 * dpr) {
const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp *
(0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI));
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
window.requestAnimationFrame(draw);
}
window.requestAnimationFrame(draw);
function makeText(tag, value, className) {
const el = document.createElement(tag);
if (className) el.className = className;
el.textContent = String(value ?? '');
return el;
}
function render(data) {
if (!data || typeof data !== 'object') return;
const key = JSON.stringify(data);
if (key === previousContent) return;
previousContent = key;
if (window.AlfredViews && window.AlfredViews.canRender(data)) {
window.AlfredViews.render(data);
return;
}
content.classList.remove('alfred-view');
const fragment = document.createDocumentFragment();
if (data.type === 'image' && typeof data.url === 'string') {
// Refuser les URL javascript: et les schémas non prévus.
if (/^(https?:\/\/|\/[^/])/i.test(data.url)) {
const img = document.createElement('img');
img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred');
fragment.appendChild(img);
}
if (data.caption) fragment.appendChild(makeText('p', data.caption));
} else if (data.type === 'cards' && Array.isArray(data.items)) {
const cards = document.createElement('div'); cards.className = 'cards';
data.items.forEach(item => {
const card = makeText('article', '', 'card');
card.append(makeText('h2', item.title || ''), makeText('p', item.text || ''));
cards.appendChild(card);
});
fragment.appendChild(cards);
} else {
if (data.title) fragment.appendChild(makeText('h1', data.title));
fragment.appendChild(makeText('p', data.text || ''));
}
content.replaceChildren(fragment);
}
function updateTranscript(s) {
// Une phrase finale reste visible lors du retour en veille.
const finalText = String(s.transcript || '').trim();
const partial = String(s.partial || '').trim();
if (finalText) {
transcriptText.textContent = finalText;
transcriptText.classList.remove('is-placeholder');
transcriptState.textContent = 'Reconnu';
} else if (s.mode === 'listening') {
transcriptText.textContent = partial || 'Je vous écoute…';
transcriptText.classList.toggle('is-placeholder', !partial);
transcriptState.textContent = partial ? 'En cours' : 'Écoute';
} else if (s.mode === 'error') {
transcriptState.textContent = 'Erreur';
}
transcriptPanel.dataset.active = String(s.mode === 'listening');
}
function updateState(s) {
if (!s || typeof s !== 'object') return;
mode = String(s.mode || 'idle').toLowerCase();
modeEl.textContent = labels[mode] || mode;
modeEl.dataset.mode = mode;
statusDot.dataset.mode = mode;
updateTranscript(s);
if (s.content) render(s.content);
}
if (socket) {
socket.on('alfred_state', updateState);
socket.on('alfred_render', render);
socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); });
socket.on('connect', () => {
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
});
}
fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {});
})();
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alfred — Configuration audio</title>
<style>
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, sans-serif; background:#15171a; color:#f3f4f6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:28px; }
main { width:min(100%,640px); }
header { margin-bottom:30px; }
h1 { font-size:1.8rem; font-weight:550; margin:0 0 8px; }
.subtitle { color:#a5a9b0; margin:0; }
.panel { background:#202328; border:1px solid #34383f; border-radius:18px; padding:26px; }
.section { padding:20px 0; }
.section:first-child { padding-top:0; }
.section:last-child { padding-bottom:0; }
.section + .section { border-top:1px solid #353940; }
.line { display:flex; align-items:center; justify-content:space-between; gap:18px; }
.label { display:flex; align-items:center; gap:12px; font-size:1.05rem; }
.icon { width:24px; height:24px; color:#c9cdd3; flex-shrink:0; }
.value { font-variant-numeric:tabular-nums; color:#c8cbd1; }
input[type=range] { width:100%; margin:23px 0 2px; accent-color:#e5e7eb; cursor:pointer; }
input[type=range]:disabled { opacity:.35; cursor:not-allowed; }
.switch { position:relative; width:54px; height:30px; display:inline-block; flex-shrink:0; }
.switch input { opacity:0; width:0; height:0; position:absolute; }
.track { position:absolute; inset:0; background:#525760; border-radius:99px; transition:background .2s; cursor:pointer; }
.track:before { content:""; position:absolute; width:22px; height:22px; top:4px; left:4px; background:white; border-radius:50%; transition:transform .2s; }
.switch input:checked + .track { background:#ad5555; }
.switch input:checked + .track:before { transform:translateX(24px); }
.switch input:focus-visible + .track { outline:2px solid white; outline-offset:3px; }
.hint { margin:9px 0 0 36px; color:#9298a2; font-size:.88rem; }
#status { min-height:1.3em; margin:18px 0 0; color:#a5a9b0; font-size:.86rem; }
a { color:#cdd2da; text-decoration:none; }
</style>
</head>
<body>
<main>
<header>
<h1>Configuration</h1>
<p class="subtitle">Alfred · Paramètres audio</p>
</header>
<div class="panel">
<section class="section">
<div class="line">
<label class="label" for="volume">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></svg>
Volume de sortie
</label>
<output class="value" id="volumeValue" for="volume">50 %</output>
</div>
<input id="volume" type="range" min="0" max="100" value="50" step="1" aria-label="Volume de sortie">
</section>
<section class="section">
<div class="line">
<span class="label" id="speakerLabel">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="m17 9 5 6m0-6-5 6"/></svg>
Couper le haut-parleur
</span>
<label class="switch"><input type="checkbox" id="speakerMute" aria-labelledby="speakerLabel"><span class="track"></span></label>
</div>
<p class="hint">Désactive les réponses vocales d’Alfred.</p>
</section>
<section class="section">
<div class="line">
<span class="label" id="micLabel">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5m-4 0h8"/></svg>
Couper le microphone
</span>
<label class="switch"><input type="checkbox" id="micMute" aria-labelledby="micLabel"><span class="track"></span></label>
</div>
<p class="hint">Suspend l’écoute et la détection du mot « Alfred ».</p>
</section>
</div>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script>
'use strict';
// API attendue côté Flask :
// GET /api/audio/settings -> {"volume": 50, "speaker_muted": false, "microphone_muted": false}
// PATCH /api/audio/settings <- mêmes clés, partielles acceptées.
// Le navigateur n'agit PAS directement sur ALSA ou Vosk.
const volume = document.getElementById('volume');
const volumeValue = document.getElementById('volumeValue');
const speakerMute = document.getElementById('speakerMute');
const micMute = document.getElementById('micMute');
const status = document.getElementById('status');
let volumeTimer;
function setStatus(message) { status.textContent = message; }
function render(data) {
if (typeof data.volume === 'number') volume.value = data.volume;
if (typeof data.speaker_muted === 'boolean') speakerMute.checked = data.speaker_muted;
if (typeof data.microphone_muted === 'boolean') micMute.checked = data.microphone_muted;
volumeValue.textContent = `${volume.value} %`;
}
async function save(change) {
try {
const response = await fetch('/api/audio/settings', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
setStatus('Paramètres appliqués.');
} catch (error) {
setStatus('Impossible d’appliquer le réglage : API Flask non disponible ou erreur serveur.');
console.error(error);
}
}
volume.addEventListener('input', () => {
volumeValue.textContent = `${volume.value} %`;
clearTimeout(volumeTimer);
volumeTimer = setTimeout(() => save({ volume: Number(volume.value) }), 180);
});
speakerMute.addEventListener('change', () => save({ speaker_muted: speakerMute.checked }));
micMute.addEventListener('change', () => save({ microphone_muted: micMute.checked }));
(async () => {
try {
const response = await fetch('/api/audio/settings');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
setStatus('');
} catch (_) {
setStatus('Interface prête. Connexion à l’API audio Flask nécessaire pour contrôler le matériel.');
}
})();
</script>
</body>
</html>
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Volume et microphone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages audio</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Caméra locale : désactivée tant que l'utilisateur ne l'ouvre pas,
pour éviter de monopoliser la webcam utilisée par le MCP visage. -->
<aside class="camera-panel" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
</body>
</html>
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{name}}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<main>
<header><span id="name">{{name}}</span><span id="mode">repos</span></header>
<section id="stage"><div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div></section>
<section id="wave"><canvas id="waveCanvas"></canvas></section>
<footer class="alfred-controls" aria-label="Contrôles audio">
<button id="speakerMute" class="audio-toggle" type="button" aria-label="Couper le haut-parleur" aria-pressed="false" title="Couper le haut-parleur">🔊</button>
<label class="volume-label" for="volumeSlider">Volume</label>
<input id="volumeSlider" type="range" min="0" max="100" step="1" value="70" aria-label="Volume sonore">
<output id="volumeValue" for="volumeSlider">70 %</output>
<span class="controls-separator" aria-hidden="true"></span>
<label class="volume-label" for="micGainSlider">Sensibilité micro</label>
<input id="micGainSlider" type="range" min="0" max="100" step="1" value="50" aria-label="Sensibilité du microphone">
<output id="micGainValue" for="micGainSlider">—</output>
<button id="micMute" class="audio-toggle" type="button" aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">🎙️</button>
</footer>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-controls.js"></script>
</body>
</html>
@@ -0,0 +1,2 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{name}}</title><link rel="stylesheet" href="/static/css/alfred.css"><script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script></head>
<body><main><header><span id="name">{{name}}</span><span id="mode">repos</span></header><section id="stage"><div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div></section><section id="wave"><canvas id="waveCanvas"></canvas></section></main><script src="/static/js/alfred.js"></script></body></html>
+19
View File
@@ -0,0 +1,19 @@
python - <<'PY'
import sounddevice as sd
import soundfile as sf
print(sd.query_devices())
print("Microphone utilisé :", sd.default.device)
print("Parle pendant 8 secondes...")
audio = sd.rec(
int(8 * 16000),
samplerate=16000,
channels=1,
dtype="int16"
)
sd.wait()
sf.write("/tmp/alfred-micro.wav", audio, 16000)
print("Enregistrement terminé : /tmp/alfred-micro.wav")
PY

Some files were not shown because too many files have changed in this diff Show More