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

This commit is contained in:
pporcheret
2026-10-27 16:44:39 +01:00
parent 5ccb0a48c2
commit 5f82b1bdf2
49 changed files with 4667 additions and 329 deletions
@@ -0,0 +1,83 @@
"""Illustrations contextuelles Wikipédia déclenchées APRÈS une réponse Hermes.
Pas de modification des prompts Hermes ou de /api/render. Échec silencieux.
"""
from __future__ import annotations
import re
import threading
import time
import unicodedata
from urllib.parse import urlencode, quote, urlparse
import requests
CACHE = {}
LOCK = threading.Lock()
def _norm(s):
s = unicodedata.normalize('NFKD', str(s)).casefold()
return re.sub(r'[^a-z0-9 ]+', '', ''.join(c for c in s if not unicodedata.combining(c)))
def entity_question(s):
text = ' '.join(str(s or '').split())
m = re.match(r'^(?:alfred[ ,.:!?]*)?(?:qui\s+(?:est|etait|était|sont)\s+|c.?est\s+qui\s+)([^?!.]{3,95})', text, re.I)
if not m:
return None
entity = m.group(1).strip(' «»\'".,?!')
entity = re.sub(r'\s+(?:au juste|exactement|s.il te plait)$', '', entity, flags=re.I)
if len(entity.split()) > 7 or len(entity) < 3:
return None
return entity
def _query_wikipedia(name):
# PageImages retourne une illustration publique et l'URL de la page source.
params = {'action':'query','generator':'search','gsrsearch':name,'gsrlimit':1,
'prop':'pageimages','piprop':'thumbnail','pithumbsize':500,
'format':'json','formatversion':'2','redirects':'1'}
headers = {'User-Agent':'AlfredAssistant/1.0 (personal home assistant; image source: Wikipedia)'}
response = requests.get('https://fr.wikipedia.org/w/api.php', params=params,
headers=headers, timeout=4, allow_redirects=False)
response.raise_for_status()
pages = response.json().get('query', {}).get('pages', [])
if not pages:
return None
page = pages[0]
title = str(page.get('title',''))
if not title or _norm(name) not in _norm(title) and _norm(title) not in _norm(name):
return None
url = str(page.get('thumbnail', {}).get('source', ''))
parsed = urlparse(url)
if parsed.scheme != 'https' or parsed.hostname not in ('upload.wikimedia.org','commons.wikimedia.org'):
return None
return {'title': title, 'image_url':url,
'source_url':'https://fr.wikipedia.org/wiki/'+quote(title.replace(' ','_')),
'credit':'Image : Wikipédia / Wikimedia Commons'}
def image_for_question(name):
key = _norm(name)
with LOCK:
old = CACHE.get(key)
if old and time.monotonic()-old[0] < 4*3600:
return old[1]
try:
result = _query_wikipedia(name)
except (requests.RequestException, ValueError, KeyError):
result = None
with LOCK:
CACHE[key] = (time.monotonic(), result)
return result
def schedule_context_image(question, answer, socketio, still_current):
name = entity_question(question)
if not name:
return
def worker():
info = image_for_question(name)
if info and still_current():
info['text'] = str(answer or '')[:500]
socketio.emit('alfred_context_image', info)
threading.Thread(target=worker, name='alfred-context-image', daemon=True).start()
+329
View File
@@ -0,0 +1,329 @@
"""Alfred 4+5: serial Hermes background jobs + dynamic contextual cards.
Designed to be mounted into an existing Flask app, preserving STT/TTS/Hermes.
No external dependencies except Flask (which Alfred already uses).
Single-process worker: do not run multiple WSGI workers with this module.
"""
from __future__ import annotations
import json
import logging
import os
import queue
import re
import sqlite3
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
from flask import jsonify, request
LOG = logging.getLogger("alfred.features")
_manager = None
_lock = threading.Lock()
def _utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _expiry(minutes):
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat(timespec="seconds")
def should_run_in_background(text):
"""Conservative opt-in for long requests; ordinary voice Q&A unchanged."""
txt = (text or "").lower()
patterns = [
r"en arri[eè]re.plan", r"en t[aâ]che de fond", r"en asynchrone",
r"pr[eé]viens.moi (?:quand|lorsque)", r"avertis.moi (?:quand|lorsque)",
r"quand (?:tu auras|c['’]est) fini", r"prends le temps de",
r"fais (?:moi )?un rapport", r"analyse (?:les?|mes?|l['’]) (?:alertes?|logs?|journaux|rapports?|fichiers?|donn[eé]es?)",
r"fais (?:moi )?une synth[eè]se (?:des?|de mes|du)",
]
return any(re.search(p, txt) for p in patterns)
def _sanitize_url(url):
if not isinstance(url, str) or not url or len(url) > 2048:
return None
try:
u = urlsplit(url)
except ValueError:
return None
# Relative paths and HTTP(S) only; block javascript:, data:, file:, etc.
if u.scheme in ("http", "https") and u.netloc:
return url
if not u.scheme and not u.netloc and url.startswith("/") and not url.startswith("//"):
return url
return None
def _safe_display(display):
if not isinstance(display, dict):
return {"type": "text", "text": str(display or "")[:12000]}
kind = str(display.get("type", "text"))[:32].lower()
if kind not in {"text", "image", "weather", "calendar", "notification"}:
kind = "text"
obj = {"type": kind}
for key in ("text", "title", "description", "temperature", "condition"):
if key in display:
obj[key] = str(display[key])[:12000 if key == "text" else 300]
if kind == "image":
obj["url"] = _sanitize_url(display.get("url"))
if kind == "calendar":
obj["events"] = [
{"title": str(e.get("title", "Événement"))[:200], "start": str(e.get("start", ""))[:80]}
for e in (display.get("events") or [])[:30] if isinstance(e, dict)
] if isinstance(display.get("events"), list) else []
return obj
class FeatureManager:
def __init__(self, db_path, hermes_call, socketio=None, start_worker=True):
self.db_path = str(db_path)
self.hermes_call = hermes_call
self.socketio = socketio
self.guard = threading.RLock()
self.pending = queue.Queue(maxsize=12)
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._setup_db()
# Cannot resume a subprocess / Hermes interaction after an app restart.
with self._connect() as db:
db.execute("UPDATE tasks SET status='interrupted', updated_at=? WHERE status IN ('queued','running')", (_utc_now(),))
db.execute("UPDATE cards SET status='interrupted', body='Interrompue par un redémarrage.' WHERE status IN ('queued','running')")
if start_worker:
self.thread = threading.Thread(target=self._work_loop, name="alfred-hermes-worker", daemon=True)
self.thread.start()
def _connect(self):
db = sqlite3.connect(self.db_path, timeout=10)
db.row_factory = sqlite3.Row
db.execute("PRAGMA busy_timeout=10000")
return db
def _setup_db(self):
with self._connect() as db:
db.execute("PRAGMA journal_mode=WAL")
db.execute("""CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
status TEXT NOT NULL, source TEXT NOT NULL, prompt TEXT NOT NULL,
speech TEXT DEFAULT '', display_json TEXT DEFAULT '{}', error TEXT DEFAULT ''
)""")
db.execute("""CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '', status TEXT DEFAULT '',
display_json TEXT DEFAULT '{}', task_id TEXT,
updated_at TEXT NOT NULL, expires_at TEXT
)""")
db.execute("CREATE INDEX IF NOT EXISTS cards_updated ON cards(updated_at)")
try:
os.chmod(self.db_path, 0o600)
except OSError:
LOG.warning("Unable to restrict database file permissions")
def _emit(self, event, payload):
if self.socketio:
try:
self.socketio.emit(event, payload)
except Exception:
LOG.exception("Websocket emission failed")
def _task(self, task_id):
with self._connect() as db:
row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
return None
d = dict(row)
d["display"] = json.loads(d.pop("display_json") or "{}")
return d
def list_tasks(self, limit=30):
with self._connect() as db:
rows = db.execute("SELECT id FROM tasks ORDER BY created_at DESC LIMIT ?", (min(max(int(limit), 1), 100),)).fetchall()
return [self._task(r["id"]) for r in rows]
def list_cards(self, limit=25):
with self.guard, self._connect() as db:
db.execute("DELETE FROM cards WHERE expires_at IS NOT NULL AND expires_at < ?", (_utc_now(),))
rows = db.execute("SELECT * FROM cards ORDER BY updated_at DESC LIMIT ?", (min(max(int(limit), 1), 50),)).fetchall()
cards = []
for row in rows:
d = dict(row)
d["display"] = json.loads(d.pop("display_json") or "{}")
cards.append(d)
return cards
def upsert_card(self, card_id, *, kind, title, body="", status="", display=None, task_id=None, ttl=60):
try:
ttl = int(ttl)
except (TypeError, ValueError):
ttl = 30
expires = _expiry(max(1, min(ttl, 60 * 24))) if ttl > 0 else None
display = _safe_display(display or {"type": "text", "text": body})
with self.guard, self._connect() as db:
db.execute("""INSERT INTO cards(id, kind, title, body, status, display_json, task_id, updated_at, expires_at)
VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
kind=excluded.kind, title=excluded.title, body=excluded.body,
status=excluded.status, display_json=excluded.display_json,
task_id=excluded.task_id, updated_at=excluded.updated_at,
expires_at=excluded.expires_at""", (
str(card_id)[:120], str(kind)[:32], str(title)[:180], str(body)[:12000],
str(status)[:32], json.dumps(display, ensure_ascii=False), task_id, _utc_now(), expires
))
self._emit("alfred_cards_changed", {"at": _utc_now()})
def publish_display(self, display):
d = _safe_display(display)
kind = d["type"]
if kind == "text" and not d.get("text", "").strip():
return
# Weather/calendar replace the previous contextual card. Others are transient.
card_id = f"context-{kind}" if kind in {"weather", "calendar"} else f"context-{uuid.uuid4().hex[:12]}"
title = d.get("title") or {"weather": "Météo", "calendar": "Agenda", "image": "Image", "notification": "Notification"}.get(kind, "Information")
self.upsert_card(card_id, kind=kind, title=title, body=d.get("text", ""), display=d,
ttl=20 if kind == "weather" else (60 if kind == "calendar" else 15))
def submit(self, prompt, source="web"):
prompt = str(prompt or "").strip()
if not prompt or len(prompt) > 8000:
raise ValueError("La demande doit contenir entre 1 et 8 000 caractères.")
source = str(source)[:32]
task_id = uuid.uuid4().hex
now = _utc_now()
with self.guard, self._connect() as db:
active = db.execute("SELECT COUNT(*) FROM tasks WHERE status IN ('queued','running')").fetchone()[0]
if active >= 8 or self.pending.full():
raise ValueError("La file Hermes est pleine (8 tâches maximum).")
db.execute("INSERT INTO tasks(id,created_at,updated_at,status,source,prompt) VALUES (?,?,?,?,?,?)",
(task_id, now, now, "queued", source, prompt))
self.upsert_card("task-" + task_id, kind="task", title=prompt[:100], body="En attente d’Hermes…",
status="queued", task_id=task_id, ttl=0)
self.pending.put_nowait(task_id)
self._emit("alfred_task", {"id": task_id, "status": "queued"})
return self._task(task_id)
def _mark(self, task_id, status, speech="", display=None, error=""):
now = _utc_now()
with self.guard, self._connect() as db:
db.execute("UPDATE tasks SET status=?, updated_at=?, speech=?, display_json=?, error=? WHERE id=?",
(status, now, speech[:12000], json.dumps(display or {}, ensure_ascii=False), error[:1200], task_id))
task = self._task(task_id)
display = display or {}
body = {
"running": "Hermes traite la demande…",
"succeeded": speech or display.get("text", "Tâche terminée."),
"failed": "Échec : " + error[:900],
}.get(status, status)
self.upsert_card("task-" + task_id, kind="task", title=task["prompt"][:100],
body=body, status=status, display=display or {"type": "text", "text": body},
task_id=task_id, ttl=0)
self._emit("alfred_task", {"id": task_id, "status": status})
def run_one(self, task_id):
self._mark(task_id, "running")
try:
# This executes serially in one worker, respecting Hermes' session semantics.
answer = self.hermes_call(self._task(task_id)["prompt"])
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")[:12000]
display = _safe_display(answer.get("display") or {"type": "text", "text": speech})
else:
speech = str(answer or "")[:12000]
display = {"type": "text", "text": speech}
self._mark(task_id, "succeeded", speech=speech, display=display)
except Exception as exc:
LOG.exception("Hermes task failed: %s", task_id)
self._mark(task_id, "failed", error=f"{type(exc).__name__}: {exc}")
def _work_loop(self):
while True:
task_id = self.pending.get()
try:
self.run_one(task_id)
except Exception:
LOG.exception("Unexpected task runner error")
finally:
self.pending.task_done()
def _local_writer_allowed():
# Web UI read-only; explicit write endpoints enabled only from the same host.
# Avoid trusting X-Forwarded-For, which clients can spoof unless configured.
return request.remote_addr in ("127.0.0.1", "::1")
def init_app(app, ask_hermes, socketio=None, db_path=None):
global _manager
with _lock:
if _manager is None:
path = db_path or os.getenv("ALFRED_FEATURES_DB") or str(Path(app.root_path) / "data" / "alfred_features.sqlite3")
_manager = FeatureManager(path, ask_hermes, socketio)
@app.before_request
def _capture_render():
if request.path == "/api/render" and request.method == "POST":
data = request.get_json(silent=True)
if isinstance(data, dict):
try:
_manager.publish_display(data)
except Exception:
LOG.exception("Unable to save display card")
@app.get("/api/alfred/tasks")
def _alfred_list_tasks():
return jsonify({"tasks": _manager.list_tasks()})
@app.get("/api/alfred/tasks/<task_id>")
def _alfred_task_detail(task_id):
task = _manager._task(task_id)
return (jsonify(task), 200) if task else (jsonify({"error": "Tâche introuvable"}), 404)
@app.post("/api/alfred/tasks")
def _alfred_start_task():
if not _local_writer_allowed():
return jsonify({"error": "Écriture réservée à localhost; utilisez la voix d’Alfred."}), 403
data = request.get_json(silent=True) or {}
try:
task = _manager.submit(data.get("prompt", ""), "api-local")
return jsonify(task), 202
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.get("/api/alfred/cards")
def _alfred_list_cards():
return jsonify({"cards": _manager.list_cards()})
@app.post("/api/alfred/cards")
def _alfred_add_card():
if not _local_writer_allowed():
return jsonify({"error": "Écriture réservée à localhost"}), 403
data = request.get_json(silent=True) or {}
if not isinstance(data, dict):
return jsonify({"error": "Payload invalide"}), 400
title = str(data.get("title") or "Information")[:180]
kind = str(data.get("kind") or "notification")[:32]
_manager.upsert_card("manual-" + uuid.uuid4().hex[:12], kind=kind, title=title,
body=str(data.get("body") or "")[:12000],
display=data.get("display"), ttl=int(data.get("ttl", 30)))
return jsonify({"ok": True}), 201
return _manager
def submit(prompt, source="voice"):
if _manager is None:
raise RuntimeError("Les fonctionnalités Alfred ne sont pas initialisées")
return _manager.submit(prompt, source)
def publish_voice_display(display):
"""Nonblocking mirror; main renderer and TTS remain authoritative."""
if _manager is None:
return
try:
_manager.publish_display(display)
except Exception:
LOG.exception("Could not publish conversational display card")
+297
View File
@@ -0,0 +1,297 @@
"""Minuteurs + chronomètres indépendants d'Hermes ; stockage JSON durable.
Le temps est exprimé à partir de l'horloge UTC, pour survivre à un redémarrage
(et continuer à avancer pendant l'arrêt de Flask). Les durées sont limitées
pour éviter les mauvaises interprétations des demandes vocales.
"""
from __future__ import annotations
import json
import os
import re
import secrets
import threading
import time
from pathlib import Path
from urllib.parse import urlsplit
from flask import Blueprint, jsonify, request
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PATH = ROOT / 'data' / 'timers.json'
MAX_SECONDS = 30 * 24 * 3600
MAX_ACTIVE = 20
class TimerError(ValueError):
pass
class TimerStore:
def __init__(self, path: Path, clock=None, on_change=None, on_finished=None):
self.path = Path(path)
self.clock = clock or time.time
self.on_change = on_change or (lambda snapshot: None)
self.on_finished = on_finished or (lambda timer: None)
self.lock = threading.RLock()
self.items: dict[str, dict] = {}
self._read()
def _read(self):
if not self.path.exists():
return
try:
data = json.loads(self.path.read_text(encoding='utf-8'))
if not isinstance(data, list):
raise ValueError('Mauvais format JSON')
for item in data:
if (isinstance(item, dict) and isinstance(item.get('id'), str)
and item.get('kind') in ('timer', 'stopwatch')
and item.get('state') in ('running', 'paused', 'finished', 'cancelled')):
self.items[item['id']] = item
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError(f'Impossible de charger {self.path} : {exc}') from exc
def _persist(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_name(self.path.name + '.tmp.' + secrets.token_hex(3))
try:
with tmp.open('w', encoding='utf-8') as out:
json.dump(list(self.items.values()), out, ensure_ascii=False, indent=2)
out.flush()
os.fsync(out.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, self.path)
finally:
tmp.unlink(missing_ok=True)
@staticmethod
def _duration(number, *, allow_zero=False):
if isinstance(number, bool) or not isinstance(number, (int, float)):
raise TimerError('Durée numérique attendue en secondes.')
if number < (0 if allow_zero else 1) or number > MAX_SECONDS:
raise TimerError('Durée autorisée : de 1 seconde à 30 jours.')
return float(number)
@staticmethod
def _label(label, kind):
label = ' '.join(str(label or '').split()).strip()
if not label:
label = 'Minuteur' if kind == 'timer' else 'Chronomètre'
if len(label) > 80 or any(ord(x) < 32 for x in label):
raise TimerError('Nom trop long ou invalide (80 caractères maximum).')
return label
def _view(self, item, now):
duration = item.get('duration')
elapsed = float(item.get('elapsed', 0))
if item['state'] == 'running':
elapsed += max(0.0, now - float(item['started_at']))
remaining = max(0.0, duration - elapsed) if duration is not None else None
result = {k: item[k] for k in ('id', 'label', 'kind', 'state', 'created_at')}
result.update(duration=duration, elapsed=round(elapsed, 2),
remaining=round(remaining, 2) if remaining is not None else None)
return result
def snapshot(self, *, include_finished=False):
with self.lock:
now = self.clock()
self._expire_locked(now)
items = [self._view(x, now) for x in self.items.values()
if include_finished or x['state'] in ('running', 'paused')]
return sorted(items, key=lambda x: (x['state'] != 'running', x['created_at']))
def _expire_locked(self, now):
finished = []
for item in self.items.values():
if item['state'] != 'running' or item.get('duration') is None:
continue
elapsed = item['elapsed'] + max(0.0, now - item['started_at'])
if elapsed + 0.005 >= item['duration']:
item['elapsed'] = item['duration']
item['started_at'] = None
item['state'] = 'finished'
item['finished_at'] = now
finished.append(item.copy())
if finished:
self._persist()
self.on_change(self._unexpired_snapshot(now))
for item in finished:
self.on_finished(item)
def _unexpired_snapshot(self, now):
return sorted([self._view(x, now) for x in self.items.values()
if x['state'] in ('running', 'paused')],
key=lambda x: x['created_at'])
def start(self, kind, seconds=None, label=None):
if kind not in ('timer', 'stopwatch'):
raise TimerError('Type attendu : timer ou stopwatch.')
if kind == 'timer' and seconds is None:
raise TimerError('Il faut préciser la durée du minuteur.')
if seconds is not None:
seconds = self._duration(seconds)
with self.lock:
now = self.clock()
self._expire_locked(now)
if sum(1 for x in self.items.values() if x['state'] in ('running', 'paused')) >= MAX_ACTIVE:
raise TimerError('Maximum de 20 minuteurs ou chronomètres en cours.')
ident = secrets.token_hex(5)
item = {'id': ident, 'label': self._label(label, kind), 'kind': kind,
'duration': seconds, 'elapsed': 0.0,
'started_at': now, 'created_at': now, 'state': 'running'}
self.items[ident] = item
# Conserver au plus 80 éléments historiques.
old = sorted((x for x in self.items.values() if x['state'] in ('finished', 'cancelled')),
key=lambda x: x['created_at'])
for x in old[:-80]:
self.items.pop(x['id'], None)
self._persist()
self.on_change(self._unexpired_snapshot(now))
return self._view(item, now)
def _resolve(self, ident):
if not isinstance(ident, str):
raise TimerError('Identifiant attendu.')
ident = ident.strip()
if ident in self.items:
return self.items[ident]
candidates = [x for x in self.items.values()
if x['state'] in ('running', 'paused') and
(x['label'].casefold() == ident.casefold() or x['id'].startswith(ident))]
if len(candidates) == 1:
return candidates[0]
if not candidates:
raise TimerError('Minuteur ou chronomètre introuvable.')
raise TimerError('Plusieurs minuteurs portent ce nom : préciser l’identifiant.')
def action(self, action, ident, seconds=None):
if action not in ('pause', 'resume', 'cancel', 'add_time'):
raise TimerError('Action inconnue.')
with self.lock:
now = self.clock()
self._expire_locked(now)
item = self._resolve(ident)
if item['state'] in ('cancelled', 'finished'):
raise TimerError('Ce minuteur est déjà terminé.')
if action == 'pause':
if item['state'] != 'running':
raise TimerError('Déjà en pause.')
item['elapsed'] += max(0, now - item['started_at'])
item['started_at'] = None
item['state'] = 'paused'
elif action == 'resume':
if item['state'] != 'paused':
raise TimerError('Ce minuteur n’est pas en pause.')
item['started_at'] = now
item['state'] = 'running'
elif action == 'cancel':
if item['state'] == 'running':
item['elapsed'] += max(0, now - item['started_at'])
item['started_at'] = None
item['state'] = 'cancelled'
else:
if item['duration'] is None:
raise TimerError('Chronomètre libre : aucune durée maximale à prolonger.')
amount = self._duration(seconds)
item['duration'] = self._duration(item['duration'] + amount)
self._persist()
self.on_change(self._unexpired_snapshot(now))
return self._view(item, now)
def cancel_all(self):
with self.lock:
now = self.clock()
affected = []
for item in self.items.values():
if item['state'] not in ('running', 'paused'):
continue
if item['state'] == 'running':
item['elapsed'] += max(0, now - item['started_at'])
item['started_at'] = None
item['state'] = 'cancelled'
affected.append(self._view(item, now))
if affected:
self._persist()
self.on_change(self._unexpired_snapshot(now))
return affected
def tick(self):
with self.lock:
self._expire_locked(self.clock())
def run(self, stop_event=None, interval=0.5):
stop_event = stop_event or threading.Event()
while not stop_event.wait(interval):
try:
self.tick()
except Exception as exc:
print('[ALFRED TIMERS] erreur boucle :', repr(exc), flush=True)
def register(app, socketio, speak_async, audio_settings):
def changed(items):
socketio.emit('alfred_timers_changed', {'items': items})
def finished(item):
payload = {'id': item['id'], 'label': item['label'], 'kind': item['kind']}
socketio.emit('alfred_timer_finished', payload)
if not audio_settings.speaker_muted():
msg = f"Le minuteur {item['label']} est terminé." if item['kind'] == 'timer' else f"Le chronomètre {item['label']} est terminé."
try:
speak_async(msg)
except Exception as exc:
print('[ALFRED TIMERS] alerte vocale :', repr(exc), flush=True)
store = TimerStore(Path(os.getenv('ALFRED_TIMERS_DB', str(DEFAULT_PATH))),
on_change=changed, on_finished=finished)
bp = Blueprint('alfred_timers', __name__)
def safe_post():
# MCP fonctionne uniquement depuis la boucle locale (aucun accès LAN).
if request.remote_addr in ('127.0.0.1', '::1') and request.headers.get('X-Alfred-MCP-Local') == 'timers':
return True
origin = request.headers.get('Origin')
if request.headers.get('X-Alfred-Request') != '1':
return False
fetch_site = request.headers.get('Sec-Fetch-Site', '').lower()
if fetch_site == 'cross-site':
return False
if not origin:
# Chromium peut omettre Origin sur certaines requêtes même origine.
return fetch_site == 'same-origin'
p = urlsplit(origin)
return p.scheme in ('http','https') and p.netloc.lower() == request.host.lower()
@bp.get('/api/timers')
def list_timers():
return jsonify({'items': store.snapshot(), 'server_time': store.clock()})
@bp.post('/api/timers/action')
def timers_action():
if not safe_post():
return jsonify({'error': 'Requête non autorisée (origine ou en-tête MCP invalide).'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'Objet JSON attendu.'}), 400
command = data.get('action')
try:
if command == 'create_timer':
item = store.start('timer', data.get('seconds'), data.get('label'))
elif command == 'create_stopwatch':
item = store.start('stopwatch', data.get('seconds'), data.get('label'))
elif command == 'cancel_all':
cancelled = store.cancel_all()
return jsonify({'ok': True, 'cancelled': cancelled, 'items': store.snapshot()})
else:
item = store.action(command, data.get('id'), data.get('seconds'))
return jsonify({'ok': True, 'item': item, 'items': store.snapshot()})
except TimerError as exc:
return jsonify({'error': str(exc)}), 400
except OSError as exc:
print('[ALFRED TIMERS] écriture échouée:', repr(exc), flush=True)
return jsonify({'error': 'Stockage des minuteurs indisponible.'}), 503
app.register_blueprint(bp)
threading.Thread(target=store.run, name='alfred-timers', daemon=True).start()
return store
+28
View File
@@ -302,6 +302,12 @@ def listen_once(stop_event: Optional[threading.Event] = None,
model_path = os.getenv("VOSK_MODEL_PATH", "")
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
# ALFRED_WYOMING_WHISPER_STT_V1: Vosk segments the utterance and
# provides UI partials. Wyoming Whisper replaces only its final transcript.
stt_engine = os.getenv("STT_ENGINE", "vosk").strip().lower()
wyoming_enabled = stt_engine in ("wyoming", "wyoming_whisper", "whisper")
wyoming_audio = bytearray()
wyoming_max_bytes = max(16000, int(os.getenv("STT_MAX_AUDIO_SECONDS", "30")) * sample_rate * 2)
if not model_path or not os.path.exists(model_path):
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
try:
@@ -311,6 +317,9 @@ def listen_once(stop_event: Optional[threading.Event] = None,
return STTResult(error=f"Init Vosk: {exc}")
def process(data: bytes) -> Optional[str]:
# ALFRED_WYOMING_WHISPER_STT_V1: retain the exact PCM heard by Vosk.
if wyoming_enabled and len(wyoming_audio) < wyoming_max_bytes:
wyoming_audio.extend(data[:wyoming_max_bytes - len(wyoming_audio)])
if on_level and data:
try:
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
@@ -321,6 +330,25 @@ def listen_once(stop_event: Optional[threading.Event] = None,
if rec.AcceptWaveform(data):
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
if text:
if wyoming_enabled and wyoming_audio:
# ALFRED_WYOMING_WHISPER_STT_V1: remote final STT, local fallback.
try:
from services.wyoming_stt import transcribe_pcm
whisper_text = transcribe_pcm(
bytes(wyoming_audio),
host=os.getenv("WYOMING_WHISPER_HOST", "192.168.1.13"),
port=int(os.getenv("WYOMING_WHISPER_PORT", "10300")),
language=os.getenv("WYOMING_WHISPER_LANGUAGE", "fr"),
sample_rate=sample_rate,
connect_timeout=float(os.getenv("WYOMING_WHISPER_CONNECT_TIMEOUT", "3")),
response_timeout=float(os.getenv("WYOMING_WHISPER_TIMEOUT", "20")),
)
if whisper_text:
print(f"[ALFRED STT] Whisper final : {whisper_text!r} (Vosk : {text!r})", flush=True)
return whisper_text
print("[ALFRED STT] Whisper réponse vide, secours Vosk", flush=True)
except Exception as exc:
print(f"[ALFRED STT] Whisper indisponible, secours Vosk : {exc}", flush=True)
return text
elif on_partial:
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
+98
View File
@@ -0,0 +1,98 @@
"""Minimal Wyoming TCP ASR client for Alfred.
No external Wyoming Python dependency. Transport: PCM signed 16-bit LE mono,
16 kHz by default, following the official transcribe/audio-start/chunk/stop flow.
"""
from __future__ import annotations
import json
import socket
from typing import Optional
class WyomingSTTError(RuntimeError):
pass
def _event(kind: str, data: Optional[dict] = None, payload: bytes = b"") -> bytes:
header = {"type": kind}
if data is not None:
header["data"] = data
if payload:
header["payload_length"] = len(payload)
return (json.dumps(header, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8") + payload
def _read_event(stream) -> tuple[str, dict]:
header_bytes = stream.readline(65537)
if not header_bytes:
raise WyomingSTTError("Connexion Wyoming fermée sans transcription")
if len(header_bytes) > 65536 or not header_bytes.endswith(b"\n"):
raise WyomingSTTError("En-tête Wyoming invalide ou trop long")
try:
header = json.loads(header_bytes)
if not isinstance(header, dict):
raise ValueError("en-tête non objet")
data_length = int(header.get("data_length", 0))
payload_length = int(header.get("payload_length", 0))
if not 0 <= data_length <= 65536 or not 0 <= payload_length <= 1_048_576:
raise ValueError("taille d'événement excessive")
data = header.get("data") or {}
if not isinstance(data, dict):
raise ValueError("données non objet")
if data_length:
extra_data = stream.read(data_length)
if len(extra_data) != data_length:
raise WyomingSTTError("Données Wyoming incomplètes")
more = json.loads(extra_data)
if not isinstance(more, dict):
raise ValueError("données additionnelles non objet")
data.update(more)
if payload_length:
payload = stream.read(payload_length)
if len(payload) != payload_length:
raise WyomingSTTError("Charge utile Wyoming incomplète")
return str(header["type"]), data
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc:
raise WyomingSTTError(f"Message Wyoming incorrect : {exc}") from exc
def transcribe_pcm(
pcm: bytes,
*,
host: str = "192.168.1.13",
port: int = 10300,
language: str = "fr",
sample_rate: int = 16000,
connect_timeout: float = 3.0,
response_timeout: float = 20.0,
) -> str:
"""Send recorded S16LE/mono PCM to Wyoming ASR and return the transcript."""
if not pcm or len(pcm) % 2:
raise WyomingSTTError("Audio PCM vide ou mal aligné")
if sample_rate <= 0:
raise WyomingSTTError("Fréquence PCM invalide")
if not 0 < response_timeout <= 300:
raise WyomingSTTError("Délai Wyoming invalide")
try:
with socket.create_connection((host, int(port)), timeout=connect_timeout) as sock:
sock.settimeout(response_timeout)
sock.sendall(_event("transcribe", {"language": language}))
audio_meta = {"rate": int(sample_rate), "width": 2, "channels": 1}
sock.sendall(_event("audio-start", audio_meta))
for offset in range(0, len(pcm), 16000):
chunk = pcm[offset : offset + 16000]
sock.sendall(_event("audio-chunk", audio_meta, chunk))
sock.sendall(_event("audio-stop"))
with sock.makefile("rb") as stream:
# Some servers send informational events before the transcript.
for _ in range(16):
kind, data = _read_event(stream)
if kind == "transcript":
return str(data.get("text") or "").strip()
if kind == "error":
raise WyomingSTTError(str(data.get("text") or data.get("message") or data))
raise WyomingSTTError("Aucune transcription après 16 événements Wyoming")
except (OSError, TimeoutError) as exc:
raise WyomingSTTError(f"Serveur Whisper {host}:{port} indisponible : {exc}") from exc