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)