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:
Ralph Mayola 2026-08-21 12:14:47 +02:00
parent 5710cb69ce
commit 68e1f336c8
5 changed files with 174 additions and 66 deletions

View File

@ -18,13 +18,21 @@ from engine import Recorder, Transcriber, inject_text, play_sound
class ZonzaController: class ZonzaController:
"""Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu).""" """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.config = config
self.pulse = pulse_window self.pulse = pulse_window
self.on_state = on_state or (lambda s: None) self.on_state = on_state or (lambda s: None)
# run_on_main : marshale un appel sur le thread principal Cocoa (requis pour l'UI). # 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). # 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.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.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block)
self.transcriber = Transcriber(config["model"], config["language"]) self.transcriber = Transcriber(config["model"], config["language"])
self.recording = False self.recording = False
@ -52,20 +60,25 @@ class ZonzaController:
self._stop_and_process() self._stop_and_process()
def _start(self): def _start(self):
try:
self._smoothed_level = 0.0 self._smoothed_level = 0.0
self.recorder.start()
self.recording = True self.recording = True
self.pulse.show() self.pulse.show()
self.pulse.set_status("Je vous écoute…") self.pulse.set_status("Je vous écoute…")
play_sound("start") play_sound("start")
self.on_state("recording") self.on_state("recording")
self._arm_safety_timer() 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.recorder.start()
except Exception as e: except Exception as e:
print(f"[erreur] démarrage enregistrement : {e}") print(f"[erreur] démarrage enregistrement : {e}")
play_sound("error") play_sound("error")
self.recording = False self.recording = False
self.pulse.hide() self._cancel_safety_timer()
self.run_on_main(self.pulse.hide)
self.on_state("idle") self.on_state("idle")
def _arm_safety_timer(self): def _arm_safety_timer(self):
@ -90,29 +103,25 @@ class ZonzaController:
self.run_on_main(self.toggle) self.run_on_main(self.toggle)
def _stop_and_process(self): def _stop_and_process(self):
"""Appelé depuis le thread principal : ne touche PAS au périphérique audio."""
self.recording = False self.recording = False
self._cancel_safety_timer() 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: try:
audio = self.recorder.stop() audio = self.recorder.stop()
play_sound("done") play_sound("done")
if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]): if not is_long_enough(len(audio), self.config["sample_rate"],
self.pulse.hide() self.config["min_duration_s"]):
self.on_state("idle")
return 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) text = self.transcriber.transcribe(audio)
if not text: if not text:
return return
@ -123,7 +132,7 @@ class ZonzaController:
print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V") print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V")
play_sound("error") play_sound("error")
except Exception as e: except Exception as e:
print(f"[erreur] transcription : {e}") print(f"[erreur] arrêt ou transcription : {e}")
play_sound("error") play_sound("error")
finally: finally:
self.run_on_main(self.pulse.hide) self.run_on_main(self.pulse.hide)

10
core.py
View File

@ -18,7 +18,10 @@ CONFIG = {
"max_duration_s": 120.0, "max_duration_s": 120.0,
# Duree de vie MAXIMALE de l'overlay, garde-fou contre un halo fige (2026-08-21). # Duree de vie MAXIMALE de l'overlay, garde-fou contre un halo fige (2026-08-21).
# Doit couvrir un enregistrement maximal PUIS sa transcription. # 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, "sample_rate": 16000,
"fps": 30, "fps": 30,
# --- overlay « barre de dictee » (bas de l'ecran) --- # --- overlay « barre de dictee » (bas de l'ecran) ---
@ -158,6 +161,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")
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): if not duree_vie_overlay_coherente(config):
raise ValueError( raise ValueError(
"duree_max_overlay_s doit couvrir max_duration_s + " "duree_max_overlay_s doit couvrir max_duration_s + "

View File

@ -237,6 +237,19 @@ class PulseWindow(NSObject):
float(CONFIG["duree_max_overlay_s"]), self, "_expiration:", None, False 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): def _expiration_(self, timer):
print("[garde] overlay ouvert trop longtemps — fermeture forcée") print("[garde] overlay ouvert trop longtemps — fermeture forcée")
self.hide() self.hide()

View File

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

View File

@ -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 Bug du 2026-08-21 (deuxieme occurrence) : le halo est reste fige sur « Je vous
affiche donc l'etape ou l'on en est « Je vous ecoute » pendant la capture, ecoute », forme d'onde plate, process a 0 % CPU. Une autre application (Loom)
« Transcription » pendant le calcul et la fenetre reste visible jusqu'au avait pris le micro ; `stream.stop()` de PortAudio s'est BLOQUE. Comme toggle()
collage, au lieu de disparaitre en laissant l'utilisateur sans retour. 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 import numpy as np
@ -16,57 +22,82 @@ class FauxPulse:
self.statuts = [] self.statuts = []
self.visible = False self.visible = False
def show(self): def show(self): self.visible = True
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): class RecorderQuiBloque:
pass """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): def start(self):
self.statuts.append(text) 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: class FauxRecorder:
def __init__(self, audio): def __init__(self, audio): self.audio = audio
self.audio = audio def start(self): pass
def stop(self): return self.audio
def start(self):
pass
def stop(self):
return self.audio
def _ctrl(audio): def _ctrl(recorder, taches=None):
pulse = FauxPulse() pulse = FauxPulse()
c = ZonzaController(CONFIG, pulse) c = ZonzaController(CONFIG, pulse, lancer_tache=taches)
c.recorder = FauxRecorder(audio) c.recorder = recorder
c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})() c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})()
return c, pulse return c, pulse
def test_la_bulle_annonce_l_ecoute_au_demarrage(): def test_demarrer_ne_touche_pas_l_audio_sur_le_thread_principal():
c, pulse = _ctrl(np.zeros(16000, dtype="float32")) differees = []
c.toggle() c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)
c.toggle() # ne doit rien lever
assert pulse.visible assert pulse.visible
assert any("écoute" in s.lower() for s in pulse.statuts) 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(): def test_arreter_ne_touche_pas_l_audio_sur_le_thread_principal():
audio = np.ones(16000, dtype="float32") * 0.1 # 1 s : au-dessus du seuil differees = []
c, pulse = _ctrl(audio) c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)
c.toggle() c.toggle() # demarre
pulse.statuts.clear() differees.clear()
c._stop_and_process() c.toggle() # arrete : ne doit rien lever non plus
assert pulse.visible, "la fenetre ne doit pas disparaitre avant la transcription" assert len(differees) == 1, "la fermeture du flux doit etre deleguee"
assert any("transcription" in s.lower() for s in pulse.statuts) assert any("transcription" in s.lower() for s in pulse.statuts)
def test_un_enregistrement_trop_court_referme_tout_de_suite(): def test_la_bulle_annonce_l_ecoute_puis_la_transcription():
c, pulse = _ctrl(np.zeros(10, dtype="float32")) # bien sous min_duration_s differees = []
c.toggle() c, pulse = _ctrl(FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append)
c._stop_and_process() 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 assert not pulse.visible