zonza/app.py
Ralph Mayola 22080eb997 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>
2026-06-12 23:57:59 +02:00

88 lines
2.9 KiB
Python

"""Point d'entrée : application macOS de barre de menu (rumps).
Possède la boucle d'événements Cocoa. Enregistre la hotkey globale, gère l'état du menu,
lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de pulsation.
La politique d'activation est forcée en « accessory » : Zonza vit dans la barre de menu,
jamais dans le Dock — même quand la fenêtre de pulsation Cocoa s'affiche.
"""
import threading
import rumps
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
from PyObjCTools import AppHelper
from pynput import keyboard
from controller import ZonzaController
from core import CONFIG, validate_config
from pulse_window import PulseWindow
_STATE_TITLES = {
"idle": "🎙️",
"recording": "🔴",
"transcribing": "",
}
class ZonzaApp(rumps.App):
def __init__(self):
super().__init__("🎙️", quit_button=None)
self.pulse = PulseWindow.alloc().init()
self.controller = ZonzaController(
CONFIG, self.pulse, on_state=self._set_state,
run_on_main=AppHelper.callAfter,
)
self.menu = [
rumps.MenuItem("Sons", callback=self._toggle_sounds),
None,
rumps.MenuItem("Quitter", callback=self._quit),
]
self.menu["Sons"].state = CONFIG["sounds"]
self._start_hotkey()
self._warmup_async()
def _set_state(self, state):
# appelé depuis des threads de fond (transcription) ; toucher le titre du menu
# (AppKit) doit se faire sur le thread principal → on marshale via callAfter.
AppHelper.callAfter(self._apply_title, state)
def _apply_title(self, state):
self.title = _STATE_TITLES.get(state, "🎙️")
def _toggle_sounds(self, sender):
CONFIG["sounds"] = not CONFIG["sounds"]
sender.state = CONFIG["sounds"]
def _quit(self, _):
rumps.quit_application()
def _start_hotkey(self):
# pynput exécute ce callback sur SON thread d'écoute, pas le thread principal.
# toggle() touche NSWindow/NSTimer (Cocoa) → on marshale sur le thread principal.
def on_activate():
AppHelper.callAfter(self.controller.toggle)
self._listener = keyboard.GlobalHotKeys({CONFIG["hotkey"]: on_activate})
self._listener.start()
def _warmup_async(self):
def run():
try:
self.controller.transcriber.warmup()
except Exception as e:
print(f"[erreur] warmup modèle : {e}")
rumps.notification("Zonza", "Modèle", f"Échec du chargement : {e}")
threading.Thread(target=run, daemon=True).start()
def main():
validate_config(CONFIG)
# barre de menu uniquement, jamais d'icône Dock — même en lançant depuis les sources
NSApplication.sharedApplication().setActivationPolicy_(
NSApplicationActivationPolicyAccessory
)
ZonzaApp().run()
if __name__ == "__main__":
main()