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 core import CONFIG, validate_config
from interface import classe_overlay_pour
from moteur import choisir_transcripteur, detecter_moteur
from systeme import choisir_systeme
_STATE_TITLES = {
@ -43,7 +44,7 @@ _STATE_TITLES = {
class ZonzaApp(NSObject):
"""Délégué applicatif : status item + menu + hotkey + contrôleur."""
def init(self):
def initWithTranscripteur_systeme_(self, transcripteur, systeme):
self = super().init()
if self is None:
return None
@ -51,7 +52,7 @@ class ZonzaApp(NSObject):
self._sounds_item = None
self.pulse = classe_overlay_pour().alloc().init()
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,
)
self._listener = None
@ -140,6 +141,12 @@ def main():
return
main._lock = lock # garder le verrou vivant tant que le process vit
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
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
@ -150,7 +157,7 @@ def main():
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
print(f"[diag] accessibilité accordée : {bool(trusted)}")
app = NSApplication.sharedApplication()
delegate = ZonzaApp.alloc().init()
delegate = ZonzaApp.alloc().initWithTranscripteur_systeme_(transcripteur, systeme)
delegate.setup()
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
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 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",
}
from core import CONFIG, attendre
_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):
def inject_text(text, systeme):
"""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).
la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
l'application cible ne colle rien — sans la moindre erreur de notre côté
(constaté le 2026-08-21).
"""
if not text:
return
pyperclip.copy(text)
libre = attendre(
lambda: not modificateurs_enfonces(),
lambda: not systeme.modificateurs_enfonces(),
CONFIG["attente_modificateurs_s"],
0.03,
)
@ -131,30 +100,3 @@ class Recorder:
if not frames:
return np.zeros(0, dtype="float32")
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
from audio_level import compute_rms, shape_level
from capture import Recorder, inject_text
from core import entree_audio_muette, is_long_enough
from engine import Recorder, Transcriber, inject_text, play_sound
class ZonzaController:
"""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,
lancer_tache=None):
def __init__(self, config, pulse_window, transcripteur, systeme, on_state=None,
run_on_main=None, lancer_tache=None):
self.config = config
self.pulse = pulse_window
self.on_state = on_state or (lambda s: None)
@ -34,7 +34,8 @@ class ZonzaController:
lambda fn: threading.Thread(target=fn, daemon=True).start()
)
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._lock = threading.Lock()
self._smoothed_level = 0.0
@ -68,7 +69,7 @@ class ZonzaController:
self.recording = True
self.pulse.show()
self.pulse.set_status("Je vous écoute…")
play_sound("start")
self.systeme.jouer_son("start")
self.on_state("recording")
self._arm_safety_timer()
self._armer_veille_silence()
@ -88,7 +89,7 @@ class ZonzaController:
if not entree_audio_muette(self._blocs_recus, self.recording):
return
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 ?"))
def _ouvrir_flux(self):
@ -97,7 +98,7 @@ class ZonzaController:
self.recorder.start()
except Exception as e:
print(f"[erreur] démarrage enregistrement : {e}")
play_sound("error")
self.systeme.jouer_son("error")
self.recording = False
self._cancel_safety_timer()
self.run_on_main(self.pulse.hide)
@ -143,22 +144,22 @@ class ZonzaController:
principal (2026-08-21)."""
try:
audio = self.recorder.stop()
play_sound("done")
self.systeme.jouer_son("done")
if not is_long_enough(len(audio), self.config["sample_rate"],
self.config["min_duration_s"]):
return
text = self.transcriber.transcribe(audio)
text = self.transcriber.transcrire(audio)
if not text:
return
print(f"[texte] {text}")
try:
inject_text(text)
inject_text(text, self.systeme)
except Exception as e:
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:
print(f"[erreur] arrêt ou transcription : {e}")
play_sound("error")
self.systeme.jouer_son("error")
finally:
self.run_on_main(self.pulse.hide)
self.on_state("idle")

View File

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

View File

@ -50,14 +50,38 @@ class FauxRecorder:
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):
pulse = FauxPulse()
c = ZonzaController(CONFIG, pulse, lancer_tache=taches)
c = ZonzaController(CONFIG, pulse, FauxTranscripteur(), FauxSysteme(), lancer_tache=taches)
c.recorder = recorder
c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})()
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():
differees = []
c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)