diff --git a/controller.py b/controller.py index ea35503..fa31b32 100644 --- a/controller.py +++ b/controller.py @@ -18,13 +18,21 @@ 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): + def __init__(self, config, pulse_window, 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) # 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()) + # lancer_tache : execute un travail HORS du thread principal. Tout ce qui + # touche au peripherique audio passe par la — PortAudio peut bloquer + # indefiniment si une autre application accapare le micro, et un blocage + # sur le thread principal gele TOUTE l'interface Cocoa (halo compris). + self.lancer_tache = lancer_tache or ( + 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.recording = False @@ -52,20 +60,25 @@ class ZonzaController: self._stop_and_process() def _start(self): + self._smoothed_level = 0.0 + self.recording = True + self.pulse.show() + self.pulse.set_status("Je vous écoute…") + play_sound("start") + self.on_state("recording") + self._arm_safety_timer() + self.lancer_tache(self._ouvrir_flux) + + def _ouvrir_flux(self): + """Ouvre le micro. Hors thread principal : peut bloquer (cf __init__).""" try: - self._smoothed_level = 0.0 self.recorder.start() - self.recording = True - self.pulse.show() - self.pulse.set_status("Je vous écoute…") - 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._cancel_safety_timer() + self.run_on_main(self.pulse.hide) self.on_state("idle") def _arm_safety_timer(self): @@ -90,29 +103,25 @@ class ZonzaController: self.run_on_main(self.toggle) def _stop_and_process(self): + """Appelé depuis le thread principal : ne touche PAS au périphérique audio.""" self.recording = False self._cancel_safety_timer() + self.pulse.set_status("Transcription…") + self.pulse.rearmer_garde(self.config["duree_max_transcription_s"]) + self.on_state("transcribing") + self.lancer_tache(self._fermer_et_transcrire) + + def _fermer_et_transcrire(self): + """Ferme le flux, transcrit, colle. Hors thread principal : `recorder.stop()` + peut bloquer si le micro a été accaparé par une autre application — c'est + précisément ce qui figeait le halo quand cet appel vivait sur le thread + principal (2026-08-21).""" try: audio = self.recorder.stop() play_sound("done") - if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]): - self.pulse.hide() - self.on_state("idle") + if not is_long_enough(len(audio), self.config["sample_rate"], + self.config["min_duration_s"]): return - # la fenetre reste affichee : la bulle porte l'etape en cours, sinon - # l'ecran se vide pendant les secondes de calcul et on croit a un plantage. - self.pulse.set_status("Transcription…") - 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 @@ -123,7 +132,7 @@ class ZonzaController: 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}") + print(f"[erreur] arrêt ou transcription : {e}") play_sound("error") finally: self.run_on_main(self.pulse.hide) diff --git a/core.py b/core.py index 1414255..c37fe1d 100644 --- a/core.py +++ b/core.py @@ -18,7 +18,10 @@ CONFIG = { "max_duration_s": 120.0, # Duree de vie MAXIMALE de l'overlay, garde-fou contre un halo fige (2026-08-21). # Doit couvrir un enregistrement maximal PUIS sa transcription. - "duree_max_overlay_s": 210.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée) + "duree_max_overlay_s": 210.0, + # Garde COURTE, armee des l'arret : il ne reste alors que la transcription, + # qui se compte en secondes. 210 s a ce stade, c'est « fige » pour l'utilisateur. + "duree_max_transcription_s": 20.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée) "sample_rate": 16000, "fps": 30, # --- overlay « barre de dictee » (bas de l'ecran) --- @@ -158,6 +161,11 @@ def validate_config(config): raise ValueError("sample_rate doit être > 0") if not config["hotkey"]: raise ValueError("hotkey ne peut pas être vide") + d = config.get("duree_max_transcription_s") + if d is not None and not (10.0 <= d <= 30.0): + raise ValueError("duree_max_transcription_s doit tenir entre 10 et 30 s") + if d is not None and d >= config["duree_max_overlay_s"]: + raise ValueError("duree_max_transcription_s doit rester sous duree_max_overlay_s") if not duree_vie_overlay_coherente(config): raise ValueError( "duree_max_overlay_s doit couvrir max_duration_s + " diff --git a/pulse_window.py b/pulse_window.py index 88e1f4e..31a250d 100644 --- a/pulse_window.py +++ b/pulse_window.py @@ -237,6 +237,19 @@ class PulseWindow(NSObject): float(CONFIG["duree_max_overlay_s"]), self, "_expiration:", None, False ) + def rearmer_garde(self, duree_s): + """Remplace la garde en cours par une plus courte. Thread principal. + + Appelee au passage en transcription : la garde longue couvre un + enregistrement de 2 min, mais une fois l'enregistrement fini il ne reste + que quelques secondes de calcul — inutile de laisser un halo trois minutes. + """ + if self._garde is not None: + self._garde.invalidate() + self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + float(duree_s), self, "_expiration:", None, False + ) + def _expiration_(self, timer): print("[garde] overlay ouvert trop longtemps — fermeture forcée") self.hide() diff --git a/tests/test_garde_transcription.py b/tests/test_garde_transcription.py new file mode 100644 index 0000000..dfd435c --- /dev/null +++ b/tests/test_garde_transcription.py @@ -0,0 +1,47 @@ +"""Deux gardes distinctes selon la phase. + +Le halo s'est fige a l'ecran une seconde fois. La garde posee ce matin est reglee +sur le PIRE cas — un enregistrement de 2 min suivi de sa transcription — soit 210 s. +Trois minutes et demie de halo, c'est fige du point de vue de l'utilisateur, meme si +le code finit par se rattraper. + +D'ou une garde COURTE des l'arret de l'enregistrement : a ce moment il ne reste que +la transcription, qui se compte en secondes. La garde longue ne couvre plus que la +phase d'enregistrement. +""" +import pytest + +from core import CONFIG, validate_config + + +def test_les_deux_gardes_existent(): + assert CONFIG["duree_max_overlay_s"] > 0 + assert CONFIG["duree_max_transcription_s"] > 0 + + +def test_la_garde_de_transcription_est_bien_plus_courte(): + assert CONFIG["duree_max_transcription_s"] < CONFIG["duree_max_overlay_s"] + + +def test_elle_laisse_le_temps_de_transcrire(): + # une transcription reelle prend 1 a 3 s ; en dessous de 10 s on couperait du travail + assert CONFIG["duree_max_transcription_s"] >= 10.0 + + +def test_elle_reste_courte_pour_l_utilisateur(): + # au-dela de 30 s, l'utilisateur parle de nouveau de « fige » + assert CONFIG["duree_max_transcription_s"] <= 30.0 + + +def test_validate_config_refuse_une_garde_de_transcription_trop_courte(): + with pytest.raises(ValueError): + validate_config(dict(CONFIG, duree_max_transcription_s=2.0)) + + +def test_validate_config_refuse_une_garde_plus_longue_que_la_globale(): + with pytest.raises(ValueError): + validate_config(dict(CONFIG, duree_max_transcription_s=CONFIG["duree_max_overlay_s"] + 1)) + + +def test_la_config_livree_reste_valide(): + validate_config(CONFIG) diff --git a/tests/test_statut_bulle.py b/tests/test_statut_bulle.py index 805e588..e4f62ab 100644 --- a/tests/test_statut_bulle.py +++ b/tests/test_statut_bulle.py @@ -1,9 +1,15 @@ -"""La bulle de l'overlay porte l'etat en cours. +"""Cycle de vie de l'overlay, et interdiction de bloquer le thread principal. -Zonza ne transcrit pas en continu : le texte n'existe qu'apres l'arret. La bulle -affiche donc l'etape ou l'on en est — « Je vous ecoute… » pendant la capture, -« Transcription… » pendant le calcul — et la fenetre reste visible jusqu'au -collage, au lieu de disparaitre en laissant l'utilisateur sans retour. +Bug du 2026-08-21 (deuxieme occurrence) : le halo est reste fige sur « Je vous +ecoute… », forme d'onde plate, process a 0 % CPU. Une autre application (Loom) +avait pris le micro ; `stream.stop()` de PortAudio s'est BLOQUE. Comme toggle() +s'execute sur le thread principal Cocoa, toute l'interface a gele — et le garde-fou +NSTimer pose le matin meme etait inoperant PAR CONSTRUCTION, puisqu'il vit sur cette +meme boucle d'evenements bloquee. + +D'ou la regle verrouillee ici : le chemin appele depuis le thread principal ne +touche JAMAIS au peripherique audio. Il change l'etat, met a jour l'overlay, et +delegue. """ import numpy as np @@ -16,57 +22,82 @@ class FauxPulse: self.statuts = [] self.visible = False - def show(self): - self.visible = True + def show(self): self.visible = True + def hide(self): self.visible = False + def set_level(self, level): pass + def set_status(self, text): self.statuts.append(text) + def rearmer_garde(self, duree_s): self.garde = duree_s - def hide(self): - self.visible = False - def set_level(self, level): - pass +class RecorderQuiBloque: + """Reproduit un peripherique accapare : start/stop ne rendent jamais la main.""" + def __init__(self): + self.start_appele = False + self.stop_appele = False - def set_status(self, text): - self.statuts.append(text) + def start(self): + self.start_appele = True + raise AssertionError("start() ne doit pas etre appele sur le thread principal") + + def stop(self): + self.stop_appele = True + raise AssertionError("stop() ne doit pas etre appele sur le thread principal") class FauxRecorder: - def __init__(self, audio): - self.audio = audio - - def start(self): - pass - - def stop(self): - return self.audio + def __init__(self, audio): self.audio = audio + def start(self): pass + def stop(self): return self.audio -def _ctrl(audio): +def _ctrl(recorder, taches=None): pulse = FauxPulse() - c = ZonzaController(CONFIG, pulse) - c.recorder = FauxRecorder(audio) + c = ZonzaController(CONFIG, pulse, lancer_tache=taches) + c.recorder = recorder c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})() return c, pulse -def test_la_bulle_annonce_l_ecoute_au_demarrage(): - c, pulse = _ctrl(np.zeros(16000, dtype="float32")) - c.toggle() +def test_demarrer_ne_touche_pas_l_audio_sur_le_thread_principal(): + differees = [] + c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append) + c.toggle() # ne doit rien lever assert pulse.visible assert any("écoute" in s.lower() for s in pulse.statuts) + assert len(differees) == 1, "l'ouverture du flux doit etre deleguee" -def test_la_fenetre_reste_visible_pendant_la_transcription(): - audio = np.ones(16000, dtype="float32") * 0.1 # 1 s : au-dessus du seuil - c, pulse = _ctrl(audio) - c.toggle() - pulse.statuts.clear() - c._stop_and_process() - assert pulse.visible, "la fenetre ne doit pas disparaitre avant la transcription" +def test_arreter_ne_touche_pas_l_audio_sur_le_thread_principal(): + differees = [] + c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append) + c.toggle() # demarre + differees.clear() + c.toggle() # arrete : ne doit rien lever non plus + assert len(differees) == 1, "la fermeture du flux doit etre deleguee" assert any("transcription" in s.lower() for s in pulse.statuts) -def test_un_enregistrement_trop_court_referme_tout_de_suite(): - c, pulse = _ctrl(np.zeros(10, dtype="float32")) # bien sous min_duration_s - c.toggle() - c._stop_and_process() +def test_la_bulle_annonce_l_ecoute_puis_la_transcription(): + differees = [] + c, pulse = _ctrl(FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append) + c.toggle(); c.toggle() + assert [s.lower() for s in pulse.statuts][:2] == ["je vous écoute…", "transcription…"] + + +def test_la_tache_differee_referme_l_overlay_a_la_fin(): + differees = [] + c, pulse = _ctrl(FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append) + c.toggle(); differees.clear(); c.toggle() + assert pulse.visible, "l'overlay reste tant que la tache n'a pas tourne" + for t in differees: + t() # on execute la tache differee + assert not pulse.visible, "l'overlay doit etre referme par la tache" + + +def test_un_enregistrement_trop_court_referme_aussi(): + differees = [] + c, pulse = _ctrl(FauxRecorder(np.zeros(10, dtype="float32")), taches=differees.append) + c.toggle(); differees.clear(); c.toggle() + for t in differees: + t() assert not pulse.visible