Init commit
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
curl -s http://127.0.0.1:54321/api/audio/settings
|
||||||
|
> status du micro, hp, volumes...
|
||||||
|
|
||||||
|
output:
|
||||||
|
|
||||||
|
{"mic_gain":74,"mic_muted":true,"speaker_muted":false,"volume":80}
|
||||||
|
|
||||||
|
|
||||||
|
curl -s http://127.0.0.1:54321/api/wake/status
|
||||||
|
> status de Vosk (stt pour le key world "alfred")
|
||||||
|
|
||||||
|
output :
|
||||||
|
|
||||||
|
{"engine":"vosk_keyword","error":null,"last_score":1.0,"paused":true,"running":true,"word":"alfred"}
|
||||||
|
|
||||||
|
|
||||||
|
hermes dashboard --host 0.0.0.0
|
||||||
|
> lancer le webui por 9119
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#VOSK_MODEL_PATH=/data/vosk-model/vosk-model-small-fr-0.22
|
||||||
|
VOSK_SAMPLE_RATE=16000
|
||||||
|
WAKE_VOSK_MODEL_PATH=/data/vosk-model/vosk-model-small-fr-0.22
|
||||||
|
VOSK_MODEL_PATH=/data/vosk-model/vosk-model-fr-0.22
|
||||||
|
PIPER_HOST=192.168.1.13
|
||||||
|
PIPER_PORT=10200
|
||||||
|
AUDIO_OUTPUT_DEVICE=plughw:1,0
|
||||||
|
#PIPER_VOICE=fr_FR-tom-medium
|
||||||
|
## fr_FR-upmc-medium
|
||||||
|
## fr_FR-upmc-medium
|
||||||
|
## PIPER_VOICE=fr_FR-mls-medium
|
||||||
|
PIPER_VOICE=fr_FR-upmc-medium
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Alfred — Interface mise à jour depuis interface.zip (24/10/2026)
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
À effectuer sur la machine Alfred après transfert du ZIP, en étant dans son dossier parent `/opt/alfred` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop alfred
|
||||||
|
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_Interface_Reorganisation_V1.zip
|
||||||
|
python3 -m py_compile /opt/alfred/app/app.py /opt/alfred/app/services/camera.py
|
||||||
|
sudo systemctl start alfred
|
||||||
|
sudo journalctl -u alfred -n 50 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Le ZIP remplace uniquement :
|
||||||
|
|
||||||
|
```
|
||||||
|
app/app.py
|
||||||
|
app/templates/index.html
|
||||||
|
app/static/css/alfred.css
|
||||||
|
app/static/css/alfred-controls.css
|
||||||
|
app/static/js/alfred.js
|
||||||
|
app/static/js/alfred-controls.js
|
||||||
|
app/static/js/alfred-camera.js (nouveau)
|
||||||
|
app/services/camera.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webcam
|
||||||
|
|
||||||
|
- Index de la caméra par défaut : 0, réglable avec `CAMERA_INDEX=1` (ou autre) dans l'environnement du service Alfred.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
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 réalisés
|
||||||
|
|
||||||
|
- Compilation syntaxique de `app.py` et `camera.py`.
|
||||||
|
- 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.
|
||||||
Binary file not shown.
@@ -0,0 +1,166 @@
|
|||||||
|
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
|
||||||
|
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||||
|
|
||||||
|
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="", 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("/")
|
||||||
|
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-f2399cb2bcf9e91094d3dc34492b463eda9cf42d09857d51b5954c5982aaaa5e"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 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. »
|
||||||
@@ -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. »
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,6 @@
|
|||||||
|
import cv2
|
||||||
|
|
||||||
|
def snapshot(path, index=0):
|
||||||
|
cap=cv2.VideoCapture(index); ok,frame=cap.read(); cap.release()
|
||||||
|
if not ok: raise RuntimeError('Impossible de lire la caméra locale')
|
||||||
|
cv2.imwrite(path,frame); return path
|
||||||
@@ -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,30 @@
|
|||||||
|
/* 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; } }
|
||||||
|
|
||||||
|
/* Deux curseurs dans la barre inférieure */
|
||||||
|
.alfred-controls { gap: 10px; }
|
||||||
|
.alfred-controls input[type="range"] { min-width: 55px; }
|
||||||
|
.alfred-controls output { min-width: 48px; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
body > main { padding-bottom: 126px; }
|
||||||
|
.alfred-controls { flex-wrap: wrap; justify-content: center; }
|
||||||
|
.alfred-controls input[type="range"] { flex: 1 1 80px; }
|
||||||
|
.alfred-controls .controls-separator { display: none; }
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
|
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:#f5f5f5;background:#242628}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 50% 30%,#303336,#202224 65%);min-height:100vh;color:#fff}main{min-height:100vh;display:grid;grid-template-rows:auto 1fr 120px;padding:28px 42px;gap:18px}header{display:flex;justify-content:space-between;opacity:.72;letter-spacing:.08em;text-transform:uppercase;font-size:.8rem}#stage{display:flex;align-items:center;justify-content:center;overflow:hidden}#content{width:min(1100px,92vw);text-align:center;transition:opacity .45s ease,transform .45s ease;animation:appear .55s ease}#content.swap{opacity:0;transform:translateY(12px) scale(.99)}h1{font-size:clamp(2rem,5vw,5rem);font-weight:350;margin:.2em}p{font-size:clamp(1rem,2vw,1.5rem);color:#d4d4d4}.hero{max-width:min(100%,900px);max-height:62vh;border-radius:18px;box-shadow:0 25px 70px #0008;animation:imageIn .9s cubic-bezier(.2,.8,.2,1)}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;text-align:left}.card{padding:18px;border:1px solid #ffffff1c;background:#ffffff0a;border-radius:16px;backdrop-filter:blur(8px)}#wave{display:flex;align-items:center}canvas{width:100%;height:100%}@keyframes appear{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}@keyframes imageIn{from{opacity:0;filter:blur(14px);transform:scale(.965)}to{opacity:1;filter:blur(0);transform:scale(1)}}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const slider = document.getElementById('volumeSlider');
|
||||||
|
const volumeValue = document.getElementById('volumeValue');
|
||||||
|
const speaker = document.getElementById('speakerMute');
|
||||||
|
const mic = document.getElementById('micMute');
|
||||||
|
const micGain = document.getElementById('micGainSlider');
|
||||||
|
const micGainValue = document.getElementById('micGainValue');
|
||||||
|
const endpoint = '/api/audio/settings';
|
||||||
|
let state = { volume: 70, mic_gain: null, speaker_muted: false, mic_muted: false };
|
||||||
|
let saveTimer;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
slider.value = String(state.volume);
|
||||||
|
volumeValue.value = `${state.volume} %`;
|
||||||
|
if (Number.isFinite(state.mic_gain)) {
|
||||||
|
micGain.value = String(state.mic_gain);
|
||||||
|
micGainValue.textContent = `${state.mic_gain} %`;
|
||||||
|
micGain.disabled = false;
|
||||||
|
} else {
|
||||||
|
micGainValue.textContent = 'N/D';
|
||||||
|
micGain.disabled = true;
|
||||||
|
}
|
||||||
|
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(Object.fromEntries(Object.entries(state).filter(([k,v]) => v !== null)))
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
micGain.addEventListener('input', () => {
|
||||||
|
state.mic_gain = Number(micGain.value);
|
||||||
|
render();
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(save, 250);
|
||||||
|
});
|
||||||
|
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.mic_gain === 'number') state.mic_gain = Math.max(0, Math.min(100, settings.mic_gain));
|
||||||
|
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,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,7 @@
|
|||||||
|
const socket=io(), content=document.querySelector('#content'), modeEl=document.querySelector('#mode');
|
||||||
|
const canvas=document.querySelector('#waveCanvas'), ctx=canvas.getContext('2d'); let mode='idle',level=.02,t=0;
|
||||||
|
function resize(){canvas.width=canvas.clientWidth*devicePixelRatio;canvas.height=canvas.clientHeight*devicePixelRatio} addEventListener('resize',resize);resize();
|
||||||
|
function draw(){t+=.035;ctx.clearRect(0,0,canvas.width,canvas.height);ctx.strokeStyle='rgba(255,255,255,.85)';ctx.lineWidth=2*devicePixelRatio;ctx.beginPath();let mid=canvas.height/2,amp=(mode==='idle'?1:mode==='thinking'?8:Math.max(5,level*45))*devicePixelRatio;for(let x=0;x<canvas.width;x+=4*devicePixelRatio){let y=mid+Math.sin(x*.018+t*4)*Math.sin(x*.006+t)*amp*(.35+.65*Math.sin(x/canvas.width*Math.PI));x?ctx.lineTo(x,y):ctx.moveTo(x,y)}ctx.stroke();requestAnimationFrame(draw)}draw();
|
||||||
|
function swap(html){content.classList.add('swap');setTimeout(()=>{content.innerHTML=html;content.classList.remove('swap')},260)}
|
||||||
|
function render(d){if(d.type==='image') swap(`<img class="hero" src="${d.url}" alt="${d.alt||''}"><p>${d.caption||''}</p>`);else if(d.type==='cards') swap(`<div class="cards">${(d.items||[]).map(i=>`<article class="card"><h2>${i.title||''}</h2><p>${i.text||''}</p></article>`).join('')}</div>`);else swap(`<h1>${d.title||''}</h1><p>${d.text||''}</p>`)}
|
||||||
|
socket.on('alfred_state',s=>{mode=s.mode||'idle';modeEl.textContent=mode;if(s.content)render(s.content)});socket.on('alfred_render',render);socket.on('audio_level',d=>level=d.level||0);
|
||||||
@@ -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,33 @@
|
|||||||
|
<!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">
|
||||||
|
<link rel="stylesheet" href="/static/css/alfred-mode-icons.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" class="alfred-mode-icon" role="img" aria-label="Repos" title="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>
|
||||||
|
<script src="/static/js/alfred-mode-icons.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>
|
||||||
@@ -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
|
||||||
Binary file not shown.
@@ -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)
|
||||||
|
|
||||||
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name: Alfred
|
||||||
|
persona: majordome_domestique
|
||||||
|
language: fr
|
||||||
|
ui:
|
||||||
|
theme: anthracite
|
||||||
|
dynamic_rendering: true
|
||||||
|
return_to_idle_seconds: 30
|
||||||
|
safety:
|
||||||
|
unknown_faces_are_unknown: true
|
||||||
|
files_write_only_in_workspace: true
|
||||||
|
home_assistant_use_assist_exposure: true
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
python3 -m venv "$ROOT/venv"
|
||||||
|
"$ROOT/venv/bin/pip" install -U pip
|
||||||
|
"$ROOT/venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||||
|
for d in "$ROOT"/hermes/MCP_*; do
|
||||||
|
echo "Installation $(basename "$d")"
|
||||||
|
rm -rf "$d/venv"
|
||||||
|
python3 -m venv "$d/venv"
|
||||||
|
"$d/venv/bin/pip" install -U pip
|
||||||
|
"$d/venv/bin/pip" install -r "$d/requirements.txt"
|
||||||
|
[ -f "$d/.env" ] || cp "$d/.env.example" "$d/.env"
|
||||||
|
done
|
||||||
|
[ -f "$ROOT/.env" ] || cp "$ROOT/.env.example" "$ROOT/.env"
|
||||||
|
mkdir -p "$HOME/.hermes/skills" "$HOME/alfred-data"
|
||||||
|
echo "Venv créés. Copie ensuite hermes/skills/* vers $HOME/.hermes/skills/ et renseigne les .env."
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
mkdir -p "$HOME/.hermes/skills"
|
||||||
|
cp -a "$ROOT/hermes/skills/." "$HOME/.hermes/skills/"
|
||||||
|
echo "Skills installées dans $HOME/.hermes/skills"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT/app"
|
||||||
|
exec "$ROOT/venv/bin/python" app.py
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from acp import Client, connect_to_agent
|
||||||
|
from acp.schema import (
|
||||||
|
InitializeResponse,
|
||||||
|
NewSessionResponse,
|
||||||
|
PromptResponse,
|
||||||
|
RequestPermissionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlfredClient(Client):
|
||||||
|
async def session_update(self, session_id, update):
|
||||||
|
print(f"[ACP] {update}", flush=True)
|
||||||
|
|
||||||
|
async def request_permission(self, options, session_id, tool_call):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Une autorisation est demandée pendant le test ACP"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("[ALFRED] Démarrage Hermes ACP...", flush=True)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"/root/.local/bin/hermes",
|
||||||
|
"acp",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = AlfredClient()
|
||||||
|
|
||||||
|
async with connect_to_agent(
|
||||||
|
client,
|
||||||
|
process.stdin,
|
||||||
|
process.stdout,
|
||||||
|
) as agent:
|
||||||
|
|
||||||
|
init = await agent.initialize(
|
||||||
|
protocol_version=1,
|
||||||
|
client_capabilities={},
|
||||||
|
client_info={
|
||||||
|
"name": "alfred",
|
||||||
|
"version": "1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[ACP] Initialisation : {init}", flush=True)
|
||||||
|
|
||||||
|
session = await agent.new_session(
|
||||||
|
cwd="/opt/alfred/app",
|
||||||
|
mcp_servers=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[ACP] Session : {session.session_id}", flush=True)
|
||||||
|
|
||||||
|
for question in (
|
||||||
|
"Combien font deux plus deux ? Réponds en français.",
|
||||||
|
"Quelle est la capitale de la France ? Réponds en français.",
|
||||||
|
):
|
||||||
|
start_question = time.perf_counter()
|
||||||
|
|
||||||
|
result = await agent.prompt(
|
||||||
|
session_id=session.session_id,
|
||||||
|
prompt=[
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": question,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - start_question
|
||||||
|
|
||||||
|
print(f"\nQuestion : {question}")
|
||||||
|
print(f"Résultat : {result}")
|
||||||
|
print(f"Temps : {elapsed:.2f} secondes\n")
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[ALFRED] Temps total : "
|
||||||
|
f"{time.perf_counter() - start:.2f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if process.returncode is None:
|
||||||
|
process.terminate()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
COMFYUI_URL=http://192.168.1.13:8188
|
||||||
|
COMFYUI_TIMEOUT=300
|
||||||
|
WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows
|
||||||
|
COMFY_PROMPT_NODE=6
|
||||||
|
COMFY_VISION_IMAGE_NODE=1
|
||||||
|
COMFY_VISION_PROMPT_NODE=2
|
||||||
|
ALFRED_RENDER_URL=http://127.0.0.1:54321/api/render
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
COMFYUI_URL=http://192.168.1.13:8188
|
||||||
|
COMFYUI_TIMEOUT=300
|
||||||
|
WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows
|
||||||
|
COMFY_PROMPT_NODE=6
|
||||||
|
COMFY_VISION_IMAGE_NODE=1
|
||||||
|
COMFY_VISION_PROMPT_NODE=2
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# MCP_comfyui
|
||||||
|
|
||||||
|
Copier `.env.example` vers `.env`, puis créer le venv et installer `requirements.txt`.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""MCP ComfyUI pour Hermes avec transmission d'images vers l'interface Alfred.
|
||||||
|
|
||||||
|
Fonctionne avec un workflow API ComfyUI possédant un nœud de sauvegarde d'image.
|
||||||
|
L'envoi HTTP à Alfred est indépendant de la réponse textuelle d'Hermes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).with_name(".env"))
|
||||||
|
|
||||||
|
mcp = FastMCP("Alfred ComfyUI")
|
||||||
|
BASE = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||||
|
WF = Path(os.getenv("WORKFLOWS_DIR", str(Path(__file__).parent / "workflows")))
|
||||||
|
ALFRED_RENDER_URL = os.getenv(
|
||||||
|
"ALFRED_RENDER_URL", "http://127.0.0.1:54321/api/render"
|
||||||
|
)
|
||||||
|
ALFRED_RENDER_TOKEN = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def submit(workflow: dict) -> str:
|
||||||
|
response = requests.post(
|
||||||
|
f"{BASE}/prompt",
|
||||||
|
json={"prompt": workflow, "client_id": str(uuid.uuid4())},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()["prompt_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def wait(prompt_id: str) -> dict:
|
||||||
|
deadline = time.monotonic() + int(os.getenv("COMFYUI_TIMEOUT", "300"))
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
response = requests.get(f"{BASE}/history/{prompt_id}", timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
if prompt_id in result:
|
||||||
|
return result[prompt_id]
|
||||||
|
time.sleep(1)
|
||||||
|
raise TimeoutError(f"ComfyUI timeout ({prompt_id})")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_images(history: dict) -> list[dict]:
|
||||||
|
"""Construit les URL de /view depuis les sorties ComfyUI (SaveImage)."""
|
||||||
|
images = []
|
||||||
|
for node_id, output in history.get("outputs", {}).items():
|
||||||
|
for image in output.get("images", []):
|
||||||
|
filename = image.get("filename")
|
||||||
|
if not filename:
|
||||||
|
continue
|
||||||
|
subfolder = image.get("subfolder", "")
|
||||||
|
image_type = image.get("type", "output")
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"subfolder": subfolder,
|
||||||
|
"type": image_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
images.append(
|
||||||
|
{
|
||||||
|
"node_id": str(node_id),
|
||||||
|
"filename": filename,
|
||||||
|
"subfolder": subfolder,
|
||||||
|
"type": image_type,
|
||||||
|
"url": f"{BASE}/view?{query}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def show_on_alfred(image: dict, caption: str) -> tuple[bool, str | None]:
|
||||||
|
"""Ne fait pas échouer la génération si Alfred est hors ligne."""
|
||||||
|
headers = {}
|
||||||
|
if ALFRED_RENDER_TOKEN:
|
||||||
|
headers["X-Alfred-Render-Token"] = ALFRED_RENDER_TOKEN
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
ALFRED_RENDER_URL,
|
||||||
|
json={
|
||||||
|
"type": "image",
|
||||||
|
"url": image["url"],
|
||||||
|
"alt": "Image générée par ComfyUI",
|
||||||
|
"caption": caption,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True, None
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def generate_image(prompt: str) -> dict:
|
||||||
|
"""Génère une image par ComfyUI et l'affiche automatiquement sur Alfred.
|
||||||
|
|
||||||
|
Utiliser cet outil lorsque l'utilisateur demande de créer/générer une image.
|
||||||
|
Ne pas prétendre que l'image a été affichée si displayed_on_alfred est faux.
|
||||||
|
"""
|
||||||
|
prompt = str(prompt).strip()
|
||||||
|
if not prompt:
|
||||||
|
raise ValueError("Le prompt de génération est vide")
|
||||||
|
|
||||||
|
workflow = json.loads((WF / "text_to_image.json").read_text(encoding="utf-8"))
|
||||||
|
prompt_node = os.getenv("COMFY_PROMPT_NODE", "6")
|
||||||
|
workflow[prompt_node]["inputs"]["text"] = prompt
|
||||||
|
|
||||||
|
prompt_id = submit(workflow)
|
||||||
|
history = wait(prompt_id)
|
||||||
|
images = extract_images(history)
|
||||||
|
if not images:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ComfyUI a terminé sans image de sortie. "
|
||||||
|
"Vérifie le nœud SaveImage du workflow."
|
||||||
|
)
|
||||||
|
|
||||||
|
displayed, display_error = show_on_alfred(
|
||||||
|
images[0], f"Image générée : {prompt}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"prompt_id": prompt_id,
|
||||||
|
"prompt": prompt,
|
||||||
|
"images": images,
|
||||||
|
"first_image_url": images[0]["url"],
|
||||||
|
"displayed_on_alfred": displayed,
|
||||||
|
"display_error": display_error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def analyze_image(image_path: str, question: str) -> dict:
|
||||||
|
"""Analyse une image via le workflow VLM configuré (inchangé)."""
|
||||||
|
workflow = json.loads((WF / "vision_analyze.json").read_text(encoding="utf-8"))
|
||||||
|
workflow[os.getenv("COMFY_VISION_IMAGE_NODE", "1")]["inputs"]["image"] = image_path
|
||||||
|
workflow[os.getenv("COMFY_VISION_PROMPT_NODE", "2")]["inputs"]["text"] = question
|
||||||
|
return wait(submit(workflow))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import os, json, time, uuid, requests
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
mcp = FastMCP("Alfred ComfyUI")
|
||||||
|
BASE = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||||
|
WF = Path(os.getenv("WORKFLOWS_DIR", Path(__file__).parent / "workflows"))
|
||||||
|
|
||||||
|
|
||||||
|
def submit(workflow):
|
||||||
|
r = requests.post(
|
||||||
|
BASE + "/prompt",
|
||||||
|
json={
|
||||||
|
"prompt": workflow,
|
||||||
|
"client_id": str(uuid.uuid4())
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["prompt_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def wait(pid):
|
||||||
|
deadline = time.time() + int(os.getenv("COMFYUI_TIMEOUT", "300"))
|
||||||
|
while time.time() < deadline:
|
||||||
|
h = requests.get(f"{BASE}/history/{pid}", timeout=15).json()
|
||||||
|
if pid in h:
|
||||||
|
return h[pid]
|
||||||
|
time.sleep(1)
|
||||||
|
raise TimeoutError("ComfyUI timeout")
|
||||||
|
|
||||||
|
|
||||||
|
def build_image_url(filename, subfolder="", img_type="output"):
|
||||||
|
return (
|
||||||
|
f"{BASE}/view?"
|
||||||
|
f"filename={quote(str(filename))}"
|
||||||
|
f"&subfolder={quote(str(subfolder))}"
|
||||||
|
f"&type={quote(str(img_type))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_images(history):
|
||||||
|
images = []
|
||||||
|
|
||||||
|
outputs = history.get("outputs", {})
|
||||||
|
for node_id, node_output in outputs.items():
|
||||||
|
for img in node_output.get("images", []):
|
||||||
|
filename = img.get("filename")
|
||||||
|
subfolder = img.get("subfolder", "")
|
||||||
|
img_type = img.get("type", "output")
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
continue
|
||||||
|
|
||||||
|
images.append({
|
||||||
|
"node_id": node_id,
|
||||||
|
"filename": filename,
|
||||||
|
"subfolder": subfolder,
|
||||||
|
"type": img_type,
|
||||||
|
"url": build_image_url(filename, subfolder, img_type)
|
||||||
|
})
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def generate_image(prompt: str):
|
||||||
|
"""Generate an image with the locked text_to_image workflow."""
|
||||||
|
w = json.loads((WF / "text_to_image.json").read_text())
|
||||||
|
node = os.getenv("COMFY_PROMPT_NODE", "6")
|
||||||
|
w[node]["inputs"]["text"] = prompt
|
||||||
|
|
||||||
|
history = wait(submit(w))
|
||||||
|
images = extract_images(history)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"Image générée pour : {prompt}",
|
||||||
|
"prompt": prompt,
|
||||||
|
"images": images,
|
||||||
|
"first_image_url": images[0]["url"] if images else None,
|
||||||
|
"first_image_filename": images[0]["filename"] if images else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def analyze_image(image_path: str, question: str):
|
||||||
|
"""Analyze a camera/local image using the locked VLM workflow. Workflow node IDs are configured in .env."""
|
||||||
|
w = json.loads((WF / "vision_analyze.json").read_text())
|
||||||
|
w[os.getenv("COMFY_VISION_IMAGE_NODE", "1")]["inputs"]["image"] = image_path
|
||||||
|
w[os.getenv("COMFY_VISION_PROMPT_NODE", "2")]["inputs"]["text"] = question
|
||||||
|
return wait(submit(w))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mcp>=1.12
|
||||||
|
requests>=2.32
|
||||||
|
python-dotenv>=1.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Exporter depuis ComfyUI les workflows API JSON et les nommer `text_to_image.json` et `vision_analyze.json`. Les IDs de nœuds sont configurables dans `.env`.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 156680208700286,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 8,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "normal",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": [
|
||||||
|
"4",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"6",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"7",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"5",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": {
|
||||||
|
"title": "KSampler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"ckpt_name": "absolutereality_v181.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load Checkpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 512,
|
||||||
|
"height": 512,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Empty Latent Image"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||||
|
"clip": [
|
||||||
|
"4",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP Text Encode (Prompt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "text, watermark",
|
||||||
|
"clip": [
|
||||||
|
"4",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP Text Encode (Prompt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"4",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE Decode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "ComfyUI",
|
||||||
|
"images": [
|
||||||
|
"8",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Save Image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 156680208700286,
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 8,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "normal",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": [
|
||||||
|
"4",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"6",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"7",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"5",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": {
|
||||||
|
"title": "KSampler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"ckpt_name": "absolutereality_v181.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load Checkpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 512,
|
||||||
|
"height": 512,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Empty Latent Image"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||||
|
"clip": [
|
||||||
|
"4",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP Text Encode (Prompt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "text, watermark",
|
||||||
|
"clip": [
|
||||||
|
"4",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CLIP Text Encode (Prompt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": [
|
||||||
|
"3",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"4",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "VAE Decode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "ComfyUI",
|
||||||
|
"images": [
|
||||||
|
"8",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Save Image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user