zonza/app.py
Ralph Mayola 2ccb614649 A chaque moteur son modele, et un forcage manuel
app.py passait CONFIG["model"] — un depot MLX — quel que soit le moteur detecte.
Sur une machine sans Apple Silicon, faster-whisper aurait recu un depot qu'il ne
sait pas charger. Le defaut etait invisible ici, la detection rendant toujours
"mlx" sur ce Mac : aucun test ne pouvait le voir.

MODELES_PAR_MOTEUR associe chaque moteur a son depot. Les valeurs viennent de la
mesure du jour (trois echantillons, amorce active, CPU Apple Silicon) :

  faster-whisper base   0,52 s  10/13 termes techniques   142 Mo
  faster-whisper small  1,42 s  11/13                     464 Mo
  faster-whisper medium 4,40 s  13/13                    1460 Mo
  MLX medium (GPU)      0,75 s  11/13                     489 Mo

Le GPU vise donc la precision (medium, 13/13, il absorbe le cout) ; le CPU vise
la latence (small, 11/13 — AUTANT que le MLX utilise aujourd'hui — pour 1,4 s).
`medium` sur CPU serait plus juste, mais ces 4,4 s sont celles d'un CPU Apple
Silicon : sur un CPU Intel ou Windows, le meme modele peut demander trois a quatre
fois plus. La commande de diagnostic mesurera la machine reelle et pourra
promouvoir `medium`.

CONFIG["model"] passe a None et devient un FORCAGE manuel qui prime sur la table,
au lieu d'un reglage mort qu'on modifie sans effet.

115 tests. Comportement inchange sur ce Mac : moteur mlx, modele identique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:45:14 +02:00

170 lines
6.5 KiB
Python

"""Point d'entrée : application macOS de barre de menu (pyobjc natif, sans rumps).
Possède la boucle d'événements Cocoa. Crée le NSStatusItem (🎙️), enregistre la hotkey
globale, lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de
pulsation.
Pourquoi pas rumps : sur macOS Tahoe (26), l'item de barre de menu créé par rumps
n'est jamais placé (fenêtre de hauteur 0) alors qu'un NSStatusItem natif créé par la
même app s'affiche normalement (isolé par tests successifs le 2026-06-13). L'API
native est de toute façon minuscule pour nos besoins : une icône, deux items de menu.
Zonza vit dans la barre de menu, jamais dans le Dock : c'est le LSUIElement de
l'Info.plist (posé par build_app.sh) qui s'en charge.
"""
import os
import threading
from objc import super
from AppKit import (
NSApplication,
NSMenu,
NSMenuItem,
NSStatusBar,
NSVariableStatusItemLength,
)
from Foundation import NSObject, NSTimer
from PyObjCTools import AppHelper
from pynput import keyboard
from controller import ZonzaController
from core import CONFIG, modele_retenu, validate_config
from interface import classe_overlay_pour
from moteur import choisir_transcripteur, detecter_moteur
from systeme import choisir_systeme
_STATE_TITLES = {
"idle": "🎙️",
"recording": "🔴",
"transcribing": "",
}
class ZonzaApp(NSObject):
"""Délégué applicatif : status item + menu + hotkey + contrôleur."""
def initWithTranscripteur_systeme_(self, transcripteur, systeme):
self = super().init()
if self is None:
return None
self._item = None
self._sounds_item = None
self.pulse = classe_overlay_pour().alloc().init()
self.controller = ZonzaController(
CONFIG, self.pulse, transcripteur, systeme, on_state=self._set_state,
run_on_main=AppHelper.callAfter,
)
self._listener = None
return self
# --- barre de menu -------------------------------------------------
def setup(self):
bar = NSStatusBar.systemStatusBar()
self._item = bar.statusItemWithLength_(NSVariableStatusItemLength)
self._item.button().setTitle_(_STATE_TITLES["idle"])
menu = NSMenu.alloc().init()
self._sounds_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
"Sons", "toggleSounds:", ""
)
self._sounds_item.setTarget_(self)
self._sounds_item.setState_(1 if CONFIG["sounds"] else 0)
menu.addItem_(self._sounds_item)
menu.addItem_(NSMenuItem.separatorItem())
quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
"Quitter", "quitApp:", "q"
)
quit_item.setTarget_(self)
menu.addItem_(quit_item)
self._item.setMenu_(menu)
self._start_hotkey()
self._warmup_async()
# auto-diagnostic : trace le placement réel de l'icône (h=33 → visible dans la
# barre ; h=0 → refusée par le gestionnaire de barre de menu, cf launcher/stub.c)
NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
5.0, self, "logPlacement:", None, False
)
def logPlacement_(self, timer):
win = self._item.button().window() if self._item else None
print(f"[diag] icône barre de menu : {win.frame() if win else 'absente'}")
# --- état (appelé depuis des threads de fond) ----------------------
def _set_state(self, state):
# toucher AppKit doit se faire sur le thread principal → callAfter.
AppHelper.callAfter(self._apply_title, state)
def _apply_title(self, state):
if self._item is not None:
self._item.button().setTitle_(_STATE_TITLES.get(state, "🎙️"))
# --- actions du menu ------------------------------------------------
def toggleSounds_(self, sender):
CONFIG["sounds"] = not CONFIG["sounds"]
sender.setState_(1 if CONFIG["sounds"] else 0)
def quitApp_(self, sender):
if self._listener is not None:
self._listener.stop()
NSApplication.sharedApplication().terminate_(None)
# --- hotkey globale ---------------------------------------------------
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()
# --- warmup modèle ----------------------------------------------------
def _warmup_async(self):
def run():
try:
self.controller.transcripteur.warmup()
except Exception as e:
print(f"[erreur] warmup modèle : {e}")
threading.Thread(target=run, daemon=True).start()
def main():
# une seule instance a la fois : sinon deux modeles en RAM et la meme
# hotkey captee deux fois (cf systeme.acquerir_verrou_instance).
systeme = choisir_systeme()
lock = systeme.acquerir_verrou_instance(
os.path.join(systeme.dossier_donnees(), "instance.lock")
)
if lock is None:
print("[garde] une autre instance de Zonza tourne deja — arret.")
return
main._lock = lock # garder le verrou vivant tant que le process vit
validate_config(CONFIG)
moteur = detecter_moteur()
transcripteur = choisir_transcripteur(
nom=moteur,
modele=modele_retenu(moteur, CONFIG["model"]),
langue=CONFIG["language"],
amorce=CONFIG["initial_prompt"],
)
# si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
from ApplicationServices import (
AXIsProcessTrustedWithOptions,
kAXTrustedCheckOptionPrompt,
)
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
print(f"[diag] accessibilité accordée : {bool(trusted)}")
app = NSApplication.sharedApplication()
delegate = ZonzaApp.alloc().initWithTranscripteur_systeme_(transcripteur, systeme)
delegate.setup()
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
main._delegate = delegate
AppHelper.runEventLoop()
if __name__ == "__main__":
main()