"""Chef d'orchestre : machine à états idle <-> recording. Branche le micro sur l'animation (niveau RMS → cercle) pendant l'enregistrement, puis lance la transcription sur un thread de fond et colle le texte. Ne meurt jamais sur une erreur : tout est attrapé et ramène à idle (fenêtre fermée). Les méthodes touchant l'UI (PulseWindow show/hide) doivent être invoquées sur le thread principal — c'est le rôle de l'appelant (app via la hotkey, qui s'exécute déjà dans le contexte de l'app). La transcription, elle, part sur un thread de fond. """ import threading from audio_level import compute_rms, shape_level from core import 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): self.config = config self.pulse = pulse_window self.on_state = on_state or (lambda s: None) # run_on_main : marshale un appel sur le thread principal Cocoa (requis pour l'UI). # Par défaut exécute directement (utile pour les tests sans boucle Cocoa). self.run_on_main = run_on_main or (lambda fn: fn()) self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block) self.transcriber = Transcriber(config["model"], config["language"]) self.recording = False self._lock = threading.Lock() self._smoothed_level = 0.0 self._safety_timer = None # arrêt auto si l'enregistrement traîne trop def _on_audio_block(self, block): # thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release # → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée # rapide quand on parle, retombée douce dans les silences. target = shape_level(compute_rms(block), self.config["level_gain"]) if target > self._smoothed_level: coef = self.config["level_attack"] else: coef = self.config["level_release"] self._smoothed_level += (target - self._smoothed_level) * coef self.pulse.set_level(self._smoothed_level) def toggle(self): with self._lock: if not self.recording: self._start() else: self._stop_and_process() def _start(self): try: self._smoothed_level = 0.0 self.recorder.start() self.recording = True self.pulse.show() play_sound("start") self.on_state("recording") self._arm_safety_timer() except Exception as e: print(f"[erreur] démarrage enregistrement : {e}") play_sound("error") self.recording = False self.pulse.hide() self.on_state("idle") def _arm_safety_timer(self): # sécurité : si l'enregistrement n'est pas arrêté manuellement après max_duration_s, # on l'arrête automatiquement → la fenêtre du cercle ne peut jamais rester figée. self._cancel_safety_timer() self._safety_timer = threading.Timer( self.config["max_duration_s"], self._safety_stop ) self._safety_timer.daemon = True self._safety_timer.start() def _cancel_safety_timer(self): if self._safety_timer is not None: self._safety_timer.cancel() self._safety_timer = None def _safety_stop(self): # déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc # on marshale toggle() sur le thread principal Cocoa. print("[sécurité] durée max atteinte → arrêt auto de l'enregistrement") self.run_on_main(self.toggle) def _stop_and_process(self): self.recording = False self._cancel_safety_timer() try: audio = self.recorder.stop() self.pulse.hide() play_sound("done") if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]): self.on_state("idle") return self.on_state("transcribing") threading.Thread(target=self._transcribe_and_inject, args=(audio,), daemon=True).start() except Exception as e: print(f"[erreur] arrêt enregistrement : {e}") play_sound("error") self.pulse.hide() self.on_state("idle") def _transcribe_and_inject(self, audio): # thread de fond : transcription lente + collage try: text = self.transcriber.transcribe(audio) if not text: return print(f"[texte] {text}") try: inject_text(text) except Exception as e: print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V") play_sound("error") except Exception as e: print(f"[erreur] transcription : {e}") play_sound("error") finally: self.on_state("idle")