Zonza previent quand le micro ne lui envoie rien

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,
puis en cherchant pourquoi le halo se figeait.

Au bout de 1,8 s sans le moindre bloc audio, la bulle affiche « Aucun son — micro
pris ailleurs ? » et un son d'erreur previent. Le delai laisse le temps au flux
CoreAudio de s'ouvrir (quelques centaines de ms) sans laisser dicter une phrase
entiere pour rien.

Le garde `en_cours` evite le faux positif de la dictee tres courte : arreter avant
la fin du delai n'est pas une panne.

Verifie dans les deux sens : micro muet -> alerte, 0 bloc ; micro normal -> bulle
inchangee, 40 blocs. Pas de faux positif.

74 tests passent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 12:18:45 +02:00
co-authored by Claude Opus 5
parent 68e1f336c8
commit ebf3c8e749
3 changed files with 71 additions and 2 deletions
+26 -1
View File
@@ -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