V1
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user