L'overlay ne peut plus rester fige a l'ecran

Le halo est reste affiche indefiniment. Depuis la refonte du design, la fenetre ne
se ferme plus a l'arret de l'enregistrement mais apres le collage : tout blocage
dans cette chaine — transcription qui traine, thread mort, bug en amont — la laisse
a l'ecran, et l'utilisateur n'a aucun moyen de s'en debarrasser.

La fenetre porte desormais sa propre duree de vie : une minuterie armee dans show()
la ferme quoi qu'il arrive, et hide() l'annule au passage normal. Le garde-fou ne
depend d'AUCUN code en amont, c'est tout l'interet.

La limite doit couvrir le pire cas reel — un enregistrement de duree maximale SUIVI
de sa transcription — sinon on remplacerait un halo fige par un halo qui s'evapore
pendant que Zonza travaille. validate_config refuse desormais une valeur trop courte
(210 s pour 120 s d'enregistrement + 30 s de marge minimale).

Prouve en conditions reelles, limite abaissee a 3 s : visible a 1,5 s (pas de
fermeture prematuree), fermee a 4 s et 5,5 s.

60 tests passent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ralph Mayola 2026-08-21 01:48:07 +02:00
parent 58ed0d9e6c
commit 5710cb69ce
3 changed files with 77 additions and 1 deletions

24
core.py
View File

@ -15,7 +15,10 @@ CONFIG = {
"min_duration_s": 0.3, "min_duration_s": 0.3,
"attente_modificateurs_s": 2.0, # on patiente avant de coller (cf inject_text) "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 "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, "sample_rate": 16000,
"fps": 30, "fps": 30,
# --- overlay « barre de dictee » (bas de l'ecran) --- # --- 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()} 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): def validate_config(config):
"""Valide la config ; lève ValueError si une valeur est aberrante.""" """Valide la config ; lève ValueError si une valeur est aberrante."""
if config["min_duration_s"] < 0: if config["min_duration_s"] < 0:
@ -141,6 +158,11 @@ def validate_config(config):
raise ValueError("sample_rate doit être > 0") raise ValueError("sample_rate doit être > 0")
if not config["hotkey"]: if not config["hotkey"]:
raise ValueError("hotkey ne peut pas être vide") 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): def is_long_enough(num_samples, sample_rate, min_duration_s):

View File

@ -175,6 +175,7 @@ class PulseWindow(NSObject):
self._window = None self._window = None
self._view = None self._view = None
self._timer = None self._timer = None
self._garde = None # ferme l'overlay quoi qu'il arrive (cf show)
return self return self
def _window_size(self): def _window_size(self):
@ -229,12 +230,25 @@ class PulseWindow(NSObject):
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
1.0 / float(CONFIG["fps"]), self, "_tick:", None, True 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): def hide(self):
"""Arrête l'animation et masque la fenêtre. Thread principal.""" """Arrête l'animation et masque la fenêtre. Thread principal."""
if self._timer is not None: if self._timer is not None:
self._timer.invalidate() self._timer.invalidate()
self._timer = None self._timer = None
if self._garde is not None:
self._garde.invalidate()
self._garde = None
if self._window is not None: if self._window is not None:
self._window.orderOut_(None) self._window.orderOut_(None)
with self._lock: with self._lock:

View File

@ -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