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
+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