diff --git a/alfred/app/app.py b/alfred/app/app.py index ed91612..8f219d9 100644 --- a/alfred/app/app.py +++ b/alfred/app/app.py @@ -26,6 +26,7 @@ from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1 from services.piper_tts import stop_playback as duplex_stop_piper from services import admin_console # ALFRED_ADMIN_PAGES_V1 from services.hermes_client import ask_hermes +from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11 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 @@ -44,6 +45,11 @@ 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="*") +# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur. +from services import piper_tts as _dona_waves_piper +_dona_waves_piper.dona_set_level_callback( + lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)}) +) 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. @@ -128,6 +134,24 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V content={"type": "text", "text": r.text} ) + # DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes. + _dona_local_clock_reply = _dona_clock_local.answer(r.text) + if _dona_local_clock_reply is not None: + with STATE_LOCK: + STATE['content']={'type':'text','text':_dona_local_clock_reply} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply)) + if not audio_settings.speaker_muted(): + emit_state('speaking') + _dona_clock_done=threading.Event() + _dona_clock_started=speak_async(_dona_local_clock_reply, + on_end=lambda: (_dona_clock_done.set(), + socketio.emit('alfred_tts_finished', {'source':'voice'}))) + if _dona_clock_started:_dona_clock_done.wait(timeout=120) + emit_state('idle',partial='') + return + # DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande. if _dona_vision_v95.is_camera_question(r.text): try: @@ -200,7 +224,7 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V return # DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe. - if _dona_dynamic.is_dynamic_request(r.text): + if _dona_dynamic.is_dynamic_request(r.text) or _dona_json_autorender.is_monitor_request(r.text): _board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard) socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord'))) _payload = {'type':'dona_dynamic', 'dashboard':_board} @@ -288,6 +312,7 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V else: speech = str(answer or "") display = {"type": "text", "text": speech} + display, speech = _dona_json_autorender.convert(answer, r.text, display, speech) # DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎. socketio.emit('dona_learning_turn', @@ -642,13 +667,21 @@ def _alfred_dashboard_quick_command(prompt): _image_revision_before = IMAGE_REVISION emit_state('thinking', transcript=prompt, partial='', content={'type':'text', 'text':prompt}) - _answer = ask_hermes(prompt) - if isinstance(_answer, dict): - _speech = str(_answer.get('speech') or _answer.get('text') or '') - _display = _answer.get('display') or {'type':'text','text':_speech} + # DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated + # dashboard session; ordinary shortcuts remain regular Hermes calls. + if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt): + _board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard) + _display = {'type':'dona_dynamic','dashboard':_board} + _speech = 'Voici le tableau de bord : ' + _board['title'] else: - _speech = str(_answer or '') - _display = {'type':'text','text':_speech} + _answer = ask_hermes(prompt) + 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} + _display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech) # Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee. with STATE_LOCK: if IMAGE_REVISION == _image_revision_before: @@ -722,6 +755,17 @@ def _dona_submit_typed(prompt): _before_image = IMAGE_REVISION emit_state('thinking', partial='', transcript=prompt, content={'type': 'text', 'text': prompt}) + # DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis. + _dona_local_clock_reply = _dona_clock_local.answer(prompt) + if _dona_local_clock_reply is not None: + with STATE_LOCK: + STATE['content']={'type':'text','text':_dona_local_clock_reply} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('typed',prompt,_dona_local_clock_reply)) + emit_state('idle',partial='') + return + # DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle. if _dona_vision_v95.is_camera_question(prompt): try: @@ -767,7 +811,7 @@ def _dona_submit_typed(prompt): return # DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel. - if _dona_dynamic.is_dynamic_request(prompt): + if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt): _board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard) socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord'))) _payload = {'type':'dona_dynamic','dashboard':_board} @@ -783,6 +827,7 @@ def _dona_submit_typed(prompt): else: _speech = str(_answer or '') _display = {'type':'text','text':_speech} + _display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech) with STATE_LOCK: if IMAGE_REVISION == _before_image: STATE['content'] = _display @@ -810,6 +855,8 @@ _dona_register_family(app, adult_command=_dona_submit_typed) # DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés. from services import dona_dynamic as _dona_dynamic +# DONA_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis +from services import dona_json_autorender_v1 as _dona_json_autorender # DONA_V8_1_ISOLATION : tableaux isolés des conversations normales from services import dona_dashboard_agent as _dona_dashboard_agent diff --git a/alfred/app/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json index c700999..672df6f 100644 --- a/alfred/app/cache/weather_outfit_cache.json +++ b/alfred/app/cache/weather_outfit_cache.json @@ -1032,5 +1032,121 @@ }, "cached_at": 1793428787.6485865, "focus_label": "aujourd’hui" + }, + "9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2": { + "file": "9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2.jpg", + "prompt_ids": [ + "679937f6-bf42-4cc2-9c69-3d21fbd000d3", + "d49063bf-3ec9-41ce-bd9a-f27e94084647" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + } + }, + "cached_at": 1793443488.2070868, + "focus_label": "aujourd’hui" + }, + "9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c": { + "file": "9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c.jpg", + "prompt_ids": [ + "15bd0716-ea47-4709-aba2-0da675d97017", + "dc1646e7-cd5f-4892-b2a0-e69d931688c0" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + } + }, + "cached_at": 1793475062.3883624, + "focus_label": "aujourd’hui" + }, + "3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c": { + "file": "3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c.jpg", + "prompt_ids": [ + "401c7ee7-48ba-4614-ab21-bcbcec060e53", + "898173ea-bba0-4c35-a025-fa45c4b72140" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + } + }, + "cached_at": 1793526148.471928, + "focus_label": "aujourd’hui" + }, + "f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2": { + "file": "f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2.jpg", + "prompt_ids": [ + "e1ccd704-a7b5-4375-8d4c-815b7366bdf5", + "9d61529b-b5d0-4853-8974-29113770531f" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": false + } + }, + "cached_at": 1793535766.1160083, + "focus_label": "aujourd’hui" } } \ No newline at end of file diff --git a/alfred/app/cache/weather_outfit_images/3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c.jpg b/alfred/app/cache/weather_outfit_images/3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c.jpg new file mode 100644 index 0000000..247ecde Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c.jpg b/alfred/app/cache/weather_outfit_images/9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c.jpg new file mode 100644 index 0000000..98d04a2 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2.jpg b/alfred/app/cache/weather_outfit_images/9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2.jpg new file mode 100644 index 0000000..9a755ee Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2.jpg b/alfred/app/cache/weather_outfit_images/f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2.jpg new file mode 100644 index 0000000..fca877f Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2.jpg differ diff --git a/alfred/app/data/alfred_features.sqlite3 b/alfred/app/data/alfred_features.sqlite3 index ed79a6f..e54fec0 100644 Binary files a/alfred/app/data/alfred_features.sqlite3 and b/alfred/app/data/alfred_features.sqlite3 differ diff --git a/alfred/app/data/dona-learning-v93.sqlite3 b/alfred/app/data/dona-learning-v93.sqlite3 index 5ec095a..ca4371d 100644 Binary files a/alfred/app/data/dona-learning-v93.sqlite3 and b/alfred/app/data/dona-learning-v93.sqlite3 differ diff --git a/alfred/app/services/__pycache__/dona_clock_local.cpython-313.pyc b/alfred/app/services/__pycache__/dona_clock_local.cpython-313.pyc new file mode 100644 index 0000000..6bede12 Binary files /dev/null and b/alfred/app/services/__pycache__/dona_clock_local.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/dona_json_autorender_v1.cpython-313.pyc b/alfred/app/services/__pycache__/dona_json_autorender_v1.cpython-313.pyc new file mode 100644 index 0000000..55dd606 Binary files /dev/null and b/alfred/app/services/__pycache__/dona_json_autorender_v1.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/piper_tts.cpython-313.pyc b/alfred/app/services/__pycache__/piper_tts.cpython-313.pyc index 1e2b886..5f93279 100644 Binary files a/alfred/app/services/__pycache__/piper_tts.cpython-313.pyc and b/alfred/app/services/__pycache__/piper_tts.cpython-313.pyc differ diff --git a/alfred/app/services/dona_clock_local.py b/alfred/app/services/dona_clock_local.py new file mode 100644 index 0000000..18da458 --- /dev/null +++ b/alfred/app/services/dona_clock_local.py @@ -0,0 +1,66 @@ +"""Dona: heure/date du système, sans appel LLM. Fuseau Europe/Paris par défaut.""" +from __future__ import annotations + +from datetime import datetime +import os +import re +import unicodedata +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +DAYS = ('lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche') +MONTHS = ('janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', + 'août', 'septembre', 'octobre', 'novembre', 'décembre') + + +def _normalized(text: str) -> str: + text = unicodedata.normalize('NFKD', str(text or '').lower()) + text = ''.join(c for c in text if not unicodedata.combining(c)) + text = re.sub(r'[^a-z0-9]+', ' ', text).strip() + return re.sub(r'^(?:dona|alfred)\s+', '', text) + + +def intent(text: str) -> str | None: + """Renvoie 'time', 'date', 'both' ou None; pas de faux positif trajet/durée.""" + s = _normalized(text) + if not s or len(s) > 140: + return None + # Ne pas répondre avec l'heure de Paris à une question sur une autre ville. + if re.search(r'\b(?:heure|est il|est elle)\s+(?:a|en|au|aux|dans)\s+[a-z]', s): + return None + # Ne pas renvoyer la date actuelle à une question concernant une date future/passée. + if re.search(r'\bdate\s+(?:du|de|pour|a)\s+(?:\d|demain|hier)', s): + return None + t = ( + bool(re.search(r'\b(?:quelle?|quel)\s+heure\s+(?:est\s+il|il\s+est|est\s+elle)\b', s)) + or bool(re.search(r'\bil\s+est\s+(?:quelle?|quel)\s+heure\b', s)) + or bool(re.search(r'\b(?:donne|dis|dites|affiche|montre)\s+(?:moi\s+)?(?:l\s+)?heure\b', s)) + or bool(re.search(r'\b(?:heure\s+actuelle|heure\s+maintenant|il\s+est\s+quelle\s+heure)\b', s)) + ) + d = ( + bool(re.search(r'\b(?:quelle?|quel)\s+(?:est\s+)?(?:la\s+)?date\s+(?:d\s+aujourd\s+hui|aujourd\s+hui|du\s+jour)?\b', s)) + or bool(re.search(r'\b(?:on\s+est|nous\s+sommes|sommes\s+nous)\s+(?:quel|quelle|le\s+combien)\s+(?:jour|date)?\b', s)) + or bool(re.search(r'\b(?:quel\s+jour\s+(?:est\s+on|sommes\s+nous|on\s+est)|quelle\s+date\s+sommes\s+nous)\b', s)) + or bool(re.search(r'\b(?:donne|dis|affiche|montre)\s+(?:moi\s+)?(?:la\s+)?date\s+(?:du\s+jour)?\b', s)) + ) + if t and d: + return 'both' + if t: + return 'time' + if d: + return 'date' + return None + + +def answer(text: str, *, now: datetime | None = None) -> str | None: + which = intent(text) + if which is None: + return None + if now is None: + try: + tz = ZoneInfo(os.getenv('DONA_TIMEZONE') or os.getenv('ALFRED_TIMEZONE') or 'Europe/Paris') + except (ZoneInfoNotFoundError, ValueError, KeyError): + tz = ZoneInfo('Europe/Paris') + now = datetime.now(tz) + hour = f'Il est {now.hour} heures {now.minute:02d}.' + day = f"Aujourd'hui, nous sommes le {DAYS[now.weekday()]} {now.day} {MONTHS[now.month-1]} {now.year}." + return (hour + ' ' + day) if which == 'both' else hour if which == 'time' else day diff --git a/alfred/app/services/dona_json_autorender_v1.py b/alfred/app/services/dona_json_autorender_v1.py new file mode 100644 index 0000000..cd5cd80 --- /dev/null +++ b/alfred/app/services/dona_json_autorender_v1.py @@ -0,0 +1,81 @@ +"""Dona: reuse the existing, data-only dashboard renderer for Hermes replies. + +This module never executes model-supplied HTML, JavaScript or instructions. Only +`dona_dynamic.normalize_dashboard` can build cards; invalid content remains text. +""" +from __future__ import annotations + +import re +from typing import Any + +from services import dona_dynamic + + +MAX_JSON = dona_dynamic.MAX_RESPONSE + + +def is_monitor_request(prompt: str) -> bool: + """Route clearly operational Proxmox/Wazuh checks to existing dashboard API.""" + q = str(prompt or '').casefold() + if not re.search(r'\b(proxmox|wazuh|cluster|clusters)\b', q): + return False + return bool(re.search( + r'\b(v[eé]rifi(?:e|es|er|ez)|contr[oô]le(?:r|s|z)?|surveille(?:r|s|z)?|' + r'montre|affiche|tableau|[eé]tat|statut|status|ressources?|' + r'performances?|noeuds?|nœuds?|serveurs?|alertes?|supervision|' + r'fonctionn(?:e|ement)|disponibilit[eé])\b', q)) + + +def _candidate_text(answer: Any, display: Any) -> str | None: + if isinstance(display, dict): + if isinstance(display.get('cards'), list): + # Raw dashboard object should not be converted via repr(). + import json + return json.dumps(display, ensure_ascii=False) + if display.get('type') in (None, 'text'): + raw = display.get('text') + if isinstance(raw, str) and '"cards"' in raw: + return raw + if isinstance(answer, str) and '"cards"' in answer: + return answer + if isinstance(answer, dict): + if isinstance(answer.get('cards'), list): + import json + return json.dumps(answer, ensure_ascii=False) + for key in ('text', 'speech'): + raw = answer.get(key) + if isinstance(raw, str) and '"cards"' in raw: + return raw + return None + + +def _clean_line_continuations(text: str) -> str: + """Repair two common harmless Hermes JSON formatting artefacts only. + + Trailing backslash + real newline (copied JSON), and invalid ``\\~`` + escaping in approximate values. Other malformed content is left untouched. + """ + text = re.sub(r'\\[ \t]*\r?\n', '\n', text) + return text.replace('\\~', '~') + + +def as_board(answer: Any, display: Any, prompt: str) -> dict | None: + """Return a server-validated dashboard, or None for an ordinary answer.""" + raw = _candidate_text(answer, display) + if not raw or len(raw) > MAX_JSON: + return None + try: + extracted = dona_dynamic.extract_dashboard(_clean_line_continuations(raw)) + return dona_dynamic.normalize_dashboard(extracted, prompt=prompt, origin='hermes') + except (ValueError, TypeError, KeyError): + return None + + +def convert(answer: Any, prompt: str, display: Any, speech: str): + """Convert ONLY actual card JSON. Preserve ordinary answers and images.""" + if isinstance(display, dict) and display.get('type') not in (None, 'text'): + return display, speech + board = as_board(answer, display, prompt) + if board is None: + return display, speech + return {'type': 'dona_dynamic', 'dashboard': board}, 'Voici le tableau de bord : ' + board['title'] diff --git a/alfred/app/services/piper_tts.py b/alfred/app/services/piper_tts.py index 91f0fb4..3678eb3 100644 --- a/alfred/app/services/piper_tts.py +++ b/alfred/app/services/piper_tts.py @@ -91,6 +91,58 @@ def stop_playback(): pass +# DONA_WAVES_V1 — volume réel du WAV Wyoming Piper (50 ms, hors micro). +from array import array as _dona_array +import sys as _dona_sys +_dona_level_callback = None + +def dona_set_level_callback(callback): + global _dona_level_callback + _dona_level_callback = callback + +def _dona_emit_level(value): + if _dona_level_callback is not None: + try: + _dona_level_callback(max(0.0, min(1.0, float(value)))) + except Exception: + pass # Une erreur côté interface ne doit jamais interrompre Piper. + +def _dona_waves_stream_envelope(wav_path, proc): + """Échantillonne le PCM pendant aplay, sans enregistrer ni exposer le son.""" + try: + with wave.open(wav_path, 'rb') as stream: + if stream.getsampwidth() != 2: + return # Autre format : état « speaking » en secours côté navigateur. + rate=stream.getframerate() + if rate <= 0: + return + frames=max(1,int(rate * .05)) + started=time.monotonic() + index=0 + while proc.poll() is None: + raw=stream.readframes(frames) + if not raw: + break + samples=_dona_array('h') + samples.frombytes(raw) + if _dona_sys.byteorder=='big': + samples.byteswap() + stride=max(1,len(samples)//420) + window=samples[::stride] + rms=(sum(s*s for s in window)/max(1,len(window)))**.5 + desired=started+index*frames/rate + remaining=desired-time.monotonic() + if remaining>0: + time.sleep(min(remaining,.07)) + if proc.poll() is not None: + break + _dona_emit_level(min(1.0,rms/6500)) + index+=1 + except Exception as exc: + print('[DONA WAVES] analyse PCM impossible :',type(exc).__name__,flush=True) + finally: + _dona_emit_level(0) + def speak(text): if not text or not text.strip(): return @@ -123,6 +175,9 @@ def speak(text): _playback_proc = proc if _playback_cancel.is_set() and proc.poll() is None: proc.terminate() + # DONA_WAVES_V1 : animation par l'amplitude du vrai son joué. + threading.Thread(target=_dona_waves_stream_envelope, + args=(wav_path,proc),daemon=True).start() try: result = proc.wait(timeout=120) except subprocess.TimeoutExpired: diff --git a/alfred/app/static/css/dona-reply-floating-v1.css b/alfred/app/static/css/dona-reply-floating-v1.css new file mode 100644 index 0000000..8f6da53 --- /dev/null +++ b/alfred/app/static/css/dona-reply-floating-v1.css @@ -0,0 +1,119 @@ +/* DONA_REPLY_FLOATING_V1 — Les réponses textuelles ne recouvrent plus les animations. */ +/* La carte occupe une bande latérale, sans voile de fond ni blocage de la scène. */ +/* La classe de l’écran principal est synchronisée en JS. */ +body.dona-family-v1 .alfred-main-v1.dona-reply-background-visible.is-focused .av-voice-hero, +body.dona-family-v1 .alfred-main-v1.dona-reply-background-visible.is-focused .dona-art-center { + opacity:1!important; visibility:visible!important; pointer-events:none!important; +} +body.dona-family-v1 .alfred-main-v1.dona-reply-background-visible.is-focused .dona-art-center > * { + visibility:visible!important; +} + +body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact:not(.dona-reply-expanded) { + position:fixed!important; + left:auto!important; + top:clamp(140px,24vh,285px)!important; + right:clamp(16px,2.5vw,44px)!important; + bottom:auto!important; + transform:none!important; + width:min(410px,calc(100vw - 32px))!important; + max-width:calc(100vw - 32px)!important; + max-height:min(65dvh,580px)!important; + min-height:0!important; + padding:48px 15px 15px!important; + overflow:auto!important; + display:block!important; + border:1px solid rgba(149,177,232,.36)!important; + border-radius:22px!important; + background:linear-gradient(145deg,rgba(12,20,43,.88),rgba(19,20,39,.84))!important; + box-shadow:0 18px 50px rgba(0,0,0,.38),inset 0 1px rgba(255,255,255,.05)!important; + -webkit-backdrop-filter:blur(10px)!important; + backdrop-filter:blur(10px)!important; + z-index:28!important; + align-items:stretch!important; + justify-content:flex-start!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) #content { + width:100%!important; + max-width:100%!important; + min-width:0!important; + max-height:none!important; + overflow:visible!important; + margin:0!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .av-reponse-actions { + top:10px!important;right:10px!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .av-reponse-close { + width:33px!important;min-width:33px!important;min-height:33px!important; + padding:6px!important;border-radius:11px!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-response { + display:flex!important;flex-direction:column!important;gap:0!important; + margin:0!important;max-width:100%!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-primary { + padding:4px 2px 2px!important; + min-height:0!important; + border:0!important;border-radius:0!important; + box-shadow:none!important;background:transparent!important; + gap:10px!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-primary::before, +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-response::after { + display:none!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-kicker { + font-size:10px!important;letter-spacing:.09em!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-headline { + font-size:clamp(17px,1.45vw,21px)!important;line-height:1.25!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-excerpt { + font-size:13px!important;line-height:1.52!important; + display:-webkit-box!important;-webkit-box-orient:vertical!important;-webkit-line-clamp:4!important; + line-clamp:4!important;overflow:hidden!important; + white-space:normal!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-facts { + display:none!important; +} +body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-more { + min-height:38px!important;padding:9px 12px!important;font-size:12px!important; + margin-top:5px!important; +} +/* Mode développé : le contenu et les cartes d'origine réapparaissent. + L'utilisateur choisit volontairement d'occuper l'écran. */ +body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact.dona-reply-expanded { + position:fixed!important; + left:clamp(8px,2vw,30px)!important;right:clamp(8px,2vw,30px)!important; + top:clamp(85px,10vh,125px)!important;bottom:clamp(95px,9vh,120px)!important; + width:auto!important;max-width:none!important;max-height:none!important; + padding:clamp(62px,8vh,92px) 20px 24px!important; + transform:none!important;overflow:auto!important; + background:linear-gradient(145deg,rgba(3,8,20,.97),rgba(10,16,35,.97))!important; + border:1px solid rgba(146,172,231,.38)!important;z-index:30!important; +} +/* L'état visuel compact ne s'applique pas aux cartes riches (météo, vidéo, tableaux...). */ +@media (max-width:1050px) { + body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact:not(.dona-reply-expanded) { + left:clamp(12px,3vw,28px)!important; + right:clamp(12px,3vw,28px)!important; + top:auto!important; + bottom:clamp(110px,12vh,160px)!important; + width:auto!important; + max-width:none!important; + max-height:min(31dvh,275px)!important; + padding:43px 14px 12px!important; + } +} +@media (max-width:600px) { + body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact:not(.dona-reply-expanded) { + left:9px!important;right:9px!important; + bottom:clamp(95px,13vh,125px)!important; + border-radius:15px!important;max-height:29dvh!important; + } + body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-excerpt { + -webkit-line-clamp:2!important;line-clamp:2!important; + } +} diff --git a/alfred/app/static/css/dona-waves-v1.css b/alfred/app/static/css/dona-waves-v1.css new file mode 100644 index 0000000..1a85b3a --- /dev/null +++ b/alfred/app/static/css/dona-waves-v1.css @@ -0,0 +1,34 @@ +/* Dona — vagues vocales, sélection réversible sans désinstaller. */ +body.dona-family-v1.dona-waves-selected .dona-organic-wrap { + width:min(93vw,1400px)!important; + height:clamp(155px,28vh,330px)!important; + min-width:0!important;min-height:155px!important; + max-width:100%!important;filter:none!important; + overflow:visible!important; +} +body.dona-family-v1.dona-waves-selected #donaOrganicCanvas, +body.dona-family-v1.dona-waves-selected .dona-orb-reflection {display:none!important} +body.dona-family-v1 .dona-organic-wrap #donaWavesCanvas { + display:none;position:absolute;inset:0;width:100%;height:100%;pointer-events:none; +} +body.dona-family-v1.dona-waves-selected .dona-organic-wrap #donaWavesCanvas {display:block} +body.dona-family-v1.dona-waves-selected .dona-art-name {margin-top:-5px!important} +.dona-waves-menu-title{color:#c7b8cc;font-size:11px;letter-spacing:.07em;padding:10px 10px 5px;text-transform:uppercase} +.dv92-menu-entry.dona-waves-option[aria-checked="true"] {background:rgba(94,148,208,.19);color:#ffe1b8} +.dona-waves-option .dona-waves-choice-icon{width:22px;min-width:22px;text-align:center;font-size:18px} +@media(max-width:900px){ + body.dona-family-v1.dona-waves-selected .dona-organic-wrap{height:clamp(145px,23vh,240px)!important;min-height:145px!important;width:min(96vw,740px)!important} + body.dona-family-v1.dona-waves-selected .dona-art-name{font-size:clamp(32px,8vw,50px)} +} + +/* DONA_SCULPTURE_V11 — Visuel organique plus concentré, mêmes events Piper. */ +body.dona-family-v1.dona-sculpture-selected .dona-organic-wrap { + width:min(94vw,990px)!important; + height:clamp(210px,39vh,440px)!important; + min-height:210px!important; +} +@media(max-width:900px){ + body.dona-family-v1.dona-sculpture-selected .dona-organic-wrap{ + width:min(97vw,820px)!important;height:clamp(180px,28vh,295px)!important;min-height:180px!important; + } +} diff --git a/alfred/app/static/js/dona-accueil-ecrit-v1.js b/alfred/app/static/js/dona-accueil-ecrit-v1.js index ab076b1..6c14bff 100644 --- a/alfred/app/static/js/dona-accueil-ecrit-v1.js +++ b/alfred/app/static/js/dona-accueil-ecrit-v1.js @@ -83,6 +83,8 @@ if(k===0)ctx.moveTo(x,y);else ctx.lineTo(x,y); }ctx.closePath();} function frame(now){raf=requestAnimationFrame(frame);if(document.hidden||now-last<30)return;last=now; + // DONA_WAVES_V1 : ne pas dessiner une orbe cachée; bascule possible à chaud. + if(window.DonaWaves && window.DonaWaves.isActive())return; const mode=root.dataset.familyMode||'idle';const speed=mode==='listening'?1.7:mode==='thinking'?1.38:mode==='speaking'?1.5:.75; if(!reduce)phase+=.024*speed; ctx.clearRect(0,0,w,h);ctx.save();ctx.translate(w/2,h/2);const r=Math.min(w,h)*.33; diff --git a/alfred/app/static/js/dona-reply-floating-v1.js b/alfred/app/static/js/dona-reply-floating-v1.js new file mode 100644 index 0000000..8c7e3d7 --- /dev/null +++ b/alfred/app/static/js/dona-reply-floating-v1.js @@ -0,0 +1,49 @@ +/* DONA_REPLY_FLOATING_V1: carte texte latérale sans recouvrir le moteur d'animations. */ +(() => { + 'use strict'; + if (window.__donaReplyFloatingV1) return; + window.__donaReplyFloatingV1 = true; + + function setup() { + const main = document.getElementById('alfredMainV1'); + const stage = document.getElementById('stage'); + const content = document.getElementById('content'); + if (!main || !stage || !content) return; + + let queued = false; + function isRich() { + // Les vues calendrier/météo, cartes, vidéo, images et modules spécialisés + // conservent la grande scène et tous leurs contrôles. + if (content.classList.contains('alfred-view')) return true; + if (content.querySelector('.alfred-view,.view-pane,.cards,.hero,iframe,video,canvas,img,table,.dona-dynamic-grid,.dona-dynamic-dashboard,[data-dona-rich]')) return true; + if (content.querySelector('.dh-response')) return false; + const nodes = [...content.children]; + return !nodes.length || nodes.some(n => !['H1','H2','H3','P','PRE','BLOCKQUOTE'].includes(n.tagName)); + } + function update() { + queued = false; + const focused = main.classList.contains('is-focused'); + const compact = focused && !isRich(); + const expanded = compact && content.querySelector('.dh-more[aria-expanded="true"]') !== null; + stage.classList.toggle('dona-reply-compact', compact); + stage.classList.toggle('dona-reply-expanded', expanded); + main.classList.toggle('dona-reply-background-visible', compact && !expanded); + stage.setAttribute('data-dona-reply-mode', compact ? (expanded ? 'expanded':'compact') : 'full'); + } + function queue() { + if (queued) return; + queued = true; + requestAnimationFrame(update); + } + new MutationObserver(queue).observe(content, {childList:true,subtree:true,attributes:true,attributeFilter:['aria-expanded','class']}); + new MutationObserver(queue).observe(main, {attributes:true,attributeFilter:['class']}); + // Le renderer transforme parfois la réponse brute en carte au frame suivant. + window.alfredSocket?.on('alfred_render', queue); + stage.addEventListener('click', e => { + if (e.target.closest('.dh-more')) setTimeout(queue, 0); + }); + queue(); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup, {once:true}); + else setup(); +})(); diff --git a/alfred/app/static/js/dona-waves-v1.js b/alfred/app/static/js/dona-waves-v1.js new file mode 100644 index 0000000..19f3b6f --- /dev/null +++ b/alfred/app/static/js/dona-waves-v1.js @@ -0,0 +1,258 @@ +/* DONA_SCULPTURE_V11 */ +/* Dona Vagues Vocales V1 — Canvas 2D, amplitude PCM Piper envoyée par Socket.IO. + Le mode orb conserve l'ancien canvas et son animation. + Aucune capture micro et aucun audio client ne sont nécessaires. */ +(() => { + 'use strict'; + if (window.DonaWaves) return; + const KEY='dona_visual_voice_v1'; + let choice='waves'; + try {if (['waves','orb','sculpture'].includes(localStorage.getItem(KEY)))choice=localStorage.getItem(KEY);}catch (_) {} + const API={isActive:()=>choice!=='orb',select:selectMode,getChoice:()=>choice}; + window.DonaWaves=API; + document.body.classList.toggle('dona-waves-selected', choice!=='orb'); + document.body.classList.toggle('dona-sculpture-selected',choice==='sculpture'); + function selectMode(next){ + if(!['waves','orb','sculpture'].includes(next))return; + choice=next; + document.body.classList.toggle('dona-waves-selected',choice!=='orb'); + document.body.classList.toggle('dona-sculpture-selected',choice==='sculpture'); + try{localStorage.setItem(KEY,choice);}catch(_){} + syncMenu(); + } + function syncMenu(){ + const menu=document.getElementById('dv92ToolsMenu');if(!menu)return; + for(const el of menu.querySelectorAll('[data-dona-visual]')){ + const active=el.dataset.donaVisual===choice; + el.setAttribute('aria-checked',String(active)); + el.querySelector('.dona-waves-choice-icon').textContent=active?'◉':'○'; + } + } + function addMenuControls(){ + const menu=document.getElementById('dv92ToolsMenu'); + if(!menu||menu.querySelector('#donaWavesMenuTitle'))return; + const title=document.createElement('div');title.id='donaWavesMenuTitle'; + title.className='dona-waves-menu-title';title.textContent='Animation centrale'; + menu.append(title); + for(const [mode,label] of [['waves','Vagues horizontales (Piper)'],['sculpture','Sculpture de vagues (Piper)'],['orb','Orbe d’origine']]){ + const b=document.createElement('button');b.type='button'; + b.className='dv92-menu-entry dona-waves-option';b.dataset.donaVisual=mode; + b.setAttribute('role','menuitemradio'); + const mark=document.createElement('span');mark.className='dona-waves-choice-icon'; + b.append(mark,document.createTextNode(label)); + b.addEventListener('click',()=>{ + selectMode(mode); + menu.hidden=true; + document.getElementById('dv92Hamburger')?.setAttribute('aria-expanded','false'); + }); + menu.append(b); + } + syncMenu(); + } + let started=false; + function init(){ + if(started)return true; + const root=document.getElementById('alfredMainV1'); + const wrap=root?.querySelector('.dona-organic-wrap'); + if(!wrap)return false; + started=true; + const canvas=document.createElement('canvas');canvas.id='donaWavesCanvas'; + canvas.setAttribute('role','img');canvas.setAttribute('aria-label','Vagues animées de Dona réagissant à la voix Piper'); + wrap.append(canvas); + const ctx=canvas.getContext('2d',{alpha:true});if(!ctx)return true; + const reduce=window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches||false; + let w=0,h=0,dpr=1,mode=String(root.dataset.familyMode||'idle'); + let phase=0,smoothed=.02,pcm=0,lastPCM=0,voiceFinished=false,then=0,raf=0; + const socket=window.alfredSocket; + socket?.on('alfred_state',data=>{ + const next=String(data?.mode||'idle').toLowerCase(); + if(next==='speaking'&&mode!=='speaking')voiceFinished=false; + mode=next; + }); + socket?.on('dona_piper_level',data=>{ + const value=Number(data?.level); + if(!Number.isFinite(value))return; + pcm=Math.max(0,Math.min(1,value)); + lastPCM=performance.now(); + }); + socket?.on('alfred_tts_finished',()=>{pcm=0;voiceFinished=true;}); + const ro=typeof ResizeObserver!=='undefined'?new ResizeObserver(resize):null; + ro?.observe(wrap);window.addEventListener('resize',resize,{passive:true}); + function resize(){ + const box=wrap.getBoundingClientRect(); + if(!box.width||!box.height)return; + const ratio=Math.min(window.devicePixelRatio||1,1.7); + const nw=Math.round(box.width*ratio),nh=Math.round(box.height*ratio); + if(nw===canvas.width&&nh===canvas.height)return; + w=box.width;h=box.height;dpr=ratio; + canvas.width=nw;canvas.height=nh;ctx.setTransform(ratio,0,0,ratio,0,0); + } + // DONA_SCULPTURE_V11 : membranes de lumière, 2D pur, animées par PCM Piper. + function drawSculpture(now){ + const cx=w*.5, cy=h*.51; + const R=Math.min(w*.23,h*.30); + const strength=Math.min(1,smoothed); + const phi=phase*.74; + ctx.save(); + // Lueur ambiante derrière les rubans, sans rectangle de fond. + let halo=ctx.createRadialGradient(cx,cy,R*.12,cx,cy,R*2.25); + halo.addColorStop(0,'rgba(255,188,186,.13)'); + halo.addColorStop(.39,'rgba(120,82,249,.12)'); + halo.addColorStop(1,'rgba(12,43,138,0)'); + ctx.fillStyle=halo;ctx.fillRect(0,0,w,h); + const shell=(layer,behind)=>{ + const n=104, z=layer/8; + const width=Math.min(w*.40,h*1.16)*(1-z*.34); + const rise=h*(.225+.07*Math.sin(layer*.87+phi*.25))*(1-z*.24); + const yShift=h*(.015*Math.sin(layer*1.8+phi*.18)); + const steps=[]; + for(let i=0;i<=n;i++){ + const t=i/n, x=cx+(t-.5)*width*2; + const env=Math.pow(Math.max(0,Math.sin(Math.PI*t)),.65); + const y=cy+yShift+Math.sin(t*Math.PI*(2.7+layer*.16)+phi*.5+layer*.63)*rise*env + +Math.cos(t*Math.PI*4.8-phi*.38+layer)*rise*.29*env; + steps.push({x,y,t,env}); + } + const thickness=h*(.21+.045*Math.sin(layer*1.24+phi*.1)); + const g=ctx.createLinearGradient(cx-width,cy,cx+width,cy); + g.addColorStop(0,'rgba(26,119,250,.60)'); + g.addColorStop(.33,'rgba(104,98,253,.58)'); + g.addColorStop(.59,'rgba(249,167,209,.70)'); + g.addColorStop(1,'rgba(252,192,123,.62)'); + ctx.beginPath();ctx.moveTo(steps[0].x,steps[0].y); + for(let i=1;i=0;i--){ + const p=steps[i]; + ctx.lineTo(p.x,p.y+thickness*p.env*(.8+strength*.44)); + } + ctx.closePath();ctx.globalAlpha=(behind?.19:.30)+strength*.09; + ctx.fillStyle=g;ctx.fill();ctx.globalAlpha=.55+strength*.25; + ctx.beginPath();ctx.moveTo(steps[0].x,steps[0].y); + for(let i=1;i=4;layer--)shell(layer,true); + // Noyau irisé : diaphragme vivant, se dilate sur les syllabes. + const pulse=1+strength*.20+.035*Math.sin(phi*1.05); + const r=R*pulse; + const core=ctx.createRadialGradient(cx-r*.19,cy-r*.26,r*.1,cx,cy,r*1.15); + core.addColorStop(0,'rgba(255,214,214,.90)'); + core.addColorStop(.26,'rgba(244,147,194,.78)'); + core.addColorStop(.53,'rgba(117,113,244,.82)'); + core.addColorStop(.82,'rgba(41,125,253,.72)'); + core.addColorStop(1,'rgba(37,135,245,.025)'); + ctx.fillStyle=core;ctx.beginPath(); + for(let i=0;i<=180;i++){ + const a=i/180*Math.PI*2; + const rad=r*(1+.12*Math.sin(4*a+phi*.5)+.085*Math.cos(3*a-phi*.3)); + const x=cx+Math.cos(a)*rad,y=cy+Math.sin(a)*rad*.82; + if(!i)ctx.moveTo(x,y);else ctx.lineTo(x,y); + } + ctx.closePath();ctx.shadowColor='#b696ff';ctx.shadowBlur=32+strength*25;ctx.fill();ctx.shadowBlur=0; + for(let layer=3;layer>=0;layer--)shell(layer,false); + // Poussière irisée légère dans le volume. + for(let i=0;i<70;i++){ + const t=(i*.61803398875)%1; + const x=cx+(t-.5)*w*.70; + const env=Math.sin(Math.PI*t); + const y=cy+Math.cos(i*1.71+phi*.15)*h*.27*env; + ctx.globalAlpha=(.08+strength*.21)*env; + ctx.fillStyle=i%3?'#accaff':'#ffccbd'; + ctx.beginPath();ctx.arc(x,y,.65+(i%4)*.22,0,Math.PI*2);ctx.fill(); + } + ctx.restore(); + } + + function draw(now){ + raf=requestAnimationFrame(draw); + if(choice==='orb'||document.hidden||now-then<30)return; + then=now; + resize();if(!w||!h)return; + const speaking=mode==='speaking'&&!voiceFinished; + const current=(now-lastPCM<240&&speaking)?pcm:0; + const base=mode==='listening'?.16:mode==='thinking'?.115:speaking?.07:.025; + const target=Math.max(base,current*.88); + smoothed+=(target-smoothed)*(target>smoothed?.35:.19); + if(!reduce)phase+=mode==='listening'?.054:mode==='thinking'?.034:speaking?.047:.018; + ctx.clearRect(0,0,w,h); + if(choice==='sculpture'){drawSculpture(now);return;} + const mid=h*.51, amplitude=h*(.245+smoothed*.35); + const palette=[ + ['#1362ff','#11c8fc','#6b56e9'], + ['#1e92ef','#9274ff','#e47dbd'], + ['#6c4bd9','#ef86b5','#ffd29b'], + ['#4a5ad8','#ffb3c2','#ffc282'], + ['#16b8ed','#8c66f5','#f596b5'], + ['#7854e5','#d891ed','#fdad8b'] + ]; + // Contours en membranes translucides, superposés pour former une onde lumineuse. + for(let layer=0;layer=0;i--){const [x,y]=points[i];ctx.lineTo(x,y+thickness);} + ctx.closePath();ctx.fillStyle=gradient;ctx.globalAlpha=.095+(layer%3)*.058+smoothed*.1;ctx.fill(); + ctx.beginPath();points.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y)); + ctx.lineWidth=(layer%2?1.5:1.9)+smoothed*1.35; + ctx.strokeStyle=gradient;ctx.globalAlpha=.60+smoothed*.30; + ctx.shadowBlur=15+smoothed*25;ctx.shadowColor=center;ctx.stroke(); + ctx.shadowBlur=0;ctx.globalAlpha=1; + } + // Lueur centrale très diffuse, avec énergie liée à l'amplitude Piper. + const glow=ctx.createRadialGradient(w*.5,mid,0,w*.5,mid,w*.34); + glow.addColorStop(0,`rgba(255,194,205,${.06+smoothed*.18})`); + glow.addColorStop(.45,`rgba(107,99,255,${.025+smoothed*.045})`); + glow.addColorStop(1,'rgba(56,138,244,0)'); + ctx.fillStyle=glow;ctx.fillRect(0,0,w,h); + // Petits points lumineux déterministes, très discrets au repos. + for(let i=0;i<22;i++){ + const nx=(i*0.61803398875)%1,ny=.5+.33*Math.sin(i*2.18+phase*.14); + const x=nx*w,y=ny*h; + ctx.globalAlpha=(.11+smoothed*.37)*Math.pow(Math.sin(nx*Math.PI),2); + ctx.fillStyle=i%3===0?'#ffb5cc':'#83ceff';ctx.beginPath();ctx.arc(x,y,.8+(i%3)*.35,0,Math.PI*2);ctx.fill(); + } + ctx.globalAlpha=1; + } + resize();raf=requestAnimationFrame(draw); + window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);ro?.disconnect();},{once:true}); + return true; + } + let attempts=0; + function ready(){ + if(!init()&&attempts++<50)setTimeout(ready,120); + addMenuControls(); + const menu=document.getElementById('dv92ToolsMenu'); + if(menu&&!menu.dataset.donaWavesObserved){ + menu.dataset.donaWavesObserved='1'; + new MutationObserver(()=>addMenuControls()).observe(menu,{childList:true}); + } + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',ready,{once:true});else ready(); +})(); diff --git a/alfred/app/templates/index.html b/alfred/app/templates/index.html index 112b220..b57194a 100644 --- a/alfred/app/templates/index.html +++ b/alfred/app/templates/index.html @@ -51,6 +51,10 @@ + + + +
@@ -254,5 +258,9 @@ + + + + diff --git a/alfred/backups/dona_avatar_2d_20261031_110849_457833/manifest.json b/alfred/backups/dona_avatar_2d_20261031_110849_457833/manifest.json new file mode 100644 index 0000000..4f29137 --- /dev/null +++ b/alfred/backups/dona_avatar_2d_20261031_110849_457833/manifest.json @@ -0,0 +1,20 @@ +{ + "kind": "dona-avatar-v1", + "app": "/opt/alfred/app", + "original_files": [ + "templates/index.html", + "static/js/dona-accueil-ecrit-v1.js" + ], + "created_files": [ + "static/css/dona-avatar-v1.css", + "static/js/dona-avatar-v1.js", + "static/img/dona-avatar-portrait.png" + ], + "installed_sha256": { + "templates/index.html": "eb039ad650db5f3a735baf702749a555c539c577020c77d9d3de89168dc3ade2", + "static/js/dona-accueil-ecrit-v1.js": "5f85b649d20270a1be55ca4dbbab30918e1d0ea792052d880613a5a76db7f834", + "static/css/dona-avatar-v1.css": "e79daa023147bcca69a3bba2f06927b79e3d1426fc24c57a9daff7314dc95d9c", + "static/js/dona-avatar-v1.js": "2399c2679693abddbc8a483bb5e6b14bc5119e0ef38222ea7d684b8fcbc31d57", + "static/img/dona-avatar-portrait.png": "ffaba09cd4528f48d93ab1aadf5e662d44872563ccf657961d08f681afee80b8" + } +} \ No newline at end of file diff --git a/alfred/backups/dona_avatar_2d_20261031_110849_457833/original/static/js/dona-accueil-ecrit-v1.js b/alfred/backups/dona_avatar_2d_20261031_110849_457833/original/static/js/dona-accueil-ecrit-v1.js new file mode 100644 index 0000000..ab076b1 --- /dev/null +++ b/alfred/backups/dona_avatar_2d_20261031_110849_457833/original/static/js/dona-accueil-ecrit-v1.js @@ -0,0 +1,104 @@ +/* DONA_FAMILY_UI_V1: non-destructive augmentation of the installed Glass V1 dashboard. */ +(() => { + 'use strict'; + let attempts=0; + function init(){ + const root=document.getElementById('alfredMainV1'); + if(!root){if(++attempts<40)setTimeout(init,150);return;} + if(root.dataset.donaFamily==='ready')return; + const $=id=>document.getElementById(id); + const head=root.querySelector('.av-header-right'); + const weather=root.querySelector('.av-weather'); + const hero=root.querySelector('.av-voice-hero'); + const shortcuts=root.querySelector('.av-shortcuts'); + if(!head||!weather||!hero||!shortcuts)return; + root.dataset.donaFamily='ready';document.body.classList.add('dona-family-v1'); + + // Move the EXISTING weather widget: old JS keeps updating the SAME IDs. + weather.classList.add('dona-inline-weather'); + weather.setAttribute('title','Ouvrir les détails météo'); + head.prepend(weather); + + // Child link is supplementary; all old admin/navigation functions remain. + const kid=document.createElement('a');kid.href='/enfant';kid.className='dona-child-link';kid.textContent='⭐ Espace enfant'; + kid.setAttribute('aria-label','Ouvrir le chat enfant, écrit uniquement'); + head.insertBefore(kid,head.querySelector('.av-head-actions')); + const toolsGrid=root.querySelector('#avMoreDialog .av-more-grid'); + if(toolsGrid&&!toolsGrid.querySelector('a[href="/enfant"]')){ + const a=document.createElement('a');a.href='/enfant';a.textContent='⭐ Espace enfant';toolsGrid.append(a); + } + + // New expressive centre; old controls stay in DOM so audio event listeners work. + const display=document.createElement('div');display.className='dona-art-center'; + display.innerHTML='
Prêt à vous aider'; + hero.prepend(display); + const oldVoice=root.querySelector('.av-voice-center'); + if(oldVoice){display.append(oldVoice);} + + // Move shortcuts into a collapsed tray, instead of removing their controls. + const tools=document.createElement('details');tools.className='dona-tools-tray'; + const summary=document.createElement('summary');summary.textContent='☷ Raccourcis'; + shortcuts.parentNode.insertBefore(tools,shortcuts);tools.append(summary,shortcuts); + + // Minimal permanent one-row text field: no expandable tray, send at right. + const compose=document.createElement('div');compose.className='dona-compose dona-compose-compact'; + compose.innerHTML=`
+ +
+ +
`; + root.append(compose); + const body=$('donaComposeBody'),input=$('donaComposeInput'),send=$('donaComposeSend'),status=$('donaComposeStatus'); + input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();body.requestSubmit();}}); + body.addEventListener('submit',async e=>{ + e.preventDefault();let message=input.value.trim();if(!message)return; + send.disabled=true;status.textContent='Envoi à Hermes…'; + try{ + const r=await fetch('/api/dona/texte',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({message})}); + let d={};try{d=await r.json();}catch(_){} + if(!r.ok)throw Error(d.error||'Erreur '+r.status); + input.value='';status.textContent='Hermes prépare sa réponse…'; + }catch(err){status.textContent=err.message;} + finally{send.disabled=false;} + }); + const modeText={idle:'Prêt à vous aider',waiting:'Prêt à vous aider',listening:'Je vous écoute…',thinking:'Je réfléchis…',speaking:'Je vous réponds…',error:'Un problème est survenu'}; + function setMode(mode){root.dataset.familyMode=mode;const t=$('donaArtSubtitle');if(t)t.textContent=modeText[mode]||'Prêt à vous aider';if(mode==='idle'&&status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';} + function setName(value){const name=String(value||$('avName')?.textContent||'Dona').trim();$('donaArtName').textContent=name;} + setName();setMode(root.dataset.mode||'idle'); + const socket=window.alfredSocket; + socket?.on('alfred_state',s=>{setMode(String(s.mode||'idle').toLowerCase());setName(s.assistant_name||s.title||$('avName')?.textContent);}); + socket?.on('alfred_render',()=>{if(status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';}); + fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s){setMode(s.mode||'idle');setName(s.assistant_name||s.title);}}).catch(()=>{}); + + // Animated multi-layer liquid orb using canvas, independent of server state. + const canvas=$('donaOrganicCanvas'),ctx=canvas.getContext('2d'); + if(!ctx)return; + let w=320,h=320,phase=0,raf=0,last=0; + const reduce=matchMedia('(prefers-reduced-motion: reduce)').matches; + function resize(){const rect=canvas.getBoundingClientRect();const dpr=Math.min(devicePixelRatio||1,2);w=Math.max(150,rect.width);h=Math.max(150,rect.height);canvas.width=Math.floor(w*dpr);canvas.height=Math.floor(h*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);} + window.addEventListener('resize',resize,{passive:true});resize(); + function path(radius,layer){const n=130;ctx.beginPath();for(let k=0;k<=n;k++){ + const a=k/n*Math.PI*2;const modulation=1+0.067*Math.sin(a*3+phase*.72+layer*.64)+0.044*Math.cos(a*5-phase*.39+layer)+0.021*Math.sin(a*8+phase*.55); + const r=radius*modulation;const x=Math.cos(a)*r,y=Math.sin(a)*r; + if(k===0)ctx.moveTo(x,y);else ctx.lineTo(x,y); + }ctx.closePath();} + function frame(now){raf=requestAnimationFrame(frame);if(document.hidden||now-last<30)return;last=now; + const mode=root.dataset.familyMode||'idle';const speed=mode==='listening'?1.7:mode==='thinking'?1.38:mode==='speaking'?1.5:.75; + if(!reduce)phase+=.024*speed; + ctx.clearRect(0,0,w,h);ctx.save();ctx.translate(w/2,h/2);const r=Math.min(w,h)*.33; + const bloom=ctx.createRadialGradient(-r*.2,-r*.15,r*.1,0,0,r*1.45); + bloom.addColorStop(0,'rgba(238,117,160,.11)');bloom.addColorStop(.7,'rgba(139,112,246,.14)');bloom.addColorStop(1,'rgba(37,180,255,0)');ctx.fillStyle=bloom;ctx.beginPath();ctx.arc(0,0,r*1.48,0,Math.PI*2);ctx.fill(); + for(let layer=3;layer>=0;layer--){const waveR=r*(1+layer*.025);path(waveR,layer); + const g=ctx.createLinearGradient(-r*.9,r*.8,r*.9,-r*.85); + if(layer%2===0){g.addColorStop(0,'rgba(47,196,244,.76)');g.addColorStop(.36,'rgba(68,94,198,.88)');g.addColorStop(.62,'rgba(244,138,173,.74)');g.addColorStop(1,'rgba(255,176,71,.9)');} + else {g.addColorStop(0,'rgba(59,164,255,.65)');g.addColorStop(.4,'rgba(196,119,245,.48)');g.addColorStop(.7,'rgba(241,125,146,.52)');g.addColorStop(1,'rgba(255,185,95,.76)');} + ctx.globalAlpha=layer===0?.76:.25;ctx.fillStyle=g;ctx.fill(); + ctx.globalAlpha=layer===0?.92:.31;ctx.strokeStyle=g;ctx.lineWidth=layer===0?2.5:1.8;ctx.shadowBlur=layer===0?23:13;ctx.shadowColor=layer%2?'#bd80ec':'#ffc685';ctx.stroke();ctx.shadowBlur=0; + } + const ribbon=ctx.createLinearGradient(-r,r*.4,r,-r*.3);ribbon.addColorStop(0,'rgba(67,219,255,.0)');ribbon.addColorStop(.48,'rgba(255,231,197,.56)');ribbon.addColorStop(1,'rgba(255,181,103,.01)'); + ctx.beginPath();for(let i=0;i<=100;i++){const x=-r+i/100*r*2;const y=Math.sin(i/100*Math.PI*2+phase*.35)*r*.25+Math.cos(i/100*Math.PI*3-phase*.45)*r*.11;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}ctx.lineWidth=r*.26;ctx.strokeStyle=ribbon;ctx.globalAlpha=.38;ctx.stroke();ctx.restore(); + } + raf=requestAnimationFrame(frame);window.addEventListener('pagehide',()=>cancelAnimationFrame(raf),{once:true}); + } + init(); +})(); diff --git a/alfred/backups/dona_avatar_2d_20261031_110849_457833/original/templates/index.html b/alfred/backups/dona_avatar_2d_20261031_110849_457833/original/templates/index.html new file mode 100644 index 0000000..112b220 --- /dev/null +++ b/alfred/backups/dona_avatar_2d_20261031_110849_457833/original/templates/index.html @@ -0,0 +1,258 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/manifest.json b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/manifest.json new file mode 100644 index 0000000..3ef807e --- /dev/null +++ b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/manifest.json @@ -0,0 +1,23 @@ +{ + "kind": "dona-clock-sculpture-v1.1", + "app": "/opt/alfred/app", + "original": [ + "app.py", + "static/js/dona-waves-v1.js", + "static/css/dona-waves-v1.css" + ], + "created": [ + "services/dona_clock_local.py" + ], + "installed_sha256": { + "app.py": "246913f10e751bbd35f71dbcfa553f8284cdd1c050d3eadf1d2f276ce33ef184", + "static/js/dona-waves-v1.js": "3a87d35280436920024f550feb9040450ff057e9c3e6b75e5d7f75f4cf194ffc", + "static/css/dona-waves-v1.css": "47df09de7549762feda8d95cfc3bd9763bd7f01c3bfb10a07844f000867b2a53", + "services/dona_clock_local.py": "705a67d28387a57bff92ab44bf0ac9195422bb47c1d311cecf13bad6050a0ee8" + }, + "original_sha256": { + "app.py": "472959331cfece1d75100f32d77c57b4b1838fafff45f0d49e13fd17837d942a", + "static/js/dona-waves-v1.js": "8c33a71da919da54533ecdffc1a4368c9ea16daff893a082a14f05170ac9b3c5", + "static/css/dona-waves-v1.css": "9c9586493b6b6ec506928f6ebac79f28053b9df36b680ce1c0835aa75bf3f1cb" + } +} \ No newline at end of file diff --git a/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/app.py b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/app.py new file mode 100644 index 0000000..1b78773 --- /dev/null +++ b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/app.py @@ -0,0 +1,887 @@ + +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 + +# Charger la configuration AVANT les imports des services +load_dotenv("/opt/alfred/.env") +load_dotenv( + "/opt/alfred/app/config/control_center.env", + override=True +) + +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener + + + + + + + +from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1 +from services.piper_tts import stop_playback as duplex_stop_piper +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 +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="*") +# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur. +from services import piper_tts as _dona_waves_piper +_dona_waves_piper.dona_set_level_callback( + lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)}) +) +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() +# 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}) +# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes +duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted, + on_interrupt=duplex_stop_piper, + on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event)) + +# 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, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2 + if not conversation_lock.acquire(blocking=False): + return + pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2 + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2 + from services.audio import STTResult + r = STTResult(text=injected_text) + else: + 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 + + # DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée. + _dona_v93_original_vosk = r.text + _dona_v93_ack = _dona_learning_v93.voice_feedback(r.text) + if _dona_v93_ack is not None: + socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack}) + if not audio_settings.speaker_muted(): + _dona_v93_done = threading.Event() + if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set): + _dona_v93_done.wait(timeout=120) + emit_state('idle',partial='') + return + r.text = _dona_learning_v93.correct_transcript(r.text) + if r.text != _dona_v93_original_vosk: + socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text}) + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande. + if _dona_vision_v95.is_camera_question(r.text): + try: + _vision_answer = _dona_vision_monitor.analyze(r.text) + except RuntimeError as _vision_exc: + _vision_answer = str(_vision_exc) + except Exception as _vision_exc: + print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True) + _vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.' + with STATE_LOCK: + STATE['content']={'type':'text','text':_vision_answer} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer)) + if _vision_answer and not audio_settings.speaker_muted(): + emit_state('speaking') + _vision_done=threading.Event() + if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_FILES_V91 : questions explicites sur la pièce jointe active. + if _dona_files_v91.is_file_question(r.text): + _file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech)) + if _file_speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _file_done = threading.Event() + if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120) + emit_state('idle', partial='') + return + + # DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes. + if _dona_suite_v9.is_request(r.text): + _v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech)) + if _v9_speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _v9_done=threading.Event() + _v9_ok=speak_async(_v9_speech,on_end=_v9_done.set) + if _v9_ok:_v9_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM. + if _dona_nav.is_request(r.text): + _nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply)) + if _nav_reply and not audio_settings.speaker_muted(): + emit_state('speaking') + _nav_done=threading.Event() + _nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'}))) + if _nav_ok:_nav_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix. + if _dona_youtube.is_search(r.text): + _speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech)) + if _speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _evt=threading.Event() + _ok=speak_async(_speech,on_end=_evt.set) + if _ok: _evt.wait(timeout=120) + emit_state('idle',partial='') + return + if _dona_youtube.is_control(r.text,_dona_youtube.player_active()): + _dona_youtube.control_and_emit(r.text,socketio) + emit_state('idle',partial='') + return + + # DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe. + if _dona_dynamic.is_dynamic_request(r.text): + _board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord'))) + _payload = {'type':'dona_dynamic', 'dashboard':_board} + with STATE_LOCK: + STATE['content'] = _payload + socketio.emit('alfred_render', _payload) + if not audio_settings.speaker_muted(): + emit_state('speaking') + _finished = threading.Event() + _started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set) + if _started: _finished.wait(timeout=120) + emit_state('idle', partial='') + return + + # 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 + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech)) + 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=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + # ALFRED_LISTES_GLASS_V1_VOICE_START + # Commandes simples garanties locales ; jamais de confirmation inventée par Hermes. + try: + _lists_reply = _alfred_handle_list_voice( + r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'})) + except Exception as _lists_error: + print('[ALFRED LISTES] Erreur :', _lists_error, flush=True) + _lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.' + if _lists_reply is not None: + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply)) + _lists_display = {'type':'text', 'text':_lists_reply} + with STATE_LOCK: + STATE['content'] = _lists_display + socketio.emit('alfred_render', _lists_display) + if _lists_reply and not audio_settings.speaker_muted(): + emit_state('speaking') + _lists_done = threading.Event() + speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + _lists_done.wait(timeout=120) + emit_state('idle', partial='') + return + # ALFRED_LISTES_GLASS_V1_VOICE_END + + 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 + # DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal. + answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(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} + + # DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎. + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('voice',_dona_v93_original_vosk,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) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1 + try: + started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + if started: + done.wait(timeout=120) + finally: + if not duplex_monitor.interrupted(): + duplex_monitor.stop() + # ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up. + if duplex_monitor.interrupted(): + pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30) + duplex_monitor.stop() + else: + # A few wake-free seconds after the assistant finishes speaking. + pending_duplex_text = duplex_monitor.listen_conversation() + if pending_duplex_text: + print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True) + + 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 pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2 + # Only the voice is cancelled; the original Hermes job is untouched. + wake.pause() + threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text}, + daemon=True, name='dona-duplex-followup').start() + elif 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/duplex/status') +def duplex_status(): + return jsonify(duplex_monitor.status()) + +@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//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)}) + + +# ALFRED_MAIN_VISUAL_V1_BACKEND_START +# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper. +def _alfred_dashboard_quick_command(prompt): + if not conversation_lock.acquire(blocking=False): + return False + + def _run(): + try: + wake.pause() + with STATE_LOCK: + _image_revision_before = IMAGE_REVISION + emit_state('thinking', transcript=prompt, partial='', + content={'type':'text', 'text':prompt}) + _answer = ask_hermes(prompt) + 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} + # Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee. + with STATE_LOCK: + if IMAGE_REVISION == _image_revision_before: + STATE['content'] = _display + socketio.emit('alfred_render', _display) + if _speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _done = threading.Event() + speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + _done.wait(timeout=120) + emit_state('idle', partial='') + except Exception as _exc: + print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True) + emit_state('error', content={'type':'text', 'text':str(_exc)}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + + threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start() + return True + +from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1 +_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command) +# ALFRED_MAIN_VISUAL_V1_BACKEND_END + + +# ALFRED_ADMIN_GLASS_MAISON_V1_START +from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass +_alfred_register_home_glass(app) +# ALFRED_ADMIN_GLASS_MAISON_V1_END + + +# ALFRED_LISTES_GLASS_V1_BACKEND_START +from services.alfred_lists import register_lists as _alfred_register_lists +from services.alfred_lists import handle_voice as _alfred_handle_list_voice +_alfred_register_lists(app, socketio) +# ALFRED_LISTES_GLASS_V1_BACKEND_END + + +# ALFRED_GLASS_V2_BACKEND_START +from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2 +_alfred_register_logs_v2(app) +# ALFRED_GLASS_V2_BACKEND_END + + +# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom. +from services.alfred_identity_page import register_identity_page as _alfred_identity_register +_alfred_identity_register(app) + + +# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur). +from services.alfred_developer_page import register_developer_page as _alfred_developer_register +_alfred_developer_register(app) + + +# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin. +from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session +_register_hermes_new_session(app) + + +# DONA_FAMILY_UI_V1_START +# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS. +def _dona_submit_typed(prompt): + if not conversation_lock.acquire(blocking=False): + return False + def _dona_run_typed(): + try: + wake.pause() + with STATE_LOCK: + _before_image = IMAGE_REVISION + emit_state('thinking', partial='', transcript=prompt, + content={'type': 'text', 'text': prompt}) + # DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle. + if _dona_vision_v95.is_camera_question(prompt): + try: + _vision_answer=_dona_vision_monitor.analyze(prompt) + except Exception as _vision_exc: + _vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.' + with STATE_LOCK: + STATE['content']={'type':'text','text':_vision_answer} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer)) + emit_state('idle',partial='') + return + + # DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe. + if _dona_files_v91.is_file_question(prompt): + _dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK) + emit_state('idle',partial='') + return + + # DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes. + if _dona_suite_v9.is_request(prompt): + _v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + + # DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes. + if _dona_nav.is_request(prompt): + _v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + + # DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes. + if _dona_youtube.is_search(prompt): + _v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + if _dona_youtube.is_control(prompt,_dona_youtube.player_active()): + _dona_youtube.control_and_emit(prompt,socketio) + emit_state('idle',partial='') + return + + # DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel. + if _dona_dynamic.is_dynamic_request(prompt): + _board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord'))) + _payload = {'type':'dona_dynamic','dashboard':_board} + with STATE_LOCK: + STATE['content'] = _payload + socketio.emit('alfred_render',_payload) + emit_state('idle', partial='') + return + _answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt)) + 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} + with STATE_LOCK: + if IMAGE_REVISION == _before_image: + STATE['content'] = _display + socketio.emit('alfred_render', _display) + # DONA_LEARNING_V93 : évaluation de l’échange écrit. + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('typed',prompt,_speech)) + # Deliberately no speak_async(): typed interaction is silent. + emit_state('idle', partial='') + except Exception as exc: + print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True) + import traceback + traceback.print_exc() + emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start() + return True + +from services.dona_family_ui import register_dona_family as _dona_register_family +_dona_register_family(app, adult_command=_dona_submit_typed) +# DONA_FAMILY_UI_V1_END + +# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés. +from services import dona_dynamic as _dona_dynamic +# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales +from services import dona_dashboard_agent as _dona_dashboard_agent + +def _dona_dynamic_submit(prompt): + if not conversation_lock.acquire(blocking=False): + return False + def _work(): + try: + wake.pause() + emit_state('thinking',transcript=prompt,partial='') + _board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard) + _payload={'type':'dona_dynamic','dashboard':_board} + with STATE_LOCK: + STATE['content']=_payload + socketio.emit('alfred_render',_payload) + emit_state('idle',partial='') + except Exception as exc: + print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True) + emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start() + return True + +_dona_dynamic.register_dynamic(app,_dona_dynamic_submit) + +# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in. +from services import dona_vision_v95 as _dona_vision_v95 +_dona_vision_monitor = _dona_vision_v95.register(app, socketio) + +# DONA_LEARNING_V93 : retours explicites et corrections approuvées. +from services import dona_learning_v93 as _dona_learning_v93 +_dona_learning_v93.register(app) + +# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur. +from services import dona_shortcuts_v92 as _dona_shortcuts_v92 +_dona_shortcuts_v92.register(app) + +# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée. +from services import dona_files_v91 as _dona_files_v91 +_dona_files_v91.register_files(app) + +# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines. +from services import dona_suite_v9 as _dona_suite_v9 +_dona_suite_v9.register_suite(app,socketio) + +# DONA_NAV_V83: géocodage, carte et transfert Waze. +from services import dona_navigation as _dona_nav +_dona_nav.register_navigation(app,socketio) + +# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé. +from services import dona_youtube as _dona_youtube +_dona_youtube.register_youtube(app,socketio) + +# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling). +from services.dona_spotify import register_spotify_panel as _register_dona_spotify +_register_dona_spotify(app) + +# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée. +from services.dona_face import (register_face as _dona_register_face, + recognize_once as _dona_face_recognize, is_face_request as _dona_face_request) +_dona_register_face(app, socketio, ask_hermes, conversation_lock) + + +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) + diff --git a/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/static/css/dona-waves-v1.css b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/static/css/dona-waves-v1.css new file mode 100644 index 0000000..3fa89b4 --- /dev/null +++ b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/static/css/dona-waves-v1.css @@ -0,0 +1,22 @@ +/* Dona — vagues vocales, sélection réversible sans désinstaller. */ +body.dona-family-v1.dona-waves-selected .dona-organic-wrap { + width:min(93vw,1400px)!important; + height:clamp(155px,28vh,330px)!important; + min-width:0!important;min-height:155px!important; + max-width:100%!important;filter:none!important; + overflow:visible!important; +} +body.dona-family-v1.dona-waves-selected #donaOrganicCanvas, +body.dona-family-v1.dona-waves-selected .dona-orb-reflection {display:none!important} +body.dona-family-v1 .dona-organic-wrap #donaWavesCanvas { + display:none;position:absolute;inset:0;width:100%;height:100%;pointer-events:none; +} +body.dona-family-v1.dona-waves-selected .dona-organic-wrap #donaWavesCanvas {display:block} +body.dona-family-v1.dona-waves-selected .dona-art-name {margin-top:-5px!important} +.dona-waves-menu-title{color:#c7b8cc;font-size:11px;letter-spacing:.07em;padding:10px 10px 5px;text-transform:uppercase} +.dv92-menu-entry.dona-waves-option[aria-checked="true"] {background:rgba(94,148,208,.19);color:#ffe1b8} +.dona-waves-option .dona-waves-choice-icon{width:22px;min-width:22px;text-align:center;font-size:18px} +@media(max-width:650px){ + body.dona-family-v1.dona-waves-selected .dona-organic-wrap{height:clamp(145px,23vh,240px)!important;min-height:145px!important;width:min(96vw,740px)!important} + body.dona-family-v1.dona-waves-selected .dona-art-name{font-size:clamp(32px,8vw,50px)} +} diff --git a/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/static/js/dona-waves-v1.js b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/static/js/dona-waves-v1.js new file mode 100644 index 0000000..bee400e --- /dev/null +++ b/alfred/backups/dona_clock_sculpture_v1_1_20261031_121433_905681/original/static/js/dona-waves-v1.js @@ -0,0 +1,165 @@ +/* Dona Vagues Vocales V1 — Canvas 2D, amplitude PCM Piper envoyée par Socket.IO. + Le mode orb conserve l'ancien canvas et son animation. + Aucune capture micro et aucun audio client ne sont nécessaires. */ +(() => { + 'use strict'; + if (window.DonaWaves) return; + const KEY='dona_visual_voice_v1'; + let choice='waves'; + try {if (localStorage.getItem(KEY)==='orb')choice='orb';}catch (_) {} + const API={isActive:()=>choice==='waves',select:selectMode,getChoice:()=>choice}; + window.DonaWaves=API; + document.body.classList.toggle('dona-waves-selected', choice==='waves'); + function selectMode(next){ + if(next!=='waves'&&next!=='orb')return; + choice=next; + document.body.classList.toggle('dona-waves-selected',choice==='waves'); + try{localStorage.setItem(KEY,choice);}catch(_){} + syncMenu(); + } + function syncMenu(){ + const menu=document.getElementById('dv92ToolsMenu');if(!menu)return; + for(const el of menu.querySelectorAll('[data-dona-visual]')){ + const active=el.dataset.donaVisual===choice; + el.setAttribute('aria-checked',String(active)); + el.querySelector('.dona-waves-choice-icon').textContent=active?'◉':'○'; + } + } + function addMenuControls(){ + const menu=document.getElementById('dv92ToolsMenu'); + if(!menu||menu.querySelector('#donaWavesMenuTitle'))return; + const title=document.createElement('div');title.id='donaWavesMenuTitle'; + title.className='dona-waves-menu-title';title.textContent='Animation centrale'; + menu.append(title); + for(const [mode,label] of [['waves','Vagues colorées (Piper)'],['orb','Orbe d’origine']]){ + const b=document.createElement('button');b.type='button'; + b.className='dv92-menu-entry dona-waves-option';b.dataset.donaVisual=mode; + b.setAttribute('role','menuitemradio'); + const mark=document.createElement('span');mark.className='dona-waves-choice-icon'; + b.append(mark,document.createTextNode(label)); + b.addEventListener('click',()=>{ + selectMode(mode); + menu.hidden=true; + document.getElementById('dv92Hamburger')?.setAttribute('aria-expanded','false'); + }); + menu.append(b); + } + syncMenu(); + } + let started=false; + function init(){ + if(started)return true; + const root=document.getElementById('alfredMainV1'); + const wrap=root?.querySelector('.dona-organic-wrap'); + if(!wrap)return false; + started=true; + const canvas=document.createElement('canvas');canvas.id='donaWavesCanvas'; + canvas.setAttribute('role','img');canvas.setAttribute('aria-label','Vagues animées de Dona réagissant à la voix Piper'); + wrap.append(canvas); + const ctx=canvas.getContext('2d',{alpha:true});if(!ctx)return true; + const reduce=window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches||false; + let w=0,h=0,dpr=1,mode=String(root.dataset.familyMode||'idle'); + let phase=0,smoothed=.02,pcm=0,lastPCM=0,voiceFinished=false,then=0,raf=0; + const socket=window.alfredSocket; + socket?.on('alfred_state',data=>{ + const next=String(data?.mode||'idle').toLowerCase(); + if(next==='speaking'&&mode!=='speaking')voiceFinished=false; + mode=next; + }); + socket?.on('dona_piper_level',data=>{ + const value=Number(data?.level); + if(!Number.isFinite(value))return; + pcm=Math.max(0,Math.min(1,value)); + lastPCM=performance.now(); + }); + socket?.on('alfred_tts_finished',()=>{pcm=0;voiceFinished=true;}); + const ro=typeof ResizeObserver!=='undefined'?new ResizeObserver(resize):null; + ro?.observe(wrap);window.addEventListener('resize',resize,{passive:true}); + function resize(){ + const box=wrap.getBoundingClientRect(); + if(!box.width||!box.height)return; + const ratio=Math.min(window.devicePixelRatio||1,1.7); + const nw=Math.round(box.width*ratio),nh=Math.round(box.height*ratio); + if(nw===canvas.width&&nh===canvas.height)return; + w=box.width;h=box.height;dpr=ratio; + canvas.width=nw;canvas.height=nh;ctx.setTransform(ratio,0,0,ratio,0,0); + } + function draw(now){ + raf=requestAnimationFrame(draw); + if(choice!=='waves'||document.hidden||now-then<30)return; + then=now; + resize();if(!w||!h)return; + const speaking=mode==='speaking'&&!voiceFinished; + const current=(now-lastPCM<240&&speaking)?pcm:0; + const base=mode==='listening'?.16:mode==='thinking'?.115:speaking?.07:.025; + const target=Math.max(base,current*.88); + smoothed+=(target-smoothed)*(target>smoothed?.35:.19); + if(!reduce)phase+=mode==='listening'?.054:mode==='thinking'?.034:speaking?.047:.018; + ctx.clearRect(0,0,w,h); + const mid=h*.51, amplitude=h*(.245+smoothed*.35); + const palette=[ + ['#1362ff','#11c8fc','#6b56e9'], + ['#1e92ef','#9274ff','#e47dbd'], + ['#6c4bd9','#ef86b5','#ffd29b'], + ['#4a5ad8','#ffb3c2','#ffc282'], + ['#16b8ed','#8c66f5','#f596b5'], + ['#7854e5','#d891ed','#fdad8b'] + ]; + // Contours en membranes translucides, superposés pour former une onde lumineuse. + for(let layer=0;layer=0;i--){const [x,y]=points[i];ctx.lineTo(x,y+thickness);} + ctx.closePath();ctx.fillStyle=gradient;ctx.globalAlpha=.095+(layer%3)*.058+smoothed*.1;ctx.fill(); + ctx.beginPath();points.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y)); + ctx.lineWidth=(layer%2?1.5:1.9)+smoothed*1.35; + ctx.strokeStyle=gradient;ctx.globalAlpha=.60+smoothed*.30; + ctx.shadowBlur=15+smoothed*25;ctx.shadowColor=center;ctx.stroke(); + ctx.shadowBlur=0;ctx.globalAlpha=1; + } + // Lueur centrale très diffuse, avec énergie liée à l'amplitude Piper. + const glow=ctx.createRadialGradient(w*.5,mid,0,w*.5,mid,w*.34); + glow.addColorStop(0,`rgba(255,194,205,${.06+smoothed*.18})`); + glow.addColorStop(.45,`rgba(107,99,255,${.025+smoothed*.045})`); + glow.addColorStop(1,'rgba(56,138,244,0)'); + ctx.fillStyle=glow;ctx.fillRect(0,0,w,h); + // Petits points lumineux déterministes, très discrets au repos. + for(let i=0;i<22;i++){ + const nx=(i*0.61803398875)%1,ny=.5+.33*Math.sin(i*2.18+phase*.14); + const x=nx*w,y=ny*h; + ctx.globalAlpha=(.11+smoothed*.37)*Math.pow(Math.sin(nx*Math.PI),2); + ctx.fillStyle=i%3===0?'#ffb5cc':'#83ceff';ctx.beginPath();ctx.arc(x,y,.8+(i%3)*.35,0,Math.PI*2);ctx.fill(); + } + ctx.globalAlpha=1; + } + resize();raf=requestAnimationFrame(draw); + window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);ro?.disconnect();},{once:true}); + return true; + } + let attempts=0; + function ready(){ + if(!init()&&attempts++<50)setTimeout(ready,120); + addMenuControls(); + const menu=document.getElementById('dv92ToolsMenu'); + if(menu&&!menu.dataset.donaWavesObserved){ + menu.dataset.donaWavesObserved='1'; + new MutationObserver(()=>addMenuControls()).observe(menu,{childList:true}); + } + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',ready,{once:true});else ready(); +})(); diff --git a/alfred/backups/dona_json_autorender_v1_20261101_141838_580140/app.py b/alfred/backups/dona_json_autorender_v1_20261101_141838_580140/app.py new file mode 100644 index 0000000..46561ac --- /dev/null +++ b/alfred/backups/dona_json_autorender_v1_20261101_141838_580140/app.py @@ -0,0 +1,917 @@ + +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 + +# Charger la configuration AVANT les imports des services +load_dotenv("/opt/alfred/.env") +load_dotenv( + "/opt/alfred/app/config/control_center.env", + override=True +) + +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener + + + + + + + +from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1 +from services.piper_tts import stop_playback as duplex_stop_piper +from services import admin_console # ALFRED_ADMIN_PAGES_V1 +from services.hermes_client import ask_hermes +from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11 +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 +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="*") +# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur. +from services import piper_tts as _dona_waves_piper +_dona_waves_piper.dona_set_level_callback( + lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)}) +) +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() +# 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}) +# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes +duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted, + on_interrupt=duplex_stop_piper, + on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event)) + +# 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, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2 + if not conversation_lock.acquire(blocking=False): + return + pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2 + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2 + from services.audio import STTResult + r = STTResult(text=injected_text) + else: + 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 + + # DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée. + _dona_v93_original_vosk = r.text + _dona_v93_ack = _dona_learning_v93.voice_feedback(r.text) + if _dona_v93_ack is not None: + socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack}) + if not audio_settings.speaker_muted(): + _dona_v93_done = threading.Event() + if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set): + _dona_v93_done.wait(timeout=120) + emit_state('idle',partial='') + return + r.text = _dona_learning_v93.correct_transcript(r.text) + if r.text != _dona_v93_original_vosk: + socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text}) + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes. + _dona_local_clock_reply = _dona_clock_local.answer(r.text) + if _dona_local_clock_reply is not None: + with STATE_LOCK: + STATE['content']={'type':'text','text':_dona_local_clock_reply} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply)) + if not audio_settings.speaker_muted(): + emit_state('speaking') + _dona_clock_done=threading.Event() + _dona_clock_started=speak_async(_dona_local_clock_reply, + on_end=lambda: (_dona_clock_done.set(), + socketio.emit('alfred_tts_finished', {'source':'voice'}))) + if _dona_clock_started:_dona_clock_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande. + if _dona_vision_v95.is_camera_question(r.text): + try: + _vision_answer = _dona_vision_monitor.analyze(r.text) + except RuntimeError as _vision_exc: + _vision_answer = str(_vision_exc) + except Exception as _vision_exc: + print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True) + _vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.' + with STATE_LOCK: + STATE['content']={'type':'text','text':_vision_answer} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer)) + if _vision_answer and not audio_settings.speaker_muted(): + emit_state('speaking') + _vision_done=threading.Event() + if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_FILES_V91 : questions explicites sur la pièce jointe active. + if _dona_files_v91.is_file_question(r.text): + _file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech)) + if _file_speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _file_done = threading.Event() + if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120) + emit_state('idle', partial='') + return + + # DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes. + if _dona_suite_v9.is_request(r.text): + _v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech)) + if _v9_speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _v9_done=threading.Event() + _v9_ok=speak_async(_v9_speech,on_end=_v9_done.set) + if _v9_ok:_v9_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM. + if _dona_nav.is_request(r.text): + _nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply)) + if _nav_reply and not audio_settings.speaker_muted(): + emit_state('speaking') + _nav_done=threading.Event() + _nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'}))) + if _nav_ok:_nav_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix. + if _dona_youtube.is_search(r.text): + _speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech)) + if _speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _evt=threading.Event() + _ok=speak_async(_speech,on_end=_evt.set) + if _ok: _evt.wait(timeout=120) + emit_state('idle',partial='') + return + if _dona_youtube.is_control(r.text,_dona_youtube.player_active()): + _dona_youtube.control_and_emit(r.text,socketio) + emit_state('idle',partial='') + return + + # DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe. + if _dona_dynamic.is_dynamic_request(r.text): + _board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord'))) + _payload = {'type':'dona_dynamic', 'dashboard':_board} + with STATE_LOCK: + STATE['content'] = _payload + socketio.emit('alfred_render', _payload) + if not audio_settings.speaker_muted(): + emit_state('speaking') + _finished = threading.Event() + _started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set) + if _started: _finished.wait(timeout=120) + emit_state('idle', partial='') + return + + # 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 + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech)) + 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=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + # ALFRED_LISTES_GLASS_V1_VOICE_START + # Commandes simples garanties locales ; jamais de confirmation inventée par Hermes. + try: + _lists_reply = _alfred_handle_list_voice( + r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'})) + except Exception as _lists_error: + print('[ALFRED LISTES] Erreur :', _lists_error, flush=True) + _lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.' + if _lists_reply is not None: + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply)) + _lists_display = {'type':'text', 'text':_lists_reply} + with STATE_LOCK: + STATE['content'] = _lists_display + socketio.emit('alfred_render', _lists_display) + if _lists_reply and not audio_settings.speaker_muted(): + emit_state('speaking') + _lists_done = threading.Event() + speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + _lists_done.wait(timeout=120) + emit_state('idle', partial='') + return + # ALFRED_LISTES_GLASS_V1_VOICE_END + + 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 + # DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal. + answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(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} + + # DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎. + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('voice',_dona_v93_original_vosk,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) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1 + try: + started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + if started: + done.wait(timeout=120) + finally: + if not duplex_monitor.interrupted(): + duplex_monitor.stop() + # ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up. + if duplex_monitor.interrupted(): + pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30) + duplex_monitor.stop() + else: + # A few wake-free seconds after the assistant finishes speaking. + pending_duplex_text = duplex_monitor.listen_conversation() + if pending_duplex_text: + print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True) + + 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 pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2 + # Only the voice is cancelled; the original Hermes job is untouched. + wake.pause() + threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text}, + daemon=True, name='dona-duplex-followup').start() + elif 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/duplex/status') +def duplex_status(): + return jsonify(duplex_monitor.status()) + +@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//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)}) + + +# ALFRED_MAIN_VISUAL_V1_BACKEND_START +# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper. +def _alfred_dashboard_quick_command(prompt): + if not conversation_lock.acquire(blocking=False): + return False + + def _run(): + try: + wake.pause() + with STATE_LOCK: + _image_revision_before = IMAGE_REVISION + emit_state('thinking', transcript=prompt, partial='', + content={'type':'text', 'text':prompt}) + _answer = ask_hermes(prompt) + 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} + # Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee. + with STATE_LOCK: + if IMAGE_REVISION == _image_revision_before: + STATE['content'] = _display + socketio.emit('alfred_render', _display) + if _speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _done = threading.Event() + speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + _done.wait(timeout=120) + emit_state('idle', partial='') + except Exception as _exc: + print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True) + emit_state('error', content={'type':'text', 'text':str(_exc)}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + + threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start() + return True + +from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1 +_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command) +# ALFRED_MAIN_VISUAL_V1_BACKEND_END + + +# ALFRED_ADMIN_GLASS_MAISON_V1_START +from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass +_alfred_register_home_glass(app) +# ALFRED_ADMIN_GLASS_MAISON_V1_END + + +# ALFRED_LISTES_GLASS_V1_BACKEND_START +from services.alfred_lists import register_lists as _alfred_register_lists +from services.alfred_lists import handle_voice as _alfred_handle_list_voice +_alfred_register_lists(app, socketio) +# ALFRED_LISTES_GLASS_V1_BACKEND_END + + +# ALFRED_GLASS_V2_BACKEND_START +from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2 +_alfred_register_logs_v2(app) +# ALFRED_GLASS_V2_BACKEND_END + + +# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom. +from services.alfred_identity_page import register_identity_page as _alfred_identity_register +_alfred_identity_register(app) + + +# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur). +from services.alfred_developer_page import register_developer_page as _alfred_developer_register +_alfred_developer_register(app) + + +# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin. +from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session +_register_hermes_new_session(app) + + +# DONA_FAMILY_UI_V1_START +# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS. +def _dona_submit_typed(prompt): + if not conversation_lock.acquire(blocking=False): + return False + def _dona_run_typed(): + try: + wake.pause() + with STATE_LOCK: + _before_image = IMAGE_REVISION + emit_state('thinking', partial='', transcript=prompt, + content={'type': 'text', 'text': prompt}) + # DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis. + _dona_local_clock_reply = _dona_clock_local.answer(prompt) + if _dona_local_clock_reply is not None: + with STATE_LOCK: + STATE['content']={'type':'text','text':_dona_local_clock_reply} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('typed',prompt,_dona_local_clock_reply)) + emit_state('idle',partial='') + return + + # DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle. + if _dona_vision_v95.is_camera_question(prompt): + try: + _vision_answer=_dona_vision_monitor.analyze(prompt) + except Exception as _vision_exc: + _vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.' + with STATE_LOCK: + STATE['content']={'type':'text','text':_vision_answer} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer)) + emit_state('idle',partial='') + return + + # DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe. + if _dona_files_v91.is_file_question(prompt): + _dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK) + emit_state('idle',partial='') + return + + # DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes. + if _dona_suite_v9.is_request(prompt): + _v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + + # DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes. + if _dona_nav.is_request(prompt): + _v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + + # DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes. + if _dona_youtube.is_search(prompt): + _v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + if _dona_youtube.is_control(prompt,_dona_youtube.player_active()): + _dona_youtube.control_and_emit(prompt,socketio) + emit_state('idle',partial='') + return + + # DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel. + if _dona_dynamic.is_dynamic_request(prompt): + _board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord'))) + _payload = {'type':'dona_dynamic','dashboard':_board} + with STATE_LOCK: + STATE['content'] = _payload + socketio.emit('alfred_render',_payload) + emit_state('idle', partial='') + return + _answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt)) + 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} + with STATE_LOCK: + if IMAGE_REVISION == _before_image: + STATE['content'] = _display + socketio.emit('alfred_render', _display) + # DONA_LEARNING_V93 : évaluation de l’échange écrit. + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('typed',prompt,_speech)) + # Deliberately no speak_async(): typed interaction is silent. + emit_state('idle', partial='') + except Exception as exc: + print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True) + import traceback + traceback.print_exc() + emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start() + return True + +from services.dona_family_ui import register_dona_family as _dona_register_family +_dona_register_family(app, adult_command=_dona_submit_typed) +# DONA_FAMILY_UI_V1_END + +# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés. +from services import dona_dynamic as _dona_dynamic +# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales +from services import dona_dashboard_agent as _dona_dashboard_agent + +def _dona_dynamic_submit(prompt): + if not conversation_lock.acquire(blocking=False): + return False + def _work(): + try: + wake.pause() + emit_state('thinking',transcript=prompt,partial='') + _board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard) + _payload={'type':'dona_dynamic','dashboard':_board} + with STATE_LOCK: + STATE['content']=_payload + socketio.emit('alfred_render',_payload) + emit_state('idle',partial='') + except Exception as exc: + print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True) + emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start() + return True + +_dona_dynamic.register_dynamic(app,_dona_dynamic_submit) + +# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in. +from services import dona_vision_v95 as _dona_vision_v95 +_dona_vision_monitor = _dona_vision_v95.register(app, socketio) + +# DONA_LEARNING_V93 : retours explicites et corrections approuvées. +from services import dona_learning_v93 as _dona_learning_v93 +_dona_learning_v93.register(app) + +# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur. +from services import dona_shortcuts_v92 as _dona_shortcuts_v92 +_dona_shortcuts_v92.register(app) + +# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée. +from services import dona_files_v91 as _dona_files_v91 +_dona_files_v91.register_files(app) + +# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines. +from services import dona_suite_v9 as _dona_suite_v9 +_dona_suite_v9.register_suite(app,socketio) + +# DONA_NAV_V83: géocodage, carte et transfert Waze. +from services import dona_navigation as _dona_nav +_dona_nav.register_navigation(app,socketio) + +# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé. +from services import dona_youtube as _dona_youtube +_dona_youtube.register_youtube(app,socketio) + +# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling). +from services.dona_spotify import register_spotify_panel as _register_dona_spotify +_register_dona_spotify(app) + +# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée. +from services.dona_face import (register_face as _dona_register_face, + recognize_once as _dona_face_recognize, is_face_request as _dona_face_request) +_dona_register_face(app, socketio, ask_hermes, conversation_lock) + + +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) + diff --git a/alfred/backups/dona_json_autorender_v1_20261101_141838_580140/manifest.json b/alfred/backups/dona_json_autorender_v1_20261101_141838_580140/manifest.json new file mode 100644 index 0000000..3fa2374 --- /dev/null +++ b/alfred/backups/dona_json_autorender_v1_20261101_141838_580140/manifest.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "appdir": "/opt/alfred/app", + "files": { + "app.py": { + "old": "246913f10e751bbd35f71dbcfa553f8284cdd1c050d3eadf1d2f276ce33ef184", + "new": "367380be744b53e3cb478b359726e18964971dac16540f88b922e90f932eafa7" + }, + "services/dona_json_autorender_v1.py": { + "old": null, + "new": "3cb1e5147af521a3a133c3568b33aa5eb6a0e5d38eee24a89a852eb575fbbf18" + } + } +} \ No newline at end of file diff --git a/alfred/backups/dona_reply_floating_v1_20261101_132228_531763/manifest.json b/alfred/backups/dona_reply_floating_v1_20261101_132228_531763/manifest.json new file mode 100644 index 0000000..61d5f5f --- /dev/null +++ b/alfred/backups/dona_reply_floating_v1_20261101_132228_531763/manifest.json @@ -0,0 +1,19 @@ +{ + "kind": "dona-reply-floating-v1", + "app": "/opt/alfred/app", + "modified": [ + "templates/index.html" + ], + "created": [ + "static/css/dona-reply-floating-v1.css", + "static/js/dona-reply-floating-v1.js" + ], + "original_sha256": { + "templates/index.html": "5033a7cdccf4ca47307afea1f70ca648d9c91d3e310969b384b23d9648f0a854" + }, + "installed_sha256": { + "templates/index.html": "8f6a1a7d9fa934d1fefa646976b6cf2b27e0ecc9e1dc0d2adf5bbf9ab3e1c390", + "static/css/dona-reply-floating-v1.css": "c3411ba829e1217885a9f32e6122099c12e921119e65ad6586339b84a3f8ee40", + "static/js/dona-reply-floating-v1.js": "d0feb5ca2152ba3b7291a2c26e81ec1541aca37051d10e2d883c9db0d7a237b3" + } +} \ No newline at end of file diff --git a/alfred/backups/dona_reply_floating_v1_20261101_132228_531763/original/templates/index.html b/alfred/backups/dona_reply_floating_v1_20261101_132228_531763/original/templates/index.html new file mode 100644 index 0000000..5342a49 --- /dev/null +++ b/alfred/backups/dona_reply_floating_v1_20261101_132228_531763/original/templates/index.html @@ -0,0 +1,262 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/manifest.json b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/manifest.json new file mode 100644 index 0000000..95727db --- /dev/null +++ b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/manifest.json @@ -0,0 +1,22 @@ +{ + "kind": "dona-waves-voice-v1", + "app": "/opt/alfred/app", + "original_files": [ + "templates/index.html", + "static/js/dona-accueil-ecrit-v1.js", + "services/piper_tts.py", + "app.py" + ], + "created_files": [ + "static/css/dona-waves-v1.css", + "static/js/dona-waves-v1.js" + ], + "installed_sha256": { + "templates/index.html": "5033a7cdccf4ca47307afea1f70ca648d9c91d3e310969b384b23d9648f0a854", + "static/js/dona-accueil-ecrit-v1.js": "aba0a743b74d8fd5f2e5e91b129db69885a350a40c095f90d15ef28c0bc20c8e", + "services/piper_tts.py": "a80aecfa38ceeeb1779da502801878ab435b6b0721ec2ffc26b020871115dd71", + "app.py": "472959331cfece1d75100f32d77c57b4b1838fafff45f0d49e13fd17837d942a", + "static/css/dona-waves-v1.css": "9c9586493b6b6ec506928f6ebac79f28053b9df36b680ce1c0835aa75bf3f1cb", + "static/js/dona-waves-v1.js": "8c33a71da919da54533ecdffc1a4368c9ea16daff893a082a14f05170ac9b3c5" + } +} \ No newline at end of file diff --git a/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/app.py b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/app.py new file mode 100644 index 0000000..ed91612 --- /dev/null +++ b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/app.py @@ -0,0 +1,882 @@ + +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 + +# Charger la configuration AVANT les imports des services +load_dotenv("/opt/alfred/.env") +load_dotenv( + "/opt/alfred/app/config/control_center.env", + override=True +) + +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener + + + + + + + +from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1 +from services.piper_tts import stop_playback as duplex_stop_piper +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 +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() +# 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}) +# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes +duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted, + on_interrupt=duplex_stop_piper, + on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event)) + +# 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, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2 + if not conversation_lock.acquire(blocking=False): + return + pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2 + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2 + from services.audio import STTResult + r = STTResult(text=injected_text) + else: + 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 + + # DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée. + _dona_v93_original_vosk = r.text + _dona_v93_ack = _dona_learning_v93.voice_feedback(r.text) + if _dona_v93_ack is not None: + socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack}) + if not audio_settings.speaker_muted(): + _dona_v93_done = threading.Event() + if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set): + _dona_v93_done.wait(timeout=120) + emit_state('idle',partial='') + return + r.text = _dona_learning_v93.correct_transcript(r.text) + if r.text != _dona_v93_original_vosk: + socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text}) + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande. + if _dona_vision_v95.is_camera_question(r.text): + try: + _vision_answer = _dona_vision_monitor.analyze(r.text) + except RuntimeError as _vision_exc: + _vision_answer = str(_vision_exc) + except Exception as _vision_exc: + print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True) + _vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.' + with STATE_LOCK: + STATE['content']={'type':'text','text':_vision_answer} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer)) + if _vision_answer and not audio_settings.speaker_muted(): + emit_state('speaking') + _vision_done=threading.Event() + if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_FILES_V91 : questions explicites sur la pièce jointe active. + if _dona_files_v91.is_file_question(r.text): + _file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech)) + if _file_speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _file_done = threading.Event() + if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120) + emit_state('idle', partial='') + return + + # DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes. + if _dona_suite_v9.is_request(r.text): + _v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech)) + if _v9_speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _v9_done=threading.Event() + _v9_ok=speak_async(_v9_speech,on_end=_v9_done.set) + if _v9_ok:_v9_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM. + if _dona_nav.is_request(r.text): + _nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply)) + if _nav_reply and not audio_settings.speaker_muted(): + emit_state('speaking') + _nav_done=threading.Event() + _nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'}))) + if _nav_ok:_nav_done.wait(timeout=120) + emit_state('idle',partial='') + return + + # DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix. + if _dona_youtube.is_search(r.text): + _speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK) + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech)) + if _speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _evt=threading.Event() + _ok=speak_async(_speech,on_end=_evt.set) + if _ok: _evt.wait(timeout=120) + emit_state('idle',partial='') + return + if _dona_youtube.is_control(r.text,_dona_youtube.player_active()): + _dona_youtube.control_and_emit(r.text,socketio) + emit_state('idle',partial='') + return + + # DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe. + if _dona_dynamic.is_dynamic_request(r.text): + _board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord'))) + _payload = {'type':'dona_dynamic', 'dashboard':_board} + with STATE_LOCK: + STATE['content'] = _payload + socketio.emit('alfred_render', _payload) + if not audio_settings.speaker_muted(): + emit_state('speaking') + _finished = threading.Event() + _started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set) + if _started: _finished.wait(timeout=120) + emit_state('idle', partial='') + return + + # 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 + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech)) + 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=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + # ALFRED_LISTES_GLASS_V1_VOICE_START + # Commandes simples garanties locales ; jamais de confirmation inventée par Hermes. + try: + _lists_reply = _alfred_handle_list_voice( + r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'})) + except Exception as _lists_error: + print('[ALFRED LISTES] Erreur :', _lists_error, flush=True) + _lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.' + if _lists_reply is not None: + socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply)) + _lists_display = {'type':'text', 'text':_lists_reply} + with STATE_LOCK: + STATE['content'] = _lists_display + socketio.emit('alfred_render', _lists_display) + if _lists_reply and not audio_settings.speaker_muted(): + emit_state('speaking') + _lists_done = threading.Event() + speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + _lists_done.wait(timeout=120) + emit_state('idle', partial='') + return + # ALFRED_LISTES_GLASS_V1_VOICE_END + + 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 + # DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal. + answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(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} + + # DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎. + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('voice',_dona_v93_original_vosk,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) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1 + try: + started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + if started: + done.wait(timeout=120) + finally: + if not duplex_monitor.interrupted(): + duplex_monitor.stop() + # ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up. + if duplex_monitor.interrupted(): + pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30) + duplex_monitor.stop() + else: + # A few wake-free seconds after the assistant finishes speaking. + pending_duplex_text = duplex_monitor.listen_conversation() + if pending_duplex_text: + print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True) + + 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 pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2 + # Only the voice is cancelled; the original Hermes job is untouched. + wake.pause() + threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text}, + daemon=True, name='dona-duplex-followup').start() + elif 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/duplex/status') +def duplex_status(): + return jsonify(duplex_monitor.status()) + +@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//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)}) + + +# ALFRED_MAIN_VISUAL_V1_BACKEND_START +# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper. +def _alfred_dashboard_quick_command(prompt): + if not conversation_lock.acquire(blocking=False): + return False + + def _run(): + try: + wake.pause() + with STATE_LOCK: + _image_revision_before = IMAGE_REVISION + emit_state('thinking', transcript=prompt, partial='', + content={'type':'text', 'text':prompt}) + _answer = ask_hermes(prompt) + 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} + # Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee. + with STATE_LOCK: + if IMAGE_REVISION == _image_revision_before: + STATE['content'] = _display + socketio.emit('alfred_render', _display) + if _speech and not audio_settings.speaker_muted(): + emit_state('speaking') + _done = threading.Event() + speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'}))) + _done.wait(timeout=120) + emit_state('idle', partial='') + except Exception as _exc: + print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True) + emit_state('error', content={'type':'text', 'text':str(_exc)}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + + threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start() + return True + +from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1 +_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command) +# ALFRED_MAIN_VISUAL_V1_BACKEND_END + + +# ALFRED_ADMIN_GLASS_MAISON_V1_START +from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass +_alfred_register_home_glass(app) +# ALFRED_ADMIN_GLASS_MAISON_V1_END + + +# ALFRED_LISTES_GLASS_V1_BACKEND_START +from services.alfred_lists import register_lists as _alfred_register_lists +from services.alfred_lists import handle_voice as _alfred_handle_list_voice +_alfred_register_lists(app, socketio) +# ALFRED_LISTES_GLASS_V1_BACKEND_END + + +# ALFRED_GLASS_V2_BACKEND_START +from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2 +_alfred_register_logs_v2(app) +# ALFRED_GLASS_V2_BACKEND_END + + +# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom. +from services.alfred_identity_page import register_identity_page as _alfred_identity_register +_alfred_identity_register(app) + + +# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur). +from services.alfred_developer_page import register_developer_page as _alfred_developer_register +_alfred_developer_register(app) + + +# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin. +from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session +_register_hermes_new_session(app) + + +# DONA_FAMILY_UI_V1_START +# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS. +def _dona_submit_typed(prompt): + if not conversation_lock.acquire(blocking=False): + return False + def _dona_run_typed(): + try: + wake.pause() + with STATE_LOCK: + _before_image = IMAGE_REVISION + emit_state('thinking', partial='', transcript=prompt, + content={'type': 'text', 'text': prompt}) + # DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle. + if _dona_vision_v95.is_camera_question(prompt): + try: + _vision_answer=_dona_vision_monitor.analyze(prompt) + except Exception as _vision_exc: + _vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.' + with STATE_LOCK: + STATE['content']={'type':'text','text':_vision_answer} + socketio.emit('alfred_render',STATE['content']) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer)) + emit_state('idle',partial='') + return + + # DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe. + if _dona_files_v91.is_file_question(prompt): + _dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK) + emit_state('idle',partial='') + return + + # DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes. + if _dona_suite_v9.is_request(prompt): + _v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + + # DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes. + if _dona_nav.is_request(prompt): + _v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + + # DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes. + if _dona_youtube.is_search(prompt): + _v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local)) + emit_state('idle',partial='') + return + if _dona_youtube.is_control(prompt,_dona_youtube.player_active()): + _dona_youtube.control_and_emit(prompt,socketio) + emit_state('idle',partial='') + return + + # DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel. + if _dona_dynamic.is_dynamic_request(prompt): + _board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard) + socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord'))) + _payload = {'type':'dona_dynamic','dashboard':_board} + with STATE_LOCK: + STATE['content'] = _payload + socketio.emit('alfred_render',_payload) + emit_state('idle', partial='') + return + _answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt)) + 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} + with STATE_LOCK: + if IMAGE_REVISION == _before_image: + STATE['content'] = _display + socketio.emit('alfred_render', _display) + # DONA_LEARNING_V93 : évaluation de l’échange écrit. + socketio.emit('dona_learning_turn', + _dona_learning_v93.track('typed',prompt,_speech)) + # Deliberately no speak_async(): typed interaction is silent. + emit_state('idle', partial='') + except Exception as exc: + print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True) + import traceback + traceback.print_exc() + emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start() + return True + +from services.dona_family_ui import register_dona_family as _dona_register_family +_dona_register_family(app, adult_command=_dona_submit_typed) +# DONA_FAMILY_UI_V1_END + +# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés. +from services import dona_dynamic as _dona_dynamic +# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales +from services import dona_dashboard_agent as _dona_dashboard_agent + +def _dona_dynamic_submit(prompt): + if not conversation_lock.acquire(blocking=False): + return False + def _work(): + try: + wake.pause() + emit_state('thinking',transcript=prompt,partial='') + _board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard) + _payload={'type':'dona_dynamic','dashboard':_board} + with STATE_LOCK: + STATE['content']=_payload + socketio.emit('alfred_render',_payload) + emit_state('idle',partial='') + except Exception as exc: + print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True) + emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'}) + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start() + return True + +_dona_dynamic.register_dynamic(app,_dona_dynamic_submit) + +# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in. +from services import dona_vision_v95 as _dona_vision_v95 +_dona_vision_monitor = _dona_vision_v95.register(app, socketio) + +# DONA_LEARNING_V93 : retours explicites et corrections approuvées. +from services import dona_learning_v93 as _dona_learning_v93 +_dona_learning_v93.register(app) + +# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur. +from services import dona_shortcuts_v92 as _dona_shortcuts_v92 +_dona_shortcuts_v92.register(app) + +# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée. +from services import dona_files_v91 as _dona_files_v91 +_dona_files_v91.register_files(app) + +# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines. +from services import dona_suite_v9 as _dona_suite_v9 +_dona_suite_v9.register_suite(app,socketio) + +# DONA_NAV_V83: géocodage, carte et transfert Waze. +from services import dona_navigation as _dona_nav +_dona_nav.register_navigation(app,socketio) + +# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé. +from services import dona_youtube as _dona_youtube +_dona_youtube.register_youtube(app,socketio) + +# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling). +from services.dona_spotify import register_spotify_panel as _register_dona_spotify +_register_dona_spotify(app) + +# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée. +from services.dona_face import (register_face as _dona_register_face, + recognize_once as _dona_face_recognize, is_face_request as _dona_face_request) +_dona_register_face(app, socketio, ask_hermes, conversation_lock) + + +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) + diff --git a/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/services/piper_tts.py b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/services/piper_tts.py new file mode 100644 index 0000000..91f0fb4 --- /dev/null +++ b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/services/piper_tts.py @@ -0,0 +1,152 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import threading # ALFRED_DUPLEX_BARGE_IN_V1 +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize, SynthesizeVoice + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize( + text=text, + voice=SynthesizeVoice( + name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium") + )).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +# ALFRED_DUPLEX_BARGE_IN_V1: control only this module's aplay process. +_playback_lock = threading.RLock() +_playback_cancel = threading.Event() +_playback_proc = None + + +def stop_playback(): + """Interrupts Piper playback or marks pending synth for cancellation.""" + global _playback_proc + _playback_cancel.set() + with _playback_lock: + process = _playback_proc + if process is not None and process.poll() is None: + try: + process.terminate() + except OSError: + pass + + +def speak(text): + if not text or not text.strip(): + return + + _playback_cancel.clear() # ALFRED_DUPLEX_BARGE_IN_V1 + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + # ALFRED_DUPLEX_BARGE_IN_V1: allow a concurrent microphone detector + # to terminate playback, without killing Hermes or the entire service. + global _playback_proc + if _playback_cancel.is_set(): + print("[ALFRED PIPER] Annulé avant lecture", flush=True) + return + proc = subprocess.Popen(["aplay", "-q", "-D", AUDIO_DEVICE, wav_path]) + with _playback_lock: + _playback_proc = proc + if _playback_cancel.is_set() and proc.poll() is None: + proc.terminate() + try: + result = proc.wait(timeout=120) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + raise + finally: + with _playback_lock: + if _playback_proc is proc: + _playback_proc = None + if _playback_cancel.is_set(): + print("[ALFRED PIPER] Lecture interrompue par la voix", flush=True) + return + if result: + raise subprocess.CalledProcessError(result, proc.args) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/static/js/dona-accueil-ecrit-v1.js b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/static/js/dona-accueil-ecrit-v1.js new file mode 100644 index 0000000..ab076b1 --- /dev/null +++ b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/static/js/dona-accueil-ecrit-v1.js @@ -0,0 +1,104 @@ +/* DONA_FAMILY_UI_V1: non-destructive augmentation of the installed Glass V1 dashboard. */ +(() => { + 'use strict'; + let attempts=0; + function init(){ + const root=document.getElementById('alfredMainV1'); + if(!root){if(++attempts<40)setTimeout(init,150);return;} + if(root.dataset.donaFamily==='ready')return; + const $=id=>document.getElementById(id); + const head=root.querySelector('.av-header-right'); + const weather=root.querySelector('.av-weather'); + const hero=root.querySelector('.av-voice-hero'); + const shortcuts=root.querySelector('.av-shortcuts'); + if(!head||!weather||!hero||!shortcuts)return; + root.dataset.donaFamily='ready';document.body.classList.add('dona-family-v1'); + + // Move the EXISTING weather widget: old JS keeps updating the SAME IDs. + weather.classList.add('dona-inline-weather'); + weather.setAttribute('title','Ouvrir les détails météo'); + head.prepend(weather); + + // Child link is supplementary; all old admin/navigation functions remain. + const kid=document.createElement('a');kid.href='/enfant';kid.className='dona-child-link';kid.textContent='⭐ Espace enfant'; + kid.setAttribute('aria-label','Ouvrir le chat enfant, écrit uniquement'); + head.insertBefore(kid,head.querySelector('.av-head-actions')); + const toolsGrid=root.querySelector('#avMoreDialog .av-more-grid'); + if(toolsGrid&&!toolsGrid.querySelector('a[href="/enfant"]')){ + const a=document.createElement('a');a.href='/enfant';a.textContent='⭐ Espace enfant';toolsGrid.append(a); + } + + // New expressive centre; old controls stay in DOM so audio event listeners work. + const display=document.createElement('div');display.className='dona-art-center'; + display.innerHTML='
Prêt à vous aider'; + hero.prepend(display); + const oldVoice=root.querySelector('.av-voice-center'); + if(oldVoice){display.append(oldVoice);} + + // Move shortcuts into a collapsed tray, instead of removing their controls. + const tools=document.createElement('details');tools.className='dona-tools-tray'; + const summary=document.createElement('summary');summary.textContent='☷ Raccourcis'; + shortcuts.parentNode.insertBefore(tools,shortcuts);tools.append(summary,shortcuts); + + // Minimal permanent one-row text field: no expandable tray, send at right. + const compose=document.createElement('div');compose.className='dona-compose dona-compose-compact'; + compose.innerHTML=`
+ +
+ +
`; + root.append(compose); + const body=$('donaComposeBody'),input=$('donaComposeInput'),send=$('donaComposeSend'),status=$('donaComposeStatus'); + input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();body.requestSubmit();}}); + body.addEventListener('submit',async e=>{ + e.preventDefault();let message=input.value.trim();if(!message)return; + send.disabled=true;status.textContent='Envoi à Hermes…'; + try{ + const r=await fetch('/api/dona/texte',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({message})}); + let d={};try{d=await r.json();}catch(_){} + if(!r.ok)throw Error(d.error||'Erreur '+r.status); + input.value='';status.textContent='Hermes prépare sa réponse…'; + }catch(err){status.textContent=err.message;} + finally{send.disabled=false;} + }); + const modeText={idle:'Prêt à vous aider',waiting:'Prêt à vous aider',listening:'Je vous écoute…',thinking:'Je réfléchis…',speaking:'Je vous réponds…',error:'Un problème est survenu'}; + function setMode(mode){root.dataset.familyMode=mode;const t=$('donaArtSubtitle');if(t)t.textContent=modeText[mode]||'Prêt à vous aider';if(mode==='idle'&&status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';} + function setName(value){const name=String(value||$('avName')?.textContent||'Dona').trim();$('donaArtName').textContent=name;} + setName();setMode(root.dataset.mode||'idle'); + const socket=window.alfredSocket; + socket?.on('alfred_state',s=>{setMode(String(s.mode||'idle').toLowerCase());setName(s.assistant_name||s.title||$('avName')?.textContent);}); + socket?.on('alfred_render',()=>{if(status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';}); + fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s){setMode(s.mode||'idle');setName(s.assistant_name||s.title);}}).catch(()=>{}); + + // Animated multi-layer liquid orb using canvas, independent of server state. + const canvas=$('donaOrganicCanvas'),ctx=canvas.getContext('2d'); + if(!ctx)return; + let w=320,h=320,phase=0,raf=0,last=0; + const reduce=matchMedia('(prefers-reduced-motion: reduce)').matches; + function resize(){const rect=canvas.getBoundingClientRect();const dpr=Math.min(devicePixelRatio||1,2);w=Math.max(150,rect.width);h=Math.max(150,rect.height);canvas.width=Math.floor(w*dpr);canvas.height=Math.floor(h*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);} + window.addEventListener('resize',resize,{passive:true});resize(); + function path(radius,layer){const n=130;ctx.beginPath();for(let k=0;k<=n;k++){ + const a=k/n*Math.PI*2;const modulation=1+0.067*Math.sin(a*3+phase*.72+layer*.64)+0.044*Math.cos(a*5-phase*.39+layer)+0.021*Math.sin(a*8+phase*.55); + const r=radius*modulation;const x=Math.cos(a)*r,y=Math.sin(a)*r; + if(k===0)ctx.moveTo(x,y);else ctx.lineTo(x,y); + }ctx.closePath();} + function frame(now){raf=requestAnimationFrame(frame);if(document.hidden||now-last<30)return;last=now; + const mode=root.dataset.familyMode||'idle';const speed=mode==='listening'?1.7:mode==='thinking'?1.38:mode==='speaking'?1.5:.75; + if(!reduce)phase+=.024*speed; + ctx.clearRect(0,0,w,h);ctx.save();ctx.translate(w/2,h/2);const r=Math.min(w,h)*.33; + const bloom=ctx.createRadialGradient(-r*.2,-r*.15,r*.1,0,0,r*1.45); + bloom.addColorStop(0,'rgba(238,117,160,.11)');bloom.addColorStop(.7,'rgba(139,112,246,.14)');bloom.addColorStop(1,'rgba(37,180,255,0)');ctx.fillStyle=bloom;ctx.beginPath();ctx.arc(0,0,r*1.48,0,Math.PI*2);ctx.fill(); + for(let layer=3;layer>=0;layer--){const waveR=r*(1+layer*.025);path(waveR,layer); + const g=ctx.createLinearGradient(-r*.9,r*.8,r*.9,-r*.85); + if(layer%2===0){g.addColorStop(0,'rgba(47,196,244,.76)');g.addColorStop(.36,'rgba(68,94,198,.88)');g.addColorStop(.62,'rgba(244,138,173,.74)');g.addColorStop(1,'rgba(255,176,71,.9)');} + else {g.addColorStop(0,'rgba(59,164,255,.65)');g.addColorStop(.4,'rgba(196,119,245,.48)');g.addColorStop(.7,'rgba(241,125,146,.52)');g.addColorStop(1,'rgba(255,185,95,.76)');} + ctx.globalAlpha=layer===0?.76:.25;ctx.fillStyle=g;ctx.fill(); + ctx.globalAlpha=layer===0?.92:.31;ctx.strokeStyle=g;ctx.lineWidth=layer===0?2.5:1.8;ctx.shadowBlur=layer===0?23:13;ctx.shadowColor=layer%2?'#bd80ec':'#ffc685';ctx.stroke();ctx.shadowBlur=0; + } + const ribbon=ctx.createLinearGradient(-r,r*.4,r,-r*.3);ribbon.addColorStop(0,'rgba(67,219,255,.0)');ribbon.addColorStop(.48,'rgba(255,231,197,.56)');ribbon.addColorStop(1,'rgba(255,181,103,.01)'); + ctx.beginPath();for(let i=0;i<=100;i++){const x=-r+i/100*r*2;const y=Math.sin(i/100*Math.PI*2+phase*.35)*r*.25+Math.cos(i/100*Math.PI*3-phase*.45)*r*.11;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}ctx.lineWidth=r*.26;ctx.strokeStyle=ribbon;ctx.globalAlpha=.38;ctx.stroke();ctx.restore(); + } + raf=requestAnimationFrame(frame);window.addEventListener('pagehide',()=>cancelAnimationFrame(raf),{once:true}); + } + init(); +})(); diff --git a/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/templates/index.html b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/templates/index.html new file mode 100644 index 0000000..112b220 --- /dev/null +++ b/alfred/backups/dona_waves_voice_v1_20261031_115646_870874/original/templates/index.html @@ -0,0 +1,258 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +