diff --git a/core.py b/core.py index 8f4747e..1414255 100644 --- a/core.py +++ b/core.py @@ -15,7 +15,10 @@ CONFIG = { "min_duration_s": 0.3, "attente_modificateurs_s": 2.0, # on patiente avant de coller (cf inject_text) "delai_presse_papier_s": 0.12, # 0.05 s etait juste : le presse-papier n'etait pas pret - "max_duration_s": 120.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée) + "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) "sample_rate": 16000, "fps": 30, # --- overlay « barre de dictee » (bas de l'ecran) --- @@ -133,6 +136,20 @@ def bar_metrics(bar_height): return {cle: bar_height * f for cle, f in _PROPORTIONS.items()} +# Marge minimale a laisser pour transcrire apres un enregistrement de duree maximale. +MARGE_TRANSCRIPTION_S = 30.0 + + +def duree_vie_overlay_coherente(config): + """La limite de vie de l'overlay couvre-t-elle le pire cas reel ? + + Pire cas : un enregistrement qui va jusqu'a max_duration_s, suivi de sa + transcription. Une limite trop courte fermerait le halo PENDANT que Zonza + travaille encore — on remplacerait un halo fige par un halo qui s'evapore. + """ + return config["duree_max_overlay_s"] >= config["max_duration_s"] + MARGE_TRANSCRIPTION_S + + def validate_config(config): """Valide la config ; lève ValueError si une valeur est aberrante.""" if config["min_duration_s"] < 0: @@ -141,6 +158,11 @@ def validate_config(config): raise ValueError("sample_rate doit être > 0") if not config["hotkey"]: raise ValueError("hotkey ne peut pas être vide") + if not duree_vie_overlay_coherente(config): + raise ValueError( + "duree_max_overlay_s doit couvrir max_duration_s + " + f"{MARGE_TRANSCRIPTION_S} s de transcription" + ) def is_long_enough(num_samples, sample_rate, min_duration_s): diff --git a/pulse_window.py b/pulse_window.py index cc427db..88e1f4e 100644 --- a/pulse_window.py +++ b/pulse_window.py @@ -175,6 +175,7 @@ class PulseWindow(NSObject): self._window = None self._view = None self._timer = None + self._garde = None # ferme l'overlay quoi qu'il arrive (cf show) return self def _window_size(self): @@ -229,12 +230,25 @@ class PulseWindow(NSObject): self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( 1.0 / float(CONFIG["fps"]), self, "_tick:", None, True ) + # Garde-fou : la fenêtre porte sa propre durée de vie. Si le contrôleur + # échoue à la refermer — transcription bloquée, thread mort, bug en amont — + # elle se ferme seule plutôt que de rester figée à l'écran (2026-08-21). + self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + float(CONFIG["duree_max_overlay_s"]), self, "_expiration:", None, False + ) + + def _expiration_(self, timer): + print("[garde] overlay ouvert trop longtemps — fermeture forcée") + self.hide() def hide(self): """Arrête l'animation et masque la fenêtre. Thread principal.""" if self._timer is not None: self._timer.invalidate() self._timer = None + if self._garde is not None: + self._garde.invalidate() + self._garde = None if self._window is not None: self._window.orderOut_(None) with self._lock: diff --git a/tests/test_garde_overlay.py b/tests/test_garde_overlay.py new file mode 100644 index 0000000..0a30e97 --- /dev/null +++ b/tests/test_garde_overlay.py @@ -0,0 +1,40 @@ +"""Durée de vie maximale de l'overlay. + +Bug du 2026-08-21 : le halo est resté figé à l'écran. Depuis la refonte du design, +la fenêtre ne se ferme plus à l'arrêt de l'enregistrement mais après le collage — +donc tout blocage dans cette chaîne la laisse affichée indéfiniment. + +La fenêtre porte désormais sa propre limite : quoi qu'il arrive en amont, elle se +referme seule. Ce test verrouille la COHÉRENCE de la limite, qui doit couvrir le +pire cas réel — un enregistrement de durée maximale SUIVI d'une transcription. +""" +import pytest + +from core import CONFIG, duree_vie_overlay_coherente + + +def test_la_limite_couvre_un_enregistrement_maximal_plus_la_transcription(): + assert duree_vie_overlay_coherente(CONFIG) is True + + +def test_une_limite_plus_courte_que_l_enregistrement_est_refusee(): + mauvais = dict(CONFIG, duree_max_overlay_s=CONFIG["max_duration_s"] - 1) + assert duree_vie_overlay_coherente(mauvais) is False + + +def test_une_limite_sans_marge_pour_transcrire_est_refusee(): + # juste egale a la duree d'enregistrement : la transcription serait coupee + serre = dict(CONFIG, duree_max_overlay_s=CONFIG["max_duration_s"]) + assert duree_vie_overlay_coherente(serre) is False + + +def test_validate_config_rejette_une_limite_incoherente(): + from core import validate_config + mauvais = dict(CONFIG, duree_max_overlay_s=10.0) + with pytest.raises(ValueError): + validate_config(mauvais) + + +def test_la_config_livree_est_coherente(): + from core import validate_config + validate_config(CONFIG) # ne doit pas lever