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:
parent
68e1f336c8
commit
ebf3c8e749
@ -11,7 +11,7 @@ contexte de l'app). La transcription, elle, part sur un thread de fond.
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
from audio_level import compute_rms, shape_level
|
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
|
from engine import Recorder, Transcriber, inject_text, play_sound
|
||||||
|
|
||||||
|
|
||||||
@ -39,11 +39,14 @@ class ZonzaController:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._smoothed_level = 0.0
|
self._smoothed_level = 0.0
|
||||||
self._safety_timer = None # arrêt auto si l'enregistrement traîne trop
|
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):
|
def _on_audio_block(self, block):
|
||||||
# thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release
|
# 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
|
# → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée
|
||||||
# rapide quand on parle, retombée douce dans les silences.
|
# rapide quand on parle, retombée douce dans les silences.
|
||||||
|
self._blocs_recus += 1
|
||||||
target = shape_level(compute_rms(block), self.config["level_gain"])
|
target = shape_level(compute_rms(block), self.config["level_gain"])
|
||||||
if target > self._smoothed_level:
|
if target > self._smoothed_level:
|
||||||
coef = self.config["level_attack"]
|
coef = self.config["level_attack"]
|
||||||
@ -61,14 +64,33 @@ class ZonzaController:
|
|||||||
|
|
||||||
def _start(self):
|
def _start(self):
|
||||||
self._smoothed_level = 0.0
|
self._smoothed_level = 0.0
|
||||||
|
self._blocs_recus = 0
|
||||||
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._armer_veille_silence()
|
||||||
self.lancer_tache(self._ouvrir_flux)
|
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):
|
def _ouvrir_flux(self):
|
||||||
"""Ouvre le micro. Hors thread principal : peut bloquer (cf __init__)."""
|
"""Ouvre le micro. Hors thread principal : peut bloquer (cf __init__)."""
|
||||||
try:
|
try:
|
||||||
@ -95,6 +117,9 @@ class ZonzaController:
|
|||||||
if self._safety_timer is not None:
|
if self._safety_timer is not None:
|
||||||
self._safety_timer.cancel()
|
self._safety_timer.cancel()
|
||||||
self._safety_timer = None
|
self._safety_timer = None
|
||||||
|
if self._veille_silence is not None:
|
||||||
|
self._veille_silence.cancel()
|
||||||
|
self._veille_silence = None
|
||||||
|
|
||||||
def _safety_stop(self):
|
def _safety_stop(self):
|
||||||
# déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc
|
# déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc
|
||||||
|
|||||||
14
core.py
14
core.py
@ -21,7 +21,10 @@ CONFIG = {
|
|||||||
"duree_max_overlay_s": 210.0,
|
"duree_max_overlay_s": 210.0,
|
||||||
# Garde COURTE, armee des l'arret : il ne reste alors que la transcription,
|
# 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.
|
# 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,
|
"sample_rate": 16000,
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
# --- overlay « barre de dictee » (bas de l'ecran) ---
|
# --- 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)
|
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):
|
def bar_metrics(bar_height):
|
||||||
"""Mesures de l'overlay pour une hauteur de barre donnee, en points."""
|
"""Mesures de l'overlay pour une hauteur de barre donnee, en points."""
|
||||||
if bar_height <= 0:
|
if bar_height <= 0:
|
||||||
|
|||||||
32
tests/test_detection_micro_muet.py
Normal file
32
tests/test_detection_micro_muet.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user