Reprend le design demande par Ralph, d'apres une capture : barre sombre arrondie posee en bas de l'ecran, et dedans, de gauche a droite, un bouton stop rond rouge-orange a carre blanc, une forme d'onde en barres verticales dont la traine retombe en pointilles, une icone de micro. Au-dessus, une bulle sombre a pointe. La forme d'onde demandait un HISTORIQUE de niveaux, la ou l'ancien cercle ne lisait que le niveau courant : push_level et bar_heights sont ajoutes a audio_level.py comme unites pures (le plus recent a gauche, les positions vides ou silencieuses retombent sur un plancher — c'est lui qui donne les pointilles). La bulle ne peut pas afficher le texte en direct comme la reference : Zonza transcrit APRES l'arret, il n'y a pas de flux continu. Elle porte donc l'etape en cours, et la fenetre reste affichee jusqu'au collage au lieu de disparaitre pendant les secondes de calcul — l'ecran ne se vide plus sans explication. Rendu verifie par capture d'ecran, pas seulement par les tests : le micro a ete redessine apres coup (il sortait en tache blanche) et une marge basse ajoutee, l'arrondi de la barre etant rogne par le bord de la fenetre. 45 tests passent. Le cdhash du stub est inchange, donc les autorisations d'Accessibilite survivent a cette mise a jour (verifie : accordee = True). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
5.4 KiB
Python
131 lines
5.4 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 core import is_long_enough
|
|
from engine import Recorder, Transcriber, inject_text, play_sound
|
|
|
|
|
|
class ZonzaController:
|
|
"""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):
|
|
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())
|
|
self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block)
|
|
self.transcriber = Transcriber(config["model"], config["language"])
|
|
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
|
|
|
|
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.
|
|
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):
|
|
try:
|
|
self._smoothed_level = 0.0
|
|
self.recorder.start()
|
|
self.recording = True
|
|
self.pulse.show()
|
|
self.pulse.set_status("Je vous écoute…")
|
|
play_sound("start")
|
|
self.on_state("recording")
|
|
self._arm_safety_timer()
|
|
except Exception as e:
|
|
print(f"[erreur] démarrage enregistrement : {e}")
|
|
play_sound("error")
|
|
self.recording = False
|
|
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
|
|
|
|
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):
|
|
self.recording = False
|
|
self._cancel_safety_timer()
|
|
try:
|
|
audio = self.recorder.stop()
|
|
play_sound("done")
|
|
if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]):
|
|
self.pulse.hide()
|
|
self.on_state("idle")
|
|
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)
|
|
if not text:
|
|
return
|
|
print(f"[texte] {text}")
|
|
try:
|
|
inject_text(text)
|
|
except Exception as e:
|
|
print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V")
|
|
play_sound("error")
|
|
except Exception as e:
|
|
print(f"[erreur] transcription : {e}")
|
|
play_sound("error")
|
|
finally:
|
|
self.run_on_main(self.pulse.hide)
|
|
self.on_state("idle")
|