Le controleur passe par les contrats, la duplication disparait

This commit is contained in:
Ralph Mayola 2026-08-23 18:19:25 +02:00
parent 3c3ea4444d
commit dcec1e61d0
5 changed files with 61 additions and 87 deletions

13
app.py
View File

@ -31,6 +31,7 @@ from pynput import keyboard
from controller import ZonzaController from controller import ZonzaController
from core import CONFIG, validate_config from core import CONFIG, validate_config
from interface import classe_overlay_pour from interface import classe_overlay_pour
from moteur import choisir_transcripteur, detecter_moteur
from systeme import choisir_systeme from systeme import choisir_systeme
_STATE_TITLES = { _STATE_TITLES = {
@ -43,7 +44,7 @@ _STATE_TITLES = {
class ZonzaApp(NSObject): class ZonzaApp(NSObject):
"""Délégué applicatif : status item + menu + hotkey + contrôleur.""" """Délégué applicatif : status item + menu + hotkey + contrôleur."""
def init(self): def initWithTranscripteur_systeme_(self, transcripteur, systeme):
self = super().init() self = super().init()
if self is None: if self is None:
return None return None
@ -51,7 +52,7 @@ class ZonzaApp(NSObject):
self._sounds_item = None self._sounds_item = None
self.pulse = classe_overlay_pour().alloc().init() self.pulse = classe_overlay_pour().alloc().init()
self.controller = ZonzaController( self.controller = ZonzaController(
CONFIG, self.pulse, on_state=self._set_state, CONFIG, self.pulse, transcripteur, systeme, on_state=self._set_state,
run_on_main=AppHelper.callAfter, run_on_main=AppHelper.callAfter,
) )
self._listener = None self._listener = None
@ -140,6 +141,12 @@ def main():
return return
main._lock = lock # garder le verrou vivant tant que le process vit main._lock = lock # garder le verrou vivant tant que le process vit
validate_config(CONFIG) validate_config(CONFIG)
transcripteur = choisir_transcripteur(
nom=detecter_moteur(),
modele=CONFIG["model"],
langue=CONFIG["language"],
amorce=CONFIG["initial_prompt"],
)
# si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle # si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste — # (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages. # bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
@ -150,7 +157,7 @@ def main():
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True}) trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
print(f"[diag] accessibilité accordée : {bool(trusted)}") print(f"[diag] accessibilité accordée : {bool(trusted)}")
app = NSApplication.sharedApplication() app = NSApplication.sharedApplication()
delegate = ZonzaApp.alloc().init() delegate = ZonzaApp.alloc().initWithTranscripteur_systeme_(transcripteur, systeme)
delegate.setup() delegate.setup()
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît) # référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
main._delegate = delegate main._delegate = delegate

View File

