zonza/engine.py
Ralph Mayola 58ed0d9e6c Attend le relachement des touches avant de coller
Deux dictees ont ete transcrites correctement puis n'ont jamais atteint la zone de
saisie, sans la moindre erreur : inject_text avait bien envoye ses touches. Cause :
la hotkey Cmd+Ctrl+D etait encore enfoncee quand Zonza a simule Cmd+V. Le systeme a
recu Cmd+Ctrl+V, que l'application cible ignore. Le code croyait avoir reussi.

inject_text patiente donc jusqu'a ce qu'aucun modificateur physique ne soit enfonce
(drapeaux Quartz), au plus 2 s. Au-dela, il colle quand meme : mieux vaut un collage
douteux qu'une transcription perdue. Le delai du presse-papier passe de 0,05 a 0,12 s,
0,05 etant juste.

Et surtout, le collage se TRACE desormais ("[collage] ok" ou l'avertissement) : sans
cela, un echec futur resterait invisible dans le log, comme celui-ci l'a ete.

L'attente est une unite pure et bornee (core.attendre, horloge et sommeil injectables).
Detecteur verifie en conditions reelles : False au repos, retour immediat.

55 tests passent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 01:42:44 +02:00

141 lines
4.3 KiB
Python

"""Moteur de dictée — capture, transcription, collage, son.
Aucune dépendance UI : importable par le contrôleur et l'app.
"""
import subprocess
import time
import mlx_whisper
import numpy as np
import pyperclip
import sounddevice as sd
from pynput.keyboard import Controller, Key
from core import CONFIG, attendre, clean_transcript
SOUNDS = {
"start": "/System/Library/Sounds/Pop.aiff",
"done": "/System/Library/Sounds/Glass.aiff",
"error": "/System/Library/Sounds/Basso.aiff",
}
_kbd = Controller()
def play_sound(name):
"""Joue un son système macOS sans bloquer. Silencieux si CONFIG['sounds'] est False."""
if not CONFIG["sounds"]:
return
path = SOUNDS.get(name)
if not path:
return
try:
subprocess.Popen(["afplay", path])
except Exception:
pass # le son ne doit jamais casser le daemon
# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
_MASQUE_MODIFICATEURS = 0x000F0000
def modificateurs_enfonces():
"""True si au moins un modificateur physique est actuellement enfoncé."""
try:
from Quartz import CGEventSourceFlagsState
return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
except Exception:
return False # dans le doute, ne jamais bloquer le collage
def inject_text(text):
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.
On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
Cmd+Ctrl+D quand on envoie Cmd+V, le système reçoit Cmd+Ctrl+V et l'application
cible ne colle rien — sans la moindre erreur de notre côté (constaté le
2026-08-21 : deux dictées transcrites, jamais collées).
"""
if not text:
return
pyperclip.copy(text)
libre = attendre(
lambda: not modificateurs_enfonces(),
CONFIG["attente_modificateurs_s"],
0.03,
)
if not libre:
print("[collage] modificateurs encore enfoncés — collage tenté quand même")
time.sleep(CONFIG["delai_presse_papier_s"]) # laisser le presse-papier se mettre à jour
with _kbd.pressed(Key.cmd):
_kbd.press("v")
_kbd.release("v")
print("[collage] ok")
class Recorder:
"""Capture le micro en mémoire (16 kHz mono float32). Start/stop par le contrôleur.
on_block : callback optionnel appelé à chaque bloc audio avec le tableau numpy du bloc
(utilisé pour alimenter l'animation en temps réel).
"""
def __init__(self, sample_rate, on_block=None):
self.sample_rate = sample_rate
self.on_block = on_block
self._frames = []
self._stream = None
def _callback(self, indata, frames, time_info, status):
block = indata.copy()
self._frames.append(block)
if self.on_block is not None:
self.on_block(block.flatten())
def start(self):
self._frames = []
self._stream = sd.InputStream(
samplerate=self.sample_rate,
channels=1,
dtype="float32",
callback=self._callback,
)
self._stream.start()
def stop(self):
"""Arrête le flux et renvoie l'audio concaténé (numpy 1-D float32)."""
if self._stream is not None:
self._stream.stop()
self._stream.close()
self._stream = None
if not self._frames:
return np.zeros(0, dtype="float32")
return np.concatenate(self._frames, axis=0).flatten()
class Transcriber:
"""Transcrit l'audio en français avec MLX-Whisper.
Note : mlx_whisper.transcribe charge/met en cache le modèle au premier appel.
Le premier lancement télécharge le modèle depuis Hugging Face.
"""
def __init__(self, model, language):
self.model = model
self.language = language
def warmup(self):
"""Force le téléchargement/chargement du modèle au démarrage (1 fois)."""
silence = np.zeros(16000, dtype="float32")
self.transcribe(silence)
def transcribe(self, audio):
"""Transcrit l'audio (numpy float32) et renvoie le texte nettoyé."""
result = mlx_whisper.transcribe(
audio,
path_or_hf_repo=self.model,
language=self.language,
initial_prompt=CONFIG["initial_prompt"],
)
return clean_transcript(result.get("text", ""))