diff --git a/controller.py b/controller.py index fa31b32..8794c4d 100644 --- a/controller.py +++ b/controller.py @@ -11,7 +11,7 @@ contexte de l'app). La transcription, elle, part sur un thread de fond. import threading from audio_level import compute_rms, shape_level -from core import is_long_enough +from core import entree_audio_muette, is_long_enough from engine import Recorder, Transcriber, inject_text, play_sound @@ -39,11 +39,14 @@ class ZonzaController: self._lock = threading.Lock() self._smoothed_level = 0.0 self._safety_timer = None # arrêt auto si l'enregistrement traîne trop + self._blocs_recus = 0 # compteur pour détecter un micro muet + self._veille_silence = None def _on_audio_block(self, block): # thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release # → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée # rapide quand on parle, retombée douce dans les silences. + self._blocs_recus += 1 target = shape_level(compute_rms(block), self.config["level_gain"]) if target > self._smoothed_level: coef = self.config["level_attack"] @@ -61,14 +64,33 @@ class ZonzaController: def _start(self): self._smoothed_level = 0.0 + self._blocs_recus = 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._armer_veille_silence() self.lancer_tache(self._ouvrir_flux) + def _armer_veille_silence(self): + """Prévient si aucun son n'arrive : le micro est pris par une autre app.""" + if self._veille_silence is not None: + self._veille_silence.cancel() + self._veille_silence = threading.Timer( + self.config["delai_detection_silence_s"], self._verifier_entree_audio + ) + self._veille_silence.daemon = True + self._veille_silence.start() + + def _verifier_entree_audio(self): + if not entree_audio_muette(self._blocs_recus, self.recording): + return + print("[micro] aucun son reçu — le micro est probablement pris par une autre application") + play_sound("error") + self.run_on_main(lambda: self.pulse.set_status("Aucun son — micro pris ailleurs ?")) + def _ouvrir_flux(self): """Ouvre le micro. Hors thread principal : peut bloquer (cf __init__).""" try: @@ -95,6 +117,9 @@ class ZonzaController: if self._safety_timer is not None: self._safety_timer.cancel() self._safety_timer = None + if self._veille_silence is not None: + self._veille_silence.cancel() + self._veille_silence = None def _safety_stop(self): # déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc diff --git a/core.py b/core.py index c37fe1d..686035c 100644 --- a/core.py +++ b/core.py @@ -21,7 +21,10 @@ CONFIG = { "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) + "duree_max_transcription_s": 20.0, + # Au bout de ce delai sans le moindre bloc audio, on previent : le micro est + # pris par une autre application (Loom, Zoom, OBS...). Sinon on dicte dans le vide. + "delai_detection_silence_s": 1.8, # 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) --- @@ -132,6 +135,15 @@ def attendre(condition, delai_max_s, pas_s, dormir=None, horloge=None): dormir(pas_s) +def entree_audio_muette(blocs_recus, en_cours): + """Faut-il alerter : le micro n'envoie-t-il rien alors qu'on enregistre ? + + `en_cours` evite un faux positif quand l'utilisateur a simplement arrete avant + la fin du delai — ce n'est pas une panne, juste une dictee courte. + """ + return bool(en_cours) and blocs_recus == 0 + + def bar_metrics(bar_height): """Mesures de l'overlay pour une hauteur de barre donnee, en points.""" if bar_height <= 0: diff --git a/tests/test_detection_micro_muet.py b/tests/test_detection_micro_muet.py new file mode 100644 index 0000000..be28d16 --- /dev/null +++ b/tests/test_detection_micro_muet.py @@ -0,0 +1,32 @@ +"""Detection d'un micro qui n'envoie rien. + +Le 2026-08-21, Loom tenait le micro : Zonza enregistrait dans le vide. La forme +d'onde restait plate et rien ne le signalait — on ne le decouvrait qu'apres avoir +parle pour rien. Zonza doit le dire pendant, pas apres. +""" +import pytest + +from core import CONFIG, entree_audio_muette + + +def test_aucun_bloc_apres_le_delai_est_muet(): + assert entree_audio_muette(blocs_recus=0, en_cours=True) is True + + +def test_des_blocs_recus_ne_sont_pas_muets(): + assert entree_audio_muette(blocs_recus=1, en_cours=True) is False + + +def test_on_n_alerte_pas_si_l_enregistrement_est_deja_fini(): + # l'utilisateur a arrete avant le delai : ce n'est pas une panne + assert entree_audio_muette(blocs_recus=0, en_cours=False) is False + + +def test_le_delai_laisse_le_temps_au_flux_de_s_ouvrir(): + # ouvrir un flux CoreAudio prend des centaines de ms ; alerter trop tot = faux positif + assert CONFIG["delai_detection_silence_s"] >= 1.0 + + +def test_le_delai_reste_assez_court_pour_servir(): + # au-dela, l'utilisateur a deja dicte sa phrase pour rien + assert CONFIG["delai_detection_silence_s"] <= 3.0