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>
141 lines
4.3 KiB
Python
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", ""))
|