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