@ -1,66 +1,35 @@
"""Moteur de dictée — capture, transcription, collage, son. """Capture audio et collage du texte transcrit.
Aucune dépendance UI : importable par le contrôleur et l'app. Aucune dépendance UI : importable par le contrôleur et l'app. La transcription
et les appels système propres à une plateforme vivent désormais ailleurs
(`moteur/`, `systeme/`) ce module ne garde que ce qui n'a pas de frontière
de plateforme : la capture du micro et le collage du résultat.
""" """
import subprocess
import time import time
import mlx_whisper
import numpy as np import numpy as np
import pyperclip import pyperclip
import sounddevice as sd import sounddevice as sd
from pynput.keyboard import Controller, Key from pynput.keyboard import Controller, Key
from core import CONFIG, attendre, clean_transcript from core import CONFIG, attendre
SOUNDS = {
"start": "/System/Library/Sounds/Pop.aiff",
"done": "/System/Library/Sounds/Glass.aiff",
"error": "/System/Library/Sounds/Basso.aiff",
}
_kbd = Controller() _kbd = Controller()
def play_sound(name): def inject_text(text, systeme):
"""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. """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 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 la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
cible ne colle rien sans la moindre erreur de notre côté (constaté le l'application cible ne colle rien — sans la moindre erreur de notre côté
2026-08-21 : deux dictées transcrites, jamais collées). (constaté le 2026-08-21).
""" """
if not text: if not text:
return return
pyperclip.copy(text) pyperclip.copy(text)
libre = attendre( libre = attendre(
lambda: not modificateurs_enfonces(), lambda: not systeme.modificateurs_enfonces(),
CONFIG["attente_modificateurs_s"], CONFIG["attente_modificateurs_s"],
0.03, 0.03,
) )
@ -131,30 +100,3 @@ class Recorder:
if not frames: if not frames:
return np.zeros(0, dtype="float32") return np.zeros(0, dtype="float32")
return np.concatenate(frames, axis=0).flatten() return np.concatenate(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", ""))

View File

@ -11,15 +11,15 @@ contexte de l'app). La transcription, elle, part sur un thread de fond.
import threading import threading
from audio_level import compute_rms, shape_level from audio_level import compute_rms, shape_level
from capture import Recorder, inject_text
from core import entree_audio_muette, is_long_enough from core import entree_audio_muette, is_long_enough
from engine import Recorder, Transcriber, inject_text, play_sound
class ZonzaController: class ZonzaController:
"""Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu).""" """Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu)."""
def __init__(self, config, pulse_window, on_state=None, run_on_main=None, def __init__(self, config, pulse_window, transcripteur, systeme, on_state=None,
lancer_tache=None): run_on_main=None, lancer_tache=None):
self.config = config self.config = config
self.pulse = pulse_window self.pulse = pulse_window
self.on_state = on_state or (lambda s: None) self.on_state = on_state or (lambda s: None)
@ -34,7 +34,8 @@ class ZonzaController:
lambda fn: threading.Thread(target=fn, daemon=True).start() lambda fn: threading.Thread(target=fn, daemon=True).start()
) )
self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block) self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block)
self.transcriber = Transcriber(config["model"], config["language"]) self.transcriber = transcripteur
self.systeme = systeme
self.recording = False self.recording = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._smoothed_level = 0.0 self._smoothed_level = 0.0
@ -68,7 +69,7 @@ class ZonzaController:
self.recording = True self.recording = True
self.pulse.show() self.pulse.show()
self.pulse.set_status("Je vous écoute…") self.pulse.set_status("Je vous écoute…")
play_sound("start") self.systeme.jouer_son("start")
self.on_state("recording") self.on_state("recording")
self._arm_safety_timer() self._arm_safety_timer()
self._armer_veille_silence() self._armer_veille_silence()
@ -88,7 +89,7 @@ class ZonzaController:
if not entree_audio_muette(self._blocs_recus, self.recording): if not entree_audio_muette(self._blocs_recus, self.recording):
return return
print("[micro] aucun son reçu — le micro est probablement pris par une autre application") print("[micro] aucun son reçu — le micro est probablement pris par une autre application")
play_sound("error") self.systeme.jouer_son("error")
self.run_on_main(lambda: self.pulse.set_status("Aucun son — micro pris ailleurs ?")) self.run_on_main(lambda: self.pulse.set_status("Aucun son — micro pris ailleurs ?"))
def _ouvrir_flux(self): def _ouvrir_flux(self):
@ -97,7 +98,7 @@ class ZonzaController:
self.recorder.start() self.recorder.start()
except Exception as e: except Exception as e:
print(f"[erreur] démarrage enregistrement : {e}") print(f"[erreur] démarrage enregistrement : {e}")
play_sound("error") self.systeme.jouer_son("error")
self.recording = False self.recording = False
self._cancel_safety_timer() self._cancel_safety_timer()
self.run_on_main(self.pulse.hide) self.run_on_main(self.pulse.hide)
@ -143,22 +144,22 @@ class ZonzaController:
principal (2026-08-21).""" principal (2026-08-21)."""
try: try:
audio = self.recorder.stop() audio = self.recorder.stop()
play_sound("done") self.systeme.jouer_son("done")
if not is_long_enough(len(audio), self.config["sample_rate"], if not is_long_enough(len(audio), self.config["sample_rate"],
self.config["min_duration_s"]): self.config["min_duration_s"]):
return return
text = self.transcriber.transcribe(audio) text = self.transcriber.transcrire(audio)
if not text: if not text:
return return
print(f"[texte] {text}") print(f"[texte] {text}")
try: try:
inject_text(text) inject_text(text, self.systeme)
except Exception as e: except Exception as e:
print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V") print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V")
play_sound("error") self.systeme.jouer_son("error")
except Exception as e: except Exception as e:
print(f"[erreur] arrêt ou transcription : {e}") print(f"[erreur] arrêt ou transcription : {e}")
play_sound("error") self.systeme.jouer_son("error")
finally: finally:
self.run_on_main(self.pulse.hide) self.run_on_main(self.pulse.hide)
self.on_state("idle") self.on_state("idle")

View File

@ -11,7 +11,7 @@ draine les tampons et peut bloquer), et sans jamais lever.
""" """
import numpy as np import numpy as np
from engine import Recorder from capture import Recorder
class FauxFlux: class FauxFlux:

View File

@ -50,14 +50,38 @@ class FauxRecorder:
def stop(self): return self.audio def stop(self): return self.audio
class FauxTranscripteur:
def warmup(self): pass
def transcrire(self, audio): return "bonjour"
class FauxSysteme:
def __init__(self): self.sons = []
def jouer_son(self, nom): self.sons.append(nom)
def modificateurs_enfonces(self): return False
def raccourci_defaut(self): return "<cmd>+<ctrl>+d"
def chemin_log(self): return "/tmp/zonza-test.log"
def dossier_donnees(self): return "/tmp/zonza-test"
def acquerir_verrou_instance(self, chemin): return object()
def _ctrl(recorder, taches=None): def _ctrl(recorder, taches=None):
pulse = FauxPulse() pulse = FauxPulse()
c = ZonzaController(CONFIG, pulse, lancer_tache=taches) c = ZonzaController(CONFIG, pulse, FauxTranscripteur(), FauxSysteme(), lancer_tache=taches)
c.recorder = recorder c.recorder = recorder
c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})()
return c, pulse return c, pulse
def test_le_controleur_n_importe_aucun_module_de_plateforme():
"""Le noyau ne doit connaître que des contrats. Si cette assertion tombe, une
dépendance de plateforme s'est glissée dans le chemin portable."""
import controller
source = open(controller.__file__, encoding="utf-8").read()
for interdit in ("AppKit", "Foundation", "objc", "Quartz", "mlx_whisper",
"afplay", "from engine import"):
assert interdit not in source, f"{interdit} n'a rien à faire dans controller.py"
def test_demarrer_ne_touche_pas_l_audio_sur_le_thread_principal(): def test_demarrer_ne_touche_pas_l_audio_sur_le_thread_principal():
differees = [] differees = []
c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append) c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)