Zonza : dictée vocale locale macOS (MLX-Whisper + cercle pulsant)
Reconstruction propre de l'ancien projet Dictée. Bundle id neuf (cloud.mrtechlab.zonza), chemin de build unique vers /Applications (plus jamais de builds /tmp enregistrés dans LaunchServices). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
"""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()
|
||||
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()
|
||||
self.pulse.hide()
|
||||
play_sound("done")
|
||||
if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]):
|
||||
self.on_state("idle")
|
||||
return
|
||||
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.on_state("idle")
|
||||
Reference in New Issue
Block a user