Compare commits
No commits in common. "96c137962114cf8a71f37253c9ddbe283c47bf57" and "3e269dfb5e1da1a219021c3c0e39797894d457e1" have entirely different histories.
96c1379621
...
3e269dfb5e
56
app.py
56
app.py
@ -25,6 +25,7 @@ if "--diagnostic" in sys.argv:
|
|||||||
print(mettre_en_forme(diagnostiquer()))
|
print(mettre_en_forme(diagnostiquer()))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from objc import super
|
from objc import super
|
||||||
@ -40,8 +41,11 @@ from Foundation import NSObject, NSTimer
|
|||||||
from PyObjCTools import AppHelper
|
from PyObjCTools import AppHelper
|
||||||
from pynput import keyboard
|
from pynput import keyboard
|
||||||
|
|
||||||
from assemblage import acquerir_verrou, construire
|
from controller import ZonzaController
|
||||||
from core import CONFIG
|
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 = {
|
_STATE_TITLES = {
|
||||||
"idle": "🎙️",
|
"idle": "🎙️",
|
||||||
@ -51,21 +55,19 @@ _STATE_TITLES = {
|
|||||||
|
|
||||||
|
|
||||||
class ZonzaApp(NSObject):
|
class ZonzaApp(NSObject):
|
||||||
"""Délégué applicatif : status item + menu + hotkey.
|
"""Délégué applicatif : status item + menu + hotkey + contrôleur."""
|
||||||
|
|
||||||
Le contrôleur (donc le transcripteur, l'overlay et le système) vient déjà
|
def initWithTranscripteur_systeme_(self, transcripteur, systeme):
|
||||||
assemblé d'`assemblage.construire()` — voir `main()`. Ce délégué ne fait
|
|
||||||
plus QUE ce qui est propre à Cocoa : le NSStatusItem, le menu, et le
|
|
||||||
marshalage `AppHelper.callAfter` vers le thread principal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def initWithController_(self, controller):
|
|
||||||
self = super().init()
|
self = super().init()
|
||||||
if self is None:
|
if self is None:
|
||||||
return None
|
return None
|
||||||
self._item = None
|
self._item = None
|
||||||
self._sounds_item = None
|
self._sounds_item = None
|
||||||
self.controller = controller
|
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
|
self._listener = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@ -142,18 +144,23 @@ class ZonzaApp(NSObject):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
# une seule instance a la fois : sinon deux modeles en RAM et la meme
|
# une seule instance a la fois : sinon deux modeles en RAM et la meme
|
||||||
# hotkey captee deux fois (cf systeme.acquerir_verrou_instance). La prise
|
# hotkey captee deux fois (cf systeme.acquerir_verrou_instance).
|
||||||
# du verrou reste ICI (pas dans assemblage.construire) : c'est au lanceur
|
|
||||||
# de décider quoi faire d'un refus, avant même de construire quoi que ce
|
|
||||||
# soit d'autre.
|
|
||||||
from systeme import choisir_systeme
|
|
||||||
systeme = choisir_systeme()
|
systeme = choisir_systeme()
|
||||||
lock = acquerir_verrou(systeme)
|
lock = systeme.acquerir_verrou_instance(
|
||||||
|
os.path.join(systeme.dossier_donnees(), "instance.lock")
|
||||||
|
)
|
||||||
if lock is None:
|
if lock is None:
|
||||||
print("[garde] une autre instance de Zonza tourne deja — arret.")
|
print("[garde] une autre instance de Zonza tourne deja — arret.")
|
||||||
return
|
return
|
||||||
main._lock = lock # garder le verrou vivant tant que le process vit
|
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
|
# 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 —
|
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
|
||||||
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
|
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
|
||||||
@ -163,19 +170,8 @@ def main():
|
|||||||
)
|
)
|
||||||
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
|
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
|
||||||
print(f"[diag] accessibilité accordée : {bool(trusted)}")
|
print(f"[diag] accessibilité accordée : {bool(trusted)}")
|
||||||
|
|
||||||
app = NSApplication.sharedApplication()
|
app = NSApplication.sharedApplication()
|
||||||
# Dépendance circulaire : `on_state` doit marshaler vers `delegate._apply_title`,
|
delegate = ZonzaApp.alloc().initWithTranscripteur_systeme_(transcripteur, systeme)
|
||||||
# mais `delegate` a besoin du contrôleur déjà construit pour naître. Cassée
|
|
||||||
# avec une case remplie APRÈS coup, comme dans lanceur_qt.py — la fermeture
|
|
||||||
# ne lit `case["delegate"]` qu'au moment de l'appel, jamais à la construction.
|
|
||||||
case = {}
|
|
||||||
assemblage = construire(
|
|
||||||
run_on_main=AppHelper.callAfter,
|
|
||||||
on_state=lambda s: case["delegate"]._set_state(s),
|
|
||||||
)
|
|
||||||
delegate = ZonzaApp.alloc().initWithController_(assemblage["controller"])
|
|
||||||
case["delegate"] = delegate
|
|
||||||
delegate.setup()
|
delegate.setup()
|
||||||
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
|
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
|
||||||
main._delegate = delegate
|
main._delegate = delegate
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
"""Assemblage commun aux deux lanceurs (`app.py` Cocoa, `lanceur_qt.py` Qt/Windows).
|
|
||||||
|
|
||||||
Avant ce module, `app.py` était le SEUL endroit qui savait choisir le système,
|
|
||||||
prendre le verrou d'instance, construire le transcripteur et l'overlay, puis
|
|
||||||
câbler le tout dans un `ZonzaController`. Un second lanceur qui recopierait
|
|
||||||
cette séquence créerait deux copies vouées à diverger en silence — exactement
|
|
||||||
le défaut déjà résorbé entre `engine.py` et `systeme/macos.py` (cf journal du
|
|
||||||
projet). Les deux lanceurs appellent donc les deux fonctions d'ici ; ce qui
|
|
||||||
reste propre à chacun (boucle d'événements, icône de barre, menu) reste dans
|
|
||||||
`app.py` et `lanceur_qt.py`.
|
|
||||||
|
|
||||||
Tout est injectable (`plateforme`, `run_on_main`...) pour rester testable sans
|
|
||||||
jamais ouvrir de fenêtre ni de micro : la construction du transcripteur charge
|
|
||||||
un vrai modèle, donc les tests injectent un `moteur.detecter_moteur`/
|
|
||||||
`moteur.choisir_transcripteur` doublés plutôt que de laisser tourner le vrai.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
import moteur
|
|
||||||
from controller import ZonzaController
|
|
||||||
from core import CONFIG, modele_retenu, validate_config
|
|
||||||
from interface import classe_overlay_pour
|
|
||||||
from systeme import choisir_systeme
|
|
||||||
|
|
||||||
|
|
||||||
def acquerir_verrou(systeme, chemin_verrou=None):
|
|
||||||
"""Prend le verrou d'instance unique ; rend le handle, ou None si pris.
|
|
||||||
|
|
||||||
Une seule instance à la fois : sinon deux modèles en RAM et la même
|
|
||||||
hotkey captée deux fois (cf systeme.acquerir_verrou_instance). Le chemin
|
|
||||||
est injectable : les tests et les essais manuels d'un lanceur doivent
|
|
||||||
pouvoir viser un fichier qui n'est PAS celui de l'instance déjà en cours
|
|
||||||
de l'utilisateur.
|
|
||||||
"""
|
|
||||||
chemin = chemin_verrou or os.path.join(systeme.dossier_donnees(), "instance.lock")
|
|
||||||
return systeme.acquerir_verrou_instance(chemin)
|
|
||||||
|
|
||||||
|
|
||||||
def construire(plateforme=None, run_on_main=None, on_state=None,
|
|
||||||
detecter_moteur=None, choisir_transcripteur=None):
|
|
||||||
"""Construit le système, le transcripteur, l'overlay et le contrôleur.
|
|
||||||
|
|
||||||
Ne prend PAS le verrou (c'est le rôle d'`acquerir_verrou`, appelée à part
|
|
||||||
par le lanceur, qui décide seul de ce qu'il fait d'un refus). Ne valide
|
|
||||||
la config qu'une fois, ici, pour que les deux lanceurs en bénéficient
|
|
||||||
sans avoir à y penser.
|
|
||||||
|
|
||||||
`detecter_moteur`/`choisir_transcripteur` sont injectables : ils
|
|
||||||
permettent aux tests de construire l'assemblage entier sans charger un
|
|
||||||
vrai modèle de transcription (opération lourde, et qui échouerait sans
|
|
||||||
matériel/poids téléchargés).
|
|
||||||
"""
|
|
||||||
detecter_moteur = detecter_moteur or moteur.detecter_moteur
|
|
||||||
choisir_transcripteur = choisir_transcripteur or moteur.choisir_transcripteur
|
|
||||||
|
|
||||||
validate_config(CONFIG)
|
|
||||||
systeme = choisir_systeme(plateforme)
|
|
||||||
moteur_nom = detecter_moteur()
|
|
||||||
transcripteur = choisir_transcripteur(
|
|
||||||
nom=moteur_nom,
|
|
||||||
modele=modele_retenu(moteur_nom, CONFIG["model"]),
|
|
||||||
langue=CONFIG["language"],
|
|
||||||
amorce=CONFIG["initial_prompt"],
|
|
||||||
)
|
|
||||||
overlay = classe_overlay_pour(plateforme)()
|
|
||||||
controller = ZonzaController(
|
|
||||||
CONFIG, overlay, transcripteur, systeme, on_state=on_state,
|
|
||||||
run_on_main=run_on_main,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"systeme": systeme,
|
|
||||||
"transcripteur": transcripteur,
|
|
||||||
"overlay": overlay,
|
|
||||||
"controller": controller,
|
|
||||||
}
|
|
||||||
227
lanceur_qt.py
227
lanceur_qt.py
@ -1,227 +0,0 @@
|
|||||||
"""Point d'entrée : application Qt de barre de tâches (PySide6, Windows).
|
|
||||||
|
|
||||||
Pendant Qt de `app.py` (Cocoa/macOS) : même icône de barre (🎙️/🔴/⏳), même
|
|
||||||
menu à deux entrées (Sons, Quitter), même hotkey globale via pynput. Ce qui
|
|
||||||
diffère de Cocoa est ISOLÉ ici — boucle d'événements Qt, `QSystemTrayIcon`,
|
|
||||||
marshalage par signal Qt — le reste (choix du système, verrou d'instance,
|
|
||||||
transcripteur, overlay, contrôleur) vient d'`assemblage.py`, partagé avec
|
|
||||||
`app.py`. Dupliquer cet assemblage créerait deux copies vouées à diverger en
|
|
||||||
silence (cf docstring d'`assemblage.py`).
|
|
||||||
|
|
||||||
Marshalage thread de fond → thread Qt : `AppHelper.callAfter` (Cocoa) n'a pas
|
|
||||||
d'équivalent Qt. La voie sûre est un signal Qt émis depuis le thread de fond
|
|
||||||
et connecté en `QueuedConnection` — Qt place alors l'exécution du slot dans
|
|
||||||
la boucle d'événements du thread PROPRIÉTAIRE de l'objet récepteur (le thread
|
|
||||||
principal, puisque `_Pont` y est instancié), quel que soit le thread émetteur.
|
|
||||||
`QTimer.singleShot` depuis un thread non-Qt n'offre pas cette garantie : un
|
|
||||||
QTimer démarré hors du thread principal s'exécute (ou pas) sur CE thread, pas
|
|
||||||
sur le thread principal — comportement non documenté comme sûr, à l'inverse
|
|
||||||
des connexions en file, documentées explicitement thread-safe.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# --diagnostic doit s'évaluer AVANT tout import PySide6/pynput : sur une
|
|
||||||
# machine qui ne les a pas, c'est le seul moyen d'éprouver la chaîne (même
|
|
||||||
# raison que dans app.py — voir sa docstring).
|
|
||||||
if "--diagnostic" in sys.argv:
|
|
||||||
from diagnostic import diagnostiquer, mettre_en_forme
|
|
||||||
print(mettre_en_forme(diagnostiquer()))
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Qt, Signal
|
|
||||||
from PySide6.QtGui import QAction, QIcon, QPixmap
|
|
||||||
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
|
||||||
from pynput import keyboard
|
|
||||||
|
|
||||||
from assemblage import acquerir_verrou, construire
|
|
||||||
from core import CONFIG
|
|
||||||
from systeme import choisir_systeme
|
|
||||||
|
|
||||||
_STATE_TITLES = {
|
|
||||||
"idle": "🎙️",
|
|
||||||
"recording": "🔴",
|
|
||||||
"transcribing": "⏳",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _icone_pour(emoji):
|
|
||||||
"""Rend un QIcon dessiné à partir d'un emoji — pas de fichier .ico à livrer."""
|
|
||||||
pixmap = QPixmap(64, 64)
|
|
||||||
pixmap.fill(Qt.GlobalColor.transparent)
|
|
||||||
from PySide6.QtGui import QFont, QPainter
|
|
||||||
with QPainter(pixmap) as painter:
|
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
||||||
font = QFont()
|
|
||||||
font.setPointSize(40)
|
|
||||||
painter.setFont(font)
|
|
||||||
painter.drawText(pixmap.rect(), int(Qt.AlignmentFlag.AlignCenter), emoji)
|
|
||||||
return QIcon(pixmap)
|
|
||||||
|
|
||||||
|
|
||||||
class _Pont(QObject):
|
|
||||||
"""Fait passer un appel du thread de fond (hotkey, contrôleur) au thread
|
|
||||||
Qt principal. `etat_change` porte le nom d'état ("idle"/"recording"/
|
|
||||||
"transcribing") ; `appel_demande` porte une fonction à exécuter sans
|
|
||||||
argument (utilisée pour `controller.toggle`).
|
|
||||||
|
|
||||||
Émettre un signal Qt depuis un thread quelconque est thread-safe par
|
|
||||||
construction ; c'est la connexion en Qt.QueuedConnection (posée à la
|
|
||||||
construction de `ZonzaTray`) qui garantit que le SLOT, lui, s'exécute
|
|
||||||
sur le thread principal.
|
|
||||||
"""
|
|
||||||
etat_change = Signal(str)
|
|
||||||
appel_demande = Signal(object)
|
|
||||||
|
|
||||||
|
|
||||||
class ZonzaTray:
|
|
||||||
"""Icône de barre de tâches + menu + hotkey + contrôleur, côté Qt."""
|
|
||||||
|
|
||||||
def __init__(self, controller, systeme):
|
|
||||||
self.controller = controller
|
|
||||||
self.systeme = systeme
|
|
||||||
self._listener = None
|
|
||||||
self._pont = _Pont()
|
|
||||||
self._pont.etat_change.connect(
|
|
||||||
self._apply_title, Qt.ConnectionType.QueuedConnection
|
|
||||||
)
|
|
||||||
self._pont.appel_demande.connect(
|
|
||||||
lambda fn: fn(), Qt.ConnectionType.QueuedConnection
|
|
||||||
)
|
|
||||||
|
|
||||||
self._current_state = "idle"
|
|
||||||
self.tray = QSystemTrayIcon(_icone_pour(_STATE_TITLES["idle"]))
|
|
||||||
self.tray.setToolTip("Zonza")
|
|
||||||
|
|
||||||
self.menu = QMenu()
|
|
||||||
self.sounds_action = QAction("Sons", self.menu)
|
|
||||||
self.sounds_action.setCheckable(True)
|
|
||||||
self.sounds_action.setChecked(bool(CONFIG["sounds"]))
|
|
||||||
self.sounds_action.triggered.connect(self.toggle_sounds)
|
|
||||||
self.menu.addAction(self.sounds_action)
|
|
||||||
self.menu.addSeparator()
|
|
||||||
self.quit_action = QAction("Quitter", self.menu)
|
|
||||||
self.quit_action.triggered.connect(self.quit_app)
|
|
||||||
self.menu.addAction(self.quit_action)
|
|
||||||
self.tray.setContextMenu(self.menu)
|
|
||||||
|
|
||||||
def run_on_main(self, fn):
|
|
||||||
"""Passé au contrôleur comme `run_on_main` : marshale `fn` sur le thread Qt."""
|
|
||||||
self._pont.appel_demande.emit(fn)
|
|
||||||
|
|
||||||
def on_state(self, state):
|
|
||||||
"""Passé au contrôleur comme `on_state` : peut arriver d'un thread de fond."""
|
|
||||||
self._pont.etat_change.emit(state)
|
|
||||||
|
|
||||||
def _apply_title(self, state):
|
|
||||||
# `_current_state` n'est pas utilisé pour dessiner (l'icône vient de
|
|
||||||
# `_STATE_TITLES` juste en dessous) : il existe pour que les tests
|
|
||||||
# puissent vérifier la correspondance état → icône sans avoir à
|
|
||||||
# comparer deux QIcon pixel à pixel.
|
|
||||||
self._current_state = state
|
|
||||||
self.tray.setIcon(_icone_pour(_STATE_TITLES.get(state, "🎙️")))
|
|
||||||
|
|
||||||
def show(self):
|
|
||||||
self.tray.show()
|
|
||||||
self._start_hotkey()
|
|
||||||
self._warmup_async()
|
|
||||||
|
|
||||||
# --- actions du menu ------------------------------------------------
|
|
||||||
def toggle_sounds(self, checked):
|
|
||||||
CONFIG["sounds"] = bool(checked)
|
|
||||||
self.sounds_action.setChecked(CONFIG["sounds"])
|
|
||||||
|
|
||||||
def quit_app(self):
|
|
||||||
if self._listener is not None:
|
|
||||||
self._listener.stop()
|
|
||||||
QApplication.instance().quit()
|
|
||||||
|
|
||||||
# --- hotkey globale ---------------------------------------------------
|
|
||||||
def _start_hotkey(self):
|
|
||||||
# pynput exécute ce callback sur SON thread d'écoute, pas le thread
|
|
||||||
# Qt principal. toggle() touche l'overlay (Qt) → marshalage requis,
|
|
||||||
# même raison que app.py avec AppHelper.callAfter.
|
|
||||||
def on_activate():
|
|
||||||
self.run_on_main(self.controller.toggle)
|
|
||||||
self._listener = keyboard.GlobalHotKeys(
|
|
||||||
{self.systeme.raccourci_defaut(): 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 _rediriger_sortie_vers_le_journal(chemin):
|
|
||||||
"""Renvoie stdout/stderr vers `chemin`.
|
|
||||||
|
|
||||||
Sur macOS, c'est `launcher/stub.c` (freopen) qui s'en charge AVANT même
|
|
||||||
de lancer Python, parce que le bundle n'a pas de terminal. Ce lanceur n'a
|
|
||||||
pas de stub équivalent sur Windows (lancé directement, pas via un
|
|
||||||
exécutable C) : c'est donc lui qui doit faire cette redirection, le plus
|
|
||||||
tôt possible dans main(), avant le premier print.
|
|
||||||
"""
|
|
||||||
parent = os.path.dirname(chemin)
|
|
||||||
if parent:
|
|
||||||
os.makedirs(parent, exist_ok=True)
|
|
||||||
journal = open(chemin, "a", buffering=1) # ligne par ligne, comme setvbuf(_IOLBF) côté stub
|
|
||||||
sys.stdout = journal
|
|
||||||
sys.stderr = journal
|
|
||||||
|
|
||||||
|
|
||||||
# Variable d'environnement permettant de faire tourner ce lanceur SANS toucher
|
|
||||||
# aux chemins réels de l'utilisateur (verrou d'instance, journal) : c'est ce
|
|
||||||
# qui rend possible de le lancer manuellement à côté d'une instance Zonza
|
|
||||||
# déjà installée, pour une vérification, sans lui disputer son verrou ni
|
|
||||||
# mélanger les journaux. Absente (cas normal) : les chemins réels du système
|
|
||||||
# sont utilisés, comme app.py.
|
|
||||||
_ENV_DOSSIER_DONNEES = "ZONZA_DOSSIER_DONNEES_TEST"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
app.setQuitOnLastWindowClosed(False) # la fenêtre, c'est l'icône de la barre
|
|
||||||
|
|
||||||
systeme = choisir_systeme()
|
|
||||||
dossier_test = os.environ.get(_ENV_DOSSIER_DONNEES)
|
|
||||||
if dossier_test:
|
|
||||||
chemin_verrou = os.path.join(dossier_test, "instance.lock")
|
|
||||||
chemin_log = os.path.join(dossier_test, "Zonza.log")
|
|
||||||
else:
|
|
||||||
chemin_verrou = None # None -> systeme.dossier_donnees(), le chemin réel
|
|
||||||
chemin_log = systeme.chemin_log()
|
|
||||||
_rediriger_sortie_vers_le_journal(chemin_log)
|
|
||||||
lock = acquerir_verrou(systeme, chemin_verrou)
|
|
||||||
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
|
|
||||||
|
|
||||||
# Dépendance circulaire : le contrôleur a besoin de run_on_main/on_state
|
|
||||||
# du tray, mais le tray a besoin du contrôleur pour être construit. On la
|
|
||||||
# casse avec une case remplie APRÈS coup : les deux fermetures ci-dessous
|
|
||||||
# ne lisent `case["tray"]` qu'au moment de l'appel, jamais à la
|
|
||||||
# construction — ce qui laisse le temps de la remplir.
|
|
||||||
case = {}
|
|
||||||
assemblage = construire(
|
|
||||||
run_on_main=lambda fn: case["tray"].run_on_main(fn),
|
|
||||||
on_state=lambda s: case["tray"].on_state(s),
|
|
||||||
)
|
|
||||||
tray = ZonzaTray(assemblage["controller"], assemblage["systeme"])
|
|
||||||
case["tray"] = tray
|
|
||||||
tray.show()
|
|
||||||
main._tray = tray # référence gardée pour toute la vie du processus
|
|
||||||
|
|
||||||
sys.exit(app.exec())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -9,24 +9,8 @@ _SONS = {
|
|||||||
"error": "/System/Library/Sounds/Basso.aiff",
|
"error": "/System/Library/Sounds/Basso.aiff",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _masque_modificateurs():
|
# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
|
||||||
"""Masque des modificateurs, COMPOSE depuis les constantes nommees de Quartz.
|
_MASQUE_MODIFICATEURS = 0x000F0000
|
||||||
|
|
||||||
Il etait ecrit 0x000F0000 a la main : ce masque couvrait Shift, Control et Alt
|
|
||||||
mais PAS Command (0x00100000), pourtant la touche du raccourci de Zonza. Un
|
|
||||||
masque calcule de tete se trompe en silence ; compose depuis les constantes,
|
|
||||||
il ne peut plus. Repli sur la valeur complete si Quartz est indisponible.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from Quartz import (kCGEventFlagMaskAlternate, kCGEventFlagMaskCommand,
|
|
||||||
kCGEventFlagMaskControl, kCGEventFlagMaskShift)
|
|
||||||
return (kCGEventFlagMaskShift | kCGEventFlagMaskControl
|
|
||||||
| kCGEventFlagMaskAlternate | kCGEventFlagMaskCommand)
|
|
||||||
except Exception:
|
|
||||||
return 0x001E0000 # sans le verrouillage majuscule : il resterait actif en permanence
|
|
||||||
|
|
||||||
|
|
||||||
_MASQUE_MODIFICATEURS = _masque_modificateurs()
|
|
||||||
|
|
||||||
|
|
||||||
class SystemeMacOS:
|
class SystemeMacOS:
|
||||||
@ -41,21 +25,10 @@ class SystemeMacOS:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # le son ne doit jamais casser la dictée
|
pass # le son ne doit jamais casser la dictée
|
||||||
|
|
||||||
def __init__(self, sonde_modificateurs=None):
|
|
||||||
"""`sonde_modificateurs` est injectable : sans cela, tout test de cette
|
|
||||||
methode lirait le CLAVIER PHYSIQUE, et son resultat dependrait des mains
|
|
||||||
de l'utilisateur au moment ou la suite tourne. Meme forme que
|
|
||||||
SystemeWindows, qui recoit deja son clavier."""
|
|
||||||
self._sonde_modificateurs = sonde_modificateurs or self._drapeaux_reels
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _drapeaux_reels():
|
|
||||||
from Quartz import CGEventSourceFlagsState
|
|
||||||
return CGEventSourceFlagsState(1)
|
|
||||||
|
|
||||||
def modificateurs_enfonces(self):
|
def modificateurs_enfonces(self):
|
||||||
try:
|
try:
|
||||||
return bool(self._sonde_modificateurs() & _MASQUE_MODIFICATEURS)
|
from Quartz import CGEventSourceFlagsState
|
||||||
|
return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False # dans le doute, ne jamais bloquer le collage
|
return False # dans le doute, ne jamais bloquer le collage
|
||||||
|
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
"""L'assemblage commun aux deux lanceurs — sans jamais ouvrir de fenêtre ni de micro.
|
|
||||||
|
|
||||||
Le transcripteur réel charge un modèle (opération lourde, parfois absente sur
|
|
||||||
la machine de test) : on injecte donc toujours un `choisir_transcripteur` et
|
|
||||||
un `detecter_moteur` doublés. L'overlay réel (Cocoa ou Qt), lui, est fait pour
|
|
||||||
être construit sans effet visible tant que `show()` n'est pas appelé — les
|
|
||||||
tests le laissent donc réel, et vérifient justement qu'aucune fenêtre ne
|
|
||||||
s'affiche.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from assemblage import acquerir_verrou, construire
|
|
||||||
from controller import ZonzaController
|
|
||||||
from systeme import choisir_systeme
|
|
||||||
|
|
||||||
|
|
||||||
class _TranscripteurFactice:
|
|
||||||
def __init__(self, modele, langue, amorce):
|
|
||||||
self.modele = modele
|
|
||||||
self.langue = langue
|
|
||||||
self.amorce = amorce
|
|
||||||
|
|
||||||
def warmup(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def transcrire(self, audio):
|
|
||||||
return "texte factice"
|
|
||||||
|
|
||||||
|
|
||||||
def _choisir_transcripteur_factice(nom, modele, langue, amorce):
|
|
||||||
return _TranscripteurFactice(modele, langue, amorce)
|
|
||||||
|
|
||||||
|
|
||||||
def _tmp_verrou():
|
|
||||||
return os.path.join(tempfile.mkdtemp(), "instance.lock")
|
|
||||||
|
|
||||||
|
|
||||||
# --- construire() ------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_construire_rend_un_controleur_cable_sur_les_bons_objets():
|
|
||||||
resultat = construire(
|
|
||||||
plateforme="darwin",
|
|
||||||
detecter_moteur=lambda: "mlx",
|
|
||||||
choisir_transcripteur=_choisir_transcripteur_factice,
|
|
||||||
)
|
|
||||||
assert isinstance(resultat["controller"], ZonzaController)
|
|
||||||
# le contrôleur pointe vers CE système, CE transcripteur, CET overlay —
|
|
||||||
# pas une copie recréée à côté.
|
|
||||||
assert resultat["controller"].systeme is resultat["systeme"]
|
|
||||||
assert resultat["controller"].transcripteur is resultat["transcripteur"]
|
|
||||||
assert resultat["controller"].pulse is resultat["overlay"]
|
|
||||||
assert isinstance(resultat["transcripteur"], _TranscripteurFactice)
|
|
||||||
|
|
||||||
|
|
||||||
def test_construire_fonctionne_aussi_pour_win32():
|
|
||||||
resultat = construire(
|
|
||||||
plateforme="win32",
|
|
||||||
detecter_moteur=lambda: "faster-cpu",
|
|
||||||
choisir_transcripteur=_choisir_transcripteur_factice,
|
|
||||||
)
|
|
||||||
from interface.qt import OverlayQt
|
|
||||||
assert isinstance(resultat["overlay"], OverlayQt)
|
|
||||||
|
|
||||||
|
|
||||||
def test_construire_transmet_run_on_main_et_on_state():
|
|
||||||
appels = []
|
|
||||||
resultat = construire(
|
|
||||||
plateforme="darwin",
|
|
||||||
run_on_main=lambda fn: appels.append(fn) or fn(),
|
|
||||||
on_state=lambda s: appels.append(s),
|
|
||||||
detecter_moteur=lambda: "mlx",
|
|
||||||
choisir_transcripteur=_choisir_transcripteur_factice,
|
|
||||||
)
|
|
||||||
resultat["controller"].on_state("recording")
|
|
||||||
assert "recording" in appels
|
|
||||||
|
|
||||||
|
|
||||||
def test_construire_ne_leve_aucune_erreur_de_config():
|
|
||||||
# validate_config(CONFIG) est appelée à l'intérieur : si elle levait, le
|
|
||||||
# test échouerait ici plutôt qu'au premier lancement réel.
|
|
||||||
construire(
|
|
||||||
plateforme="darwin",
|
|
||||||
detecter_moteur=lambda: "mlx",
|
|
||||||
choisir_transcripteur=_choisir_transcripteur_factice,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- acquerir_verrou() --------------------------------------------------------
|
|
||||||
|
|
||||||
def test_acquerir_verrou_rend_un_handle_pour_la_premiere_instance():
|
|
||||||
systeme = choisir_systeme()
|
|
||||||
handle = acquerir_verrou(systeme, _tmp_verrou())
|
|
||||||
assert handle is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_acquerir_verrou_refuse_une_seconde_instance():
|
|
||||||
systeme = choisir_systeme()
|
|
||||||
chemin = _tmp_verrou()
|
|
||||||
premier = acquerir_verrou(systeme, chemin)
|
|
||||||
assert premier is not None
|
|
||||||
assert acquerir_verrou(systeme, chemin) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_acquerir_verrou_sans_chemin_utilise_le_dossier_de_donnees_du_systeme():
|
|
||||||
# Pas de deuxième prise ici (on toucherait le verrou réel de l'utilisateur) :
|
|
||||||
# on vérifie seulement que le chemin par défaut est bien dérivé du système.
|
|
||||||
class _SystemeSonde:
|
|
||||||
def __init__(self):
|
|
||||||
self.chemin_demande = None
|
|
||||||
|
|
||||||
def dossier_donnees(self):
|
|
||||||
return "/tmp/zonza-sonde"
|
|
||||||
|
|
||||||
def acquerir_verrou_instance(self, chemin):
|
|
||||||
self.chemin_demande = chemin
|
|
||||||
return "handle-factice"
|
|
||||||
|
|
||||||
sonde = _SystemeSonde()
|
|
||||||
resultat = acquerir_verrou(sonde)
|
|
||||||
assert resultat == "handle-factice"
|
|
||||||
assert sonde.chemin_demande == "/tmp/zonza-sonde/instance.lock"
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
"""Ce qui est testable dans le lanceur Qt SANS boucle d'événements.
|
|
||||||
|
|
||||||
`ZonzaTray.__init__` construit le menu et l'icône de façon synchrone (aucun
|
|
||||||
`.show()`, aucune boucle) : c'est déjà éprouvable. Le marshalage par signal
|
|
||||||
Qt.QueuedConnection, lui, N'EST PAS testé ici — sa livraison dépend d'une
|
|
||||||
boucle d'événements qui tourne, ce qu'aucun test de ce fichier ne fait
|
|
||||||
tourner. On teste donc directement les méthodes qu'un slot appellerait
|
|
||||||
(`_apply_title`, `toggle_sounds`), pas le trajet signal → file → slot.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
PySide6 = pytest.importorskip("PySide6")
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
|
||||||
|
|
||||||
from core import CONFIG # noqa: E402
|
|
||||||
from lanceur_qt import _STATE_TITLES, ZonzaTray # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
|
||||||
def _app_qt():
|
|
||||||
# QIcon/QPixmap/QSystemTrayIcon exigent une QApplication déjà construite —
|
|
||||||
# sans .exec() : on ne fait jamais tourner la boucle d'événements.
|
|
||||||
app = QApplication.instance() or QApplication([])
|
|
||||||
yield app
|
|
||||||
|
|
||||||
|
|
||||||
class _ControleurFactice:
|
|
||||||
"""Ni toggle() ni transcripteur ne sont appelés tant que .show() ne l'est
|
|
||||||
pas — ce que ces tests évitent justement."""
|
|
||||||
def toggle(self):
|
|
||||||
raise AssertionError("ne doit pas être appelé sans .show()")
|
|
||||||
|
|
||||||
|
|
||||||
class _SystemeFactice:
|
|
||||||
def raccourci_defaut(self):
|
|
||||||
return "<ctrl>+<alt>+d"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tray():
|
|
||||||
t = ZonzaTray(_ControleurFactice(), _SystemeFactice())
|
|
||||||
yield t
|
|
||||||
# évite qu'un menu/tray fantôme traîne d'un test à l'autre (pas de .show()
|
|
||||||
# appelé ici, donc rien n'a jamais été rendu visible).
|
|
||||||
t.menu.deleteLater()
|
|
||||||
|
|
||||||
|
|
||||||
# --- construction du menu -----------------------------------------------
|
|
||||||
|
|
||||||
def test_le_menu_contient_exactement_sons_et_quitter():
|
|
||||||
t = ZonzaTray(_ControleurFactice(), _SystemeFactice())
|
|
||||||
titres = [a.text() for a in t.menu.actions() if not a.isSeparator()]
|
|
||||||
assert titres == ["Sons", "Quitter"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sons_est_une_case_a_cocher():
|
|
||||||
t = ZonzaTray(_ControleurFactice(), _SystemeFactice())
|
|
||||||
assert t.sounds_action.isCheckable()
|
|
||||||
|
|
||||||
|
|
||||||
def test_l_etat_initial_de_la_case_suit_la_config():
|
|
||||||
CONFIG["sounds"] = True
|
|
||||||
t = ZonzaTray(_ControleurFactice(), _SystemeFactice())
|
|
||||||
assert t.sounds_action.isChecked() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_le_menu_est_bien_celui_du_tray():
|
|
||||||
t = ZonzaTray(_ControleurFactice(), _SystemeFactice())
|
|
||||||
assert t.tray.contextMenu() is t.menu
|
|
||||||
|
|
||||||
|
|
||||||
# --- bascule des sons -----------------------------------------------------
|
|
||||||
|
|
||||||
def test_toggle_sounds_coupe_le_son(tray):
|
|
||||||
CONFIG["sounds"] = True
|
|
||||||
tray.toggle_sounds(False)
|
|
||||||
assert CONFIG["sounds"] is False
|
|
||||||
assert tray.sounds_action.isChecked() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_sounds_reactive_le_son(tray):
|
|
||||||
CONFIG["sounds"] = False
|
|
||||||
tray.toggle_sounds(True)
|
|
||||||
assert CONFIG["sounds"] is True
|
|
||||||
assert tray.sounds_action.isChecked() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_sounds_ne_touche_a_rien_d_autre_dans_la_config(tray):
|
|
||||||
avant = dict(CONFIG)
|
|
||||||
tray.toggle_sounds(True)
|
|
||||||
apres = dict(CONFIG)
|
|
||||||
del avant["sounds"]
|
|
||||||
del apres["sounds"]
|
|
||||||
assert avant == apres
|
|
||||||
|
|
||||||
|
|
||||||
# --- correspondance état → icône ------------------------------------------
|
|
||||||
|
|
||||||
def test_trois_etats_sont_couverts():
|
|
||||||
assert set(_STATE_TITLES) == {"idle", "recording", "transcribing"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_etat_idle_par_defaut(tray):
|
|
||||||
assert tray._current_state == "idle"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("etat", ["idle", "recording", "transcribing"])
|
|
||||||
def test_apply_title_memorise_l_etat_recu(tray, etat):
|
|
||||||
tray._apply_title(etat)
|
|
||||||
assert tray._current_state == etat
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_title_pose_une_icone_non_nulle_pour_chaque_etat(tray):
|
|
||||||
for etat in _STATE_TITLES:
|
|
||||||
tray._apply_title(etat)
|
|
||||||
assert not tray.tray.icon().isNull()
|
|
||||||
|
|
||||||
|
|
||||||
def test_un_etat_inconnu_retombe_sur_l_icone_de_repos(tray):
|
|
||||||
# _apply_title ne doit jamais planter sur un état imprévu — repli sur
|
|
||||||
# "idle" (même garde que app.py : `_STATE_TITLES.get(state, "🎙️")`).
|
|
||||||
tray._apply_title("etat-invente")
|
|
||||||
assert not tray.tray.icon().isNull()
|
|
||||||
@ -35,37 +35,5 @@ def test_le_verrou_refuse_une_seconde_prise():
|
|||||||
assert s.acquerir_verrou_instance(chemin) is None
|
assert s.acquerir_verrou_instance(chemin) is None
|
||||||
|
|
||||||
|
|
||||||
def test_les_modificateurs_sont_lus_par_le_masque():
|
def test_aucun_modificateur_enfonce_pendant_les_tests():
|
||||||
"""La sonde est injectee : sans cela ce test lirait le CLAVIER PHYSIQUE et
|
assert choisir_systeme().modificateurs_enfonces() is False
|
||||||
echouerait au hasard, selon qu'une touche est enfoncee ou non pendant la
|
|
||||||
suite. C'est arrive le 2026-08-23."""
|
|
||||||
from systeme.macos import SystemeMacOS
|
|
||||||
assert SystemeMacOS(sonde_modificateurs=lambda: 0x00000000).modificateurs_enfonces() is False
|
|
||||||
# chaque modificateur, un par un, depuis les constantes nommees
|
|
||||||
from Quartz import (kCGEventFlagMaskAlternate, kCGEventFlagMaskCommand,
|
|
||||||
kCGEventFlagMaskControl, kCGEventFlagMaskShift)
|
|
||||||
for drapeau in (kCGEventFlagMaskShift, kCGEventFlagMaskControl,
|
|
||||||
kCGEventFlagMaskAlternate, kCGEventFlagMaskCommand):
|
|
||||||
assert SystemeMacOS(sonde_modificateurs=lambda d=drapeau: d).modificateurs_enfonces() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_une_sonde_qui_leve_ne_bloque_jamais_le_collage():
|
|
||||||
from systeme.macos import SystemeMacOS
|
|
||||||
|
|
||||||
def sonde_cassee():
|
|
||||||
raise RuntimeError("Quartz indisponible")
|
|
||||||
|
|
||||||
assert SystemeMacOS(sonde_modificateurs=sonde_cassee).modificateurs_enfonces() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_le_masque_couvre_command():
|
|
||||||
"""Command etait ABSENT du masque ecrit a la main : Zonza ne voyait jamais
|
|
||||||
cette touche, pourtant celle de son propre raccourci."""
|
|
||||||
from Quartz import kCGEventFlagMaskCommand
|
|
||||||
from systeme.macos import _MASQUE_MODIFICATEURS
|
|
||||||
assert kCGEventFlagMaskCommand & _MASQUE_MODIFICATEURS
|
|
||||||
|
|
||||||
|
|
||||||
def test_la_sonde_reelle_rend_un_booleen_sans_lever():
|
|
||||||
"""On n'affirme PAS sa valeur : elle depend du clavier de l'utilisateur."""
|
|
||||||
assert isinstance(choisir_systeme().modificateurs_enfonces(), bool)
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user