176 lines
7.5 KiB
Python
176 lines
7.5 KiB
Python
"""Chef d'orchestre : machine à états idle <-> recording.
|
|
|
|
Branche le micro sur l'animation (niveau RMS → cercle) pendant l'enregistrement, puis
|
|
lance la transcription sur un thread de fond et colle le texte. Ne meurt jamais sur une
|
|
erreur : tout est attrapé et ramène à idle (fenêtre fermée).
|
|
|
|
Les méthodes touchant l'UI (PulseWindow show/hide) doivent être invoquées sur le thread
|
|
principal — c'est le rôle de l'appelant (app via la hotkey, qui s'exécute déjà dans le
|
|
contexte de l'app). La transcription, elle, part sur un thread de fond.
|
|
"""
|
|
import threading
|
|
|
|
from audio_level import compute_rms, shape_level
|
|
from capture import Recorder, inject_text
|
|
from core import entree_audio_muette, is_long_enough
|
|
|
|
|
|
class ZonzaController:
|
|
"""Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu)."""
|
|
|
|
def __init__(self, config, pulse_window, transcripteur, systeme, on_state=None,
|
|
run_on_main=None, lancer_tache=None):
|
|
self.config = config
|
|
self.pulse = pulse_window
|
|
self.on_state = on_state or (lambda s: None)
|
|
# 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).
|
|
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.transcripteur = transcripteur
|
|
self.systeme = systeme
|
|
self.recording = False
|
|
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 _jouer_son(self, nom):
|
|
"""Joue un son, sauf si l'utilisateur les a coupés par le menu.
|
|
|
|
La décision vit ICI et non dans la couche système : celle-ci doit rester
|
|
bête, sinon chaque nouvelle plateforme devra repenser à ce garde-fou.
|
|
"""
|
|
if not self.config["sounds"]:
|
|
return
|
|
self.systeme.jouer_son(nom)
|
|
|
|
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"]
|
|
else:
|
|
coef = self.config["level_release"]
|
|
self._smoothed_level += (target - self._smoothed_level) * coef
|
|
self.pulse.set_level(self._smoothed_level)
|
|
|
|
def toggle(self):
|
|
with self._lock:
|
|
if not self.recording:
|
|
self._start()
|
|
else:
|
|
self._stop_and_process()
|
|
|
|
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…")
|
|
self._jouer_son("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")
|
|
self._jouer_son("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:
|
|
self.recorder.start()
|
|
except Exception as e:
|
|
print(f"[erreur] démarrage enregistrement : {e}")
|
|
self._jouer_son("error")
|
|
self.recording = False
|
|
self._cancel_safety_timer()
|
|
self.run_on_main(self.pulse.hide)
|
|
self.on_state("idle")
|
|
|
|
def _arm_safety_timer(self):
|
|
# sécurité : si l'enregistrement n'est pas arrêté manuellement après max_duration_s,
|
|
# on l'arrête automatiquement → la fenêtre du cercle ne peut jamais rester figée.
|
|
self._cancel_safety_timer()
|
|
self._safety_timer = threading.Timer(
|
|
self.config["max_duration_s"], self._safety_stop
|
|
)
|
|
self._safety_timer.daemon = True
|
|
self._safety_timer.start()
|
|
|
|
def _cancel_safety_timer(self):
|
|
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
|
|
# on marshale toggle() sur le thread principal Cocoa.
|
|
print("[sécurité] durée max atteinte → arrêt auto de l'enregistrement")
|
|
self.run_on_main(self.toggle)
|
|
|
|
def _stop_and_process(self):
|
|
"""Appelé depuis le thread principal : ne touche PAS au périphérique audio."""
|
|
self.recording = False
|
|
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:
|
|
audio = self.recorder.stop()
|
|
self._jouer_son("done")
|
|
if not is_long_enough(len(audio), self.config["sample_rate"],
|
|
self.config["min_duration_s"]):
|
|
return
|
|
text = self.transcripteur.transcrire(audio)
|
|
if not text:
|
|
return
|
|
print(f"[texte] {text}")
|
|
try:
|
|
inject_text(text, self.systeme)
|
|
except Exception as e:
|
|
print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V")
|
|
self._jouer_son("error")
|
|
except Exception as e:
|
|
print(f"[erreur] arrêt ou transcription : {e}")
|
|
self._jouer_son("error")
|
|
finally:
|
|
self.run_on_main(self.pulse.hide)
|
|
self.on_state("idle")
|