Le peripherique audio ne peut plus geler l'interface
Le halo s'est fige une seconde fois, bloque sur « Je vous ecoute… », forme d'onde plate, process a 0 % CPU. Une autre application (Loom) tenait le micro : le stream.stop() de PortAudio s'est bloque. Or toggle() s'executait sur le thread principal Cocoa — toute l'interface a gele avec lui. Et le garde-fou pose le matin meme etait inoperant PAR CONSTRUCTION : c'est un NSTimer, il vit sur la boucle d'evenements que le blocage venait justement d'arreter. Un garde-fou qui depend de ce qu'il est cense sauver ne sauve rien. Desormais, le chemin appele depuis le thread principal ne touche PLUS JAMAIS au peripherique : ouverture et fermeture du flux partent en tache de fond, le thread principal ne fait que changer l'etat et rafraichir l'overlay. Deux tests verrouillent la regle avec un recorder qui leve si on l'appelle depuis le mauvais endroit. S'ajoute une garde COURTE (20 s) armee des l'arret : passe ce point il ne reste que la transcription, quelques secondes ; laisser 210 s de halo, c'est « fige » pour l'utilisateur meme si le code finit par se rattraper. Prouve en reproduisant la panne — un stop() qui ne rend jamais la main : l'interface continue de battre (6, 12, 24 battements) et le halo se ferme seul. Avant, le compteur serait reste fige. 69 tests passent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
+69
-38
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user