v2 - STT via Whisper for more precision and if nor available, twist to Vox

This commit is contained in:
pporcheret
2026-10-27 16:44:39 +01:00
parent 5ccb0a48c2
commit 5f82b1bdf2
49 changed files with 4667 additions and 329 deletions
+8
View File
@@ -25,3 +25,11 @@ COMFYUI_URL=http://192.168.1.13:8188
WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows
COMFY_PROMPT_NODE=6
COMFYUI_TIMEOUT=300
ALFRED_ALLOW_WEB_RESTART=true
STT_ENGINE=wyoming
WYOMING_WHISPER_HOST=192.168.1.13
WYOMING_WHISPER_PORT=10300
WYOMING_WHISPER_LANGUAGE=fr
WYOMING_WHISPER_TIMEOUT=20
@@ -0,0 +1,425 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
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})
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
return
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@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/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,13 @@
{
"files": [
"app.py",
"templates/index.html",
"services/alfred_features.py",
"static/css/alfred-features.css",
"static/js/alfred-features.js"
],
"originals": [
"app.py",
"templates/index.html"
]
}
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" 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>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" 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>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</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>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
</body>
</html>
Binary file not shown.
+26
View File
@@ -8,6 +8,8 @@ from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
@@ -29,6 +31,8 @@ STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
@@ -107,6 +111,17 @@ def conversation(score=1.0):
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
@@ -119,11 +134,19 @@ def conversation(score=1.0):
speech = str(answer or "")
display = {"type": "text", "text": speech}
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# 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
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
@@ -233,6 +256,9 @@ def camera_stream():
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
@@ -0,0 +1,441 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
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})
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
return
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
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
alfred_features.publish_voice_display(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))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@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/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@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'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -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 []
+203
View File
@@ -220,5 +220,208 @@
},
"cached_at": 1792973038.224328,
"focus_label": "aujourd’hui"
},
"15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2": {
"file": "15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2.jpg",
"prompt_ids": [
"8d166bb7-3261-43f9-bb1b-1e38258057db",
"72f1f4b6-f15e-453f-8de5-f2099fdaa2a5"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793008787.1798131,
"focus_label": "aujourd’hui"
},
"374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a": {
"file": "374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a.jpg",
"prompt_ids": [
"23ee327d-c004-4275-85d0-213c962b0205",
"50c78e8e-3416-4b93-8c2f-f96cce79ffcc"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793094060.4219398,
"focus_label": "aujourd’hui"
},
"1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb": {
"file": "1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb.jpg",
"prompt_ids": [
"6e8dd4ee-c156-473b-954d-6d556bd1e835",
"613c90ea-5139-4bd0-9626-a6a27da62f77"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793107791.0520644,
"focus_label": "aujourd’hui"
},
"789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee": {
"file": "789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee.jpg",
"prompt_ids": [
"fe04dfc2-2b2c-4b1e-aa17-1f22d98d37fb",
"2072f159-d0e5-43ff-89ff-9f3beea58013"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793108064.0707133,
"focus_label": "aujourd’hui"
},
"85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176": {
"file": "85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176.jpg",
"prompt_ids": [
"b92d5bea-b532-45dc-86d5-0180138b1d04",
"6cccd63a-0258-43fd-a42c-0389e8bf1c08"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793109801.1181734,
"focus_label": "actuellement"
},
"fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8": {
"file": "fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8.jpg",
"prompt_ids": [
"1f481cdc-071d-4cc3-b394-524ffbe2da6b",
"e8a0aa26-6cb3-4691-9c5c-ea921870ad63"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793111812.2039518,
"focus_label": "aujourd’hui"
},
"ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57": {
"file": "ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57.jpg",
"prompt_ids": [
"2e552503-10af-4349-8694-ab6bc2d1a2d3",
"d8420f14-30de-49cf-a5ba-4427d6c12944"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793111827.3695734,
"focus_label": "samedi 31 octobre"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+5
View File
@@ -1,2 +1,7 @@
# Configuration générale Alfred (page /config)
# ALFRED_ALLOW_WEB_RESTART=true
ALFRED_NAME=Dona
WAKE_WORD=dona
PIPER_VOICE=fr_FR-upmc-medium
ALFRED_OLLAMA_URL=
HA_WEATHER_ENTITY=
+1 -1
View File
@@ -1,3 +1,3 @@
{
"session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599"
"session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737"
}
@@ -0,0 +1,3 @@
{
"session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599"
}
@@ -0,0 +1,3 @@
{
"session_id": "local-0dc2466df78900d9f7fd572cb26be3c88dbfcd31ee2e66056be44f5e3f966595"
}
+2 -1
View File
@@ -15,8 +15,9 @@ ALFRED_WEATHER_REVIEW=true
# 'off' : desactive les references
ALFRED_WEATHER_REFERENCE_MODE=auto
# Intensite de transformation de la reference (0.25 stable, 0.85 plus libre)
ALFRED_WEATHER_REFERENCE_DENOISE=0.56
ALFRED_WEATHER_REFERENCE_DENOISE=0.7
# Maintient visuellement les visages de reference dans le rendu img2img
ALFRED_WEATHER_PRESERVE_FACE=true
# Nombre maximum d'accessoires visibles par personnage
ALFRED_WEATHER_ACCESSORY_LIMIT=1
ALFRED_COMFYUI_URL=
Binary file not shown.
Binary file not shown.
Binary file not shown.
+67
View File
@@ -0,0 +1,67 @@
[
{
"id": "ff0d02e2b2",
"label": "Essai",
"kind": "timer",
"duration": 15.0,
"elapsed": 15.0,
"started_at": null,
"created_at": 1793101033.4255264,
"state": "finished",
"finished_at": 1793101048.657067
},
{
"id": "91edf67b2b",
"label": "Minuteur",
"kind": "timer",
"duration": 30.0,
"elapsed": 30.0,
"started_at": null,
"created_at": 1793103592.8418453,
"state": "finished",
"finished_at": 1793103623.1536205
},
{
"id": "a8a9d9ff2d",
"label": "Minuteur",
"kind": "timer",
"duration": 20.0,
"elapsed": 8.051732301712036,
"started_at": null,
"created_at": 1793103921.956481,
"state": "cancelled"
},
{
"id": "4db3749825",
"label": "Minuteur",
"kind": "timer",
"duration": 20.0,
"elapsed": 20.0,
"started_at": null,
"created_at": 1793104544.2226057,
"state": "finished",
"finished_at": 1793104564.4760897
},
{
"id": "0776628fcf",
"label": "Minuteur",
"kind": "timer",
"duration": 20.0,
"elapsed": 20.0,
"started_at": null,
"created_at": 1793104604.8489864,
"state": "finished",
"finished_at": 1793104625.0561383
},
{
"id": "3444e4aea3",
"label": "Minuteur",
"kind": "timer",
"duration": 20.0,
"elapsed": 20.0,
"started_at": null,
"created_at": 1793109993.4915156,
"state": "finished",
"finished_at": 1793110013.7477841
}
]
@@ -0,0 +1,83 @@
"""Illustrations contextuelles Wikipédia déclenchées APRÈS une réponse Hermes.
Pas de modification des prompts Hermes ou de /api/render. Échec silencieux.
"""
from __future__ import annotations
import re
import threading
import time
import unicodedata
from urllib.parse import urlencode, quote, urlparse
import requests
CACHE = {}
LOCK = threading.Lock()
def _norm(s):
s = unicodedata.normalize('NFKD', str(s)).casefold()
return re.sub(r'[^a-z0-9 ]+', '', ''.join(c for c in s if not unicodedata.combining(c)))
def entity_question(s):
text = ' '.join(str(s or '').split())
m = re.match(r'^(?:alfred[ ,.:!?]*)?(?:qui\s+(?:est|etait|était|sont)\s+|c.?est\s+qui\s+)([^?!.]{3,95})', text, re.I)
if not m:
return None
entity = m.group(1).strip(' «»\'".,?!')
entity = re.sub(r'\s+(?:au juste|exactement|s.il te plait)$', '', entity, flags=re.I)
if len(entity.split()) > 7 or len(entity) < 3:
return None
return entity
def _query_wikipedia(name):
# PageImages retourne une illustration publique et l'URL de la page source.
params = {'action':'query','generator':'search','gsrsearch':name,'gsrlimit':1,
'prop':'pageimages','piprop':'thumbnail','pithumbsize':500,
'format':'json','formatversion':'2','redirects':'1'}
headers = {'User-Agent':'AlfredAssistant/1.0 (personal home assistant; image source: Wikipedia)'}
response = requests.get('https://fr.wikipedia.org/w/api.php', params=params,
headers=headers, timeout=4, allow_redirects=False)
response.raise_for_status()
pages = response.json().get('query', {}).get('pages', [])
if not pages:
return None
page = pages[0]
title = str(page.get('title',''))
if not title or _norm(name) not in _norm(title) and _norm(title) not in _norm(name):
return None
url = str(page.get('thumbnail', {}).get('source', ''))
parsed = urlparse(url)
if parsed.scheme != 'https' or parsed.hostname not in ('upload.wikimedia.org','commons.wikimedia.org'):
return None
return {'title': title, 'image_url':url,
'source_url':'https://fr.wikipedia.org/wiki/'+quote(title.replace(' ','_')),
'credit':'Image : Wikipédia / Wikimedia Commons'}
def image_for_question(name):
key = _norm(name)
with LOCK:
old = CACHE.get(key)
if old and time.monotonic()-old[0] < 4*3600:
return old[1]
try:
result = _query_wikipedia(name)
except (requests.RequestException, ValueError, KeyError):
result = None
with LOCK:
CACHE[key] = (time.monotonic(), result)
return result
def schedule_context_image(question, answer, socketio, still_current):
name = entity_question(question)
if not name:
return
def worker():
info = image_for_question(name)
if info and still_current():
info['text'] = str(answer or '')[:500]
socketio.emit('alfred_context_image', info)
threading.Thread(target=worker, name='alfred-context-image', daemon=True).start()
+329
View File
@@ -0,0 +1,329 @@
"""Alfred 4+5: serial Hermes background jobs + dynamic contextual cards.
Designed to be mounted into an existing Flask app, preserving STT/TTS/Hermes.
No external dependencies except Flask (which Alfred already uses).
Single-process worker: do not run multiple WSGI workers with this module.
"""
from __future__ import annotations
import json
import logging
import os
import queue
import re
import sqlite3
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
from flask import jsonify, request
LOG = logging.getLogger("alfred.features")
_manager = None
_lock = threading.Lock()
def _utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _expiry(minutes):
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat(timespec="seconds")
def should_run_in_background(text):
"""Conservative opt-in for long requests; ordinary voice Q&A unchanged."""
txt = (text or "").lower()
patterns = [
r"en arri[eè]re.plan", r"en t[aâ]che de fond", r"en asynchrone",
r"pr[eé]viens.moi (?:quand|lorsque)", r"avertis.moi (?:quand|lorsque)",
r"quand (?:tu auras|c['’]est) fini", r"prends le temps de",
r"fais (?:moi )?un rapport", r"analyse (?:les?|mes?|l['’]) (?:alertes?|logs?|journaux|rapports?|fichiers?|donn[eé]es?)",
r"fais (?:moi )?une synth[eè]se (?:des?|de mes|du)",
]
return any(re.search(p, txt) for p in patterns)
def _sanitize_url(url):
if not isinstance(url, str) or not url or len(url) > 2048:
return None
try:
u = urlsplit(url)
except ValueError:
return None
# Relative paths and HTTP(S) only; block javascript:, data:, file:, etc.
if u.scheme in ("http", "https") and u.netloc:
return url
if not u.scheme and not u.netloc and url.startswith("/") and not url.startswith("//"):
return url
return None
def _safe_display(display):
if not isinstance(display, dict):
return {"type": "text", "text": str(display or "")[:12000]}
kind = str(display.get("type", "text"))[:32].lower()
if kind not in {"text", "image", "weather", "calendar", "notification"}:
kind = "text"
obj = {"type": kind}
for key in ("text", "title", "description", "temperature", "condition"):
if key in display:
obj[key] = str(display[key])[:12000 if key == "text" else 300]
if kind == "image":
obj["url"] = _sanitize_url(display.get("url"))
if kind == "calendar":
obj["events"] = [
{"title": str(e.get("title", "Événement"))[:200], "start": str(e.get("start", ""))[:80]}
for e in (display.get("events") or [])[:30] if isinstance(e, dict)
] if isinstance(display.get("events"), list) else []
return obj
class FeatureManager:
def __init__(self, db_path, hermes_call, socketio=None, start_worker=True):
self.db_path = str(db_path)
self.hermes_call = hermes_call
self.socketio = socketio
self.guard = threading.RLock()
self.pending = queue.Queue(maxsize=12)
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._setup_db()
# Cannot resume a subprocess / Hermes interaction after an app restart.
with self._connect() as db:
db.execute("UPDATE tasks SET status='interrupted', updated_at=? WHERE status IN ('queued','running')", (_utc_now(),))
db.execute("UPDATE cards SET status='interrupted', body='Interrompue par un redémarrage.' WHERE status IN ('queued','running')")
if start_worker:
self.thread = threading.Thread(target=self._work_loop, name="alfred-hermes-worker", daemon=True)
self.thread.start()
def _connect(self):
db = sqlite3.connect(self.db_path, timeout=10)
db.row_factory = sqlite3.Row
db.execute("PRAGMA busy_timeout=10000")
return db
def _setup_db(self):
with self._connect() as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute("""CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
status TEXT NOT NULL, source TEXT NOT NULL, prompt TEXT NOT NULL,
speech TEXT DEFAULT '', display_json TEXT DEFAULT '{}', error TEXT DEFAULT ''
)""")
db.execute("""CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '', status TEXT DEFAULT '',
display_json TEXT DEFAULT '{}', task_id TEXT,
updated_at TEXT NOT NULL, expires_at TEXT
)""")
db.execute("CREATE INDEX IF NOT EXISTS cards_updated ON cards(updated_at)")
try:
os.chmod(self.db_path, 0o600)
except OSError:
LOG.warning("Unable to restrict database file permissions")
def _emit(self, event, payload):
if self.socketio:
try:
self.socketio.emit(event, payload)
except Exception:
LOG.exception("Websocket emission failed")
def _task(self, task_id):
with self._connect() as db:
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
return None
d = dict(row)
d["display"] = json.loads(d.pop("display_json") or "{}")
return d
def list_tasks(self, limit=30):
with self._connect() as db:
rows = db.execute("SELECT id FROM tasks ORDER BY created_at DESC LIMIT ?", (min(max(int(limit), 1), 100),)).fetchall()
return [self._task(r["id"]) for r in rows]
def list_cards(self, limit=25):
with self.guard, self._connect() as db:
db.execute("DELETE FROM cards WHERE expires_at IS NOT NULL AND expires_at < ?", (_utc_now(),))
rows = db.execute("SELECT * FROM cards ORDER BY updated_at DESC LIMIT ?", (min(max(int(limit), 1), 50),)).fetchall()
cards = []
for row in rows:
d = dict(row)
d["display"] = json.loads(d.pop("display_json") or "{}")
cards.append(d)
return cards
def upsert_card(self, card_id, *, kind, title, body="", status="", display=None, task_id=None, ttl=60):
try:
ttl = int(ttl)
except (TypeError, ValueError):
ttl = 30
expires = _expiry(max(1, min(ttl, 60 * 24))) if ttl > 0 else None
display = _safe_display(display or {"type": "text", "text": body})
with self.guard, self._connect() as db:
db.execute("""INSERT INTO cards(id, kind, title, body, status, display_json, task_id, updated_at, expires_at)
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
kind=excluded.kind, title=excluded.title, body=excluded.body,
status=excluded.status, display_json=excluded.display_json,
task_id=excluded.task_id, updated_at=excluded.updated_at,
expires_at=excluded.expires_at""", (
str(card_id)[:120], str(kind)[:32], str(title)[:180], str(body)[:12000],
str(status)[:32], json.dumps(display, ensure_ascii=False), task_id, _utc_now(), expires
))
self._emit("alfred_cards_changed", {"at": _utc_now()})
def publish_display(self, display):
d = _safe_display(display)
kind = d["type"]
if kind == "text" and not d.get("text", "").strip():
return
# Weather/calendar replace the previous contextual card. Others are transient.
card_id = f"context-{kind}" if kind in {"weather", "calendar"} else f"context-{uuid.uuid4().hex[:12]}"
title = d.get("title") or {"weather": "Météo", "calendar": "Agenda", "image": "Image", "notification": "Notification"}.get(kind, "Information")
self.upsert_card(card_id, kind=kind, title=title, body=d.get("text", ""), display=d,
ttl=20 if kind == "weather" else (60 if kind == "calendar" else 15))
def submit(self, prompt, source="web"):
prompt = str(prompt or "").strip()
if not prompt or len(prompt) > 8000:
raise ValueError("La demande doit contenir entre 1 et 8 000 caractères.")
source = str(source)[:32]
task_id = uuid.uuid4().hex
now = _utc_now()
with self.guard, self._connect() as db:
active = db.execute("SELECT COUNT(*) FROM tasks WHERE status IN ('queued','running')").fetchone()[0]
if active >= 8 or self.pending.full():
raise ValueError("La file Hermes est pleine (8 tâches maximum).")
db.execute("INSERT INTO tasks(id,created_at,updated_at,status,source,prompt) VALUES (?,?,?,?,?,?)",
(task_id, now, now, "queued", source, prompt))
self.upsert_card("task-" + task_id, kind="task", title=prompt[:100], body="En attente d’Hermes…",
status="queued", task_id=task_id, ttl=0)
self.pending.put_nowait(task_id)
self._emit("alfred_task", {"id": task_id, "status": "queued"})
return self._task(task_id)
def _mark(self, task_id, status, speech="", display=None, error=""):
now = _utc_now()
with self.guard, self._connect() as db:
db.execute("UPDATE tasks SET status=?, updated_at=?, speech=?, display_json=?, error=? WHERE id=?",
(status, now, speech[:12000], json.dumps(display or {}, ensure_ascii=False), error[:1200], task_id))
task = self._task(task_id)
display = display or {}
body = {
"running": "Hermes traite la demande…",
"succeeded": speech or display.get("text", "Tâche terminée."),
"failed": "Échec : " + error[:900],
}.get(status, status)
self.upsert_card("task-" + task_id, kind="task", title=task["prompt"][:100],
body=body, status=status, display=display or {"type": "text", "text": body},
task_id=task_id, ttl=0)
self._emit("alfred_task", {"id": task_id, "status": status})
def run_one(self, task_id):
self._mark(task_id, "running")
try:
# This executes serially in one worker, respecting Hermes' session semantics.
answer = self.hermes_call(self._task(task_id)["prompt"])
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")[:12000]
display = _safe_display(answer.get("display") or {"type": "text", "text": speech})
else:
speech = str(answer or "")[:12000]
display = {"type": "text", "text": speech}
self._mark(task_id, "succeeded", speech=speech, display=display)
except Exception as exc:
LOG.exception("Hermes task failed: %s", task_id)
self._mark(task_id, "failed", error=f"{type(exc).__name__}: {exc}")
def _work_loop(self):
while True:
task_id = self.pending.get()
try:
self.run_one(task_id)
except Exception:
LOG.exception("Unexpected task runner error")
finally:
self.pending.task_done()
def _local_writer_allowed():
# Web UI read-only; explicit write endpoints enabled only from the same host.
# Avoid trusting X-Forwarded-For, which clients can spoof unless configured.
return request.remote_addr in ("127.0.0.1", "::1")
def init_app(app, ask_hermes, socketio=None, db_path=None):
global _manager
with _lock:
if _manager is None:
path = db_path or os.getenv("ALFRED_FEATURES_DB") or str(Path(app.root_path) / "data" / "alfred_features.sqlite3")
_manager = FeatureManager(path, ask_hermes, socketio)
@app.before_request
def _capture_render():
if request.path == "/api/render" and request.method == "POST":
data = request.get_json(silent=True)
if isinstance(data, dict):
try:
_manager.publish_display(data)
except Exception:
LOG.exception("Unable to save display card")
@app.get("/api/alfred/tasks")
def _alfred_list_tasks():
return jsonify({"tasks": _manager.list_tasks()})
@app.get("/api/alfred/tasks/<task_id>")
def _alfred_task_detail(task_id):
task = _manager._task(task_id)
return (jsonify(task), 200) if task else (jsonify({"error": "Tâche introuvable"}), 404)
@app.post("/api/alfred/tasks")
def _alfred_start_task():
if not _local_writer_allowed():
return jsonify({"error": "Écriture réservée à localhost; utilisez la voix d’Alfred."}), 403
data = request.get_json(silent=True) or {}
try:
task = _manager.submit(data.get("prompt", ""), "api-local")
return jsonify(task), 202
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.get("/api/alfred/cards")
def _alfred_list_cards():
return jsonify({"cards": _manager.list_cards()})
@app.post("/api/alfred/cards")
def _alfred_add_card():
if not _local_writer_allowed():
return jsonify({"error": "Écriture réservée à localhost"}), 403
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({"error": "Payload invalide"}), 400
title = str(data.get("title") or "Information")[:180]
kind = str(data.get("kind") or "notification")[:32]
_manager.upsert_card("manual-" + uuid.uuid4().hex[:12], kind=kind, title=title,
body=str(data.get("body") or "")[:12000],
display=data.get("display"), ttl=int(data.get("ttl", 30)))
return jsonify({"ok": True}), 201
return _manager
def submit(prompt, source="voice"):
if _manager is None:
raise RuntimeError("Les fonctionnalités Alfred ne sont pas initialisées")
return _manager.submit(prompt, source)
def publish_voice_display(display):
"""Nonblocking mirror; main renderer and TTS remain authoritative."""
if _manager is None:
return
try:
_manager.publish_display(display)
except Exception:
LOG.exception("Could not publish conversational display card")
+297
View File
@@ -0,0 +1,297 @@
"""Minuteurs + chronomètres indépendants d'Hermes ; stockage JSON durable.
Le temps est exprimé à partir de l'horloge UTC, pour survivre à un redémarrage
(et continuer à avancer pendant l'arrêt de Flask). Les durées sont limitées
pour éviter les mauvaises interprétations des demandes vocales.
"""
from __future__ import annotations
import json
import os
import re
import secrets
import threading
import time
from pathlib import Path
from urllib.parse import urlsplit
from flask import Blueprint, jsonify, request
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PATH = ROOT / 'data' / 'timers.json'
MAX_SECONDS = 30 * 24 * 3600
MAX_ACTIVE = 20
class TimerError(ValueError):
pass
class TimerStore:
def __init__(self, path: Path, clock=None, on_change=None, on_finished=None):
self.path = Path(path)
self.clock = clock or time.time
self.on_change = on_change or (lambda snapshot: None)
self.on_finished = on_finished or (lambda timer: None)
self.lock = threading.RLock()
self.items: dict[str, dict] = {}
self._read()
def _read(self):
if not self.path.exists():
return
try:
data = json.loads(self.path.read_text(encoding='utf-8'))
if not isinstance(data, list):
raise ValueError('Mauvais format JSON')
for item in data:
if (isinstance(item, dict) and isinstance(item.get('id'), str)
and item.get('kind') in ('timer', 'stopwatch')
and item.get('state') in ('running', 'paused', 'finished', 'cancelled')):
self.items[item['id']] = item
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError(f'Impossible de charger {self.path} : {exc}') from exc
def _persist(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_name(self.path.name + '.tmp.' + secrets.token_hex(3))
try:
with tmp.open('w', encoding='utf-8') as out:
json.dump(list(self.items.values()), out, ensure_ascii=False, indent=2)
out.flush()
os.fsync(out.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, self.path)
finally:
tmp.unlink(missing_ok=True)
@staticmethod
def _duration(number, *, allow_zero=False):
if isinstance(number, bool) or not isinstance(number, (int, float)):
raise TimerError('Durée numérique attendue en secondes.')
if number < (0 if allow_zero else 1) or number > MAX_SECONDS:
raise TimerError('Durée autorisée : de 1 seconde à 30 jours.')
return float(number)
@staticmethod
def _label(label, kind):
label = ' '.join(str(label or '').split()).strip()
if not label:
label = 'Minuteur' if kind == 'timer' else 'Chronomètre'
if len(label) > 80 or any(ord(x) < 32 for x in label):
raise TimerError('Nom trop long ou invalide (80 caractères maximum).')
return label
def _view(self, item, now):
duration = item.get('duration')
elapsed = float(item.get('elapsed', 0))
if item['state'] == 'running':
elapsed += max(0.0, now - float(item['started_at']))
remaining = max(0.0, duration - elapsed) if duration is not None else None
result = {k: item[k] for k in ('id', 'label', 'kind', 'state', 'created_at')}
result.update(duration=duration, elapsed=round(elapsed, 2),
remaining=round(remaining, 2) if remaining is not None else None)
return result
def snapshot(self, *, include_finished=False):
with self.lock:
now = self.clock()
self._expire_locked(now)
items = [self._view(x, now) for x in self.items.values()
if include_finished or x['state'] in ('running', 'paused')]
return sorted(items, key=lambda x: (x['state'] != 'running', x['created_at']))
def _expire_locked(self, now):
finished = []
for item in self.items.values():
if item['state'] != 'running' or item.get('duration') is None:
continue
elapsed = item['elapsed'] + max(0.0, now - item['started_at'])
if elapsed + 0.005 >= item['duration']:
item['elapsed'] = item['duration']
item['started_at'] = None
item['state'] = 'finished'
item['finished_at'] = now
finished.append(item.copy())
if finished:
self._persist()
self.on_change(self._unexpired_snapshot(now))
for item in finished:
self.on_finished(item)
def _unexpired_snapshot(self, now):
return sorted([self._view(x, now) for x in self.items.values()
if x['state'] in ('running', 'paused')],
key=lambda x: x['created_at'])
def start(self, kind, seconds=None, label=None):
if kind not in ('timer', 'stopwatch'):
raise TimerError('Type attendu : timer ou stopwatch.')
if kind == 'timer' and seconds is None:
raise TimerError('Il faut préciser la durée du minuteur.')
if seconds is not None:
seconds = self._duration(seconds)
with self.lock:
now = self.clock()
self._expire_locked(now)
if sum(1 for x in self.items.values() if x['state'] in ('running', 'paused')) >= MAX_ACTIVE:
raise TimerError('Maximum de 20 minuteurs ou chronomètres en cours.')
ident = secrets.token_hex(5)
item = {'id': ident, 'label': self._label(label, kind), 'kind': kind,
'duration': seconds, 'elapsed': 0.0,
'started_at': now, 'created_at': now, 'state': 'running'}
self.items[ident] = item
# Conserver au plus 80 éléments historiques.
old = sorted((x for x in self.items.values() if x['state'] in ('finished', 'cancelled')),
key=lambda x: x['created_at'])
for x in old[:-80]:
self.items.pop(x['id'], None)
self._persist()
self.on_change(self._unexpired_snapshot(now))
return self._view(item, now)
def _resolve(self, ident):
if not isinstance(ident, str):
raise TimerError('Identifiant attendu.')
ident = ident.strip()
if ident in self.items:
return self.items[ident]
candidates = [x for x in self.items.values()
if x['state'] in ('running', 'paused') and
(x['label'].casefold() == ident.casefold() or x['id'].startswith(ident))]
if len(candidates) == 1:
return candidates[0]
if not candidates:
raise TimerError('Minuteur ou chronomètre introuvable.')
raise TimerError('Plusieurs minuteurs portent ce nom : préciser l’identifiant.')
def action(self, action, ident, seconds=None):
if action not in ('pause', 'resume', 'cancel', 'add_time'):
raise TimerError('Action inconnue.')
with self.lock:
now = self.clock()
self._expire_locked(now)
item = self._resolve(ident)
if item['state'] in ('cancelled', 'finished'):
raise TimerError('Ce minuteur est déjà terminé.')
if action == 'pause':
if item['state'] != 'running':
raise TimerError('Déjà en pause.')
item['elapsed'] += max(0, now - item['started_at'])
item['started_at'] = None
item['state'] = 'paused'
elif action == 'resume':
if item['state'] != 'paused':
raise TimerError('Ce minuteur n’est pas en pause.')
item['started_at'] = now
item['state'] = 'running'
elif action == 'cancel':
if item['state'] == 'running':
item['elapsed'] += max(0, now - item['started_at'])
item['started_at'] = None
item['state'] = 'cancelled'
else:
if item['duration'] is None:
raise TimerError('Chronomètre libre : aucune durée maximale à prolonger.')
amount = self._duration(seconds)
item['duration'] = self._duration(item['duration'] + amount)
self._persist()
self.on_change(self._unexpired_snapshot(now))
return self._view(item, now)
def cancel_all(self):
with self.lock:
now = self.clock()
affected = []
for item in self.items.values():
if item['state'] not in ('running', 'paused'):
continue
if item['state'] == 'running':
item['elapsed'] += max(0, now - item['started_at'])
item['started_at'] = None
item['state'] = 'cancelled'
affected.append(self._view(item, now))
if affected:
self._persist()
self.on_change(self._unexpired_snapshot(now))
return affected
def tick(self):
with self.lock:
self._expire_locked(self.clock())
def run(self, stop_event=None, interval=0.5):
stop_event = stop_event or threading.Event()
while not stop_event.wait(interval):
try:
self.tick()
except Exception as exc:
print('[ALFRED TIMERS] erreur boucle :', repr(exc), flush=True)
def register(app, socketio, speak_async, audio_settings):
def changed(items):
socketio.emit('alfred_timers_changed', {'items': items})
def finished(item):
payload = {'id': item['id'], 'label': item['label'], 'kind': item['kind']}
socketio.emit('alfred_timer_finished', payload)
if not audio_settings.speaker_muted():
msg = f"Le minuteur {item['label']} est terminé." if item['kind'] == 'timer' else f"Le chronomètre {item['label']} est terminé."
try:
speak_async(msg)
except Exception as exc:
print('[ALFRED TIMERS] alerte vocale :', repr(exc), flush=True)
store = TimerStore(Path(os.getenv('ALFRED_TIMERS_DB', str(DEFAULT_PATH))),
on_change=changed, on_finished=finished)
bp = Blueprint('alfred_timers', __name__)
def safe_post():
# MCP fonctionne uniquement depuis la boucle locale (aucun accès LAN).
if request.remote_addr in ('127.0.0.1', '::1') and request.headers.get('X-Alfred-MCP-Local') == 'timers':
return True
origin = request.headers.get('Origin')
if request.headers.get('X-Alfred-Request') != '1':
return False
fetch_site = request.headers.get('Sec-Fetch-Site', '').lower()
if fetch_site == 'cross-site':
return False
if not origin:
# Chromium peut omettre Origin sur certaines requêtes même origine.
return fetch_site == 'same-origin'
p = urlsplit(origin)
return p.scheme in ('http','https') and p.netloc.lower() == request.host.lower()
@bp.get('/api/timers')
def list_timers():
return jsonify({'items': store.snapshot(), 'server_time': store.clock()})
@bp.post('/api/timers/action')
def timers_action():
if not safe_post():
return jsonify({'error': 'Requête non autorisée (origine ou en-tête MCP invalide).'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'Objet JSON attendu.'}), 400
command = data.get('action')
try:
if command == 'create_timer':
item = store.start('timer', data.get('seconds'), data.get('label'))
elif command == 'create_stopwatch':
item = store.start('stopwatch', data.get('seconds'), data.get('label'))
elif command == 'cancel_all':
cancelled = store.cancel_all()
return jsonify({'ok': True, 'cancelled': cancelled, 'items': store.snapshot()})
else:
item = store.action(command, data.get('id'), data.get('seconds'))
return jsonify({'ok': True, 'item': item, 'items': store.snapshot()})
except TimerError as exc:
return jsonify({'error': str(exc)}), 400
except OSError as exc:
print('[ALFRED TIMERS] écriture échouée:', repr(exc), flush=True)
return jsonify({'error': 'Stockage des minuteurs indisponible.'}), 503
app.register_blueprint(bp)
threading.Thread(target=store.run, name='alfred-timers', daemon=True).start()
return store
+28
View File
@@ -302,6 +302,12 @@ def listen_once(stop_event: Optional[threading.Event] = None,
model_path = os.getenv("VOSK_MODEL_PATH", "")
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
# ALFRED_WYOMING_WHISPER_STT_V1: Vosk segments the utterance and
# provides UI partials. Wyoming Whisper replaces only its final transcript.
stt_engine = os.getenv("STT_ENGINE", "vosk").strip().lower()
wyoming_enabled = stt_engine in ("wyoming", "wyoming_whisper", "whisper")
wyoming_audio = bytearray()
wyoming_max_bytes = max(16000, int(os.getenv("STT_MAX_AUDIO_SECONDS", "30")) * sample_rate * 2)
if not model_path or not os.path.exists(model_path):
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
try:
@@ -311,6 +317,9 @@ def listen_once(stop_event: Optional[threading.Event] = None,
return STTResult(error=f"Init Vosk: {exc}")
def process(data: bytes) -> Optional[str]:
# ALFRED_WYOMING_WHISPER_STT_V1: retain the exact PCM heard by Vosk.
if wyoming_enabled and len(wyoming_audio) < wyoming_max_bytes:
wyoming_audio.extend(data[:wyoming_max_bytes - len(wyoming_audio)])
if on_level and data:
try:
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
@@ -321,6 +330,25 @@ def listen_once(stop_event: Optional[threading.Event] = None,
if rec.AcceptWaveform(data):
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
if text:
if wyoming_enabled and wyoming_audio:
# ALFRED_WYOMING_WHISPER_STT_V1: remote final STT, local fallback.
try:
from services.wyoming_stt import transcribe_pcm
whisper_text = transcribe_pcm(
bytes(wyoming_audio),
host=os.getenv("WYOMING_WHISPER_HOST", "192.168.1.13"),
port=int(os.getenv("WYOMING_WHISPER_PORT", "10300")),
language=os.getenv("WYOMING_WHISPER_LANGUAGE", "fr"),
sample_rate=sample_rate,
connect_timeout=float(os.getenv("WYOMING_WHISPER_CONNECT_TIMEOUT", "3")),
response_timeout=float(os.getenv("WYOMING_WHISPER_TIMEOUT", "20")),
)
if whisper_text:
print(f"[ALFRED STT] Whisper final : {whisper_text!r} (Vosk : {text!r})", flush=True)
return whisper_text
print("[ALFRED STT] Whisper réponse vide, secours Vosk", flush=True)
except Exception as exc:
print(f"[ALFRED STT] Whisper indisponible, secours Vosk : {exc}", flush=True)
return text
elif on_partial:
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
+98
View File
@@ -0,0 +1,98 @@
"""Minimal Wyoming TCP ASR client for Alfred.
No external Wyoming Python dependency. Transport: PCM signed 16-bit LE mono,
16 kHz by default, following the official transcribe/audio-start/chunk/stop flow.
"""
from __future__ import annotations
import json
import socket
from typing import Optional
class WyomingSTTError(RuntimeError):
pass
def _event(kind: str, data: Optional[dict] = None, payload: bytes = b"") -> bytes:
header = {"type": kind}
if data is not None:
header["data"] = data
if payload:
header["payload_length"] = len(payload)
return (json.dumps(header, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8") + payload
def _read_event(stream) -> tuple[str, dict]:
header_bytes = stream.readline(65537)
if not header_bytes:
raise WyomingSTTError("Connexion Wyoming fermée sans transcription")
if len(header_bytes) > 65536 or not header_bytes.endswith(b"\n"):
raise WyomingSTTError("En-tête Wyoming invalide ou trop long")
try:
header = json.loads(header_bytes)
if not isinstance(header, dict):
raise ValueError("en-tête non objet")
data_length = int(header.get("data_length", 0))
payload_length = int(header.get("payload_length", 0))
if not 0 <= data_length <= 65536 or not 0 <= payload_length <= 1_048_576:
raise ValueError("taille d'événement excessive")
data = header.get("data") or {}
if not isinstance(data, dict):
raise ValueError("données non objet")
if data_length:
extra_data = stream.read(data_length)
if len(extra_data) != data_length:
raise WyomingSTTError("Données Wyoming incomplètes")
more = json.loads(extra_data)
if not isinstance(more, dict):
raise ValueError("données additionnelles non objet")
data.update(more)
if payload_length:
payload = stream.read(payload_length)
if len(payload) != payload_length:
raise WyomingSTTError("Charge utile Wyoming incomplète")
return str(header["type"]), data
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
raise WyomingSTTError(f"Message Wyoming incorrect : {exc}") from exc
def transcribe_pcm(
pcm: bytes,
*,
host: str = "192.168.1.13",
port: int = 10300,
language: str = "fr",
sample_rate: int = 16000,
connect_timeout: float = 3.0,
response_timeout: float = 20.0,
) -> str:
"""Send recorded S16LE/mono PCM to Wyoming ASR and return the transcript."""
if not pcm or len(pcm) % 2:
raise WyomingSTTError("Audio PCM vide ou mal aligné")
if sample_rate <= 0:
raise WyomingSTTError("Fréquence PCM invalide")
if not 0 < response_timeout <= 300:
raise WyomingSTTError("Délai Wyoming invalide")
try:
with socket.create_connection((host, int(port)), timeout=connect_timeout) as sock:
sock.settimeout(response_timeout)
sock.sendall(_event("transcribe", {"language": language}))
audio_meta = {"rate": int(sample_rate), "width": 2, "channels": 1}
sock.sendall(_event("audio-start", audio_meta))
for offset in range(0, len(pcm), 16000):
chunk = pcm[offset : offset + 16000]
sock.sendall(_event("audio-chunk", audio_meta, chunk))
sock.sendall(_event("audio-stop"))
with sock.makefile("rb") as stream:
# Some servers send informational events before the transcript.
for _ in range(16):
kind, data = _read_event(stream)
if kind == "transcript":
return str(data.get("text") or "").strip()
if kind == "error":
raise WyomingSTTError(str(data.get("text") or data.get("message") or data))
raise WyomingSTTError("Aucune transcription après 16 événements Wyoming")
except (OSError, TimeoutError) as exc:
raise WyomingSTTError(f"Serveur Whisper {host}:{port} indisponible : {exc}") from exc
+34
View File
@@ -0,0 +1,34 @@
/* Additive CSS: activity dock occupies the right side below the audio controls. */
.alfred-feature-dock {
position: fixed; z-index: 110; top: 104px; right: 16px;
width: min(370px, 34vw); max-height: calc(100dvh - 220px);
overflow: hidden; display: flex; flex-direction: column;
background: rgba(19, 24, 32, .94); color: #e7eef7;
border: 1px solid rgba(104, 159, 216, .30); border-radius: 15px;
box-shadow: 0 10px 44px rgba(0,0,0,.36);
font-family: system-ui, -apple-system, sans-serif; font-size: 13px;
backdrop-filter: blur(12px);
}
.alfred-feature-header { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid rgba(255,255,255,.08); }
.alfred-feature-toggle { background: transparent; color: #dceafe; border: 0; font-weight: 650; font: inherit; cursor: pointer; text-align: left; flex: 1; padding: 3px; }
.alfred-feature-count { color:#92c5fc; border: 1px solid rgba(113,175,255,.3); border-radius: 99px; padding: 1px 7px; font-variant-numeric: tabular-nums; }
.alfred-feature-contents { min-height: 44px; max-height: calc(100dvh - 280px); overflow: auto; padding: 8px; }
.alfred-feature-collapsed { width: 190px; }
.alfred-feature-collapsed .alfred-feature-header { border-bottom: 0; }
.alfred-feature-collapsed .alfred-feature-contents { display: none; }
.alfred-feature-card { padding: 11px 12px; margin-bottom: 9px; background: rgba(35,44,56,.95); border: 1px solid rgba(160,180,220,.13); border-radius: 10px; overflow-wrap: anywhere; }
.alfred-feature-card-header { display: flex; align-items: flex-start; gap: 8px; }
.alfred-feature-card-title { flex: 1; font-size: 13px; font-weight: 650; line-height: 1.45; }
.alfred-feature-status { flex: none; padding: 2px 6px; font-size: 11px; border-radius: 9px; background: #445065; color:#f5f5f5; }
.alfred-feature-status.running { color: #c3e3ff; background:#245074; }
.alfred-feature-status.succeeded { color: #b8e7d0; background:#234c3a; }
.alfred-feature-status.failed, .alfred-feature-status.interrupted { color:#ffd5d5; background:#75383a; }
.alfred-feature-body, .alfred-feature-detail { margin: 9px 0 5px; color:#d9e2ed; line-height: 1.5; white-space: pre-wrap; }
.alfred-feature-detail { font-size: 12px; color: #b8d1ec; }
.alfred-feature-image { display:block; width: 100%; max-height: 230px; object-fit: contain; margin: 8px 0; border-radius: 8px; }
.alfred-feature-weather { color: #f0cd88; font-size: 16px; }
.alfred-feature-event { padding: 5px 0; border-top: 1px solid rgba(255,255,255,.07); }
.alfred-feature-footer { display:flex; align-items:center; justify-content:space-between; margin-top:8px; color:#91a7c0; font-size: 11px; }
.alfred-feature-more, .alfred-feature-speak { background: rgba(70,119,175,.17); border: 1px solid rgba(118,173,230,.35); color:#d2e8ff; border-radius:7px; padding:3px 9px; cursor:pointer; }
.alfred-feature-empty { color:#9db2c8; padding: 0 6px; }
@media (max-width: 720px) { .alfred-feature-dock { top: 86px; right: 8px; width: min(310px, 82vw); max-height: calc(100dvh - 190px); } .alfred-feature-contents { max-height: calc(100dvh - 255px); } }
@@ -0,0 +1,21 @@
/* Dock discrets : la caméra reste accessible via son bouton popup. */
#alfredTimersDock[hidden],#alfredContextPopup[hidden],#alfredTimerToast[hidden]{display:none!important}
#alfredTimersDock{position:fixed;left:36px;bottom:32px;z-index:5;width:min(315px,30vw);max-height:min(46vh,380px);overflow-y:auto;border:1px solid #ffffff25;background:#242a2fe8;border-radius:17px;padding:12px 14px;box-shadow:0 15px 45px #0005;backdrop-filter:blur(14px);color:#eef3f5}
.alfred-time-head{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:650;color:#bdd5df;padding:0 0 8px;border-bottom:1px solid #ffffff15}
.alfred-time-card{display:grid;grid-template-columns:1fr auto;gap:6px 4px;align-items:center;padding:9px 0;border-bottom:1px solid #ffffff11}
.alfred-time-card:last-child{border-bottom:0}
.alfred-time-label{font-size:12px;color:#e3ebed;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;grid-column:1/3}
.alfred-time-value{font-size:25px;font-variant-numeric:tabular-nums;letter-spacing:.02em;font-weight:550}
.alfred-time-value.is-paused{color:#e2c183}
.alfred-time-actions{display:flex;gap:5px}
.alfred-time-icon{color:#dae4e8;background:#ffffff10;border:1px solid #ffffff1e;border-radius:8px;width:32px;height:31px;cursor:pointer;font-size:18px}
.alfred-time-icon:hover{background:#ffffff2a}
#alfredTimerToast{position:fixed;bottom:205px;left:36px;max-width:350px;background:#354a4b;border:1px solid #96d3c8;border-radius:12px;padding:13px 16px;color:#f1faf9;z-index:40;box-shadow:0 15px 40px #0008}
#alfredContextPopup{position:fixed;inset:0;display:grid;place-items:center;background:#05090dcc;backdrop-filter:blur(4px);z-index:48;padding:20px}
.alfred-context-card{position:relative;display:grid;grid-template-columns:minmax(160px,280px) minmax(220px,380px);gap:24px;align-items:center;max-width:min(92vw,740px);max-height:90vh;overflow:auto;border:1px solid #ffffff35;border-radius:20px;background:#252d32;padding:25px;box-shadow:0 26px 80px #000b}
#alfredContextImage{display:block;width:100%;height:280px;object-fit:contain;border-radius:12px;background:#14191d}
.alfred-context-details h2{font-size:clamp(21px,2vw,31px);line-height:1.2;margin:0 0 15px;color:#f3f7f9}
.alfred-context-details p{font-size:14px;color:#d3dfe4;line-height:1.6;max-height:225px;overflow-y:auto;white-space:pre-wrap}
.alfred-context-details a{color:#a0d9f0;font-size:12px;text-decoration:underline}
.alfred-context-close{position:absolute;right:12px;top:12px;border:1px solid #ffffff2c;background:#414b50;color:#f3f5f6;border-radius:10px;cursor:pointer;width:33px;height:33px;font-size:23px}
@media(max-width:850px){#alfredTimersDock{width:255px;left:14px;bottom:15px;max-height:36vh}#alfredTimerToast{left:14px;bottom:180px}.alfred-context-card{grid-template-columns:1fr;gap:12px;max-width:420px}#alfredContextImage{height:220px}}
+172
View File
@@ -0,0 +1,172 @@
/* Alfred V4+V5: visual activity dock. Does not alter the existing interface. */
(() => {
'use strict';
const CSS = 'alfred-feature-';
const state = { cards: [], collapsed: false, lastStatus: new Map(), receivedOnce: false };
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = CSS + className;
if (text != null) node.textContent = String(text);
return node;
};
const root = el('aside', 'dock');
root.setAttribute('aria-label', 'Activité et informations d’Alfred');
const header = el('header', 'header');
const toggle = el('button', 'toggle', 'Activité Hermes');
toggle.type = 'button';
toggle.setAttribute('aria-expanded', 'true');
const count = el('span', 'count', '0');
const contents = el('section', 'contents');
contents.setAttribute('aria-live', 'polite');
contents.setAttribute('aria-relevant', 'additions text');
header.append(toggle, count);
root.append(header, contents);
document.body.append(root);
const toggleDock = (collapse) => {
state.collapsed = collapse;
root.classList.toggle(CSS + 'collapsed', collapse);
toggle.setAttribute('aria-expanded', String(!collapse));
toggle.textContent = collapse ? '☰ Activité Hermes' : '⌄ Activité Hermes';
};
toggle.addEventListener('click', () => toggleDock(!state.collapsed));
const addText = (parent, cls, text, maxlength=1600) => {
if (text === undefined || text === null || String(text).trim() === '') return;
const value = String(text);
if (value.length <= maxlength) {
parent.append(el('p', cls, value));
return;
}
const p = el('p', cls, value.slice(0, maxlength) + '…');
const button = el('button', 'more', 'Afficher tout');
button.type = 'button';
let open = false;
button.addEventListener('click', () => {
open = !open;
p.textContent = open ? value : value.slice(0, maxlength) + '…';
button.textContent = open ? 'Réduire' : 'Afficher tout';
});
parent.append(p, button);
};
const safeImage = url => {
if (typeof url !== 'string') return null;
try {
const parsed = new URL(url, location.origin);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : null;
} catch { return null; }
};
const displayExtra = (item, display) => {
if (!display || typeof display !== 'object') return;
const kind = display.type;
if (kind === 'image' && display.url) {
const src = safeImage(display.url);
if (src) {
const img = el('img', 'image');
img.src = src;
img.alt = display.title || 'Illustration Alfred';
img.loading = 'lazy';
item.append(img);
}
} else if (kind === 'weather') {
const line = [display.temperature, display.condition || display.description].filter(Boolean).join(' · ');
addText(item, 'weather', line, 300);
} else if (kind === 'calendar' && Array.isArray(display.events)) {
for (const event of display.events.slice(0, 6)) {
addText(item, 'event', [event.start, event.title].filter(Boolean).join(' — '), 300);
}
}
if (display.text && display.text !== item.dataset.body) addText(item, 'detail', display.text);
};
const STATUS_LABELS = {
queued: 'En attente', running: 'En cours', succeeded: 'Terminé',
failed: 'Erreur', interrupted: 'Interrompu'
};
function render() {
contents.replaceChildren();
count.textContent = String(state.cards.length);
if (!state.cards.length) {
contents.append(el('p', 'empty', 'Aucune activité pour le moment.'));
return;
}
state.cards.forEach(card => {
const item = el('article', 'card');
item.dataset.body = card.body || '';
const heading = el('div', 'card-header');
const title = el('strong', 'card-title', card.title || 'Information');
heading.append(title);
if (card.status) {
const status = el('span', 'status ' + (card.status || ''), STATUS_LABELS[card.status] || card.status);
heading.append(status);
}
item.append(heading);
const body = card.body || '';
if (body && (!card.display?.text || card.display.text !== body)) addText(item, 'body', body);
displayExtra(item, card.display);
if (body && card.display?.text === body) addText(item, 'body', body);
const footer = el('div', 'footer');
if (card.updated_at) {
const date = new Date(card.updated_at);
footer.append(el('time', 'date', isNaN(date.getTime()) ? '' : date.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'})));
}
if (card.status === 'succeeded' && body) {
const say = el('button', 'speak', 'Lire');
say.type = 'button';
say.title = 'Faire lire le résultat par la voix Piper configurée';
say.addEventListener('click', async () => {
say.disabled = true;
try {
const response = await fetch('/api/tts/say', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:body.slice(0,1800)})
});
if (!response.ok) throw new Error('TTS indisponible');
} catch (error) { say.title = String(error); }
finally { say.disabled = false; }
});
footer.append(say);
}
item.append(footer);
contents.append(item);
});
}
let fetching = false;
async function refresh() {
if (fetching) return;
fetching = true;
try {
const response = await fetch('/api/alfred/cards', {cache:'no-store'});
if (!response.ok) throw new Error('Cartes indisponibles');
const payload = await response.json();
const cards = Array.isArray(payload.cards) ? payload.cards : [];
const sig = JSON.stringify(cards);
if (sig === state.signature) return;
state.signature = sig;
const hasNewCompletion = cards.some(card =>
card.status === 'succeeded' && state.lastStatus.has(card.id) &&
state.lastStatus.get(card.id) !== 'succeeded');
const hasNewCard = cards.some(card => !state.lastStatus.has(card.id));
state.lastStatus = new Map(cards.map(c => [c.id, c.status]));
state.cards = cards;
render();
if (!state.receivedOnce) {
state.receivedOnce = true;
toggleDock(cards.length === 0);
} else if (hasNewCompletion || hasNewCard) {
toggleDock(false);
}
} catch (error) {
if (!state.receivedOnce) contents.replaceChildren(el('p', 'empty', 'Connexion aux cartes indisponible.'));
} finally { fetching = false; }
}
refresh();
setInterval(refresh, 10000); // safety fallback; Socket.IO wakes immediately
if (typeof window.io === 'function') {
const socket = window.io();
socket.on('alfred_cards_changed', refresh);
socket.on('alfred_task', refresh);
socket.on('connect', refresh);
}
})();
@@ -0,0 +1,132 @@
/* ALFRED_HIDE_HERMES_ACTIVITY_V2
Supprime uniquement l'ancienne vue "Activité Hermes" du DOM visuel.
N'interrompt ni les taches de fond, ni le MCP, ni les evenements Socket.IO.
*/
(() => {
'use strict';
const TITLE = /\bactivit[eé]\s+hermes\b/i;
const EXACT_TITLE = /^\s*[⌄˅∨▾▿▼v]?\s*activit[eé]\s+hermes\s*(?:\d+)?\s*$/i;
const SPECIFIC_SELECTOR = [
'#hermesActivityPopup', '#hermesActivityModal',
'[aria-label="Activité Hermes"]', '[aria-label="Activite Hermes"]',
'[data-component="hermes-activity"]', '[data-widget="hermes-activity"]',
'.hermes-activity-panel', '.hermes-activity-popup',
'.hermes-activity-widget', '.hermes-activity-dock',
'.hermes-task-panel', '.hermes-tasks-panel'
].join(',');
const CLASS_HINT = /(?:hermes.{0,30}(?:activit|activity|tasks?|jobs?)|(?:activit|activity|tasks?|jobs?).{0,30}hermes)/i;
const PANEL_HINT = /(?:activit|activity|tasks?|jobs?|queue|history|panel|widget|dock|drawer|popover|sidebar)/i;
function markHidden(element) {
if (!element || !element.isConnected || element === document.body) return;
if (!element.hasAttribute('data-alfred-hermes-hidden')) {
element.setAttribute('data-alfred-hermes-hidden', 'true');
}
// Important : certains widgets reinitialisent leur attribut hidden ou display.
if (!element.hidden) element.hidden = true;
if (element.style.getPropertyValue('display') !== 'none' ||
element.style.getPropertyPriority('display') !== 'important') {
element.style.setProperty('display', 'none', 'important');
}
}
function isTitle(text) {
return EXACT_TITLE.test(String(text || '').replace(/\u00a0/g, ' ').trim());
}
function isSafeCandidate(el) {
if (!el || !el.isConnected) return false;
if (el.matches('html,body,main,.alfred-shell,#stage,#content')) return false;
if (el.contains(document.querySelector('#stage')) && document.querySelector('#stage')) return false;
if (el.contains(document.querySelector('#alfredTimersDock')) && document.querySelector('#alfredTimersDock')) return false;
return true;
}
function choosePanel(titleNode) {
// La capture montre un dock : le titre est accompagne d'un compteur et
// d'une carte de tache (badge "Termine", bouton "Lire").
const ancestors = [];
let el = titleNode.nodeType === 1 ? titleNode : titleNode.parentElement;
for (let depth = 0; el && depth < 10; depth++, el = el.parentElement) {
if (!isSafeCandidate(el)) break;
ancestors.push(el);
}
if (!ancestors.length) return null;
// Quand l'identifiant ou la classe indiquent explicitement le widget,
// ces conteneurs sont preferes au contenu de la carte.
for (const candidate of ancestors) {
const identity = `${candidate.id || ''} ${typeof candidate.className === 'string' ? candidate.className : ''}`;
if (CLASS_HINT.test(identity)) return candidate;
if (candidate.matches('[role="dialog"],[role="complementary"]') && TITLE.test(candidate.textContent || '')) return candidate;
}
// Cas general (et cas de la capture) : choisir le plus petit ancetre
// suffisamment grand pour contenir l'entete ET la liste des taches.
for (const candidate of ancestors) {
const allText = (candidate.innerText || candidate.textContent || '').trim();
if (allText.length > 15000) continue; // l'ancetre contient deja le titre detecte
const hasTask = /\b(?:termin[eé]|en\s+cours|en\s+attente|erreur|lire|annuler|rapport)\b/i.test(allText);
const childCount = candidate.children ? candidate.children.length : 0;
if (!hasTask || childCount < 2) continue;
const rect = candidate.getBoundingClientRect();
if (rect.width >= 180 && rect.height >= 100 &&
rect.width <= Math.max(window.innerWidth * 0.82, 600) &&
rect.height <= window.innerHeight * 1.5) {
return candidate;
}
}
// Panneau vide : se baser sur la bordure/classe du conteneur, sans cacher
// un parent generic (page entiere, scene, panneau de transcription).
for (const candidate of ancestors) {
const identity = `${candidate.id || ''} ${typeof candidate.className === 'string' ? candidate.className : ''}`;
if (!PANEL_HINT.test(identity)) continue;
const rect = candidate.getBoundingClientRect();
if (rect.width >= 150 && rect.width <= Math.max(window.innerWidth * 0.82, 600)) return candidate;
}
return null;
}
function hideActivity() {
for (const el of document.querySelectorAll(SPECIFIC_SELECTOR)) {
if (isSafeCandidate(el)) markHidden(el);
}
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let textNode;
while ((textNode = walker.nextNode())) {
if (!textNode.parentElement || textNode.parentElement.closest('[data-alfred-hermes-hidden]')) continue;
if (!isTitle(textNode.nodeValue)) continue;
const panel = choosePanel(textNode);
if (panel) markHidden(panel);
}
}
let queued = false;
function scheduleHide() {
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (document.body) hideActivity();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
hideActivity();
observe();
}, {once:true});
} else {
hideActivity();
observe();
}
function observe() {
const observer = new MutationObserver(scheduleHide);
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
})();
@@ -0,0 +1,157 @@
/* ALFRED_TIMERS_CONTEXT_V1 — chronos/minuteurs dans un coin, illustrations en popup. */
(() => {
'use strict';
const root = document.getElementById('alfredTimersDock');
const list = document.getElementById('alfredTimersList');
const context = document.getElementById('alfredContextPopup');
const contextImage = document.getElementById('alfredContextImage');
const contextTitle = document.getElementById('alfredContextTitle');
const contextText = document.getElementById('alfredContextText');
const contextSource = document.getElementById('alfredContextSource');
const contextClose = document.getElementById('alfredContextClose');
const toast = document.getElementById('alfredTimerToast');
if (!root || !list) return;
let items = [];
let anchorMs = Date.now();
let lastSnapshotClientMs = Date.now();
let toastTimeout = null;
function format(seconds) {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor(s % 3600 / 60);
const sec = s % 60;
return h > 0 ? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}` :
`${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
}
function textNode(tag, cls, text) {
const el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined) el.textContent = text;
return el;
}
async function action(op, id, seconds) {
const payload = {action:op, id};
if (seconds !== undefined) payload.seconds = seconds;
const r = await fetch('/api/timers/action', {method:'POST',
headers:{'Content-Type':'application/json', 'X-Alfred-Request':'1'},
body:JSON.stringify(payload)});
const data = await r.json();
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
update(data.items || []);
}
function update(next) {
items = Array.isArray(next) ? next : [];
lastSnapshotClientMs = Date.now();
list.replaceChildren();
root.hidden = !items.length;
for (const item of items) {
const card = textNode('div','alfred-time-card');
card.dataset.itemId = item.id;
const label = textNode('span','alfred-time-label', (item.kind === 'timer' ? '⌛ ' : '⏱ ') + item.label);
const counter = textNode('strong','alfred-time-value','00:00');
counter.dataset.kind = item.kind;
card.append(label,counter);
const actions = textNode('div','alfred-time-actions');
const pause = textNode('button','alfred-time-icon', item.state === 'paused' ? '▶' : 'Ⅱ');
pause.type='button'; pause.title=item.state === 'paused' ? 'Reprendre':'Mettre en pause';
pause.setAttribute('aria-label',pause.title);
pause.onclick=()=>action(item.state==='paused'?'resume':'pause',item.id).catch(showError);
const stop=textNode('button','alfred-time-icon','×');
stop.type='button'; stop.title='Annuler'; stop.setAttribute('aria-label','Annuler '+item.label);
stop.onclick=()=>action('cancel',item.id).catch(showError);
actions.append(pause,stop);card.append(actions);list.append(card);
}
redraw();
}
function redraw() {
const delta = Math.max(0, (Date.now()-lastSnapshotClientMs)/1000);
for (const item of items) {
const node=[...list.querySelectorAll('[data-item-id]')].find(el=>el.dataset.itemId===item.id);
if (!node) continue;
let elapsed=Number(item.elapsed)||0;
if(item.state==='running') elapsed+=delta;
const seconds = item.kind==='timer' ? Math.max(0,(Number(item.duration)||0)-elapsed) :
elapsed;
const counter=node.querySelector('.alfred-time-value');
counter.textContent=format(seconds);
counter.classList.toggle('is-paused',item.state==='paused');
if(item.kind==='stopwatch' && item.duration!==null)
counter.title='Limite '+format(item.duration);
}
}
function notify(message) {
if (!toast) return;
toast.textContent=message;toast.hidden=false;
clearTimeout(toastTimeout);
toastTimeout=setTimeout(()=>{toast.hidden=true;},9000);
}
function showError(error) {notify(error.message || String(error));}
async function refresh() {
try {
const r=await fetch('/api/timers',{cache:'no-store'});
if(!r.ok) throw new Error(`HTTP ${r.status}`);
const data=await r.json(); update(data.items || []);
} catch (err) { console.warn('[ALFRED TIMERS]',err); }
}
const socket = window.alfredSocket;
socket?.on('alfred_timers_changed', data => update(data.items || []));
socket?.on('alfred_timer_finished', data => {
notify(`⏰ ${data.label} : terminé !`);
refresh();
});
socket?.on('connect', refresh);
refresh();
setInterval(redraw, 250);
setInterval(()=>{if(!document.hidden)refresh();}, 15000);
// Illustration des demandes « qui est ... ? » : source = Wikipédia, réponse = Hermes.
function closeContext() {
if (context) context.hidden=true;
if (contextImage) contextImage.removeAttribute('src');
}
if (context && contextImage && contextTitle) {
socket?.on('alfred_context_image', data => {
try {
const image = new URL(data.image_url);
const source = new URL(data.source_url);
if (image.protocol !== 'https:' || !['upload.wikimedia.org','commons.wikimedia.org'].includes(image.hostname))return;
if (source.protocol !== 'https:' || source.hostname !== 'fr.wikipedia.org')return;
contextTitle.textContent = String(data.title || 'Illustration');
contextText.textContent = String(data.text || '').slice(0,500);
contextImage.src = image.href;
contextSource.href = source.href;
contextSource.textContent = String(data.credit || 'Source : Wikipédia');
context.hidden=false;
} catch (err) { console.warn('[ALFRED CONTEXT]',err); }
});
contextClose?.addEventListener('click', closeContext);
context.addEventListener('pointerdown', e => {if(e.target === context)closeContext();});
document.addEventListener('keydown', e=>{if(e.key === 'Escape' && !context.hidden)closeContext();});
}
// Masque uniquement les anciennes fenêtres intitulées exactement « Activité Hermes ».
// N'interfère ni avec la session Hermes ni avec ses réponses dans la scène.
function hideLegacyHermesActivity() {
const titlePattern=/^activit[eé]\s+hermes$/i;
for (const el of document.querySelectorAll('h1,h2,h3,h4,[role="heading"],.popup-title,.modal-title')) {
if (!titlePattern.test((el.textContent || '').trim()))continue;
const popup = el.closest('[role="dialog"],.modal,.popup,.popover,.overlay,.hermes-activity-panel,.hermes-activity-popup');
if (popup && popup !== document.body) {
popup.hidden = true;
popup.style.display = 'none';
}
}
for (const el of document.querySelectorAll('[aria-label="Activité Hermes"],[aria-label="Activite Hermes"],#hermesActivityPopup,#hermesActivityModal')) {
el.hidden=true;el.style.display='none';
}
}
hideLegacyHermesActivity();
let pending=false;
const observer = new MutationObserver(() => {
if(pending)return;
pending=true;
queueMicrotask(()=>{pending=false;hideLegacyHermesActivity();});
});
observer.observe(document.body,{childList:true,subtree:true});
})();
+27
View File
@@ -10,6 +10,8 @@
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
</head>
<body>
<main class="alfred-shell">
@@ -156,5 +158,30 @@
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
</body>
</html>
@@ -0,0 +1,185 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" 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>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" 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>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</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>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
</body>
</html>
@@ -0,0 +1,162 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" 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>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" 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>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</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>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
</body>
</html>