Compare commits
27
Commits
6dea252013
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1cd6373a | ||
|
|
8d316a3e0b | ||
|
|
50b12795d3 | ||
|
|
9be2ea030b | ||
|
|
5eedda0467 | ||
|
|
901d8f695a | ||
|
|
96c1379621 | ||
|
|
bfafc6a48c | ||
|
|
aa63e94239 | ||
|
|
3e269dfb5e | ||
|
|
205424f68a | ||
|
|
99376a8e12 | ||
|
|
82e0e16393 | ||
|
|
d7680227db | ||
|
|
2ccb614649 | ||
|
|
6974a3406c | ||
|
|
5cf8f448ed | ||
|
|
7661cbce36 | ||
|
|
dcec1e61d0 | ||
|
|
3c3ea4444d | ||
|
|
5f83e4c6a3 | ||
|
|
734bba31da | ||
|
|
5c4477fe1d | ||
|
|
434944def6 | ||
|
|
54479d9af9 | ||
|
|
332afede0e | ||
|
|
372df70c62 |
@@ -12,6 +12,19 @@ native est de toute façon minuscule pour nos besoins : une icône, deux items d
|
|||||||
Zonza vit dans la barre de menu, jamais dans le Dock : c'est le LSUIElement de
|
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.
|
l'Info.plist (posé par build_app.sh) qui s'en charge.
|
||||||
"""
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --diagnostic doit s'évaluer AVANT tout import Cocoa/pyobjc : sur une machine
|
||||||
|
# qui ne les a pas (aujourd'hui aucune Windows n'est disponible pour ce
|
||||||
|
# portage, mais c'est la raison d'être de cette commande), c'est le seul
|
||||||
|
# moyen d'éprouver la chaîne. La sortie se fait donc ICI, au chargement du
|
||||||
|
# module — jamais dans main(), qui serait déjà trop tard pour éviter
|
||||||
|
# `from AppKit import ...` ci-dessous.
|
||||||
|
if "--diagnostic" in sys.argv:
|
||||||
|
from diagnostic import diagnostiquer, mettre_en_forme
|
||||||
|
print(mettre_en_forme(diagnostiquer()))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from objc import super
|
from objc import super
|
||||||
@@ -27,9 +40,8 @@ from Foundation import NSObject, NSTimer
|
|||||||
from PyObjCTools import AppHelper
|
from PyObjCTools import AppHelper
|
||||||
from pynput import keyboard
|
from pynput import keyboard
|
||||||
|
|
||||||
from controller import ZonzaController
|
from assemblage import acquerir_verrou, construire
|
||||||
from core import CONFIG, acquire_single_instance_lock, validate_config
|
from core import CONFIG
|
||||||
from pulse_window import PulseWindow
|
|
||||||
|
|
||||||
_STATE_TITLES = {
|
_STATE_TITLES = {
|
||||||
"idle": "🎙️",
|
"idle": "🎙️",
|
||||||
@@ -39,19 +51,21 @@ _STATE_TITLES = {
|
|||||||
|
|
||||||
|
|
||||||
class ZonzaApp(NSObject):
|
class ZonzaApp(NSObject):
|
||||||
"""Délégué applicatif : status item + menu + hotkey + contrôleur."""
|
"""Délégué applicatif : status item + menu + hotkey.
|
||||||
|
|
||||||
def init(self):
|
Le contrôleur (donc le transcripteur, l'overlay et le système) vient déjà
|
||||||
|
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.pulse = PulseWindow.alloc().init()
|
self.controller = controller
|
||||||
self.controller = ZonzaController(
|
|
||||||
CONFIG, self.pulse, on_state=self._set_state,
|
|
||||||
run_on_main=AppHelper.callAfter,
|
|
||||||
)
|
|
||||||
self._listener = None
|
self._listener = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -120,7 +134,7 @@ class ZonzaApp(NSObject):
|
|||||||
def _warmup_async(self):
|
def _warmup_async(self):
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
self.controller.transcriber.warmup()
|
self.controller.transcripteur.warmup()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[erreur] warmup modèle : {e}")
|
print(f"[erreur] warmup modèle : {e}")
|
||||||
threading.Thread(target=run, daemon=True).start()
|
threading.Thread(target=run, daemon=True).start()
|
||||||
@@ -128,13 +142,18 @@ 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 acquire_single_instance_lock).
|
# hotkey captee deux fois (cf systeme.acquerir_verrou_instance). La prise
|
||||||
lock = acquire_single_instance_lock()
|
# 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()
|
||||||
|
lock = acquerir_verrou(systeme)
|
||||||
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)
|
|
||||||
# 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.
|
||||||
@@ -144,8 +163,19 @@ 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()
|
||||||
delegate = ZonzaApp.alloc().init()
|
# Dépendance circulaire : `on_state` doit marshaler vers `delegate._apply_title`,
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
+11
-69
@@ -1,66 +1,35 @@
|
|||||||
"""Moteur de dictée — capture, transcription, collage, son.
|
"""Capture audio et collage du texte transcrit.
|
||||||
|
|
||||||
Aucune dépendance UI : importable par le contrôleur et l'app.
|
Aucune dépendance UI : importable par le contrôleur et l'app. La transcription
|
||||||
|
et les appels système propres à une plateforme vivent désormais ailleurs
|
||||||
|
(`moteur/`, `systeme/`) — ce module ne garde que ce qui n'a pas de frontière
|
||||||
|
de plateforme : la capture du micro et le collage du résultat.
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import mlx_whisper
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyperclip
|
import pyperclip
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
from pynput.keyboard import Controller, Key
|
from pynput.keyboard import Controller, Key
|
||||||
|
|
||||||
from core import CONFIG, attendre, clean_transcript
|
from core import CONFIG, attendre
|
||||||
|
|
||||||
SOUNDS = {
|
|
||||||
"start": "/System/Library/Sounds/Pop.aiff",
|
|
||||||
"done": "/System/Library/Sounds/Glass.aiff",
|
|
||||||
"error": "/System/Library/Sounds/Basso.aiff",
|
|
||||||
}
|
|
||||||
|
|
||||||
_kbd = Controller()
|
_kbd = Controller()
|
||||||
|
|
||||||
|
|
||||||
def play_sound(name):
|
def inject_text(text, systeme):
|
||||||
"""Joue un son système macOS sans bloquer. Silencieux si CONFIG['sounds'] est False."""
|
|
||||||
if not CONFIG["sounds"]:
|
|
||||||
return
|
|
||||||
path = SOUNDS.get(name)
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
subprocess.Popen(["afplay", path])
|
|
||||||
except Exception:
|
|
||||||
pass # le son ne doit jamais casser le daemon
|
|
||||||
|
|
||||||
|
|
||||||
# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
|
|
||||||
_MASQUE_MODIFICATEURS = 0x000F0000
|
|
||||||
|
|
||||||
|
|
||||||
def modificateurs_enfonces():
|
|
||||||
"""True si au moins un modificateur physique est actuellement enfoncé."""
|
|
||||||
try:
|
|
||||||
from Quartz import CGEventSourceFlagsState
|
|
||||||
return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
|
|
||||||
except Exception:
|
|
||||||
return False # dans le doute, ne jamais bloquer le collage
|
|
||||||
|
|
||||||
|
|
||||||
def inject_text(text):
|
|
||||||
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.
|
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.
|
||||||
|
|
||||||
On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
|
On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
|
||||||
Cmd+Ctrl+D quand on envoie Cmd+V, le système reçoit Cmd+Ctrl+V et l'application
|
la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
|
||||||
cible ne colle rien — sans la moindre erreur de notre côté (constaté le
|
l'application cible ne colle rien — sans la moindre erreur de notre côté
|
||||||
2026-08-21 : deux dictées transcrites, jamais collées).
|
(constaté le 2026-08-21).
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
pyperclip.copy(text)
|
pyperclip.copy(text)
|
||||||
libre = attendre(
|
libre = attendre(
|
||||||
lambda: not modificateurs_enfonces(),
|
lambda: not systeme.modificateurs_enfonces(),
|
||||||
CONFIG["attente_modificateurs_s"],
|
CONFIG["attente_modificateurs_s"],
|
||||||
0.03,
|
0.03,
|
||||||
)
|
)
|
||||||
@@ -131,30 +100,3 @@ class Recorder:
|
|||||||
if not frames:
|
if not frames:
|
||||||
return np.zeros(0, dtype="float32")
|
return np.zeros(0, dtype="float32")
|
||||||
return np.concatenate(frames, axis=0).flatten()
|
return np.concatenate(frames, axis=0).flatten()
|
||||||
|
|
||||||
|
|
||||||
class Transcriber:
|
|
||||||
"""Transcrit l'audio en français avec MLX-Whisper.
|
|
||||||
|
|
||||||
Note : mlx_whisper.transcribe charge/met en cache le modèle au premier appel.
|
|
||||||
Le premier lancement télécharge le modèle depuis Hugging Face.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, model, language):
|
|
||||||
self.model = model
|
|
||||||
self.language = language
|
|
||||||
|
|
||||||
def warmup(self):
|
|
||||||
"""Force le téléchargement/chargement du modèle au démarrage (1 fois)."""
|
|
||||||
silence = np.zeros(16000, dtype="float32")
|
|
||||||
self.transcribe(silence)
|
|
||||||
|
|
||||||
def transcribe(self, audio):
|
|
||||||
"""Transcrit l'audio (numpy float32) et renvoie le texte nettoyé."""
|
|
||||||
result = mlx_whisper.transcribe(
|
|
||||||
audio,
|
|
||||||
path_or_hf_repo=self.model,
|
|
||||||
language=self.language,
|
|
||||||
initial_prompt=CONFIG["initial_prompt"],
|
|
||||||
)
|
|
||||||
return clean_transcript(result.get("text", ""))
|
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"""Les trois frontières de Zonza.
|
||||||
|
|
||||||
|
Chaque contrat est délibérément minuscule : ce sont les seuls points par lesquels
|
||||||
|
le noyau parle au monde extérieur. Tout ce qui dépend d'un système d'exploitation,
|
||||||
|
d'une bibliothèque d'affichage ou d'un moteur de transcription vit DERRIÈRE l'un
|
||||||
|
d'eux — jamais dans `core.py`, `controller.py` ni `audio_level.py`.
|
||||||
|
"""
|
||||||
|
from typing import IO, Protocol
|
||||||
|
|
||||||
|
# numpy entre dans ce fichier volontairement minuscule car c'est déjà une
|
||||||
|
# dépendance dure du projet, et le type du signal audio fait partie du
|
||||||
|
# contrat : un `Any` implicite laisserait passer un mauvais type sans que
|
||||||
|
# rien ne bronche.
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class Transcripteur(Protocol):
|
||||||
|
"""Transforme un signal audio en texte."""
|
||||||
|
|
||||||
|
def warmup(self) -> None:
|
||||||
|
"""Charge le modèle une fois, pour que la première dictée ne traîne pas."""
|
||||||
|
|
||||||
|
def transcrire(self, audio: np.ndarray) -> str:
|
||||||
|
"""Rend le texte d'un tableau numpy float32 mono."""
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay(Protocol):
|
||||||
|
"""La fenêtre flottante affichée pendant la dictée."""
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
|
||||||
|
|
||||||
|
def hide(self) -> None:
|
||||||
|
"""Masque la fenêtre et arrête l'animation. Thread principal."""
|
||||||
|
|
||||||
|
def set_level(self, level: float) -> None:
|
||||||
|
"""Pousse un niveau audio dans [0, 1]. Appelable depuis un thread de fond."""
|
||||||
|
|
||||||
|
def set_status(self, text: str) -> None:
|
||||||
|
"""Texte affiché dans la bulle. Appelable depuis un thread de fond."""
|
||||||
|
|
||||||
|
def rearmer_garde(self, duree_s: float) -> None:
|
||||||
|
"""Remplace la durée de vie maximale de la fenêtre. Thread principal."""
|
||||||
|
|
||||||
|
|
||||||
|
class Systeme(Protocol):
|
||||||
|
"""Les appels qui diffèrent d'un système d'exploitation à l'autre."""
|
||||||
|
|
||||||
|
def jouer_son(self, nom: str) -> None:
|
||||||
|
"""Joue « start », « done » ou « error » sans bloquer."""
|
||||||
|
|
||||||
|
def modificateurs_enfonces(self) -> bool:
|
||||||
|
"""True si une touche modificatrice est physiquement enfoncée."""
|
||||||
|
|
||||||
|
def raccourci_defaut(self) -> str:
|
||||||
|
"""Raccourci global au format pynput, propre à la plateforme."""
|
||||||
|
|
||||||
|
def chemin_log(self) -> str:
|
||||||
|
"""Chemin absolu du journal."""
|
||||||
|
|
||||||
|
def dossier_donnees(self) -> str:
|
||||||
|
"""Dossier des données de l'application (verrou d'instance, réglages)."""
|
||||||
|
|
||||||
|
def acquerir_verrou_instance(self, chemin: str) -> IO | None:
|
||||||
|
"""Verrou exclusif non bloquant ; rend le fichier, ou None s'il est pris."""
|
||||||
+23
-12
@@ -11,15 +11,15 @@ contexte de l'app). La transcription, elle, part sur un thread de fond.
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
from audio_level import compute_rms, shape_level
|
from audio_level import compute_rms, shape_level
|
||||||
|
from capture import Recorder, inject_text
|
||||||
from core import entree_audio_muette, is_long_enough
|
from core import entree_audio_muette, is_long_enough
|
||||||
from engine import Recorder, Transcriber, inject_text, play_sound
|
|
||||||
|
|
||||||
|
|
||||||
class ZonzaController:
|
class ZonzaController:
|
||||||
"""Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu)."""
|
"""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,
|
def __init__(self, config, pulse_window, transcripteur, systeme, on_state=None,
|
||||||
lancer_tache=None):
|
run_on_main=None, lancer_tache=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.pulse = pulse_window
|
self.pulse = pulse_window
|
||||||
self.on_state = on_state or (lambda s: None)
|
self.on_state = on_state or (lambda s: None)
|
||||||
@@ -34,7 +34,8 @@ class ZonzaController:
|
|||||||
lambda fn: threading.Thread(target=fn, daemon=True).start()
|
lambda fn: threading.Thread(target=fn, daemon=True).start()
|
||||||
)
|
)
|
||||||
self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block)
|
self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block)
|
||||||
self.transcriber = Transcriber(config["model"], config["language"])
|
self.transcripteur = transcripteur
|
||||||
|
self.systeme = systeme
|
||||||
self.recording = False
|
self.recording = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._smoothed_level = 0.0
|
self._smoothed_level = 0.0
|
||||||
@@ -42,6 +43,16 @@ class ZonzaController:
|
|||||||
self._blocs_recus = 0 # compteur pour détecter un micro muet
|
self._blocs_recus = 0 # compteur pour détecter un micro muet
|
||||||
self._veille_silence = None
|
self._veille_silence = None
|
||||||
|
|
||||||
|
def _jouer_son(self, nom):
|
||||||
|
"""Joue un son, sauf si l'utilisateur les a coupés par le menu.
|
||||||
|
|
||||||
|
La décision vit ICI et non dans la couche système : celle-ci doit rester
|
||||||
|
bête, sinon chaque nouvelle plateforme devra repenser à ce garde-fou.
|
||||||
|
"""
|
||||||
|
if not self.config["sounds"]:
|
||||||
|
return
|
||||||
|
self.systeme.jouer_son(nom)
|
||||||
|
|
||||||
def _on_audio_block(self, block):
|
def _on_audio_block(self, block):
|
||||||
# thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release
|
# 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
|
# → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée
|
||||||
@@ -68,7 +79,7 @@ class ZonzaController:
|
|||||||
self.recording = True
|
self.recording = True
|
||||||
self.pulse.show()
|
self.pulse.show()
|
||||||
self.pulse.set_status("Je vous écoute…")
|
self.pulse.set_status("Je vous écoute…")
|
||||||
play_sound("start")
|
self._jouer_son("start")
|
||||||
self.on_state("recording")
|
self.on_state("recording")
|
||||||
self._arm_safety_timer()
|
self._arm_safety_timer()
|
||||||
self._armer_veille_silence()
|
self._armer_veille_silence()
|
||||||
@@ -88,7 +99,7 @@ class ZonzaController:
|
|||||||
if not entree_audio_muette(self._blocs_recus, self.recording):
|
if not entree_audio_muette(self._blocs_recus, self.recording):
|
||||||
return
|
return
|
||||||
print("[micro] aucun son reçu — le micro est probablement pris par une autre application")
|
print("[micro] aucun son reçu — le micro est probablement pris par une autre application")
|
||||||
play_sound("error")
|
self._jouer_son("error")
|
||||||
self.run_on_main(lambda: self.pulse.set_status("Aucun son — micro pris ailleurs ?"))
|
self.run_on_main(lambda: self.pulse.set_status("Aucun son — micro pris ailleurs ?"))
|
||||||
|
|
||||||
def _ouvrir_flux(self):
|
def _ouvrir_flux(self):
|
||||||
@@ -97,7 +108,7 @@ class ZonzaController:
|
|||||||
self.recorder.start()
|
self.recorder.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[erreur] démarrage enregistrement : {e}")
|
print(f"[erreur] démarrage enregistrement : {e}")
|
||||||
play_sound("error")
|
self._jouer_son("error")
|
||||||
self.recording = False
|
self.recording = False
|
||||||
self._cancel_safety_timer()
|
self._cancel_safety_timer()
|
||||||
self.run_on_main(self.pulse.hide)
|
self.run_on_main(self.pulse.hide)
|
||||||
@@ -143,22 +154,22 @@ class ZonzaController:
|
|||||||
principal (2026-08-21)."""
|
principal (2026-08-21)."""
|
||||||
try:
|
try:
|
||||||
audio = self.recorder.stop()
|
audio = self.recorder.stop()
|
||||||
play_sound("done")
|
self._jouer_son("done")
|
||||||
if not is_long_enough(len(audio), self.config["sample_rate"],
|
if not is_long_enough(len(audio), self.config["sample_rate"],
|
||||||
self.config["min_duration_s"]):
|
self.config["min_duration_s"]):
|
||||||
return
|
return
|
||||||
text = self.transcriber.transcribe(audio)
|
text = self.transcripteur.transcrire(audio)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
print(f"[texte] {text}")
|
print(f"[texte] {text}")
|
||||||
try:
|
try:
|
||||||
inject_text(text)
|
inject_text(text, self.systeme)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V")
|
print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V")
|
||||||
play_sound("error")
|
self._jouer_son("error")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[erreur] arrêt ou transcription : {e}")
|
print(f"[erreur] arrêt ou transcription : {e}")
|
||||||
play_sound("error")
|
self._jouer_son("error")
|
||||||
finally:
|
finally:
|
||||||
self.run_on_main(self.pulse.hide)
|
self.run_on_main(self.pulse.hide)
|
||||||
self.on_state("idle")
|
self.on_state("idle")
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
"model": "mlx-community/whisper-medium",
|
# Moteur de transcription. None = detection materielle (mlx, faster-cpu,
|
||||||
|
# faster-gpu, tous locaux). "gemini" (API Google directe) ou "openrouter"
|
||||||
|
# (meme modele, via OpenRouter) = l'audio quitte le Mac, avec le moteur
|
||||||
|
# local en repli si l'API ne repond pas (cf moteur/gemini.py).
|
||||||
|
"moteur": "openrouter",
|
||||||
|
# Forcage manuel du modele. None = on suit MODELES_PAR_MOTEUR, qui choisit
|
||||||
|
# d'apres le moteur detecte. Renseigner cette cle prime sur la table.
|
||||||
|
"model": None,
|
||||||
"language": "fr",
|
"language": "fr",
|
||||||
"hotkey": "<cmd>+<ctrl>+d", # Cmd+Ctrl+D (libre : ni macOS ni VS Code ne l'utilisent)
|
"hotkey": "<cmd>+<ctrl>+d", # Cmd+Ctrl+D (libre : ni macOS ni VS Code ne l'utilisent)
|
||||||
"sounds": True,
|
"sounds": True,
|
||||||
@@ -65,6 +70,8 @@ VOCABULAIRE = [
|
|||||||
"Gitea", "GitHub", "Hetzner", "Hostinger", "iCloud", "Clerk",
|
"Gitea", "GitHub", "Hetzner", "Hostinger", "iCloud", "Clerk",
|
||||||
# paiement, mail, automatisation
|
# paiement, mail, automatisation
|
||||||
"Stripe", "PayPal", "Shopify", "Brevo", "n8n", "Airtable", "Yabetoo",
|
"Stripe", "PayPal", "Shopify", "Brevo", "n8n", "Airtable", "Yabetoo",
|
||||||
|
# IA et medias
|
||||||
|
"Higgsfield",
|
||||||
# projets
|
# projets
|
||||||
"FacturPro", "Forescale", "Reflow", "CiteGain", "Faramaya", "Soko",
|
"FacturPro", "Forescale", "Reflow", "CiteGain", "Faramaya", "Soko",
|
||||||
"Moubote", "Yelessa", "Zonza", "MR TECH LAB",
|
"Moubote", "Yelessa", "Zonza", "MR TECH LAB",
|
||||||
@@ -165,6 +172,54 @@ def duree_vie_overlay_coherente(config):
|
|||||||
return config["duree_max_overlay_s"] >= config["max_duration_s"] + MARGE_TRANSCRIPTION_S
|
return config["duree_max_overlay_s"] >= config["max_duration_s"] + MARGE_TRANSCRIPTION_S
|
||||||
|
|
||||||
|
|
||||||
|
# --- modele par moteur ----------------------------------------------------
|
||||||
|
# Chaque moteur a son propre format de depot : MLX lit `mlx-community/*`,
|
||||||
|
# faster-whisper lit `Systran/faster-whisper-*`. Passer l'un a l'autre echoue.
|
||||||
|
#
|
||||||
|
# Mesure du 2026-08-23, trois echantillons, amorce de vocabulaire active,
|
||||||
|
# sur le CPU d'un 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
|
||||||
|
#
|
||||||
|
# D'ou les choix : le GPU vise la precision (medium, 13/13, le GPU absorbe le
|
||||||
|
# cout) ; le CPU vise la latence (small, 11/13 — soit AUTANT que le MLX utilise
|
||||||
|
# aujourd'hui — pour 1,4 s ici). `medium` sur CPU serait plus juste, mais 4,4 s
|
||||||
|
# ont ete mesures sur un CPU Apple Silicon, rapide : sur un CPU Intel ou Windows,
|
||||||
|
# le meme modele peut demander trois a quatre fois plus. La commande de
|
||||||
|
# diagnostic (etape 4) mesurera la machine reelle et pourra promouvoir `medium`.
|
||||||
|
MODELES_PAR_MOTEUR = {
|
||||||
|
"mlx": "mlx-community/whisper-medium",
|
||||||
|
"faster-gpu": "Systran/faster-whisper-medium",
|
||||||
|
"faster-cpu": "Systran/faster-whisper-small",
|
||||||
|
# Pas la variante « -tts » : celle-ci ne fait que produire de la voix a
|
||||||
|
# partir de texte, elle ne transcrit pas. Il faut un modele qui ACCEPTE
|
||||||
|
# l'audio en entree.
|
||||||
|
"gemini": "gemini-3.1-flash-lite",
|
||||||
|
# Mesure du 2026-09-25 (phrase de 12 s / dictee de 94 s, 2 a 3 essais) :
|
||||||
|
# 3.5-flash-lite 1,4 s / 3-5 s AJOUTE une phrase non dite -> ecarte
|
||||||
|
# 3.1-flash-lite 1,5 s / 3,7 s « Gitea » -> « GitHub » sur le long
|
||||||
|
# 3.8-flash 4-9 s / 9 s tout juste, a chaque essai -> retenu
|
||||||
|
# « 3.8-flash-lite-tts » est refuse par OpenRouter (HTTP 400) : c'est un
|
||||||
|
# modele texte -> voix, il n'accepte pas d'audio en entree.
|
||||||
|
"openrouter": "google/gemini-3.8-flash",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def modele_retenu(nom_moteur, forcage=None):
|
||||||
|
"""Modele finalement utilise : le forcage manuel s'il est renseigne, sinon la
|
||||||
|
table. Une chaine vide est traitee comme une absence de forcage."""
|
||||||
|
return forcage or modele_pour_moteur(nom_moteur)
|
||||||
|
|
||||||
|
|
||||||
|
def modele_pour_moteur(nom):
|
||||||
|
"""Rend le depot de modele adapte a ce moteur. Leve KeyError si inconnu."""
|
||||||
|
if nom not in MODELES_PAR_MOTEUR:
|
||||||
|
raise KeyError(f"Aucun modele connu pour le moteur : {nom}")
|
||||||
|
return MODELES_PAR_MOTEUR[nom]
|
||||||
|
|
||||||
|
|
||||||
def validate_config(config):
|
def validate_config(config):
|
||||||
"""Valide la config ; lève ValueError si une valeur est aberrante."""
|
"""Valide la config ; lève ValueError si une valeur est aberrante."""
|
||||||
if config["min_duration_s"] < 0:
|
if config["min_duration_s"] < 0:
|
||||||
@@ -196,33 +251,4 @@ def clean_transcript(text):
|
|||||||
return re.sub(r"\s+", " ", text).strip()
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
LOCK_PATH = os.path.expanduser("~/Library/Application Support/Zonza/instance.lock")
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_single_instance_lock(path=LOCK_PATH):
|
|
||||||
"""Prend un verrou exclusif non bloquant ; renvoie le fichier, ou None si une
|
|
||||||
autre instance le detient deja.
|
|
||||||
|
|
||||||
Pourquoi : deux bundles Zonza de meme identifiant (dont un fantome reste dans
|
|
||||||
la Corbeille mais toujours enregistre aupres de LaunchServices) pouvaient
|
|
||||||
tourner en meme temps. Chacun chargeait son propre modele MLX-Whisper et
|
|
||||||
captait la MEME hotkey globale : un appui, deux transcriptions, machine
|
|
||||||
saturee. Purger le fantome corrige l'incident ; ce verrou corrige la classe.
|
|
||||||
|
|
||||||
Le handle doit rester vivant tant que l'app tourne : le verrou tombe a la
|
|
||||||
fermeture du fichier, et le noyau le libere si le processus meurt brutalement
|
|
||||||
(donc pas de verrou orphelin apres un plantage).
|
|
||||||
"""
|
|
||||||
parent = os.path.dirname(path)
|
|
||||||
if parent:
|
|
||||||
os.makedirs(parent, exist_ok=True)
|
|
||||||
handle = open(path, "a")
|
|
||||||
try:
|
|
||||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except OSError:
|
|
||||||
handle.close()
|
|
||||||
return None
|
|
||||||
return handle
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG["initial_prompt"] = build_initial_prompt()
|
CONFIG["initial_prompt"] = build_initial_prompt()
|
||||||
|
|||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
"""`zonza --diagnostic` : éprouve toute la chaîne sur la machine cible et dit où
|
||||||
|
elle casse.
|
||||||
|
|
||||||
|
Aucune machine Windows n'étant disponible pour ce portage, cette commande est
|
||||||
|
le SEUL moyen pour l'utilisateur de valider `systeme/windows.py` en conditions
|
||||||
|
réelles. D'où deux règles strictes suivies ici :
|
||||||
|
|
||||||
|
1. Chaque épreuve attrape SES PROPRES erreurs : une panne (micro absent,
|
||||||
|
moteur introuvable, `say` manquant...) ne doit jamais faire tomber les
|
||||||
|
épreuves suivantes ni le rapport entier.
|
||||||
|
2. Une épreuve qui n'a pas pu être menée est marquée `reussite=None`
|
||||||
|
(« non concluante ») — JAMAIS `True`. Mentir sur une réussite serait pire
|
||||||
|
que ne rien dire : mieux vaut « je ne sais pas » qu'un faux vert.
|
||||||
|
|
||||||
|
Deux fonctions séparées, pour rester testable : `diagnostiquer()` rend une
|
||||||
|
liste de `ResultatEpreuve` (aucun `print`) ; `mettre_en_forme()` la traduit en
|
||||||
|
texte affichable. Toutes les dépendances matérielles/plateforme sont
|
||||||
|
injectables, ce qui permet d'éprouver l'ENCHAÎNEMENT des épreuves depuis ce
|
||||||
|
Mac sans jamais toucher un vrai micro ni un vrai moteur de transcription.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import wave
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import core
|
||||||
|
import moteur
|
||||||
|
from moteur import _cuda_disponible
|
||||||
|
from systeme import choisir_systeme
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultatEpreuve:
|
||||||
|
"""Le compte-rendu d'une seule épreuve.
|
||||||
|
|
||||||
|
`reussite` : True (menée, et tout va bien), False (menée, et quelque chose
|
||||||
|
est cassé), ou None (n'a PAS pu être menée — non concluante, jamais
|
||||||
|
présentée comme une réussite).
|
||||||
|
"""
|
||||||
|
nom: str
|
||||||
|
reussite: bool | None
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 1 : plateforme -------------------------------------------------
|
||||||
|
|
||||||
|
def epreuve_plateforme(plateforme=None, machine=None, cuda_disponible=None):
|
||||||
|
"""Décrit la machine : plateforme Python, architecture, présence de CUDA."""
|
||||||
|
plateforme = plateforme if plateforme is not None else sys.platform
|
||||||
|
machine = machine if machine is not None else platform.machine()
|
||||||
|
sonder_cuda = cuda_disponible if cuda_disponible is not None else _cuda_disponible
|
||||||
|
try:
|
||||||
|
cuda = sonder_cuda()
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Plateforme", None, f"sonde CUDA en échec : {e}")
|
||||||
|
cuda_txt = "présent" if cuda else "absent"
|
||||||
|
return ResultatEpreuve("Plateforme", True, f"{plateforme} / {machine} / CUDA {cuda_txt}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 2 : moteur retenu ----------------------------------------------
|
||||||
|
|
||||||
|
def epreuve_moteur(detecter_moteur=None, resoudre_modele=None, creer_transcripteur=None):
|
||||||
|
"""Décide du moteur, résout son modèle, instancie le transcripteur.
|
||||||
|
|
||||||
|
Rend `(resultat, transcripteur_ou_None)` : le transcripteur sert à
|
||||||
|
l'épreuve Transcription sans être reconstruit — mais seulement si cette
|
||||||
|
épreuve a réussi ; sinon la Transcription sera honnêtement sautée.
|
||||||
|
"""
|
||||||
|
detecter_moteur = detecter_moteur or moteur.detecter_moteur
|
||||||
|
resoudre_modele = resoudre_modele or core.modele_retenu
|
||||||
|
creer_transcripteur = creer_transcripteur or moteur.choisir_transcripteur
|
||||||
|
try:
|
||||||
|
nom = detecter_moteur()
|
||||||
|
nom_modele = resoudre_modele(nom, core.CONFIG["model"])
|
||||||
|
transcripteur = creer_transcripteur(
|
||||||
|
nom=nom, modele=nom_modele, langue=core.CONFIG["language"],
|
||||||
|
amorce=core.CONFIG["initial_prompt"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Moteur retenu", None, f"détection impossible : {e}"), None
|
||||||
|
return ResultatEpreuve("Moteur retenu", True, f"{nom} + {nom_modele}"), transcripteur
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 3 : micro -------------------------------------------------------
|
||||||
|
|
||||||
|
def epreuve_micro(ouvrir_flux=None, sample_rate=None, duree_s=1.0, dormir=None):
|
||||||
|
"""Ouvre VRAIMENT le micro pendant `duree_s` et compte les blocs reçus.
|
||||||
|
|
||||||
|
`ouvrir_flux` suit le même contrat que `Recorder._ouvrir_flux` dans
|
||||||
|
`capture.py` : injectable, pour que les tests ne touchent à aucun
|
||||||
|
périphérique réel.
|
||||||
|
|
||||||
|
Le débit vient de CONFIG et n'est pas codé en dur : le diagnostic doit
|
||||||
|
éprouver le micro AU DÉBIT QUE L'APPLICATION UTILISE VRAIMENT, sinon il
|
||||||
|
cesserait silencieusement d'être représentatif si ce réglage changeait.
|
||||||
|
"""
|
||||||
|
from core import CONFIG
|
||||||
|
sample_rate = sample_rate or CONFIG["sample_rate"]
|
||||||
|
dormir = dormir or time.sleep
|
||||||
|
if ouvrir_flux is None:
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Micro", None, f"sounddevice indisponible : {e}")
|
||||||
|
ouvrir_flux = lambda **kw: sd.InputStream(**kw) # noqa: E731
|
||||||
|
|
||||||
|
compte = {"n": 0}
|
||||||
|
|
||||||
|
def on_block(indata, frames, time_info, status):
|
||||||
|
compte["n"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
flux = ouvrir_flux(samplerate=sample_rate, channels=1, dtype="float32",
|
||||||
|
callback=on_block)
|
||||||
|
flux.start()
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Micro", None, f"ouverture du micro impossible : {e}")
|
||||||
|
|
||||||
|
dormir(duree_s)
|
||||||
|
|
||||||
|
for action in ("stop", "close"):
|
||||||
|
try:
|
||||||
|
getattr(flux, action)()
|
||||||
|
except Exception:
|
||||||
|
pass # meilleur effort : le compte déjà reçu reste valable
|
||||||
|
|
||||||
|
n = compte["n"]
|
||||||
|
return ResultatEpreuve("Micro", n > 0, f"{n} blocs reçus en {duree_s:g} s")
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 4 : transcription -----------------------------------------------
|
||||||
|
|
||||||
|
def _generer_echantillon_parole(texte="Bonjour, ceci est un test de dictée Zonza.",
|
||||||
|
sample_rate=None):
|
||||||
|
"""Synthétise un court échantillon de parole via la commande `say` de macOS.
|
||||||
|
|
||||||
|
LÈVE si `say` est absent, échoue, ou si le WAV produit est illisible —
|
||||||
|
c'est volontaire : l'appelant retombe alors sur du silence plutôt que
|
||||||
|
d'inventer un résultat. Sur une machine sans `say` (Windows, Linux),
|
||||||
|
c'est TOUJOURS ce chemin qui est emprunté.
|
||||||
|
"""
|
||||||
|
from core import CONFIG
|
||||||
|
sample_rate = sample_rate or CONFIG["sample_rate"]
|
||||||
|
if shutil.which("say") is None:
|
||||||
|
raise RuntimeError("commande `say` absente (probablement pas macOS)")
|
||||||
|
chemin = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["say", "-o", chemin, f"--data-format=LEI16@{sample_rate}", texte],
|
||||||
|
check=True, capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
with wave.open(chemin, "rb") as w:
|
||||||
|
brut = w.readframes(w.getnframes())
|
||||||
|
return np.frombuffer(brut, dtype="<i2").astype("float32") / 32768.0
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(chemin)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def epreuve_transcription(transcripteur, generer_parole=None, horloge=None):
|
||||||
|
"""Transcrit VRAIMENT un échantillon — parole synthétisée si possible,
|
||||||
|
sinon une seconde de silence — et rapporte la durée réellement mesurée."""
|
||||||
|
horloge = horloge or time.monotonic
|
||||||
|
generer_parole = generer_parole or _generer_echantillon_parole
|
||||||
|
try:
|
||||||
|
audio = generer_parole()
|
||||||
|
source = "parole synthétisée"
|
||||||
|
except Exception as e:
|
||||||
|
from core import CONFIG
|
||||||
|
audio = np.zeros(CONFIG["sample_rate"], dtype="float32") # une seconde
|
||||||
|
source = f"silence (synthèse vocale indisponible : {e})"
|
||||||
|
debut = horloge()
|
||||||
|
try:
|
||||||
|
texte = transcripteur.transcrire(audio)
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Transcription", None,
|
||||||
|
f"échec de la transcription ({source}) : {e}")
|
||||||
|
duree = horloge() - debut
|
||||||
|
return ResultatEpreuve("Transcription", True, f"{duree:.2f} s — « {texte} » [{source}]")
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 5 : presse-papier ------------------------------------------------
|
||||||
|
|
||||||
|
def epreuve_presse_papier(copier=None, lire=None):
|
||||||
|
"""Copie un texte marqueur, le relit, vérifie qu'il n'a pas changé en route."""
|
||||||
|
if copier is None or lire is None:
|
||||||
|
try:
|
||||||
|
import pyperclip
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Presse-papier", None, f"pyperclip indisponible : {e}")
|
||||||
|
copier = copier or pyperclip.copy
|
||||||
|
lire = lire or pyperclip.paste
|
||||||
|
texte = f"zonza-diagnostic-{uuid.uuid4().hex[:8]}"
|
||||||
|
try:
|
||||||
|
copier(texte)
|
||||||
|
relu = lire()
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Presse-papier", None, f"copie/collage impossible : {e}")
|
||||||
|
if relu == texte:
|
||||||
|
return ResultatEpreuve("Presse-papier", True, "copie puis relecture identiques")
|
||||||
|
return ResultatEpreuve("Presse-papier", False, f"attendu {texte!r}, relu {relu!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 6 : raccourci ----------------------------------------------------
|
||||||
|
|
||||||
|
def epreuve_raccourci(systeme):
|
||||||
|
"""Rapporte le raccourci par défaut de cette plateforme (aucune action)."""
|
||||||
|
try:
|
||||||
|
raccourci = systeme.raccourci_defaut()
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Raccourci", None, f"indisponible : {e}")
|
||||||
|
return ResultatEpreuve("Raccourci", True, raccourci)
|
||||||
|
|
||||||
|
|
||||||
|
# --- épreuve 7 : verrou --------------------------------------------------------
|
||||||
|
|
||||||
|
def epreuve_verrou(systeme, chemin=None):
|
||||||
|
"""Vérifie qu'une seconde tentative de verrou sur le même chemin est refusée."""
|
||||||
|
chemin = chemin or os.path.join(
|
||||||
|
tempfile.mkdtemp(prefix="zonza-diagnostic-"), "diagnostic.lock"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
premier = systeme.acquerir_verrou_instance(chemin)
|
||||||
|
second = systeme.acquerir_verrou_instance(chemin)
|
||||||
|
except Exception as e:
|
||||||
|
return ResultatEpreuve("Verrou", None, f"indisponible : {e}")
|
||||||
|
|
||||||
|
if premier is not None:
|
||||||
|
try:
|
||||||
|
premier.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if premier is None:
|
||||||
|
return ResultatEpreuve("Verrou", None, "impossible d'obtenir le premier verrou")
|
||||||
|
if second is None:
|
||||||
|
return ResultatEpreuve("Verrou", True, "seconde instance refusée")
|
||||||
|
return ResultatEpreuve("Verrou", False, "seconde instance NON refusée — verrou inefficace")
|
||||||
|
|
||||||
|
|
||||||
|
# --- orchestration -------------------------------------------------------------
|
||||||
|
|
||||||
|
def diagnostiquer(systeme=None, plateforme=None, machine=None, cuda_disponible=None,
|
||||||
|
detecter_moteur=None, resoudre_modele=None, creer_transcripteur=None,
|
||||||
|
ouvrir_flux=None, duree_micro_s=1.0, dormir=None,
|
||||||
|
generer_parole=None, horloge=None, copier=None, lire=None):
|
||||||
|
"""Éprouve toute la chaîne, dans l'ordre du rapport.
|
||||||
|
|
||||||
|
Ne lève JAMAIS : chaque épreuve attrape ses propres erreurs et se marque
|
||||||
|
non concluante plutôt que de faire tomber les suivantes ou le diagnostic
|
||||||
|
entier. Rend une liste de `ResultatEpreuve` — pas de `print` ici, pour que
|
||||||
|
ce soit testable indépendamment de l'affichage (cf `mettre_en_forme`).
|
||||||
|
"""
|
||||||
|
systeme = systeme or choisir_systeme()
|
||||||
|
resultats = [epreuve_plateforme(plateforme, machine, cuda_disponible)]
|
||||||
|
|
||||||
|
resultat_moteur, transcripteur = epreuve_moteur(
|
||||||
|
detecter_moteur, resoudre_modele, creer_transcripteur
|
||||||
|
)
|
||||||
|
resultats.append(resultat_moteur)
|
||||||
|
|
||||||
|
resultats.append(epreuve_micro(ouvrir_flux, duree_s=duree_micro_s, dormir=dormir))
|
||||||
|
|
||||||
|
if transcripteur is not None:
|
||||||
|
resultats.append(epreuve_transcription(transcripteur, generer_parole, horloge))
|
||||||
|
else:
|
||||||
|
resultats.append(ResultatEpreuve(
|
||||||
|
"Transcription", None, "sautée — aucun moteur n'a pu être instancié"
|
||||||
|
))
|
||||||
|
|
||||||
|
resultats.append(epreuve_presse_papier(copier, lire))
|
||||||
|
resultats.append(epreuve_raccourci(systeme))
|
||||||
|
resultats.append(epreuve_verrou(systeme))
|
||||||
|
return resultats
|
||||||
|
|
||||||
|
|
||||||
|
_SYMBOLES = {True: "✅", False: "❌", None: "➖"}
|
||||||
|
|
||||||
|
|
||||||
|
def formater_resultat(resultat):
|
||||||
|
"""Une ligne du rapport : symbole, nom aligné, détail."""
|
||||||
|
symbole = _SYMBOLES.get(resultat.reussite, "➖")
|
||||||
|
return f"{symbole} {resultat.nom.ljust(16)}: {resultat.detail}"
|
||||||
|
|
||||||
|
|
||||||
|
def mettre_en_forme(resultats):
|
||||||
|
"""Traduit une liste de `ResultatEpreuve` en texte affichable.
|
||||||
|
|
||||||
|
Séparée de `diagnostiquer()` à dessein : cette fonction ne fait aucun
|
||||||
|
travail, uniquement de la mise en forme — donc testable sur des résultats
|
||||||
|
fabriqués à la main, sans jamais exécuter une seule épreuve.
|
||||||
|
"""
|
||||||
|
lignes = [formater_resultat(r) for r in resultats]
|
||||||
|
reussies = sum(1 for r in resultats if r.reussite is True)
|
||||||
|
echouees = sum(1 for r in resultats if r.reussite is False)
|
||||||
|
non_conclu = sum(1 for r in resultats if r.reussite is None)
|
||||||
|
lignes.append("")
|
||||||
|
lignes.append(
|
||||||
|
f"{reussies}/{len(resultats)} réussies, {echouees} en échec, "
|
||||||
|
f"{non_conclu} non concluante(s)."
|
||||||
|
)
|
||||||
|
return "\n".join(lignes)
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
# Zonza multiplateforme — Étape 1 : extraire les contrats
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Nommer les trois frontières (moteur, interface, système) derrière des contrats explicites, sans changer un seul comportement.
|
||||||
|
|
||||||
|
**Architecture:** `controller.py` reçoit ses collaborateurs par injection au lieu de les importer. Le code Cocoa est **déplacé sans être modifié**. Une fabrique par frontière choisit l'implémentation ; sur ce Mac, elle rend exactement ce qui tourne aujourd'hui.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14, pytest, pyobjc (inchangé), sounddevice, pynput, mlx-whisper.
|
||||||
|
|
||||||
|
**Critère de réussite :** les 80 tests restent verts, `./build_app.sh` produit un stub au **cdhash identique**, et Zonza dicte comme avant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contrainte non négociable
|
||||||
|
|
||||||
|
`build_app.sh:43` grave `-DAPP_PY="$HERE/app.py"` dans le stub C. **Renommer ou déplacer `app.py` change le binaire, donc son cdhash, donc révoque l'autorisation d'Accessibilité de macOS** (vérifié le 2026-08-21). `app.py` reste donc à la racine sous ce nom — la spec parlait de `lanceur.py`, on s'en écarte volontairement.
|
||||||
|
|
||||||
|
Vérification à faire à la fin de chaque tâche qui touche au build :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure de fichiers
|
||||||
|
|
||||||
|
| Fichier | Responsabilité |
|
||||||
|
|---|---|
|
||||||
|
| `contrats.py` | **créer** — les trois protocoles, et rien d'autre |
|
||||||
|
| `systeme/__init__.py` | **créer** — `choisir_systeme()` |
|
||||||
|
| `systeme/macos.py` | **créer** — son, modificateurs, journal, verrou, raccourci |
|
||||||
|
| `moteur/__init__.py` | **créer** — `choisir_transcripteur()` |
|
||||||
|
| `moteur/mlx.py` | **créer** — `TranscripteurMLX`, extrait de `engine.Transcriber` |
|
||||||
|
| `interface/__init__.py` | **créer** — `choisir_interface()` |
|
||||||
|
| `interface/cocoa/overlay.py` | **créer** — `pulse_window.py` déplacé **tel quel** |
|
||||||
|
| `capture.py` | **créer** — `Recorder` et `inject_text`, déjà portables |
|
||||||
|
| `engine.py` | **supprimer** en fin de parcours |
|
||||||
|
| `pulse_window.py` | **supprimer** en fin de parcours |
|
||||||
|
| `controller.py` | **modifier** — injection au lieu d'imports |
|
||||||
|
| `app.py` | **modifier** — câblage, nom inchangé |
|
||||||
|
| `tests/test_contrats.py` | **créer** — suite rejouée contre chaque implémentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Les contrats
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `contrats.py`
|
||||||
|
- Test: `tests/test_contrats.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_contrats.py
|
||||||
|
"""Une suite unique, rejouée contre TOUTES les implémentations d'un contrat.
|
||||||
|
|
||||||
|
Le jour où une implémentation oublie une méthode, c'est ici que ça rougit — y
|
||||||
|
compris pour les plateformes absentes de la machine de développement.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contrats import Overlay, Systeme, Transcripteur
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("contrat, methodes", [
|
||||||
|
(Transcripteur, ["warmup", "transcrire"]),
|
||||||
|
(Overlay, ["show", "hide", "set_level", "set_status", "rearmer_garde"]),
|
||||||
|
(Systeme, ["jouer_son", "modificateurs_enfonces", "raccourci_defaut",
|
||||||
|
"chemin_log", "dossier_donnees", "acquerir_verrou_instance"]),
|
||||||
|
])
|
||||||
|
def test_le_contrat_declare_exactement_ses_methodes(contrat, methodes):
|
||||||
|
declarees = [n for n, _ in inspect.getmembers(contrat, inspect.isfunction)
|
||||||
|
if not n.startswith("_")]
|
||||||
|
assert sorted(declarees) == sorted(methodes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_contrats_sont_documentes():
|
||||||
|
for contrat in (Transcripteur, Overlay, Systeme):
|
||||||
|
assert contrat.__doc__, f"{contrat.__name__} doit dire à quoi il sert"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_contrats.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'contrats'`
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Écrire l'implémentation minimale**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# contrats.py
|
||||||
|
"""Les trois frontières de Zonza.
|
||||||
|
|
||||||
|
Chaque contrat est délibérément minuscule : ce sont les seuls points par lesquels
|
||||||
|
le noyau parle au monde extérieur. Tout ce qui dépend d'un système d'exploitation,
|
||||||
|
d'une bibliothèque d'affichage ou d'un moteur de transcription vit DERRIÈRE l'un
|
||||||
|
d'eux — jamais dans `core.py`, `controller.py` ni `audio_level.py`.
|
||||||
|
"""
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Transcripteur(Protocol):
|
||||||
|
"""Transforme un signal audio en texte."""
|
||||||
|
|
||||||
|
def warmup(self) -> None:
|
||||||
|
"""Charge le modèle une fois, pour que la première dictée ne traîne pas."""
|
||||||
|
|
||||||
|
def transcrire(self, audio) -> str:
|
||||||
|
"""Rend le texte d'un tableau numpy float32 mono."""
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay(Protocol):
|
||||||
|
"""La fenêtre flottante affichée pendant la dictée."""
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
|
||||||
|
|
||||||
|
def hide(self) -> None:
|
||||||
|
"""Masque la fenêtre et arrête l'animation. Thread principal."""
|
||||||
|
|
||||||
|
def set_level(self, level: float) -> None:
|
||||||
|
"""Pousse un niveau audio dans [0, 1]. Appelable depuis un thread de fond."""
|
||||||
|
|
||||||
|
def set_status(self, text: str) -> None:
|
||||||
|
"""Texte affiché dans la bulle. Appelable depuis un thread de fond."""
|
||||||
|
|
||||||
|
def rearmer_garde(self, duree_s: float) -> None:
|
||||||
|
"""Remplace la durée de vie maximale de la fenêtre. Thread principal."""
|
||||||
|
|
||||||
|
|
||||||
|
class Systeme(Protocol):
|
||||||
|
"""Les appels qui diffèrent d'un système d'exploitation à l'autre."""
|
||||||
|
|
||||||
|
def jouer_son(self, nom: str) -> None:
|
||||||
|
"""Joue « start », « done » ou « error » sans bloquer."""
|
||||||
|
|
||||||
|
def modificateurs_enfonces(self) -> bool:
|
||||||
|
"""True si une touche modificatrice est physiquement enfoncée."""
|
||||||
|
|
||||||
|
def raccourci_defaut(self) -> str:
|
||||||
|
"""Raccourci global au format pynput, propre à la plateforme."""
|
||||||
|
|
||||||
|
def chemin_log(self) -> str:
|
||||||
|
"""Chemin absolu du journal."""
|
||||||
|
|
||||||
|
def dossier_donnees(self) -> str:
|
||||||
|
"""Dossier des données de l'application (verrou d'instance, réglages)."""
|
||||||
|
|
||||||
|
def acquerir_verrou_instance(self, chemin: str):
|
||||||
|
"""Verrou exclusif non bloquant ; rend le fichier, ou None s'il est pris."""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer les tests**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_contrats.py -q`
|
||||||
|
Expected: PASS — 4 tests
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add contrats.py tests/test_contrats.py
|
||||||
|
git commit -m "Declare les trois contrats de Zonza"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : La couche système macOS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `systeme/__init__.py`, `systeme/macos.py`
|
||||||
|
- Modify: `core.py` (retirer `acquire_single_instance_lock` et `LOCK_PATH`)
|
||||||
|
- Test: `tests/test_systeme.py`, `tests/test_single_instance.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_systeme.py
|
||||||
|
"""La fabrique rend une implémentation conforme au contrat Systeme."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contrats import Systeme
|
||||||
|
from systeme import choisir_systeme
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_une_implementation_complete():
|
||||||
|
s = choisir_systeme()
|
||||||
|
for methode in ("jouer_son", "modificateurs_enfonces", "raccourci_defaut",
|
||||||
|
"chemin_log", "dossier_donnees", "acquerir_verrou_instance"):
|
||||||
|
assert callable(getattr(s, methode)), f"{methode} manque"
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_plateforme_inconnue_est_refusee_clairement():
|
||||||
|
with pytest.raises(NotImplementedError) as e:
|
||||||
|
choisir_systeme(plateforme="haiku-os")
|
||||||
|
assert "haiku-os" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_raccourci_macos_est_celui_d_aujourd_hui():
|
||||||
|
assert choisir_systeme(plateforme="darwin").raccourci_defaut() == "<cmd>+<ctrl>+d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_verrou_refuse_une_seconde_prise():
|
||||||
|
s = choisir_systeme()
|
||||||
|
chemin = os.path.join(tempfile.mkdtemp(), "sous-dossier", "z.lock")
|
||||||
|
assert s.acquerir_verrou_instance(chemin) is not None
|
||||||
|
assert s.acquerir_verrou_instance(chemin) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_aucun_modificateur_enfonce_pendant_les_tests():
|
||||||
|
assert choisir_systeme().modificateurs_enfonces() is False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_systeme.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'systeme'`
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Écrire l'implémentation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# systeme/macos.py
|
||||||
|
"""Appels propres à macOS. Rien ici ne doit être importé directement par le noyau."""
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
_SONS = {
|
||||||
|
"start": "/System/Library/Sounds/Pop.aiff",
|
||||||
|
"done": "/System/Library/Sounds/Glass.aiff",
|
||||||
|
"error": "/System/Library/Sounds/Basso.aiff",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
|
||||||
|
_MASQUE_MODIFICATEURS = 0x000F0000
|
||||||
|
|
||||||
|
|
||||||
|
class SystemeMacOS:
|
||||||
|
"""Implémentation du contrat Systeme pour macOS."""
|
||||||
|
|
||||||
|
def jouer_son(self, nom):
|
||||||
|
chemin = _SONS.get(nom)
|
||||||
|
if not chemin:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.Popen(["afplay", chemin])
|
||||||
|
except Exception:
|
||||||
|
pass # le son ne doit jamais casser la dictée
|
||||||
|
|
||||||
|
def modificateurs_enfonces(self):
|
||||||
|
try:
|
||||||
|
from Quartz import CGEventSourceFlagsState
|
||||||
|
return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
|
||||||
|
except Exception:
|
||||||
|
return False # dans le doute, ne jamais bloquer le collage
|
||||||
|
|
||||||
|
def raccourci_defaut(self):
|
||||||
|
return "<cmd>+<ctrl>+d"
|
||||||
|
|
||||||
|
def chemin_log(self):
|
||||||
|
return os.path.expanduser("~/Library/Logs/Zonza.log")
|
||||||
|
|
||||||
|
def dossier_donnees(self):
|
||||||
|
return os.path.expanduser("~/Library/Application Support/Zonza")
|
||||||
|
|
||||||
|
def acquerir_verrou_instance(self, chemin):
|
||||||
|
parent = os.path.dirname(chemin)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
fichier = open(chemin, "a")
|
||||||
|
try:
|
||||||
|
fcntl.flock(fichier, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
fichier.close()
|
||||||
|
return None
|
||||||
|
return fichier
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# systeme/__init__.py
|
||||||
|
"""Choisit la couche système selon la plateforme."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def choisir_systeme(plateforme=None):
|
||||||
|
"""Rend l'implémentation du contrat Systeme pour cette machine.
|
||||||
|
|
||||||
|
`plateforme` est injectable pour tester les branches absentes de la machine
|
||||||
|
de développement.
|
||||||
|
"""
|
||||||
|
plateforme = plateforme or sys.platform
|
||||||
|
if plateforme.startswith("darwin"):
|
||||||
|
from systeme.macos import SystemeMacOS
|
||||||
|
return SystemeMacOS()
|
||||||
|
raise NotImplementedError(f"Plateforme non prise en charge : {plateforme}")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Retirer le verrou de `core.py`**
|
||||||
|
|
||||||
|
Supprimer de `core.py` : l'import `fcntl`, la constante `LOCK_PATH` et la fonction `acquire_single_instance_lock` entière. Garder `import os`, encore utilisé par le reste du fichier.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Réécrire `tests/test_single_instance.py` pour viser la couche système**
|
||||||
|
|
||||||
|
Remplacer la ligne d'import :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from systeme import choisir_systeme
|
||||||
|
```
|
||||||
|
|
||||||
|
et, dans chaque test, remplacer `acquire_single_instance_lock(...)` par `choisir_systeme().acquerir_verrou_instance(...)`. Les assertions ne changent pas.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Lancer toute la suite**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest -q`
|
||||||
|
Expected: PASS — **89 tests** (80 au depart, +4 en tache 1, +5 ici)
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add systeme contrats.py core.py tests/test_systeme.py tests/test_single_instance.py
|
||||||
|
git commit -m "Extrait les appels macOS derriere le contrat Systeme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Le moteur de transcription
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `moteur/__init__.py`, `moteur/mlx.py`
|
||||||
|
- Test: `tests/test_moteur.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_moteur.py
|
||||||
|
"""La fabrique choisit le moteur d'après le matériel — décision pure, testable
|
||||||
|
depuis n'importe quelle machine."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moteur import choisir_transcripteur, nom_moteur_pour
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("plateforme, machine, cuda, attendu", [
|
||||||
|
("darwin", "arm64", False, "mlx"),
|
||||||
|
("darwin", "x86_64", False, "faster-cpu"),
|
||||||
|
("win32", "AMD64", True, "faster-gpu"),
|
||||||
|
("win32", "AMD64", False, "faster-cpu"),
|
||||||
|
])
|
||||||
|
def test_la_decision_suit_le_materiel(plateforme, machine, cuda, attendu):
|
||||||
|
assert nom_moteur_pour(plateforme, machine, cuda) == attendu
|
||||||
|
|
||||||
|
|
||||||
|
def test_apple_silicon_prend_mlx_meme_sans_cuda():
|
||||||
|
assert nom_moteur_pour("darwin", "arm64", cuda_disponible=False) == "mlx"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_un_transcripteur_complet():
|
||||||
|
t = choisir_transcripteur(nom="mlx", modele="mlx-community/whisper-medium",
|
||||||
|
langue="fr", amorce="")
|
||||||
|
assert callable(t.warmup) and callable(t.transcrire)
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_moteur_inconnu_est_refuse_clairement():
|
||||||
|
with pytest.raises(NotImplementedError) as e:
|
||||||
|
choisir_transcripteur(nom="vosk", modele="x", langue="fr", amorce="")
|
||||||
|
assert "vosk" in str(e.value)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_moteur.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'moteur'`
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Écrire l'implémentation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# moteur/mlx.py
|
||||||
|
"""Transcription par MLX-Whisper — GPU Apple Silicon uniquement."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from core import clean_transcript
|
||||||
|
|
||||||
|
|
||||||
|
class TranscripteurMLX:
|
||||||
|
"""Implémentation du contrat Transcripteur pour Apple Silicon."""
|
||||||
|
|
||||||
|
def __init__(self, modele, langue, amorce):
|
||||||
|
self.modele = modele
|
||||||
|
self.langue = langue
|
||||||
|
self.amorce = amorce
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
"""Charge le modèle une fois, sur une seconde de silence."""
|
||||||
|
self.transcrire(np.zeros(16000, dtype="float32"))
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
import mlx_whisper
|
||||||
|
resultat = mlx_whisper.transcribe(
|
||||||
|
audio,
|
||||||
|
path_or_hf_repo=self.modele,
|
||||||
|
language=self.langue,
|
||||||
|
initial_prompt=self.amorce,
|
||||||
|
)
|
||||||
|
return clean_transcript(resultat.get("text", ""))
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# moteur/__init__.py
|
||||||
|
"""Choisit le moteur de transcription d'après le matériel."""
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def nom_moteur_pour(plateforme, machine, cuda_disponible):
|
||||||
|
"""Décide du moteur. Fonction PURE : aucune interrogation de la machine ici,
|
||||||
|
ce qui rend les quatre configurations testables depuis un seul poste."""
|
||||||
|
if plateforme.startswith("darwin") and machine in ("arm64", "aarch64"):
|
||||||
|
return "mlx"
|
||||||
|
return "faster-gpu" if cuda_disponible else "faster-cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def detecter_moteur():
|
||||||
|
"""Interroge la machine, puis délègue la décision à `nom_moteur_pour`."""
|
||||||
|
return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible())
|
||||||
|
|
||||||
|
|
||||||
|
def _cuda_disponible():
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
ctypes.CDLL("libcudart.so")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def choisir_transcripteur(nom, modele, langue, amorce):
|
||||||
|
"""Rend l'implémentation du contrat Transcripteur portant ce nom."""
|
||||||
|
if nom == "mlx":
|
||||||
|
from moteur.mlx import TranscripteurMLX
|
||||||
|
return TranscripteurMLX(modele, langue, amorce)
|
||||||
|
raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer les tests**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_moteur.py -q`
|
||||||
|
Expected: PASS — 7 tests
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add moteur tests/test_moteur.py
|
||||||
|
git commit -m "Extrait la transcription derriere le contrat Transcripteur"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Déplacer l'interface Cocoa sans la modifier
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `interface/__init__.py`, `interface/cocoa/__init__.py`, `interface/cocoa/overlay.py`
|
||||||
|
- Delete: `pulse_window.py`
|
||||||
|
- Test: `tests/test_interface.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_interface.py
|
||||||
|
"""La fabrique rend un overlay conforme au contrat, sans ouvrir de fenêtre."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from interface import classe_overlay_pour
|
||||||
|
|
||||||
|
|
||||||
|
def test_l_overlay_macos_declare_tout_le_contrat():
|
||||||
|
classe = classe_overlay_pour("darwin")
|
||||||
|
for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"):
|
||||||
|
assert callable(getattr(classe, methode, None)), f"{methode} manque"
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_plateforme_inconnue_est_refusee_clairement():
|
||||||
|
with pytest.raises(NotImplementedError) as e:
|
||||||
|
classe_overlay_pour("haiku-os")
|
||||||
|
assert "haiku-os" in str(e.value)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_interface.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'interface'`
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Déplacer le fichier SANS toucher à son contenu**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p interface/cocoa
|
||||||
|
git mv pulse_window.py interface/cocoa/overlay.py
|
||||||
|
touch interface/cocoa/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne modifier **aucune ligne** du corps de `overlay.py`. Ses imports `from audio_level import ...` et `from core import ...` continuent de fonctionner : `conftest.py` place la racine du projet sur `sys.path`.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Écrire la fabrique**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# interface/__init__.py
|
||||||
|
"""Choisit l'implémentation d'overlay selon la plateforme."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def classe_overlay_pour(plateforme=None):
|
||||||
|
"""Rend la CLASSE d'overlay (pas une instance : Cocoa exige `alloc().init()`)."""
|
||||||
|
plateforme = plateforme or sys.platform
|
||||||
|
if plateforme.startswith("darwin"):
|
||||||
|
from interface.cocoa.overlay import PulseWindow
|
||||||
|
return PulseWindow
|
||||||
|
raise NotImplementedError(f"Aucune interface pour : {plateforme}")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer toute la suite**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest -q`
|
||||||
|
Expected: PASS — **91 tests** (+2 pour l'interface)
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add interface tests/test_interface.py
|
||||||
|
git commit -m "Deplace l'overlay Cocoa derriere le contrat Overlay, sans le modifier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : La capture audio, déjà portable
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `capture.py`
|
||||||
|
- Modify: `controller.py:15`
|
||||||
|
- Delete: `engine.py`
|
||||||
|
- Test: `tests/test_liberation_flux.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Déplacer `Recorder` et `inject_text`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git mv engine.py capture.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, dans `capture.py` :
|
||||||
|
- supprimer la classe `Transcriber` (elle vit désormais dans `moteur/mlx.py`) ;
|
||||||
|
- supprimer `SOUNDS`, `play_sound` et `modificateurs_enfonces` (ils vivent dans `systeme/macos.py`) ;
|
||||||
|
- remplacer l'import `from core import CONFIG, attendre, clean_transcript` par `from core import CONFIG, attendre` ;
|
||||||
|
- donner à `inject_text` un paramètre `systeme` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def inject_text(text, systeme):
|
||||||
|
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.
|
||||||
|
|
||||||
|
On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
|
||||||
|
la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
|
||||||
|
l'application cible ne colle rien — sans la moindre erreur de notre côté
|
||||||
|
(constaté le 2026-08-21).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
pyperclip.copy(text)
|
||||||
|
libre = attendre(
|
||||||
|
lambda: not systeme.modificateurs_enfonces(),
|
||||||
|
CONFIG["attente_modificateurs_s"],
|
||||||
|
0.03,
|
||||||
|
)
|
||||||
|
if not libre:
|
||||||
|
print("[collage] modificateurs encore enfoncés — collage tenté quand même")
|
||||||
|
time.sleep(CONFIG["delai_presse_papier_s"])
|
||||||
|
with _kbd.pressed(Key.cmd):
|
||||||
|
_kbd.press("v")
|
||||||
|
_kbd.release("v")
|
||||||
|
print("[collage] ok")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Mettre à jour l'import des tests**
|
||||||
|
|
||||||
|
Dans `tests/test_liberation_flux.py`, remplacer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine import Recorder
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from capture import Recorder
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Lancer les tests de capture**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_liberation_flux.py -q`
|
||||||
|
Expected: PASS — 6 tests
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add capture.py tests/test_liberation_flux.py
|
||||||
|
git commit -m "Isole la capture audio, deja portable, dans capture.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Le contrôleur reçoit ses collaborateurs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `controller.py`
|
||||||
|
- Test: `tests/test_statut_bulle.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||||
|
|
||||||
|
Ajouter à `tests/test_statut_bulle.py` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_le_controleur_n_importe_aucun_module_de_plateforme():
|
||||||
|
"""Le noyau ne doit connaître que des contrats. Si cette assertion tombe, une
|
||||||
|
dépendance de plateforme s'est glissée dans le chemin portable."""
|
||||||
|
import controller
|
||||||
|
source = open(controller.__file__, encoding="utf-8").read()
|
||||||
|
for interdit in ("AppKit", "Foundation", "objc", "Quartz", "mlx_whisper",
|
||||||
|
"afplay", "from engine import"):
|
||||||
|
assert interdit not in source, f"{interdit} n'a rien à faire dans controller.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_statut_bulle.py -q -k importe`
|
||||||
|
Expected: FAIL — `from engine import n'a rien à faire dans controller.py`
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Injecter les collaborateurs**
|
||||||
|
|
||||||
|
Dans `controller.py`, remplacer la ligne 15 :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine import Recorder, Transcriber, inject_text, play_sound
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from capture import Recorder, inject_text
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis modifier la signature et le corps de `__init__` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(self, config, pulse_window, transcripteur, systeme,
|
||||||
|
on_state=None, run_on_main=None, lancer_tache=None):
|
||||||
|
self.config = config
|
||||||
|
self.pulse = pulse_window
|
||||||
|
self.transcriber = transcripteur
|
||||||
|
self.systeme = systeme
|
||||||
|
```
|
||||||
|
|
||||||
|
Le reste de `__init__` est inchangé, sauf la ligne qui construisait le transcripteur, à supprimer :
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.transcriber = Transcriber(config["model"], config["language"]) # SUPPRIMER
|
||||||
|
```
|
||||||
|
|
||||||
|
Enfin, remplacer partout dans le fichier `play_sound(` par `self.systeme.jouer_son(`, et `inject_text(text)` par `inject_text(text, self.systeme)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Adapter les doublures des tests**
|
||||||
|
|
||||||
|
Dans `tests/test_statut_bulle.py`, ajouter une doublure de système et la passer à la fabrique locale `_ctrl` :
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FauxSysteme:
|
||||||
|
def __init__(self): self.sons = []
|
||||||
|
def jouer_son(self, nom): self.sons.append(nom)
|
||||||
|
def modificateurs_enfonces(self): return False
|
||||||
|
def raccourci_defaut(self): return "<cmd>+<ctrl>+d"
|
||||||
|
def chemin_log(self): return "/tmp/zonza-test.log"
|
||||||
|
def dossier_donnees(self): return "/tmp/zonza-test"
|
||||||
|
def acquerir_verrou_instance(self, chemin): return object()
|
||||||
|
|
||||||
|
|
||||||
|
def _ctrl(recorder, taches=None):
|
||||||
|
pulse = FauxPulse()
|
||||||
|
faux_transcripteur = type("T", (), {
|
||||||
|
"transcrire": staticmethod(lambda a: "bonjour"),
|
||||||
|
"warmup": staticmethod(lambda: None),
|
||||||
|
})()
|
||||||
|
c = ZonzaController(CONFIG, pulse, faux_transcripteur, FauxSysteme(),
|
||||||
|
lancer_tache=taches)
|
||||||
|
c.recorder = recorder
|
||||||
|
return c, pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contrôleur appelle `transcrire`, non plus `transcribe` : renommer l'appel dans `_fermer_et_transcrire`.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Lancer toute la suite**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest -q`
|
||||||
|
Expected: PASS — **92 tests** (+1 pour la garde d'imports)
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add controller.py tests/test_statut_bulle.py
|
||||||
|
git commit -m "Le controleur recoit transcripteur et systeme par injection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 : Câbler le lanceur et prouver l'absence de régression
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app.py`
|
||||||
|
- Delete: `pulse_window.py` (déjà déplacé), `engine.py` (déjà déplacé)
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Relever le cdhash AVANT toute reconstruction**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='
|
||||||
|
```
|
||||||
|
|
||||||
|
Noter la valeur. Elle devra être **identique** à la fin : `app.py` ne change pas de nom, donc le stub ne change pas, donc l'autorisation d'Accessibilité survit.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Câbler les fabriques dans `app.py`**
|
||||||
|
|
||||||
|
Remplacer les imports (lignes 30-32) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
from controller import ZonzaController
|
||||||
|
from core import CONFIG, validate_config
|
||||||
|
from interface import classe_overlay_pour
|
||||||
|
from moteur import choisir_transcripteur, detecter_moteur
|
||||||
|
from systeme import choisir_systeme
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `ZonzaApp.init`, remplacer la construction du contrôleur :
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.systeme = choisir_systeme()
|
||||||
|
self.pulse = classe_overlay_pour().alloc().init()
|
||||||
|
transcripteur = choisir_transcripteur(
|
||||||
|
nom=detecter_moteur(),
|
||||||
|
modele=CONFIG["model"],
|
||||||
|
langue=CONFIG["language"],
|
||||||
|
amorce=CONFIG["initial_prompt"],
|
||||||
|
)
|
||||||
|
self.controller = ZonzaController(
|
||||||
|
CONFIG, self.pulse, transcripteur, self.systeme,
|
||||||
|
on_state=self._set_state, run_on_main=AppHelper.callAfter,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `main()`, remplacer la prise du verrou :
|
||||||
|
|
||||||
|
```python
|
||||||
|
systeme = choisir_systeme()
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Et dans `_start_hotkey`, lire le raccourci depuis le système :
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._listener = keyboard.GlobalHotKeys(
|
||||||
|
{self.systeme.raccourci_defaut(): on_activate}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Lancer toute la suite**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest -q`
|
||||||
|
Expected: PASS — **92 tests**
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Reconstruire et vérifier le cdhash**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f '/Applications/Zonza.app/Contents/MacOS/zonza'
|
||||||
|
./build_app.sh
|
||||||
|
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: **la même valeur qu'à l'étape 1**. Si elle diffère, l'autorisation d'Accessibilité sera révoquée — s'arrêter et comprendre pourquoi avant de continuer.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Vérifier que Zonza fonctionne réellement**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open -a Zonza
|
||||||
|
sleep 10
|
||||||
|
grep 'accessibilité accordée' ~/Library/Logs/Zonza.log | tail -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `[diag] accessibilité accordée : True`
|
||||||
|
|
||||||
|
Puis dicter une phrase contenant « Playwright » et « Gitea », et vérifier dans le journal :
|
||||||
|
- une ligne `[texte]` avec les deux termes correctement orthographiés ;
|
||||||
|
- une ligne `[collage] ok` juste après.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app.py
|
||||||
|
git commit -m "Cable le lanceur sur les trois fabriques"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vérification finale de l'étape
|
||||||
|
|
||||||
|
- [ ] `.venv/bin/python -m pytest -q` → **92 tests verts** (80 au depart, +12)
|
||||||
|
- [ ] `codesign -dvvv /Applications/Zonza.app | grep CDHash` → identique à avant l'étape
|
||||||
|
- [ ] `[diag] accessibilité accordée : True` dans le journal
|
||||||
|
- [ ] une dictée réelle produit `[texte]` puis `[collage] ok`
|
||||||
|
- [ ] `grep -rE 'AppKit|Quartz|mlx_whisper' core.py controller.py audio_level.py capture.py` → **aucun résultat**
|
||||||
|
- [ ] `git status --porcelain` → vide
|
||||||
@@ -67,13 +67,28 @@ sinon → faster-whisper CPU
|
|||||||
|
|
||||||
La **décision** est une fonction pure recevant `(plateforme, processeur, cuda_disponible)` ; l'**interrogation** de la machine est séparée. Les quatre configurations sont donc testables depuis un seul Mac.
|
La **décision** est une fonction pure recevant `(plateforme, processeur, cuda_disponible)` ; l'**interrogation** de la machine est séparée. Les quatre configurations sont donc testables depuis un seul Mac.
|
||||||
|
|
||||||
| Configuration | Moteur | Modèle | Latence |
|
**Mesuré le 2026-08-23** — trois échantillons, amorce de vocabulaire active, sur le CPU d'un Apple Silicon :
|
||||||
|---|---|---|---|
|
|
||||||
| Mac Apple Silicon | MLX | `whisper-medium` | ~1,3 s (mesuré) |
|
|
||||||
| Windows + NVIDIA | faster-whisper CUDA | `medium` float16 | 1–2 s (**à mesurer**) |
|
|
||||||
| Mac Intel · Windows CPU | faster-whisper CPU | `small` int8 | 3–8 s (**à mesurer**) |
|
|
||||||
|
|
||||||
`medium` est écarté sur CPU : 15 à 30 s tuerait l'usage.
|
| Moteur et modèle | Latence | Termes techniques | Taille |
|
||||||
|
|---|---|---|---|
|
||||||
|
| faster-whisper `base` int8 CPU | 0,52 s | 10/13 | 142 Mo |
|
||||||
|
| faster-whisper `small` int8 CPU | 1,42 s | 11/13 | 464 Mo |
|
||||||
|
| faster-whisper `medium` int8 CPU | 4,40 s | **13/13** | 1460 Mo |
|
||||||
|
| MLX `medium` GPU *(moteur actuel)* | 0,75 s | 11/13 | 489 Mo |
|
||||||
|
|
||||||
|
🔴 **Ces chiffres démentent l'estimation initiale de cette spec**, qui annonçait 15 à 30 s pour `medium` sur CPU et l'écartait d'avance. C'est 4,4 s. Et `faster-whisper medium` sur CPU est **plus juste que le moteur GPU en service** (13/13 contre 11/13).
|
||||||
|
|
||||||
|
Choix retenus dans `core.MODELES_PAR_MOTEUR` :
|
||||||
|
|
||||||
|
| Configuration | Moteur | Modèle |
|
||||||
|
|---|---|---|
|
||||||
|
| Mac Apple Silicon | MLX | `mlx-community/whisper-medium` |
|
||||||
|
| Windows + NVIDIA | faster-whisper CUDA | `Systran/faster-whisper-medium` |
|
||||||
|
| Mac Intel · Windows CPU | faster-whisper CPU | `Systran/faster-whisper-small` |
|
||||||
|
|
||||||
|
⚠️ **Réserve sur le CPU** : les 4,4 s de `medium` ont été mesurées sur un CPU Apple Silicon, rapide. Un CPU Intel ou Windows peut demander trois à quatre fois plus. D'où `small` par défaut — même précision (11/13) que le MLX utilisé aujourd'hui, pour 1,4 s ici. **La commande de diagnostic mesurera la machine réelle et pourra promouvoir `medium`.**
|
||||||
|
|
||||||
|
`CONFIG["model"]`, désormais à `None`, force manuellement le modèle et prime sur la table.
|
||||||
|
|
||||||
**Première étape de l'implémentation : mesurer réellement les deux dernières lignes.** Le 2026-08-21, une mesure analogue a renversé l'intuition — `large-v3-turbo` s'est révélé PIRE que `medium`.
|
**Première étape de l'implémentation : mesurer réellement les deux dernières lignes.** Le 2026-08-21, une mesure analogue a renversé l'intuition — `large-v3-turbo` s'est révélé PIRE que `medium`.
|
||||||
|
|
||||||
@@ -151,7 +166,7 @@ Le périmètre dépasse un seul plan. Quatre étapes, chacune livrable et vérif
|
|||||||
|
|
||||||
**Étape 1 — Extraire les contrats.** Créer `moteur/`, `interface/`, `systeme/` ; y déplacer le code Cocoa **sans le modifier** ; faire passer `controller.py` par les contrats. Aucun changement de comportement : les 80 tests actuels doivent rester verts, et Zonza fonctionner à l'identique sur le Mac. C'est l'étape qui dé-risque toutes les suivantes.
|
**Étape 1 — Extraire les contrats.** Créer `moteur/`, `interface/`, `systeme/` ; y déplacer le code Cocoa **sans le modifier** ; faire passer `controller.py` par les contrats. Aucun changement de comportement : les 80 tests actuels doivent rester verts, et Zonza fonctionner à l'identique sur le Mac. C'est l'étape qui dé-risque toutes les suivantes.
|
||||||
|
|
||||||
**Étape 2 — Moteur interchangeable.** Ajouter `faster-whisper`, la détection pure, et **mesurer** les latences réelles CPU. Vérifiable immédiatement sur le Mac, en forçant le moteur CPU.
|
**Étape 2 — Moteur interchangeable.** ✅ **Fait le 2026-08-23** (`2ccb614`, 115 tests) : `moteur/faster.py`, table `MODELES_PAR_MOTEUR`, forçage manuel, et les mesures ci-dessus.
|
||||||
|
|
||||||
**Étape 3 — Interface Qt.** Rejouer la suite de contrat de l'overlay contre l'implémentation Qt. Vérifiable sur le Mac : Qt y tourne aussi, ce qui permet de comparer les deux overlays côte à côte.
|
**Étape 3 — Interface Qt.** Rejouer la suite de contrat de l'overlay contre l'implémentation Qt. Vérifiable sur le Mac : Qt y tourne aussi, ce qui permet de comparer les deux overlays côte à côte.
|
||||||
|
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
# Installe Zonza sur Windows.
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File .\install.ps1
|
||||||
|
#
|
||||||
|
# Cree l'environnement, installe UNIQUEMENT ce qui sert a Windows (les marqueurs
|
||||||
|
# de requirements.txt ecartent MLX et pyobjc), pose le lancement au demarrage,
|
||||||
|
# puis lance le diagnostic.
|
||||||
|
#
|
||||||
|
# AVERTISSEMENT D'HONNETETE : ce script n'a JAMAIS ete execute sur une machine
|
||||||
|
# Windows. Il a ete ecrit depuis un Mac, d'apres la documentation. Le diagnostic
|
||||||
|
# de la derniere etape est justement la pour dire ce qui marche et ce qui casse.
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location -Path $PSScriptRoot
|
||||||
|
|
||||||
|
Write-Host "-> Verification de Python..."
|
||||||
|
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if (-not $py) { throw "Python introuvable. Installe-le depuis python.org, en cochant 'Add python.exe to PATH'." }
|
||||||
|
$version = & python -c "import sys; print('.'.join(map(str, sys.version_info[:3])))"
|
||||||
|
Write-Host " Python $version"
|
||||||
|
& python -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Python 3.11 minimum requis, trouve $version." }
|
||||||
|
|
||||||
|
if (-not (Test-Path ".venv")) {
|
||||||
|
Write-Host "-> Creation de l'environnement..."
|
||||||
|
& python -m venv .venv
|
||||||
|
} else {
|
||||||
|
Write-Host "-> Environnement deja present, on le reutilise."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "-> Installation des dependances de CETTE machine..."
|
||||||
|
& .\.venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||||
|
& .\.venv\Scripts\python.exe -m pip install --quiet -r requirements.txt
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "L'installation des dependances a echoue." }
|
||||||
|
|
||||||
|
Write-Host "-> Lancement au demarrage..."
|
||||||
|
# pythonw.exe : sans lui, une console noire resterait ouverte en permanence.
|
||||||
|
$demarrage = [Environment]::GetFolderPath("Startup")
|
||||||
|
$raccourci = Join-Path $demarrage "Zonza.lnk"
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
$lien = $shell.CreateShortcut($raccourci)
|
||||||
|
$lien.TargetPath = (Resolve-Path ".\.venv\Scripts\pythonw.exe").Path
|
||||||
|
$lien.Arguments = '"' + (Resolve-Path ".\lanceur_qt.py").Path + '"'
|
||||||
|
$lien.WorkingDirectory = $PSScriptRoot
|
||||||
|
$lien.Description = "Zonza - dictee vocale locale"
|
||||||
|
$lien.Save()
|
||||||
|
Write-Host " $raccourci"
|
||||||
|
|
||||||
|
Write-Host "-> Diagnostic..."
|
||||||
|
& .\.venv\Scripts\python.exe app.py --diagnostic
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Le diagnostic signale un probleme. Rien n'est casse : les lignes"
|
||||||
|
Write-Host "ci-dessus disent laquelle des epreuves a echoue."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installe."
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Usage : Ctrl+Alt+D pour demarrer et arreter la dictee."
|
||||||
|
Write-Host "Journal : %LOCALAPPDATA%\Zonza\Zonza.log"
|
||||||
|
Write-Host "Lancer maintenant, sans redemarrer : .\.venv\Scripts\pythonw.exe lanceur_qt.py"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Si le raccourci ne repond pas, relance le diagnostic :"
|
||||||
|
Write-Host " .\.venv\Scripts\python.exe app.py --diagnostic"
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Installe Zonza sur macOS (et Linux, pour la partie portable).
|
||||||
|
#
|
||||||
|
# ./install.sh
|
||||||
|
#
|
||||||
|
# Cree l'environnement, installe UNIQUEMENT ce qui sert a cette machine (les
|
||||||
|
# marqueurs de requirements.txt s'en chargent), construit l'app macOS, puis lance
|
||||||
|
# le diagnostic. Il ne masque aucune erreur : si une etape echoue, il s'arrete.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
MINIMUM="3.11"
|
||||||
|
PY="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
echo "→ Verification de Python…"
|
||||||
|
command -v "$PY" >/dev/null || { echo "✗ '$PY' introuvable. Installe Python $MINIMUM ou plus, ou renseigne PYTHON=/chemin/vers/python3." >&2; exit 1; }
|
||||||
|
"$PY" - <<PYCODE || { echo "✗ Python $MINIMUM minimum requis." >&2; exit 1; }
|
||||||
|
import sys
|
||||||
|
raise SystemExit(0 if sys.version_info >= tuple(int(n) for n in "$MINIMUM".split(".")) else 1)
|
||||||
|
PYCODE
|
||||||
|
echo " $("$PY" --version)"
|
||||||
|
|
||||||
|
if [[ ! -d .venv ]]; then
|
||||||
|
echo "→ Creation de l'environnement…"
|
||||||
|
"$PY" -m venv .venv
|
||||||
|
else
|
||||||
|
echo "→ Environnement deja present, on le reutilise."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Installation des dependances de CETTE machine…"
|
||||||
|
.venv/bin/python -m pip install --quiet --upgrade pip
|
||||||
|
.venv/bin/python -m pip install --quiet -r requirements.txt
|
||||||
|
echo " $(.venv/bin/python -m pip list 2>/dev/null | wc -l | tr -d ' ') paquets installes"
|
||||||
|
|
||||||
|
if [[ "$(uname)" == "Darwin" ]]; then
|
||||||
|
# Garde-fou : build_app.sh grave le chemin de CE dossier dans le binaire de
|
||||||
|
# /Applications/Zonza.app. Si une Zonza y est deja installee depuis un AUTRE
|
||||||
|
# dossier, reconstruire la detournerait vers ici — et changerait son cdhash,
|
||||||
|
# ce qui REVOQUE silencieusement les autorisations macOS deja accordees.
|
||||||
|
# Faute de ce garde-fou, un simple essai depuis une copie casse l'installation
|
||||||
|
# en service (arrive le 2026-08-23, rattrape de justesse).
|
||||||
|
CIBLE_ACTUELLE=""
|
||||||
|
if [[ -x /Applications/Zonza.app/Contents/MacOS/zonza ]]; then
|
||||||
|
CIBLE_ACTUELLE="$(strings /Applications/Zonza.app/Contents/MacOS/zonza 2>/dev/null | grep -m1 -E '/app\.py$' || true)"
|
||||||
|
fi
|
||||||
|
ICI="$(pwd)/app.py"
|
||||||
|
|
||||||
|
if [[ -n "$CIBLE_ACTUELLE" && "$CIBLE_ACTUELLE" != "$ICI" ]]; then
|
||||||
|
echo
|
||||||
|
echo "⚠ Une Zonza est deja installee, et elle pointe ailleurs :"
|
||||||
|
echo " installee ici : $CIBLE_ACTUELLE"
|
||||||
|
echo " ce dossier : $ICI"
|
||||||
|
echo
|
||||||
|
echo " Reconstruire la detournerait vers ce dossier et REVOQUERAIT ses"
|
||||||
|
echo " autorisations macOS (Accessibilite, Surveillance de l'entree)."
|
||||||
|
echo " L'application n'est donc PAS reconstruite."
|
||||||
|
echo
|
||||||
|
echo " Si c'est bien ce que tu veux, lance : ZONZA_REMPLACER=1 ./install.sh"
|
||||||
|
if [[ "${ZONZA_REMPLACER:-}" != "1" ]]; then
|
||||||
|
CONSTRUIRE=0
|
||||||
|
else
|
||||||
|
echo " ZONZA_REMPLACER=1 : reconstruction demandee explicitement."
|
||||||
|
CONSTRUIRE=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
CONSTRUIRE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CONSTRUIRE" == "1" ]]; then
|
||||||
|
echo "→ Construction de Zonza.app…"
|
||||||
|
./build_app.sh
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Diagnostic…"
|
||||||
|
.venv/bin/python app.py --diagnostic || {
|
||||||
|
echo
|
||||||
|
echo "✗ Le diagnostic signale un probleme. Rien n'est casse : lis les lignes"
|
||||||
|
echo " ci-dessus, elles disent laquelle des epreuves a echoue." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cat <<'FIN'
|
||||||
|
|
||||||
|
✓ Installe.
|
||||||
|
|
||||||
|
Il reste UNE chose que seul toi peux faire, et sans elle le raccourci ne
|
||||||
|
fonctionnera pas : accorder les autorisations macOS.
|
||||||
|
|
||||||
|
Reglages systeme > Confidentialite et securite
|
||||||
|
• Accessibilite → ajoute /Applications/Zonza.app, puis active
|
||||||
|
• Surveillance de l'entree → la meme chose
|
||||||
|
|
||||||
|
Puis quitte Zonza par son icone 🎙️ et relance-la : l'autorisation n'est lue
|
||||||
|
qu'au demarrage.
|
||||||
|
|
||||||
|
Usage : Cmd+Ctrl+D pour demarrer et arreter la dictee.
|
||||||
|
Journal : ~/Library/Logs/Zonza.log
|
||||||
|
FIN
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Choisit l'implémentation d'overlay selon la plateforme.
|
||||||
|
|
||||||
|
Convention des trois frontières (`moteur/`, `interface/`, `systeme/`) : **un module
|
||||||
|
plat par plateforme** — `interface/cocoa.py`, `systeme/macos.py`, `moteur/mlx.py`.
|
||||||
|
On ne passe à un sous-paquet que le jour où une plateforme exige réellement
|
||||||
|
plusieurs fichiers. Sans cette règle écrite, la couche Windows aurait eu à deviner
|
||||||
|
lequel des deux motifs imiter.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def classe_overlay_pour(plateforme=None):
|
||||||
|
"""Rend la CLASSE d'overlay (pas une instance : Cocoa exige `alloc().init()`)."""
|
||||||
|
plateforme = plateforme or sys.platform
|
||||||
|
if plateforme.startswith("darwin"):
|
||||||
|
from interface.cocoa import PulseWindow
|
||||||
|
return PulseWindow
|
||||||
|
if plateforme.startswith("win32"):
|
||||||
|
from interface.qt import OverlayQt
|
||||||
|
return OverlayQt
|
||||||
|
raise NotImplementedError(f"Aucune interface pour : {plateforme}")
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran (Qt).
|
||||||
|
|
||||||
|
Copie conforme de `interface/cocoa.py`, pour les plateformes sans Cocoa
|
||||||
|
(Windows aujourd'hui). Mêmes trois zones, de gauche à droite : un bouton stop
|
||||||
|
rond rouge-orange, une forme d'onde en barres verticales, une icône de micro.
|
||||||
|
Au-dessus, une bulle sombre affiche l'état en cours.
|
||||||
|
|
||||||
|
Qt natif (PySide6). Tout le dessin a lieu sur le thread principal via un
|
||||||
|
QTimer ; les niveaux audio arrivent d'un thread de fond par set_level(), et
|
||||||
|
l'historique qui alimente la forme d'onde est protégé par un verrou — même
|
||||||
|
disposition que Cocoa.
|
||||||
|
|
||||||
|
Repère : Qt place l'origine en HAUT à gauche (y croît vers le bas), alors que
|
||||||
|
Cocoa la place en BAS à gauche (y croît vers le haut). C'est la source
|
||||||
|
d'erreur la plus probable d'un portage Cocoa → Qt : décaler un signe à un
|
||||||
|
seul endroit du dessin fait sortir une forme du mauvais côté sans qu'aucun
|
||||||
|
test ne le révèle (cf. les 42 tests verts du 21/08 pendant qu'une icône
|
||||||
|
sortait en tache blanche — seule une capture d'écran l'a montré). On bascule
|
||||||
|
donc le repère une seule fois, ici, avant de dessiner les formes qui
|
||||||
|
raisonnent comme Cocoa ; le texte de la bulle est dessiné à part, hors de ce
|
||||||
|
repère basculé, pour ne pas sortir à l'envers.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
|
||||||
|
from PySide6.QtGui import QColor, QFontMetrics, QGuiApplication, QPainter, QPainterPath, QPen
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
from audio_level import bar_heights, push_level
|
||||||
|
from core import CONFIG, bar_metrics
|
||||||
|
|
||||||
|
_BOTTOM_MARGIN = 80.0 # au-dessus de la barre des tâches
|
||||||
|
_BUBBLE_GAP = 12.0 # espace entre la bulle et la barre
|
||||||
|
_BUBBLE_PAD = 12.0 # marge intérieure de la bulle
|
||||||
|
_TAIL = 7.0 # demi-largeur de la pointe de la bulle
|
||||||
|
_BOTTOM_INSET = 8.0 # évite que l'arrondi bas de la barre touche le bord
|
||||||
|
|
||||||
|
|
||||||
|
def _qcolor(triplet, alpha=1.0):
|
||||||
|
r, g, b = triplet[:3]
|
||||||
|
c = QColor()
|
||||||
|
c.setRgbF(r, g, b, alpha)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class VueOverlay(QWidget):
|
||||||
|
"""Dessine la barre, la forme d'onde et la bulle d'état.
|
||||||
|
|
||||||
|
Widget Qt top-level SANS PARENT : Qt n'exige pas, contrairement à Cocoa,
|
||||||
|
une fenêtre (NSWindow) séparée de sa vue de contenu (NSView) — un QWidget
|
||||||
|
sans parent EST déjà une fenêtre. Cette classe joue donc les deux rôles :
|
||||||
|
« vue qui dessine » ET « fenêtre affichée », la façade `OverlayQt`
|
||||||
|
n'ayant qu'à la montrer/masquer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._bars = []
|
||||||
|
self._status = ""
|
||||||
|
# Fenêtre sans cadre, toujours au-dessus, à fond transparent, et
|
||||||
|
# transparente aux clics : la souris doit traverser l'overlay jusqu'à
|
||||||
|
# l'application en dessous (Qt.WindowTransparentForInput).
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.WindowType.FramelessWindowHint
|
||||||
|
| Qt.WindowType.WindowStaysOnTopHint
|
||||||
|
| Qt.WindowType.Tool
|
||||||
|
| Qt.WindowType.WindowTransparentForInput
|
||||||
|
)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
|
||||||
|
|
||||||
|
def set_bars(self, bars):
|
||||||
|
self._bars = bars
|
||||||
|
|
||||||
|
def set_status(self, text):
|
||||||
|
self._status = text or ""
|
||||||
|
|
||||||
|
# --- morceaux de dessin (repère Cocoa : y croît vers le HAUT) ---------
|
||||||
|
def _draw_bar_background(self, painter, x, y, w, h):
|
||||||
|
painter.setBrush(_qcolor(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(x, y, w, h), CONFIG["bar_radius"], CONFIG["bar_radius"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_stop_button(self, painter, cx, cy, diametre):
|
||||||
|
painter.setBrush(_qcolor(CONFIG["stop_button_rgb"]))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.drawEllipse(QPointF(cx, cy), diametre / 2, diametre / 2)
|
||||||
|
# carré arrondi blanc au centre = « arrêter »
|
||||||
|
side = diametre * 0.34
|
||||||
|
painter.setBrush(_qcolor(CONFIG["stop_glyph_rgb"]))
|
||||||
|
painter.drawRoundedRect(QRectF(cx - side / 2, cy - side / 2, side, side), 2.0, 2.0)
|
||||||
|
|
||||||
|
def _draw_waveform(self, painter, x0, cy, width):
|
||||||
|
bars = self._bars
|
||||||
|
if not bars:
|
||||||
|
return
|
||||||
|
n = len(bars)
|
||||||
|
step = width / float(n)
|
||||||
|
bw = max(2.0, step * 0.38)
|
||||||
|
half_max = CONFIG["bar_height"] * 0.30 # amplitude verticale max
|
||||||
|
painter.setBrush(_qcolor(CONFIG["waveform_rgb"]))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
for i, level in enumerate(bars):
|
||||||
|
cxi = x0 + step * (i + 0.5)
|
||||||
|
half = max(bw / 2.0, level * half_max) # jamais moins qu'un point rond
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(cxi - bw / 2, cy - half, bw, half * 2), bw / 2, bw / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_mic(self, painter, cx, cy, m):
|
||||||
|
painter.setBrush(_qcolor(CONFIG["mic_rgb"]))
|
||||||
|
largeur = m["mic_largeur"]
|
||||||
|
body_h = m["mic_corps_hauteur"]
|
||||||
|
body_y = cy - body_h / 2 + body_h * 0.23
|
||||||
|
# capsule (le micro lui-même)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(cx - largeur / 2, body_y, largeur, body_h), largeur / 2, largeur / 2
|
||||||
|
)
|
||||||
|
# arceau en U qui passe SOUS la capsule. PIÈGE vérifié par capture
|
||||||
|
# d'écran (cf tâche) : `drawArc` mesure ses angles dans le repère
|
||||||
|
# ÉCRAN natif de Qt (0° = 3 h, sens visuellement anti-horaire), et ne
|
||||||
|
# tient PAS compte du basculement `scale(1, -1)` appliqué plus haut —
|
||||||
|
# contrairement aux formes remplies (rect, ellipse), qui suivent la
|
||||||
|
# transformation courante. Un premier essai avec les bornes de Cocoa
|
||||||
|
# telles quelles (195°→345°) donnait un arc reflété verticalement
|
||||||
|
# (ouvert vers le BAS, au-dessus de la capsule au lieu de dessous).
|
||||||
|
# On compense en reflétant l'angle : depart' = -(depart+etendue),
|
||||||
|
# etendue' = etendue.
|
||||||
|
rayon = m["mic_arceau_rayon"]
|
||||||
|
arc_cx, arc_cy = cx, body_y + body_h * 0.23
|
||||||
|
depart_cocoa, etendue_cocoa = 195, 345 - 195
|
||||||
|
depart = -(depart_cocoa + etendue_cocoa) % 360
|
||||||
|
# QPen neuf : `painter.pen()` aurait hérité le style NoPen laissé par
|
||||||
|
# le dessin de la capsule juste au-dessus — un pen NoPen ignore
|
||||||
|
# silencieusement couleur et épaisseur, l'arc ne se serait jamais vu.
|
||||||
|
pen = QPen(_qcolor(CONFIG["mic_rgb"]))
|
||||||
|
pen.setWidthF(max(1.0, body_h * 0.12))
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
rect_arc = QRectF(arc_cx - rayon, arc_cy - rayon, rayon * 2, rayon * 2)
|
||||||
|
painter.drawArc(rect_arc, depart * 16, etendue_cocoa * 16)
|
||||||
|
# pied vertical sous l'arceau
|
||||||
|
pied = max(1.2, largeur * 0.18)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(_qcolor(CONFIG["mic_rgb"]))
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(cx - pied / 2, body_y - body_h * 0.62, pied, body_h * 0.31),
|
||||||
|
pied / 2, pied / 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _bubble_box(self, bar_x, bar_top, bar_w):
|
||||||
|
"""Géométrie de la bulle (repère Cocoa), ou None si aucun texte."""
|
||||||
|
if not self._status:
|
||||||
|
return None
|
||||||
|
font = self.font()
|
||||||
|
font.setPointSizeF(CONFIG["bubble_font_size"])
|
||||||
|
metrics = QFontMetrics(font)
|
||||||
|
bounds = metrics.boundingRect(
|
||||||
|
QRectF(0, 0, CONFIG["bubble_max_width"], 400.0).toRect(),
|
||||||
|
int(Qt.TextFlag.TextWordWrap),
|
||||||
|
self._status,
|
||||||
|
)
|
||||||
|
tw, th = float(bounds.width()), float(bounds.height())
|
||||||
|
w, h = tw + 2 * _BUBBLE_PAD, th + 2 * _BUBBLE_PAD
|
||||||
|
x = bar_x + CONFIG["bar_height"] * 0.46 # alignée à gauche, comme la référence
|
||||||
|
y = bar_top + _BUBBLE_GAP
|
||||||
|
return {"x": x, "y": y, "w": w, "h": h, "tw": tw, "th": th, "font": font}
|
||||||
|
|
||||||
|
def _draw_bubble_shape(self, painter, box):
|
||||||
|
painter.setBrush(_qcolor(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
x, y, w, h = box["x"], box["y"], box["w"], box["h"]
|
||||||
|
painter.drawRoundedRect(QRectF(x, y, w, h), 10.0, 10.0)
|
||||||
|
# petite pointe vers la barre, en bas à gauche
|
||||||
|
tail = QPainterPath()
|
||||||
|
tail.moveTo(x + 20.0 - _TAIL, y + 1.0)
|
||||||
|
tail.lineTo(x + 20.0 + _TAIL, y + 1.0)
|
||||||
|
tail.lineTo(x + 20.0, y - 7.0)
|
||||||
|
tail.closeSubpath()
|
||||||
|
painter.drawPath(tail)
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
# `with` ferme le QPainter (.end()) meme si une exception surgit en
|
||||||
|
# cours de dessin : sans ca, la fermeture ne tenait qu'au comptage de
|
||||||
|
# references de PySide6 — suffisant aujourd'hui, fragile demain.
|
||||||
|
with QPainter(self) as painter:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
|
||||||
|
w = CONFIG["bar_width"]
|
||||||
|
h = CONFIG["bar_height"]
|
||||||
|
x = (self.width() - w) / 2.0
|
||||||
|
y = _BOTTOM_INSET
|
||||||
|
cy = y + h / 2.0
|
||||||
|
|
||||||
|
m = bar_metrics(h)
|
||||||
|
pad, stop_d, mic_w, ecart = (
|
||||||
|
m["marge_laterale"], m["stop_diametre"], m["mic_largeur"], m["ecart_interne"]
|
||||||
|
)
|
||||||
|
bubble = self._bubble_box(x, y + h, w)
|
||||||
|
|
||||||
|
# Bascule de repère : à partir d'ici, y croît vers le HAUT comme dans
|
||||||
|
# Cocoa (voir docstring du module). On la retire (`restore`) avant de
|
||||||
|
# dessiner le texte de la bulle plus bas, pour qu'il ne sorte pas à
|
||||||
|
# l'envers.
|
||||||
|
painter.save()
|
||||||
|
painter.translate(0, self.height())
|
||||||
|
painter.scale(1, -1)
|
||||||
|
|
||||||
|
self._draw_bar_background(painter, x, y, w, h)
|
||||||
|
self._draw_stop_button(painter, x + pad + stop_d / 2, cy, stop_d)
|
||||||
|
self._draw_mic(painter, x + w - pad - mic_w / 2, cy, m)
|
||||||
|
wave_x = x + pad + stop_d + ecart
|
||||||
|
wave_w = (x + w - pad - mic_w - ecart) - wave_x
|
||||||
|
self._draw_waveform(painter, wave_x, cy, wave_w)
|
||||||
|
if bubble is not None:
|
||||||
|
self._draw_bubble_shape(painter, bubble)
|
||||||
|
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
if bubble is not None:
|
||||||
|
# Repère Qt normal (y vers le bas) : on convertit le coin bas-gauche
|
||||||
|
# « repère Cocoa » de la bulle en un rectangle Qt haut-gauche.
|
||||||
|
text_top_qt = self.height() - (bubble["y"] + _BUBBLE_PAD + bubble["th"])
|
||||||
|
painter.setFont(bubble["font"])
|
||||||
|
painter.setPen(_qcolor(CONFIG["bubble_text_rgb"]))
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(bubble["x"] + _BUBBLE_PAD, text_top_qt, bubble["tw"], bubble["th"]),
|
||||||
|
int(Qt.TextFlag.TextWordWrap),
|
||||||
|
self._status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayQt:
|
||||||
|
"""Fenêtre flottante Qt : show(), hide(), set_level(), set_status(). Thread-safe.
|
||||||
|
|
||||||
|
Contrairement à `PulseWindow` (NSObject, exige `alloc().init()`), c'est une
|
||||||
|
classe Python ordinaire : `classe_overlay_pour("win32")()` suffit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._history = []
|
||||||
|
self._status = ""
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._view = None
|
||||||
|
self._timer = None
|
||||||
|
self._garde = None # ferme l'overlay quoi qu'il arrive (cf show)
|
||||||
|
|
||||||
|
def _window_size(self):
|
||||||
|
return (CONFIG["bar_width"] + 80.0,
|
||||||
|
CONFIG["bar_height"] + _BOTTOM_INSET + 140.0)
|
||||||
|
|
||||||
|
def _ensure_view(self):
|
||||||
|
if self._view is not None:
|
||||||
|
return
|
||||||
|
# PySide6 est déjà exigé dès l'IMPORT de ce module (QWidget, QTimer...
|
||||||
|
# ci-dessus) : le report réel n'est pas ici, mais dans la fabrique
|
||||||
|
# (interface/__init__.py::classe_overlay_pour), qui n'importe
|
||||||
|
# `interface.qt` que pour la branche win32 — jamais sur ce Mac, où
|
||||||
|
# Cocoa suffit.
|
||||||
|
if QApplication.instance() is None:
|
||||||
|
QApplication([])
|
||||||
|
|
||||||
|
ww, wh = self._window_size()
|
||||||
|
self._view = VueOverlay()
|
||||||
|
self._view.resize(int(ww), int(wh))
|
||||||
|
ecran = QGuiApplication.primaryScreen().geometry()
|
||||||
|
px = ecran.left() + (ecran.width() - ww) / 2.0
|
||||||
|
py = ecran.top() + ecran.height() - _BOTTOM_MARGIN - wh
|
||||||
|
self._view.move(int(px), int(py))
|
||||||
|
|
||||||
|
def set_level(self, level):
|
||||||
|
"""Pousse le dernier niveau audio (appelable depuis un thread de fond)."""
|
||||||
|
with self._lock:
|
||||||
|
self._history = push_level(self._history, level, CONFIG["waveform_bars"])
|
||||||
|
|
||||||
|
def set_status(self, text):
|
||||||
|
"""Texte affiché dans la bulle (thread de fond autorisé)."""
|
||||||
|
with self._lock:
|
||||||
|
self._status = text or ""
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
with self._lock:
|
||||||
|
history, status = list(self._history), self._status
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.set_bars(
|
||||||
|
bar_heights(history, CONFIG["waveform_bars"], CONFIG["waveform_floor"])
|
||||||
|
)
|
||||||
|
self._view.set_status(status)
|
||||||
|
self._view.update()
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
|
||||||
|
self._ensure_view()
|
||||||
|
with self._lock:
|
||||||
|
self._history = []
|
||||||
|
self._view.show()
|
||||||
|
self._timer = QTimer()
|
||||||
|
self._timer.timeout.connect(self._tick)
|
||||||
|
self._timer.start(int(1000.0 / float(CONFIG["fps"])))
|
||||||
|
# Garde-fou : la fenêtre porte sa propre durée de vie. Si le contrôleur
|
||||||
|
# échoue à la refermer — transcription bloquée, thread mort, bug en
|
||||||
|
# amont — elle se ferme seule plutôt que de rester figée à l'écran
|
||||||
|
# (même correctif que Cocoa, 2026-08-21).
|
||||||
|
self._garde = QTimer()
|
||||||
|
self._garde.setSingleShot(True)
|
||||||
|
self._garde.timeout.connect(self._expiration)
|
||||||
|
self._garde.start(int(float(CONFIG["duree_max_overlay_s"]) * 1000.0))
|
||||||
|
|
||||||
|
def rearmer_garde(self, duree_s):
|
||||||
|
"""Remplace la garde en cours par une plus courte. Thread principal.
|
||||||
|
|
||||||
|
Appelée au passage en transcription : la garde longue couvre un
|
||||||
|
enregistrement de 2 min, mais une fois l'enregistrement fini il ne
|
||||||
|
reste que quelques secondes de calcul — inutile de laisser un halo
|
||||||
|
trois minutes.
|
||||||
|
"""
|
||||||
|
if self._garde is not None:
|
||||||
|
self._garde.stop()
|
||||||
|
self._garde = QTimer()
|
||||||
|
self._garde.setSingleShot(True)
|
||||||
|
self._garde.timeout.connect(self._expiration)
|
||||||
|
self._garde.start(int(float(duree_s) * 1000.0))
|
||||||
|
|
||||||
|
def _expiration(self):
|
||||||
|
print("[garde] overlay ouvert trop longtemps — fermeture forcée")
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
def hide(self):
|
||||||
|
"""Arrête l'animation et masque la fenêtre. Thread principal."""
|
||||||
|
if self._timer is not None:
|
||||||
|
self._timer.stop()
|
||||||
|
self._timer = None
|
||||||
|
if self._garde is not None:
|
||||||
|
self._garde.stop()
|
||||||
|
self._garde = None
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.hide()
|
||||||
|
with self._lock:
|
||||||
|
self._status = ""
|
||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Choisit le moteur de transcription d'après le matériel."""
|
||||||
|
import ctypes
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def nom_moteur_pour(plateforme, machine, cuda_disponible):
|
||||||
|
"""Décide du moteur. Fonction PURE : aucune interrogation de la machine ici,
|
||||||
|
ce qui rend les quatre configurations testables depuis un seul poste."""
|
||||||
|
if plateforme.startswith("darwin") and machine in ("arm64", "aarch64"):
|
||||||
|
return "mlx"
|
||||||
|
return "faster-gpu" if cuda_disponible else "faster-cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def detecter_moteur(forcage=None):
|
||||||
|
"""Le moteur forcé par `CONFIG["moteur"]` s'il est renseigné ; sinon on
|
||||||
|
interroge la machine et on délègue la décision à `nom_moteur_pour`."""
|
||||||
|
if forcage is None:
|
||||||
|
from core import CONFIG
|
||||||
|
forcage = CONFIG.get("moteur")
|
||||||
|
return forcage or _moteur_local()
|
||||||
|
|
||||||
|
|
||||||
|
def _moteur_local():
|
||||||
|
"""Le moteur qui tourne sur CETTE machine, sans réseau."""
|
||||||
|
return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible())
|
||||||
|
|
||||||
|
|
||||||
|
# Le nom de la bibliothèque partagée CUDA diffère radicalement par plateforme ET
|
||||||
|
# par version : `libcudart.so` (parfois suffixé `.12`, `.11.0`...) sous Linux/macOS,
|
||||||
|
# mais `cudart64_12.dll`, `cudart64_110.dll`... sous Windows, où le numéro de version
|
||||||
|
# est ENCODÉ DANS LE NOM du fichier. `ctypes.util.find_library` ne résout pas ces DLL
|
||||||
|
# versionnées de façon fiable — il cherche un nom canonique qui n'existe pas pour
|
||||||
|
# CUDA sous Windows. On essaie donc une liste explicite de noms candidats, du plus
|
||||||
|
# récent au plus ancien, et on s'arrête au premier qui charge.
|
||||||
|
_NOMS_CUDART_WINDOWS = [
|
||||||
|
"cudart64_12.dll",
|
||||||
|
"cudart64_110.dll",
|
||||||
|
"cudart64_101.dll",
|
||||||
|
"cudart64_100.dll",
|
||||||
|
]
|
||||||
|
_NOMS_CUDART_UNIX = [
|
||||||
|
"libcudart.so",
|
||||||
|
"libcudart.so.12",
|
||||||
|
"libcudart.so.11.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cuda_disponible(plateforme=None, charger=None):
|
||||||
|
"""Sonde la présence du runtime CUDA en tentant de charger sa bibliothèque
|
||||||
|
partagée. `plateforme` et `charger` sont injectables : ça rend la sonde
|
||||||
|
testable avec une doublure, sans dépendre d'une vraie machine CUDA ni d'un
|
||||||
|
vrai Windows. Ne lève jamais — dans le doute, on rend False."""
|
||||||
|
plateforme = plateforme if plateforme is not None else sys.platform
|
||||||
|
charger = charger if charger is not None else ctypes.CDLL
|
||||||
|
noms = _NOMS_CUDART_WINDOWS if plateforme.startswith("win") else _NOMS_CUDART_UNIX
|
||||||
|
for nom in noms:
|
||||||
|
try:
|
||||||
|
charger(nom)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def choisir_transcripteur(nom, modele, langue, amorce):
|
||||||
|
"""Rend l'implémentation du contrat Transcripteur portant ce nom."""
|
||||||
|
if nom == "mlx":
|
||||||
|
from moteur.mlx import TranscripteurMLX
|
||||||
|
return TranscripteurMLX(modele, langue, amorce)
|
||||||
|
if nom in ("faster-cpu", "faster-gpu"):
|
||||||
|
# C'est ICI, et nulle part dans TranscripteurFasterWhisper, que le
|
||||||
|
# peripherique et le type de calcul sont decides : la classe reste
|
||||||
|
# testable sans jamais sonder de vrai materiel.
|
||||||
|
from moteur.faster import TranscripteurFasterWhisper
|
||||||
|
peripherique = "cuda" if nom == "faster-gpu" else "cpu"
|
||||||
|
type_calcul = "float16" if nom == "faster-gpu" else "int8"
|
||||||
|
return TranscripteurFasterWhisper(modele, langue, amorce, peripherique, type_calcul)
|
||||||
|
if nom in ("gemini", "openrouter"):
|
||||||
|
# Toujours enveloppe d'un repli : la dictee ne doit jamais etre perdue
|
||||||
|
# parce que le reseau ou l'API tombe (cf moteur/gemini.py).
|
||||||
|
from core import modele_pour_moteur
|
||||||
|
from moteur.gemini import TranscripteurAvecRepli, TranscripteurGemini
|
||||||
|
from moteur.openrouter import TranscripteurOpenRouter
|
||||||
|
classe = TranscripteurGemini if nom == "gemini" else TranscripteurOpenRouter
|
||||||
|
local = _moteur_local()
|
||||||
|
return TranscripteurAvecRepli(
|
||||||
|
classe(modele, langue, amorce),
|
||||||
|
fabriquer_secours=lambda: choisir_transcripteur(
|
||||||
|
local, modele_pour_moteur(local), langue, amorce),
|
||||||
|
)
|
||||||
|
raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Transcription par faster-whisper (CTranslate2) — tourne partout : Mac Intel,
|
||||||
|
Windows avec ou sans GPU. Complement de moteur/mlx.py, qui lui est reserve a
|
||||||
|
l'Apple Silicon.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from core import clean_transcript
|
||||||
|
|
||||||
|
|
||||||
|
class TranscripteurFasterWhisper:
|
||||||
|
"""Implementation du contrat Transcripteur pour faster-whisper.
|
||||||
|
|
||||||
|
Le peripherique (`cpu`/`cuda`) et le type de calcul (`int8`/`float16`...)
|
||||||
|
sont recus en parametres plutot que devines ici : cette classe ne sonde
|
||||||
|
jamais le materiel elle-meme. C'est `moteur/__init__.py::choisir_transcripteur`
|
||||||
|
qui tranche, d'apres `_cuda_disponible` — garder la decision hors de cette
|
||||||
|
classe la rend testable sans machine CUDA ni GPU.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, modele, langue, amorce, peripherique, type_calcul):
|
||||||
|
self.modele = modele
|
||||||
|
self.langue = langue
|
||||||
|
self.amorce = amorce
|
||||||
|
self.peripherique = peripherique
|
||||||
|
self.type_calcul = type_calcul
|
||||||
|
self._instance = None # modele CTranslate2 charge paresseusement
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
"""Charge le modele une fois, sur une seconde de silence."""
|
||||||
|
self.transcrire(np.zeros(16000, dtype="float32"))
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
# Import differe dans la methode, jamais au niveau module : sinon toute
|
||||||
|
# machine sans faster-whisper installe casserait a l'import de ce
|
||||||
|
# fichier, y compris pour les tests qui n'exercent que la fabrique
|
||||||
|
# (meme raison que moteur/mlx.py pour mlx_whisper).
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
if self._instance is None:
|
||||||
|
self._instance = WhisperModel(
|
||||||
|
self.modele, device=self.peripherique, compute_type=self.type_calcul
|
||||||
|
)
|
||||||
|
segments, _info = self._instance.transcribe(
|
||||||
|
audio, language=self.langue, initial_prompt=self.amorce
|
||||||
|
)
|
||||||
|
# faster-whisper rend un generateur de segments, pas un texte unique :
|
||||||
|
# il faut le consommer entierement avant de pouvoir le joindre.
|
||||||
|
return clean_transcript("".join(segment.text for segment in segments))
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Transcription par l'API Gemini — l'audio part chez Google.
|
||||||
|
|
||||||
|
Contrairement à `mlx` et `faster`, ce moteur N'EST PAS local : chaque dictée
|
||||||
|
est envoyée à `generativelanguage.googleapis.com`. Il est donc toujours
|
||||||
|
enveloppé d'un `TranscripteurAvecRepli` : réseau coupé, clé refusée, quota
|
||||||
|
épuisé ou réponse vide, la dictée repasse par le moteur local au lieu d'être
|
||||||
|
perdue.
|
||||||
|
|
||||||
|
Aucune dépendance nouvelle : l'appel HTTP passe par `urllib` de la bibliothèque
|
||||||
|
standard, et la clé se lit dans le trousseau macOS (`security`) ou, à défaut,
|
||||||
|
dans la variable d'environnement GEMINI_API_KEY.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from core import clean_transcript
|
||||||
|
|
||||||
|
URL_API = "https://generativelanguage.googleapis.com/v1beta/models/{modele}:generateContent"
|
||||||
|
SERVICE_TROUSSEAU = "zonza-gemini"
|
||||||
|
|
||||||
|
# Borné sous `duree_max_transcription_s` (20 s) : si l'API ne répond pas à
|
||||||
|
# temps, il doit rester de quoi basculer sur le moteur local avant que la
|
||||||
|
# garde du halo ne tombe. 15 s couvrent les 9 s mesurées de gemini-3.8-flash
|
||||||
|
# sur une dictée de 94 s.
|
||||||
|
DELAI_RESEAU_S = 15.0
|
||||||
|
|
||||||
|
CONSIGNE = (
|
||||||
|
"Tu es un moteur de dictée. Transcris mot pour mot ce que dit la personne, "
|
||||||
|
"dans la langue parlée ({langue}), avec la ponctuation et les majuscules "
|
||||||
|
"d'un texte écrit. Ne réponds pas, ne résume pas, ne commente pas, "
|
||||||
|
"n'ajoute rien : rends UNIQUEMENT le texte dicté. Si l'enregistrement ne "
|
||||||
|
"contient aucune parole, rends une chaîne vide. Termes techniques à "
|
||||||
|
"orthographier exactement : {amorce}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErreurGemini(RuntimeError):
|
||||||
|
"""Tout échec côté Gemini : clé, réseau, quota, réponse inexploitable."""
|
||||||
|
|
||||||
|
|
||||||
|
def audio_en_wav(audio, sample_rate):
|
||||||
|
"""Encode un signal float32 mono dans [-1, 1] en WAV PCM 16 bits."""
|
||||||
|
pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype("<i2")
|
||||||
|
tampon = io.BytesIO()
|
||||||
|
with wave.open(tampon, "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sample_rate)
|
||||||
|
w.writeframes(pcm.tobytes())
|
||||||
|
return tampon.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _lire_trousseau(service=SERVICE_TROUSSEAU):
|
||||||
|
"""Clé rangée par `security add-generic-password -s <service> ...`.
|
||||||
|
|
||||||
|
Le trousseau, et non `~/.zshrc` : Zonza est lancée par le Finder, qui ne
|
||||||
|
lit jamais la configuration du shell — une clé exportée dans `.zshrc`
|
||||||
|
serait invisible pour l'application.
|
||||||
|
"""
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sortie = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", service, "-w"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if sortie.returncode != 0:
|
||||||
|
return None
|
||||||
|
return sortie.stdout.strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def lire_cle(trousseau=None, environ=None, service=SERVICE_TROUSSEAU,
|
||||||
|
variable="GEMINI_API_KEY"):
|
||||||
|
"""La clé du trousseau, sinon la variable d'environnement, sinon None."""
|
||||||
|
trousseau = trousseau or (lambda: _lire_trousseau(service))
|
||||||
|
environ = os.environ if environ is None else environ
|
||||||
|
return trousseau() or environ.get(variable) or None
|
||||||
|
|
||||||
|
|
||||||
|
def _envoyer(url, cle, corps, delai_s):
|
||||||
|
"""La clé part en en-tête, jamais dans l'URL (qui finit en journal)."""
|
||||||
|
return poster_json(url, {"x-goog-api-key": cle}, corps, delai_s)
|
||||||
|
|
||||||
|
|
||||||
|
def poster_json(url, entetes, corps, delai_s):
|
||||||
|
"""POST JSON ; toute erreur HTTP ou réseau devient une ErreurGemini."""
|
||||||
|
requete = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(corps).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json", **entetes},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(requete, timeout=delai_s) as reponse:
|
||||||
|
return json.loads(reponse.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
brut = e.read().decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
detail = json.loads(brut)["error"]["message"]
|
||||||
|
except Exception:
|
||||||
|
detail = " ".join(brut.split())[:300]
|
||||||
|
raise ErreurGemini(f"HTTP {e.code} : {detail}") from e
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
|
raise ErreurGemini(f"réseau : {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class TranscripteurGemini:
|
||||||
|
"""Implémentation du contrat Transcripteur via l'API Gemini."""
|
||||||
|
|
||||||
|
def __init__(self, modele, langue, amorce, sample_rate=16000,
|
||||||
|
lire_cle=lire_cle, envoyer=_envoyer):
|
||||||
|
self.modele = modele
|
||||||
|
self.langue = langue
|
||||||
|
self.amorce = amorce or ""
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
self._lire_cle = lire_cle
|
||||||
|
self._envoyer = envoyer
|
||||||
|
|
||||||
|
def _cle(self):
|
||||||
|
cle = self._lire_cle()
|
||||||
|
if not cle:
|
||||||
|
raise ErreurGemini(
|
||||||
|
"aucune clé Gemini : lancer `security add-generic-password "
|
||||||
|
f"-s {SERVICE_TROUSSEAU} -a zonza -w` puis relancer Zonza"
|
||||||
|
)
|
||||||
|
return cle
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
"""Rien à charger : on vérifie seulement la clé, sans appel payant."""
|
||||||
|
self._cle()
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
corps = {
|
||||||
|
"system_instruction": {"parts": [{"text": CONSIGNE.format(
|
||||||
|
langue=self.langue, amorce=self.amorce)}]},
|
||||||
|
"contents": [{"role": "user", "parts": [
|
||||||
|
{"inline_data": {
|
||||||
|
"mime_type": "audio/wav",
|
||||||
|
"data": base64.b64encode(audio_en_wav(audio, self.sample_rate)).decode("ascii"),
|
||||||
|
}},
|
||||||
|
{"text": "Transcris cet enregistrement."},
|
||||||
|
]}],
|
||||||
|
}
|
||||||
|
reponse = self._envoyer(URL_API.format(modele=self.modele), self._cle(), corps, DELAI_RESEAU_S)
|
||||||
|
candidats = reponse.get("candidates") or []
|
||||||
|
if not candidats:
|
||||||
|
raise ErreurGemini(f"réponse sans transcription : {json.dumps(reponse)[:300]}")
|
||||||
|
parts = (candidats[0].get("content") or {}).get("parts") or []
|
||||||
|
return clean_transcript("".join(p.get("text", "") for p in parts))
|
||||||
|
|
||||||
|
|
||||||
|
class TranscripteurAvecRepli:
|
||||||
|
"""Essaie le principal ; au moindre échec, bascule sur le moteur local.
|
||||||
|
|
||||||
|
Le moteur local n'est fabriqué qu'au premier besoin : tant que Gemini
|
||||||
|
répond, on n'immobilise pas 500 Mo de modèle en mémoire pour rien.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, principal, fabriquer_secours):
|
||||||
|
self.principal = principal
|
||||||
|
self._fabriquer_secours = fabriquer_secours
|
||||||
|
self._secours = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secours_charge(self):
|
||||||
|
return self._secours is not None
|
||||||
|
|
||||||
|
def _secours_pret(self):
|
||||||
|
if self._secours is None:
|
||||||
|
self._secours = self._fabriquer_secours()
|
||||||
|
self._secours.warmup()
|
||||||
|
return self._secours
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
try:
|
||||||
|
self.principal.warmup()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[api] indisponible ({e}) — moteur local chargé en secours")
|
||||||
|
self._secours_pret()
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
try:
|
||||||
|
return self.principal.transcrire(audio)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[api] échec ({e}) — transcription locale")
|
||||||
|
return self._secours_pret().transcrire(audio)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Transcription par MLX-Whisper — GPU Apple Silicon uniquement."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from core import clean_transcript
|
||||||
|
|
||||||
|
|
||||||
|
class TranscripteurMLX:
|
||||||
|
"""Implémentation du contrat Transcripteur pour Apple Silicon."""
|
||||||
|
|
||||||
|
def __init__(self, modele, langue, amorce):
|
||||||
|
self.modele = modele
|
||||||
|
self.langue = langue
|
||||||
|
self.amorce = amorce
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
"""Charge le modèle une fois, sur une seconde de silence."""
|
||||||
|
self.transcrire(np.zeros(16000, dtype="float32"))
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
import mlx_whisper
|
||||||
|
resultat = mlx_whisper.transcribe(
|
||||||
|
audio,
|
||||||
|
path_or_hf_repo=self.modele,
|
||||||
|
language=self.langue,
|
||||||
|
initial_prompt=self.amorce,
|
||||||
|
)
|
||||||
|
return clean_transcript(resultat.get("text", ""))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Transcription par OpenRouter — même famille Gemini, autre guichet.
|
||||||
|
|
||||||
|
Comme `moteur/gemini.py`, ce moteur N'EST PAS local : l'audio part chez
|
||||||
|
OpenRouter, qui le relaie à Google. L'encodage, la consigne, le repli local et
|
||||||
|
la lecture de la clé sont partagés avec le moteur Gemini direct ; seuls
|
||||||
|
l'adresse, l'authentification et le format de requête (compatible OpenAI,
|
||||||
|
partie `input_audio`) diffèrent.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
from core import clean_transcript
|
||||||
|
from moteur.gemini import (
|
||||||
|
CONSIGNE, DELAI_RESEAU_S, ErreurGemini, audio_en_wav, lire_cle, poster_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
URL_API = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
SERVICE_TROUSSEAU = "zonza-openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
def lire_cle_openrouter(trousseau=None, environ=None):
|
||||||
|
"""La clé du trousseau `zonza-openrouter`, sinon OPENROUTER_API_KEY."""
|
||||||
|
return lire_cle(trousseau, environ, service=SERVICE_TROUSSEAU,
|
||||||
|
variable="OPENROUTER_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def _envoyer(url, entetes, corps, delai_s):
|
||||||
|
return poster_json(url, entetes, corps, delai_s)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscripteurOpenRouter:
|
||||||
|
"""Implémentation du contrat Transcripteur via OpenRouter."""
|
||||||
|
|
||||||
|
def __init__(self, modele, langue, amorce, sample_rate=16000,
|
||||||
|
lire_cle=lire_cle_openrouter, envoyer=_envoyer):
|
||||||
|
self.modele = modele
|
||||||
|
self.langue = langue
|
||||||
|
self.amorce = amorce or ""
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
self._lire_cle = lire_cle
|
||||||
|
self._envoyer = envoyer
|
||||||
|
|
||||||
|
def _cle(self):
|
||||||
|
cle = self._lire_cle()
|
||||||
|
if not cle:
|
||||||
|
raise ErreurGemini(
|
||||||
|
"aucune clé OpenRouter : lancer `security add-generic-password "
|
||||||
|
f"-s {SERVICE_TROUSSEAU} -a zonza -w` puis relancer Zonza"
|
||||||
|
)
|
||||||
|
return cle
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
"""Rien à charger : on vérifie seulement la clé, sans appel payant."""
|
||||||
|
self._cle()
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
wav = base64.b64encode(audio_en_wav(audio, self.sample_rate)).decode("ascii")
|
||||||
|
corps = {
|
||||||
|
"model": self.modele,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": CONSIGNE.format(
|
||||||
|
langue=self.langue, amorce=self.amorce)},
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "input_audio", "input_audio": {"data": wav, "format": "wav"}},
|
||||||
|
{"type": "text", "text": "Transcris cet enregistrement."},
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
entetes = {"Authorization": f"Bearer {self._cle()}", "X-Title": "Zonza"}
|
||||||
|
reponse = self._envoyer(URL_API, entetes, corps, DELAI_RESEAU_S)
|
||||||
|
# OpenRouter peut rendre un 200 portant un objet `error` quand le
|
||||||
|
# fournisseur en aval tombe : ce n'est PAS une transcription vide.
|
||||||
|
if "error" in reponse:
|
||||||
|
erreur = reponse["error"]
|
||||||
|
raise ErreurGemini(f"OpenRouter : {erreur.get('message', erreur)}")
|
||||||
|
choix = reponse.get("choices") or []
|
||||||
|
if not choix:
|
||||||
|
raise ErreurGemini(f"réponse sans transcription : {json.dumps(reponse)[:300]}")
|
||||||
|
return clean_transcript(choix[0].get("message", {}).get("content") or "")
|
||||||
+26
-1
@@ -1,7 +1,32 @@
|
|||||||
mlx-whisper>=0.4.3
|
# Zonza — dependances, avec marqueurs de plateforme.
|
||||||
|
#
|
||||||
|
# `pip install -r requirements.txt` n'installe QUE ce qui sert a la machine :
|
||||||
|
# MLX et pyobjc sont propres a macOS, PySide6 sert l'overlay Qt. Sans ces
|
||||||
|
# marqueurs, l'installation echouerait sur Windows (mlx-whisper n'y existe pas).
|
||||||
|
|
||||||
|
# --- transcription -------------------------------------------------------
|
||||||
|
# Deux moteurs, car aucun ne couvre a lui seul les plateformes visees
|
||||||
|
# (cf moteur/__init__.py::nom_moteur_pour).
|
||||||
|
mlx-whisper>=0.4.3; sys_platform == "darwin" and platform_machine == "arm64"
|
||||||
|
faster-whisper>=1.0
|
||||||
|
|
||||||
|
# --- interface -----------------------------------------------------------
|
||||||
|
# Cocoa sert l'overlay macOS. Ces paquets n'etaient PAS declares ici : une
|
||||||
|
# installation neuve sur un autre Mac aurait produit une Zonza qui ne demarre pas.
|
||||||
|
pyobjc-core>=12.2; sys_platform == "darwin"
|
||||||
|
pyobjc-framework-Cocoa>=12.2; sys_platform == "darwin"
|
||||||
|
pyobjc-framework-Quartz>=12.2; sys_platform == "darwin"
|
||||||
|
pyobjc-framework-ApplicationServices>=12.2; sys_platform == "darwin"
|
||||||
|
# Qt sert l'overlay partout ailleurs. Installe aussi sur macOS : la suite de
|
||||||
|
# tests y eprouve les DEUX interfaces, c'est ce qui protege le portage.
|
||||||
|
PySide6>=6.11
|
||||||
|
|
||||||
|
# --- capture, collage, calcul --------------------------------------------
|
||||||
sounddevice>=0.5.5
|
sounddevice>=0.5.5
|
||||||
numpy>=2.4
|
numpy>=2.4
|
||||||
pynput>=1.8.2
|
pynput>=1.8.2
|
||||||
pyperclip>=1.11
|
pyperclip>=1.11
|
||||||
pillow>=11
|
pillow>=11
|
||||||
|
|
||||||
|
# --- developpement -------------------------------------------------------
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Choisit la couche système selon la plateforme."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def choisir_systeme(plateforme=None):
|
||||||
|
"""Rend l'implémentation du contrat Systeme pour cette machine.
|
||||||
|
|
||||||
|
`plateforme` est injectable pour tester les branches absentes de la machine
|
||||||
|
de développement.
|
||||||
|
"""
|
||||||
|
plateforme = plateforme or sys.platform
|
||||||
|
if plateforme.startswith("darwin"):
|
||||||
|
from systeme.macos import SystemeMacOS
|
||||||
|
return SystemeMacOS()
|
||||||
|
if plateforme.startswith("win32"):
|
||||||
|
from systeme.windows import SystemeWindows
|
||||||
|
return SystemeWindows()
|
||||||
|
raise NotImplementedError(f"Plateforme non prise en charge : {plateforme}")
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Appels propres à macOS. Rien ici ne doit être importé directement par le noyau."""
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
_SONS = {
|
||||||
|
"start": "/System/Library/Sounds/Pop.aiff",
|
||||||
|
"done": "/System/Library/Sounds/Glass.aiff",
|
||||||
|
"error": "/System/Library/Sounds/Basso.aiff",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _masque_modificateurs():
|
||||||
|
"""Masque des modificateurs, COMPOSE depuis les constantes nommees de Quartz.
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Implémentation du contrat Systeme pour macOS."""
|
||||||
|
|
||||||
|
def jouer_son(self, nom):
|
||||||
|
chemin = _SONS.get(nom)
|
||||||
|
if not chemin:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.Popen(["afplay", chemin])
|
||||||
|
except Exception:
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
return bool(self._sonde_modificateurs() & _MASQUE_MODIFICATEURS)
|
||||||
|
except Exception:
|
||||||
|
return False # dans le doute, ne jamais bloquer le collage
|
||||||
|
|
||||||
|
def raccourci_defaut(self):
|
||||||
|
return "<cmd>+<ctrl>+d"
|
||||||
|
|
||||||
|
def chemin_log(self):
|
||||||
|
return os.path.expanduser("~/Library/Logs/Zonza.log")
|
||||||
|
|
||||||
|
def dossier_donnees(self):
|
||||||
|
return os.path.expanduser("~/Library/Application Support/Zonza")
|
||||||
|
|
||||||
|
def acquerir_verrou_instance(self, chemin):
|
||||||
|
parent = os.path.dirname(chemin)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
fichier = open(chemin, "a")
|
||||||
|
try:
|
||||||
|
fcntl.flock(fichier, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
fichier.close()
|
||||||
|
return None
|
||||||
|
return fichier
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Appels propres à Windows. Rien ici ne doit être importé directement par le noyau.
|
||||||
|
|
||||||
|
Miroir de `systeme/macos.py`, mais avec une contrainte que macOS n'a pas :
|
||||||
|
`winsound`, `msvcrt` et `ctypes.windll` n'EXISTENT PAS sur macOS. Un import en
|
||||||
|
tête de fichier rendrait ce module impossible à charger — donc impossible à
|
||||||
|
tester — depuis la machine de développement (aucune machine Windows n'est
|
||||||
|
disponible pour ce portage). Les trois dépendances sont donc INJECTABLES :
|
||||||
|
`jouer`, `clavier`, `verrouiller`. Par défaut (None), elles se résolvent
|
||||||
|
paresseusement vers l'API Windows réelle au moment de l'appel, jamais à
|
||||||
|
l'import. Les tests injectent des doublures et n'exécutent donc jamais une
|
||||||
|
ligne de code Windows — ils éprouvent la LOGIQUE de cette classe (mapping des
|
||||||
|
sons, masque des touches, construction des chemins, séquence d'acquisition du
|
||||||
|
verrou), pas le comportement réel de winsound/msvcrt/user32, qu'aucune
|
||||||
|
machine ici ne peut vérifier.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Alias système Windows joués par winsound.PlaySound(alias, SND_ALIAS | SND_ASYNC).
|
||||||
|
_SONS = {
|
||||||
|
"start": "SystemAsterisk",
|
||||||
|
"done": "SystemExclamation",
|
||||||
|
"error": "SystemHand",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Virtual-key codes Windows des cinq touches modificatrices à surveiller.
|
||||||
|
# ctrl, shift, alt (menu), windows gauche, windows droite.
|
||||||
|
_TOUCHES_MODIFICATRICES = (0x11, 0x10, 0x12, 0x5B, 0x5C)
|
||||||
|
|
||||||
|
# Dans le retour de GetAsyncKeyState, le bit de poids fort indique que la
|
||||||
|
# touche est enfoncée EN CE MOMENT (les bits bas concernent un état passé
|
||||||
|
# qui ne nous intéresse pas ici).
|
||||||
|
_BIT_ENFONCEE = 0x8000
|
||||||
|
|
||||||
|
|
||||||
|
class SystemeWindows:
|
||||||
|
"""Implémentation du contrat Systeme pour Windows.
|
||||||
|
|
||||||
|
jouer(alias) : joue un son. clavier(vk) : rend l'état d'une touche.
|
||||||
|
verrouiller(fichier) : pose un verrou exclusif non bloquant sur `fichier`,
|
||||||
|
lève OSError s'il est déjà pris. Les trois par défaut à None : résolus
|
||||||
|
paresseusement vers l'API Windows réelle au premier usage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, jouer=None, clavier=None, verrouiller=None):
|
||||||
|
self._jouer = jouer
|
||||||
|
self._clavier = clavier
|
||||||
|
self._verrouiller = verrouiller
|
||||||
|
|
||||||
|
# --- son ------------------------------------------------------------
|
||||||
|
def jouer_son(self, nom):
|
||||||
|
alias = _SONS.get(nom)
|
||||||
|
if not alias:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
(self._jouer or self._jouer_reel)(alias)
|
||||||
|
except Exception:
|
||||||
|
pass # le son ne doit jamais casser la dictée (même raison qu'en macOS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _jouer_reel(alias):
|
||||||
|
import winsound
|
||||||
|
winsound.PlaySound(alias, winsound.SND_ALIAS | winsound.SND_ASYNC)
|
||||||
|
|
||||||
|
# --- clavier ----------------------------------------------------------
|
||||||
|
def modificateurs_enfonces(self):
|
||||||
|
sonder = self._clavier or self._clavier_reel
|
||||||
|
try:
|
||||||
|
return any(sonder(vk) & _BIT_ENFONCEE for vk in _TOUCHES_MODIFICATRICES)
|
||||||
|
except Exception:
|
||||||
|
return False # dans le doute, ne jamais bloquer le collage (idem macOS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clavier_reel(vk):
|
||||||
|
import ctypes
|
||||||
|
return ctypes.windll.user32.GetAsyncKeyState(vk)
|
||||||
|
|
||||||
|
# --- raccourci --------------------------------------------------------
|
||||||
|
def raccourci_defaut(self):
|
||||||
|
# <cmd> n'existe pas sur Windows : pas d'équivalent à la touche Commande.
|
||||||
|
return "<ctrl>+<alt>+d"
|
||||||
|
|
||||||
|
# --- chemins ------------------------------------------------------------
|
||||||
|
def chemin_log(self):
|
||||||
|
return os.path.join(self._dossier_local_appdata(), "Zonza", "Zonza.log")
|
||||||
|
|
||||||
|
def dossier_donnees(self):
|
||||||
|
return os.path.join(self._dossier_local_appdata(), "Zonza")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dossier_local_appdata():
|
||||||
|
# os.environ est standard : lisible (et falsifiable par les tests via
|
||||||
|
# monkeypatch.setenv) depuis n'importe quelle plateforme, contrairement
|
||||||
|
# à winsound/ctypes.windll/msvcrt.
|
||||||
|
return os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
||||||
|
|
||||||
|
# --- verrou d'instance --------------------------------------------------
|
||||||
|
def acquerir_verrou_instance(self, chemin):
|
||||||
|
parent = os.path.dirname(chemin)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
fichier = open(chemin, "a+")
|
||||||
|
fichier.seek(0)
|
||||||
|
# msvcrt.locking verrouille un OCTET à partir de la position courante ;
|
||||||
|
# il exige que cet octet existe déjà dans le fichier, d'où cette garde.
|
||||||
|
if os.fstat(fichier.fileno()).st_size == 0:
|
||||||
|
fichier.write("0")
|
||||||
|
fichier.flush()
|
||||||
|
fichier.seek(0)
|
||||||
|
try:
|
||||||
|
(self._verrouiller or self._verrouiller_reel)(fichier)
|
||||||
|
except OSError:
|
||||||
|
fichier.close()
|
||||||
|
return None
|
||||||
|
return fichier
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verrouiller_reel(fichier):
|
||||||
|
import msvcrt
|
||||||
|
fichier.seek(0)
|
||||||
|
msvcrt.locking(fichier.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""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"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Une suite unique, rejouée contre TOUTES les implémentations d'un contrat.
|
||||||
|
|
||||||
|
Le jour où une implémentation oublie une méthode, c'est ici que ça rougit — y
|
||||||
|
compris pour les plateformes absentes de la machine de développement.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contrats import Overlay, Systeme, Transcripteur
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("contrat, methodes", [
|
||||||
|
(Transcripteur, ["warmup", "transcrire"]),
|
||||||
|
(Overlay, ["show", "hide", "set_level", "set_status", "rearmer_garde"]),
|
||||||
|
(Systeme, ["jouer_son", "modificateurs_enfonces", "raccourci_defaut",
|
||||||
|
"chemin_log", "dossier_donnees", "acquerir_verrou_instance"]),
|
||||||
|
])
|
||||||
|
def test_le_contrat_declare_exactement_ses_methodes(contrat, methodes):
|
||||||
|
declarees = [n for n, _ in inspect.getmembers(contrat, inspect.isfunction)
|
||||||
|
if not n.startswith("_")]
|
||||||
|
assert sorted(declarees) == sorted(methodes)
|
||||||
|
|
||||||
|
# Ce sont les docstrings de MÉTHODE qui portent l'essentiel du contrat
|
||||||
|
# (la plage [0, 1] de set_level, l'affinité thread, la sémantique de
|
||||||
|
# retour) : une docstring vidée ici doit rougir, pas seulement celle
|
||||||
|
# de la classe.
|
||||||
|
for nom in methodes:
|
||||||
|
methode = getattr(contrat, nom)
|
||||||
|
assert methode.__doc__, f"{contrat.__name__}.{nom} doit dire à quoi il sert"
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_contrats_sont_documentes():
|
||||||
|
for contrat in (Transcripteur, Overlay, Systeme):
|
||||||
|
assert contrat.__doc__, f"{contrat.__name__} doit dire à quoi il sert"
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""`zonza --diagnostic` : enchaînement des épreuves et mise en forme.
|
||||||
|
|
||||||
|
Chaque épreuve qui toucherait normalement du matériel réel (micro, moteur de
|
||||||
|
transcription, presse-papier, verrou) est ici exercée avec une doublure
|
||||||
|
injectée — ces tests éprouvent l'ORCHESTRATION (ordre, propagation d'un échec,
|
||||||
|
non-invention d'un résultat), pas le matériel lui-même.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from diagnostic import (
|
||||||
|
ResultatEpreuve,
|
||||||
|
diagnostiquer,
|
||||||
|
epreuve_micro,
|
||||||
|
epreuve_moteur,
|
||||||
|
epreuve_plateforme,
|
||||||
|
epreuve_presse_papier,
|
||||||
|
epreuve_raccourci,
|
||||||
|
epreuve_transcription,
|
||||||
|
epreuve_verrou,
|
||||||
|
formater_resultat,
|
||||||
|
mettre_en_forme,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_plateforme ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_epreuve_plateforme_rapporte_cuda_present():
|
||||||
|
r = epreuve_plateforme(plateforme="win32", machine="AMD64", cuda_disponible=lambda: True)
|
||||||
|
assert r.reussite is True
|
||||||
|
assert r.detail == "win32 / AMD64 / CUDA présent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_plateforme_rapporte_cuda_absent():
|
||||||
|
r = epreuve_plateforme(plateforme="darwin", machine="arm64", cuda_disponible=lambda: False)
|
||||||
|
assert "CUDA absent" in r.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_plateforme_non_concluante_si_la_sonde_cuda_leve():
|
||||||
|
def leve():
|
||||||
|
raise RuntimeError("ctypes en échec")
|
||||||
|
r = epreuve_plateforme(plateforme="win32", machine="AMD64", cuda_disponible=leve)
|
||||||
|
assert r.reussite is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_moteur -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_epreuve_moteur_rapporte_le_nom_et_le_modele():
|
||||||
|
r, transcripteur = epreuve_moteur(
|
||||||
|
detecter_moteur=lambda: "faster-cpu",
|
||||||
|
resoudre_modele=lambda nom, forcage: "Systran/faster-whisper-small",
|
||||||
|
creer_transcripteur=lambda **kw: "un-faux-transcripteur",
|
||||||
|
)
|
||||||
|
assert r.reussite is True
|
||||||
|
assert r.detail == "faster-cpu + Systran/faster-whisper-small"
|
||||||
|
assert transcripteur == "un-faux-transcripteur"
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_moteur_non_concluante_si_la_detection_echoue():
|
||||||
|
def leve():
|
||||||
|
raise RuntimeError("aucun moteur compatible")
|
||||||
|
r, transcripteur = epreuve_moteur(detecter_moteur=leve)
|
||||||
|
assert r.reussite is None
|
||||||
|
assert transcripteur is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_micro -------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FauxFlux:
|
||||||
|
def __init__(self, callback, n_blocs):
|
||||||
|
self._callback = callback
|
||||||
|
self._n_blocs = n_blocs
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
for _ in range(self._n_blocs):
|
||||||
|
self._callback(np.zeros(10, dtype="float32"), 10, None, None)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_micro_compte_les_blocs_recus():
|
||||||
|
def ouvrir_flux(**kw):
|
||||||
|
return _FauxFlux(kw["callback"], n_blocs=7)
|
||||||
|
r = epreuve_micro(ouvrir_flux=ouvrir_flux, dormir=lambda s: None)
|
||||||
|
assert r.reussite is True
|
||||||
|
assert r.detail == "7 blocs reçus en 1 s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_micro_echoue_si_aucun_bloc_recu():
|
||||||
|
def ouvrir_flux(**kw):
|
||||||
|
return _FauxFlux(kw["callback"], n_blocs=0)
|
||||||
|
r = epreuve_micro(ouvrir_flux=ouvrir_flux, dormir=lambda s: None)
|
||||||
|
assert r.reussite is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_micro_non_concluante_si_louverture_echoue():
|
||||||
|
def ouvrir_flux(**kw):
|
||||||
|
raise OSError("aucun périphérique")
|
||||||
|
r = epreuve_micro(ouvrir_flux=ouvrir_flux, dormir=lambda s: None)
|
||||||
|
assert r.reussite is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_micro_utilise_bien_la_duree_demandee():
|
||||||
|
appels = []
|
||||||
|
|
||||||
|
def ouvrir_flux(**kw):
|
||||||
|
return _FauxFlux(kw["callback"], n_blocs=1)
|
||||||
|
|
||||||
|
def dormir(s):
|
||||||
|
appels.append(s)
|
||||||
|
|
||||||
|
epreuve_micro(ouvrir_flux=ouvrir_flux, duree_s=2.5, dormir=dormir)
|
||||||
|
assert appels == [2.5]
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_transcription -----------------------------------------------------
|
||||||
|
|
||||||
|
class _FauxTranscripteur:
|
||||||
|
def __init__(self, texte="bonjour", leve=False):
|
||||||
|
self._texte = texte
|
||||||
|
self._leve = leve
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
if self._leve:
|
||||||
|
raise RuntimeError("modèle indisponible")
|
||||||
|
return self._texte
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_transcription_rapporte_duree_et_texte():
|
||||||
|
horloges = iter([10.0, 10.8])
|
||||||
|
r = epreuve_transcription(
|
||||||
|
_FauxTranscripteur(texte="bonjour le monde"),
|
||||||
|
generer_parole=lambda: np.zeros(16000, dtype="float32"),
|
||||||
|
horloge=lambda: next(horloges),
|
||||||
|
)
|
||||||
|
assert r.reussite is True
|
||||||
|
assert "0.80 s" in r.detail
|
||||||
|
assert "bonjour le monde" in r.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_transcription_retombe_sur_le_silence_si_la_synthese_echoue():
|
||||||
|
def leve():
|
||||||
|
raise RuntimeError("`say` absent")
|
||||||
|
r = epreuve_transcription(
|
||||||
|
_FauxTranscripteur(texte=""),
|
||||||
|
generer_parole=leve,
|
||||||
|
horloge=lambda: 0.0,
|
||||||
|
)
|
||||||
|
assert r.reussite is True
|
||||||
|
assert "silence" in r.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_transcription_non_concluante_si_la_transcription_echoue():
|
||||||
|
r = epreuve_transcription(
|
||||||
|
_FauxTranscripteur(leve=True),
|
||||||
|
generer_parole=lambda: np.zeros(16000, dtype="float32"),
|
||||||
|
horloge=lambda: 0.0,
|
||||||
|
)
|
||||||
|
assert r.reussite is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_presse_papier -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_epreuve_presse_papier_reussit_si_la_relecture_est_identique():
|
||||||
|
stockage = {}
|
||||||
|
r = epreuve_presse_papier(
|
||||||
|
copier=lambda t: stockage.__setitem__("v", t),
|
||||||
|
lire=lambda: stockage["v"],
|
||||||
|
)
|
||||||
|
assert r.reussite is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_presse_papier_echoue_si_la_relecture_differe():
|
||||||
|
r = epreuve_presse_papier(copier=lambda t: None, lire=lambda: "autre chose")
|
||||||
|
assert r.reussite is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_presse_papier_non_concluante_si_la_copie_leve():
|
||||||
|
def leve(t):
|
||||||
|
raise RuntimeError("presse-papier indisponible")
|
||||||
|
r = epreuve_presse_papier(copier=leve, lire=lambda: "")
|
||||||
|
assert r.reussite is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_raccourci -----------------------------------------------------------
|
||||||
|
|
||||||
|
class _FauxSysteme:
|
||||||
|
def __init__(self, raccourci="<ctrl>+<alt>+d", verrou_sequence=None):
|
||||||
|
self._raccourci = raccourci
|
||||||
|
self._verrou_sequence = iter(verrou_sequence or [])
|
||||||
|
|
||||||
|
def raccourci_defaut(self):
|
||||||
|
return self._raccourci
|
||||||
|
|
||||||
|
def acquerir_verrou_instance(self, chemin):
|
||||||
|
return next(self._verrou_sequence)
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_raccourci_rapporte_la_valeur_de_la_plateforme():
|
||||||
|
r = epreuve_raccourci(_FauxSysteme(raccourci="<ctrl>+<alt>+d"))
|
||||||
|
assert r.reussite is True
|
||||||
|
assert r.detail == "<ctrl>+<alt>+d"
|
||||||
|
|
||||||
|
|
||||||
|
# --- epreuve_verrou ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_epreuve_verrou_reussit_si_la_seconde_prise_est_refusee():
|
||||||
|
r = epreuve_verrou(_FauxSysteme(verrou_sequence=["handle", None]), chemin="/tmp/x.lock")
|
||||||
|
assert r.reussite is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_verrou_echoue_si_la_seconde_prise_nest_pas_refusee():
|
||||||
|
r = epreuve_verrou(_FauxSysteme(verrou_sequence=["h1", "h2"]), chemin="/tmp/x.lock")
|
||||||
|
assert r.reussite is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_epreuve_verrou_non_concluante_si_le_premier_verrou_echoue_deja():
|
||||||
|
r = epreuve_verrou(_FauxSysteme(verrou_sequence=[None, None]), chemin="/tmp/x.lock")
|
||||||
|
assert r.reussite is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- diagnostiquer : enchaînement complet -----------------------------------------
|
||||||
|
|
||||||
|
def test_diagnostiquer_rend_les_sept_epreuves_dans_lordre():
|
||||||
|
resultats = diagnostiquer(
|
||||||
|
systeme=_FauxSysteme(verrou_sequence=["h", None]),
|
||||||
|
plateforme="win32", machine="AMD64", cuda_disponible=lambda: False,
|
||||||
|
detecter_moteur=lambda: "faster-cpu",
|
||||||
|
resoudre_modele=lambda nom, forcage: "modele-test",
|
||||||
|
creer_transcripteur=lambda **kw: _FauxTranscripteur(texte="ok"),
|
||||||
|
ouvrir_flux=lambda **kw: _FauxFlux(kw["callback"], n_blocs=3),
|
||||||
|
dormir=lambda s: None,
|
||||||
|
generer_parole=lambda: np.zeros(16000, dtype="float32"),
|
||||||
|
horloge=lambda: 0.0,
|
||||||
|
copier=lambda t: None,
|
||||||
|
lire=lambda: "peu importe",
|
||||||
|
)
|
||||||
|
assert [r.nom for r in resultats] == [
|
||||||
|
"Plateforme", "Moteur retenu", "Micro", "Transcription",
|
||||||
|
"Presse-papier", "Raccourci", "Verrou",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostiquer_saute_honnetement_la_transcription_si_le_moteur_echoue():
|
||||||
|
def leve():
|
||||||
|
raise RuntimeError("aucun moteur")
|
||||||
|
resultats = diagnostiquer(
|
||||||
|
systeme=_FauxSysteme(verrou_sequence=["h", None]),
|
||||||
|
detecter_moteur=leve,
|
||||||
|
ouvrir_flux=lambda **kw: _FauxFlux(kw["callback"], n_blocs=1),
|
||||||
|
dormir=lambda s: None,
|
||||||
|
copier=lambda t: None,
|
||||||
|
lire=lambda: "peu importe",
|
||||||
|
)
|
||||||
|
transcription = [r for r in resultats if r.nom == "Transcription"][0]
|
||||||
|
# jamais une réussite inventée quand rien n'a pu être mené.
|
||||||
|
assert transcription.reussite is None
|
||||||
|
assert "sautée" in transcription.detail
|
||||||
|
|
||||||
|
|
||||||
|
class _SystemeTombeToujours:
|
||||||
|
"""Doublure dont chaque méthode lève — pour prouver que rien n'est jamais
|
||||||
|
inventé même quand la couche système est totalement en panne."""
|
||||||
|
|
||||||
|
def raccourci_defaut(self):
|
||||||
|
raise RuntimeError("tout casse")
|
||||||
|
|
||||||
|
def acquerir_verrou_instance(self, chemin):
|
||||||
|
raise RuntimeError("tout casse")
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostiquer_ne_leve_jamais_meme_si_toutes_les_epreuves_echouent():
|
||||||
|
def leve(*a, **kw):
|
||||||
|
raise RuntimeError("tout casse")
|
||||||
|
resultats = diagnostiquer(
|
||||||
|
systeme=_SystemeTombeToujours(),
|
||||||
|
plateforme="win32", machine="AMD64", cuda_disponible=leve,
|
||||||
|
detecter_moteur=leve,
|
||||||
|
ouvrir_flux=leve,
|
||||||
|
dormir=lambda s: None,
|
||||||
|
copier=leve,
|
||||||
|
lire=leve,
|
||||||
|
)
|
||||||
|
assert len(resultats) == 7
|
||||||
|
assert all(r.reussite is None for r in resultats)
|
||||||
|
|
||||||
|
|
||||||
|
# --- mise en forme -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_formater_resultat_aligne_le_nom_et_prefixe_un_symbole():
|
||||||
|
r = ResultatEpreuve("Plateforme", True, "darwin / arm64 / CUDA absent")
|
||||||
|
ligne = formater_resultat(r)
|
||||||
|
assert ligne == "✅ Plateforme : darwin / arm64 / CUDA absent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_formater_resultat_marque_lechec():
|
||||||
|
r = ResultatEpreuve("Micro", False, "0 blocs reçus en 1 s")
|
||||||
|
assert formater_resultat(r).startswith("❌ ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_formater_resultat_marque_le_non_concluant():
|
||||||
|
r = ResultatEpreuve("Transcription", None, "sautée")
|
||||||
|
assert formater_resultat(r).startswith("➖ ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mettre_en_forme_ne_prend_aucun_travail_a_sa_charge():
|
||||||
|
# fabriqué à la main : aucune épreuve n'est exécutée ici.
|
||||||
|
resultats = [
|
||||||
|
ResultatEpreuve("Plateforme", True, "darwin / arm64 / CUDA absent"),
|
||||||
|
ResultatEpreuve("Moteur retenu", True, "mlx + mlx-community/whisper-medium"),
|
||||||
|
ResultatEpreuve("Micro", False, "0 blocs reçus en 1 s"),
|
||||||
|
ResultatEpreuve("Transcription", None, "sautée"),
|
||||||
|
]
|
||||||
|
rapport = mettre_en_forme(resultats)
|
||||||
|
for r in resultats:
|
||||||
|
assert formater_resultat(r) in rapport
|
||||||
|
assert "1 en échec" in rapport
|
||||||
|
assert "1 non concluante" in rapport
|
||||||
|
|
||||||
|
|
||||||
|
def test_mettre_en_forme_sur_zero_resultat_ne_leve_pas():
|
||||||
|
assert "0/0" in mettre_en_forme([])
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Moteur Gemini : la transcription part chez Google, avec repli local.
|
||||||
|
|
||||||
|
Aucun test ne touche le réseau : l'envoi HTTP et la lecture de la clé sont
|
||||||
|
injectés. Ce qu'on éprouve ici, c'est la forme de la requête, la lecture de
|
||||||
|
la réponse, et surtout le repli — une dictée ne doit JAMAIS être perdue parce
|
||||||
|
que le réseau ou l'API tombe.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moteur import choisir_transcripteur, detecter_moteur
|
||||||
|
from moteur.gemini import (
|
||||||
|
ErreurGemini, TranscripteurAvecRepli, TranscripteurGemini, audio_en_wav,
|
||||||
|
lire_cle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reponse(texte):
|
||||||
|
return {"candidates": [{"content": {"parts": [{"text": texte}]}}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _transcripteur(envoyer, cle="cle-test"):
|
||||||
|
return TranscripteurGemini(
|
||||||
|
"gemini-x", "fr", "Vocabulaire : Gitea, iCloud.",
|
||||||
|
lire_cle=lambda: cle, envoyer=envoyer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- encodage audio ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_l_audio_devient_un_wav_16_bits_mono_a_16_khz():
|
||||||
|
audio = np.array([0.0, 0.5, -0.5, 1.0], dtype="float32")
|
||||||
|
with wave.open(io.BytesIO(audio_en_wav(audio, 16000))) as w:
|
||||||
|
assert (w.getnchannels(), w.getsampwidth(), w.getframerate()) == (1, 2, 16000)
|
||||||
|
echantillons = np.frombuffer(w.readframes(4), dtype="<i2")
|
||||||
|
assert list(echantillons) == [0, 16383, -16383, 32767]
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_audio_sature_est_borne_au_lieu_de_deborder():
|
||||||
|
with wave.open(io.BytesIO(audio_en_wav(np.array([3.0, -3.0], dtype="float32"), 16000))) as w:
|
||||||
|
assert list(np.frombuffer(w.readframes(2), dtype="<i2")) == [32767, -32767]
|
||||||
|
|
||||||
|
|
||||||
|
# --- requête et réponse -----------------------------------------------------
|
||||||
|
|
||||||
|
def test_la_requete_porte_l_audio_le_modele_et_le_vocabulaire():
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
def envoyer(url, cle, corps, delai_s):
|
||||||
|
vu.update(url=url, cle=cle, corps=corps)
|
||||||
|
return _reponse("Bonjour.")
|
||||||
|
|
||||||
|
_transcripteur(envoyer).transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
|
||||||
|
assert vu["url"].endswith("/models/gemini-x:generateContent")
|
||||||
|
assert vu["cle"] == "cle-test"
|
||||||
|
parts = vu["corps"]["contents"][0]["parts"]
|
||||||
|
audio = next(p["inline_data"] for p in parts if "inline_data" in p)
|
||||||
|
assert audio["mime_type"] == "audio/wav"
|
||||||
|
assert base64.b64decode(audio["data"])[:4] == b"RIFF"
|
||||||
|
consigne = vu["corps"]["system_instruction"]["parts"][0]["text"]
|
||||||
|
assert "Gitea" in consigne and "iCloud" in consigne
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_cle_ne_part_jamais_dans_l_url():
|
||||||
|
vu = {}
|
||||||
|
_transcripteur(lambda url, cle, corps, delai_s: vu.setdefault("url", url) and _reponse("x")) \
|
||||||
|
.transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
assert "cle-test" not in vu["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_texte_rendu_est_nettoye_et_les_morceaux_joints():
|
||||||
|
reponse = {"candidates": [{"content": {"parts": [{"text": "Je pousse "}, {"text": "sur Gitea. "}]}}]}
|
||||||
|
t = _transcripteur(lambda *a: reponse)
|
||||||
|
assert t.transcrire(np.zeros(1600, dtype="float32")) == "Je pousse sur Gitea."
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_reponse_sans_candidat_est_une_erreur_explicite():
|
||||||
|
t = _transcripteur(lambda *a: {"promptFeedback": {"blockReason": "OTHER"}})
|
||||||
|
with pytest.raises(ErreurGemini) as e:
|
||||||
|
t.transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
assert "OTHER" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_cle_absente_est_signalee_des_le_warmup():
|
||||||
|
t = _transcripteur(lambda *a: _reponse("x"), cle=None)
|
||||||
|
with pytest.raises(ErreurGemini) as e:
|
||||||
|
t.warmup()
|
||||||
|
assert "zonza-gemini" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_warmup_ne_consomme_aucun_appel_payant():
|
||||||
|
appels = []
|
||||||
|
_transcripteur(lambda *a: appels.append(a) or _reponse("x")).warmup()
|
||||||
|
assert appels == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- lecture de la clé ------------------------------------------------------
|
||||||
|
|
||||||
|
def test_la_cle_du_trousseau_prime_sur_l_environnement():
|
||||||
|
assert lire_cle(trousseau=lambda: "du-trousseau", environ={"GEMINI_API_KEY": "env"}) == "du-trousseau"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sans_trousseau_on_se_rabat_sur_l_environnement():
|
||||||
|
assert lire_cle(trousseau=lambda: None, environ={"GEMINI_API_KEY": "env"}) == "env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sans_aucune_cle_on_rend_none():
|
||||||
|
assert lire_cle(trousseau=lambda: None, environ={}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- repli local ------------------------------------------------------------
|
||||||
|
|
||||||
|
class _Local:
|
||||||
|
def __init__(self):
|
||||||
|
self.appels = 0
|
||||||
|
|
||||||
|
def warmup(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def transcrire(self, audio):
|
||||||
|
self.appels += 1
|
||||||
|
return "texte local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_gemini_tombe_la_dictee_passe_par_le_moteur_local():
|
||||||
|
def en_panne(audio):
|
||||||
|
raise ErreurGemini("réseau coupé")
|
||||||
|
|
||||||
|
principal = type("P", (), {"warmup": lambda s: None, "transcrire": lambda s, a: en_panne(a)})()
|
||||||
|
local = _Local()
|
||||||
|
t = TranscripteurAvecRepli(principal, fabriquer_secours=lambda: local)
|
||||||
|
assert t.transcrire(np.zeros(10, dtype="float32")) == "texte local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_moteur_local_n_est_charge_qu_au_premier_repli():
|
||||||
|
fabrications = []
|
||||||
|
principal = type("P", (), {"warmup": lambda s: None, "transcrire": lambda s, a: "de gemini"})()
|
||||||
|
t = TranscripteurAvecRepli(principal, fabriquer_secours=lambda: fabrications.append(1) or _Local())
|
||||||
|
t.warmup()
|
||||||
|
assert t.transcrire(np.zeros(10, dtype="float32")) == "de gemini"
|
||||||
|
assert fabrications == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_cle_absente_au_warmup_ne_bloque_pas_le_demarrage():
|
||||||
|
def sans_cle(self):
|
||||||
|
raise ErreurGemini("aucune clé")
|
||||||
|
|
||||||
|
principal = type("P", (), {"warmup": sans_cle, "transcrire": lambda s, a: "x"})()
|
||||||
|
local = _Local()
|
||||||
|
t = TranscripteurAvecRepli(principal, fabriquer_secours=lambda: local)
|
||||||
|
t.warmup() # ne lève pas : le moteur local prendra le relais
|
||||||
|
assert t.secours_charge
|
||||||
|
|
||||||
|
|
||||||
|
# --- sélection du moteur ----------------------------------------------------
|
||||||
|
|
||||||
|
def test_le_forcage_de_config_prime_sur_la_detection_materielle():
|
||||||
|
assert detecter_moteur(forcage="gemini") == "gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_gemini_enveloppe_d_un_repli():
|
||||||
|
t = choisir_transcripteur(nom="gemini", modele="gemini-x", langue="fr", amorce="")
|
||||||
|
assert isinstance(t, TranscripteurAvecRepli)
|
||||||
|
assert isinstance(t.principal, TranscripteurGemini)
|
||||||
|
assert callable(t.warmup) and callable(t.transcrire)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""La fabrique rend un overlay conforme au contrat, sans ouvrir de fenêtre."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from interface import classe_overlay_pour
|
||||||
|
|
||||||
|
|
||||||
|
def test_l_overlay_macos_declare_tout_le_contrat():
|
||||||
|
classe = classe_overlay_pour("darwin")
|
||||||
|
for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"):
|
||||||
|
assert callable(getattr(classe, methode, None)), f"{methode} manque"
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_plateforme_inconnue_est_refusee_clairement():
|
||||||
|
with pytest.raises(NotImplementedError) as e:
|
||||||
|
classe_overlay_pour("haiku-os")
|
||||||
|
assert "haiku-os" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_overlayqt_pour_win32():
|
||||||
|
classe = classe_overlay_pour("win32")
|
||||||
|
from interface.qt import OverlayQt
|
||||||
|
assert classe is OverlayQt
|
||||||
|
|
||||||
|
|
||||||
|
def test_l_overlay_qt_declare_tout_le_contrat():
|
||||||
|
classe = classe_overlay_pour("win32")
|
||||||
|
for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"):
|
||||||
|
assert callable(getattr(classe, methode, None)), f"{methode} manque"
|
||||||
|
|
||||||
|
|
||||||
|
# --- garde de duree de vie : annulation reelle a hide() ---------------------
|
||||||
|
#
|
||||||
|
# Exception assumee au principe "aucune fenetre" du haut de ce fichier : la
|
||||||
|
# seule facon honnete de prouver qu'une garde est REELLEMENT annulee (et pas
|
||||||
|
# seulement oubliee dans le champ `_garde`) est d'interroger l'etat vrai de la
|
||||||
|
# minuterie native — NSTimer.isValid() cote Cocoa, QTimer.isActive() cote Qt.
|
||||||
|
# Une doublure (fake) ne fournirait aucune garantie sur ce point : elle ne
|
||||||
|
# ferait que rejouer ce que le code de production affirme deja faire.
|
||||||
|
#
|
||||||
|
# Piege du 21/08 (halo fige) : une garde dereferencee (`_garde = None`) mais
|
||||||
|
# jamais invalidee continue de courir en coulisse, et peut refermer un overlay
|
||||||
|
# rouvert entre-temps, en pleine dictee. D'ou : on capture l'objet minuterie
|
||||||
|
# AVANT hide()/rearmer_garde(), pour verifier SON etat a lui, pas seulement le
|
||||||
|
# champ qui le reference.
|
||||||
|
from interface.cocoa import PulseWindow
|
||||||
|
from interface.qt import OverlayQt
|
||||||
|
|
||||||
|
|
||||||
|
def _garde_active_cocoa(garde):
|
||||||
|
return bool(garde.isValid())
|
||||||
|
|
||||||
|
|
||||||
|
def _garde_active_qt(garde):
|
||||||
|
return garde.isActive()
|
||||||
|
|
||||||
|
|
||||||
|
_IMPLEMENTATIONS_REELLES = [
|
||||||
|
(lambda: PulseWindow.alloc().init(), _garde_active_cocoa),
|
||||||
|
(OverlayQt, _garde_active_qt),
|
||||||
|
]
|
||||||
|
_IDS_IMPLEMENTATIONS = ["cocoa", "qt"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=_IMPLEMENTATIONS_REELLES, ids=_IDS_IMPLEMENTATIONS)
|
||||||
|
def overlay_et_garde_active(request):
|
||||||
|
"""Une vraie instance d'overlay, plus la fonction qui lit l'etat reel de sa
|
||||||
|
minuterie de garde. Ferme toujours la fenetre a la fin, meme si le test echoue."""
|
||||||
|
construire, garde_active = request.param
|
||||||
|
ov = construire()
|
||||||
|
try:
|
||||||
|
yield ov, garde_active
|
||||||
|
finally:
|
||||||
|
ov.hide()
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_arme_une_garde_valide(overlay_et_garde_active):
|
||||||
|
ov, garde_active = overlay_et_garde_active
|
||||||
|
ov.show()
|
||||||
|
assert ov._garde is not None
|
||||||
|
assert garde_active(ov._garde)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hide_annule_reellement_la_garde(overlay_et_garde_active):
|
||||||
|
ov, garde_active = overlay_et_garde_active
|
||||||
|
ov.show()
|
||||||
|
garde = ov._garde # reference gardee AVANT hide()
|
||||||
|
ov.hide()
|
||||||
|
assert ov._garde is None
|
||||||
|
assert not garde_active(garde), "la minuterie doit etre invalidee, pas seulement oubliee"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rearmer_garde_ne_laisse_qu_une_seule_garde_active(overlay_et_garde_active):
|
||||||
|
ov, garde_active = overlay_et_garde_active
|
||||||
|
ov.show()
|
||||||
|
ancienne_garde = ov._garde
|
||||||
|
ov.rearmer_garde(5.0)
|
||||||
|
assert ov._garde is not ancienne_garde
|
||||||
|
assert not garde_active(ancienne_garde), "l'ancienne garde doit etre invalidee au remplacement"
|
||||||
|
assert garde_active(ov._garde)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""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()
|
||||||
@@ -11,7 +11,7 @@ draine les tampons et peut bloquer), et sans jamais lever.
|
|||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from engine import Recorder
|
from capture import Recorder
|
||||||
|
|
||||||
|
|
||||||
class FauxFlux:
|
class FauxFlux:
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""À chaque moteur son modèle.
|
||||||
|
|
||||||
|
`app.py` passait `CONFIG["model"]` — un dépôt MLX — quel que soit le moteur détecté.
|
||||||
|
Sur une machine sans Apple Silicon, `faster-whisper` aurait reçu un dépôt qu'il ne
|
||||||
|
sait pas charger. Le défaut était invisible ici, puisque la détection rend toujours
|
||||||
|
`mlx` sur ce Mac.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import MODELES_PAR_MOTEUR, modele_pour_moteur
|
||||||
|
|
||||||
|
|
||||||
|
def test_chaque_moteur_connu_a_son_modele():
|
||||||
|
for nom in ("mlx", "faster-cpu", "faster-gpu"):
|
||||||
|
assert modele_pour_moteur(nom)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mlx_garde_le_modele_d_aujourd_hui():
|
||||||
|
assert modele_pour_moteur("mlx") == "mlx-community/whisper-medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_moteurs_faster_ne_recoivent_jamais_un_depot_mlx():
|
||||||
|
for nom in ("faster-cpu", "faster-gpu"):
|
||||||
|
assert "mlx" not in modele_pour_moteur(nom).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_gpu_vise_la_precision_le_cpu_la_latence():
|
||||||
|
# mesuré le 2026-08-23 : medium = 13/13 termes mais 4,4 s sur CPU ; small = 11/13 en 1,4 s
|
||||||
|
assert "medium" in modele_pour_moteur("faster-gpu")
|
||||||
|
assert "small" in modele_pour_moteur("faster-cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_moteur_inconnu_est_refuse_clairement():
|
||||||
|
with pytest.raises(KeyError) as e:
|
||||||
|
modele_pour_moteur("vosk")
|
||||||
|
assert "vosk" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_table_ne_contient_que_les_moteurs_reconnus():
|
||||||
|
assert set(MODELES_PAR_MOTEUR) == {"mlx", "faster-cpu", "faster-gpu", "gemini", "openrouter"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_reglage_manuel_prime_sur_la_table():
|
||||||
|
from core import modele_retenu
|
||||||
|
assert modele_retenu("faster-cpu", forcage=None) == modele_pour_moteur("faster-cpu")
|
||||||
|
assert modele_retenu("faster-cpu", forcage="Systran/faster-whisper-medium") \
|
||||||
|
== "Systran/faster-whisper-medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_forcage_vide_est_ignore():
|
||||||
|
from core import modele_retenu
|
||||||
|
assert modele_retenu("mlx", forcage="") == modele_pour_moteur("mlx")
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_moteurs_distants_ne_recoivent_jamais_un_modele_tts():
|
||||||
|
# Un modele « -tts » produit de la voix a partir de texte : il refuse
|
||||||
|
# l'audio en entree (HTTP 400 chez OpenRouter, verifie le 2026-09-25).
|
||||||
|
for nom in ("gemini", "openrouter"):
|
||||||
|
assert "tts" not in modele_pour_moteur(nom).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_delai_reseau_laisse_la_place_au_repli_sous_la_garde():
|
||||||
|
from core import CONFIG
|
||||||
|
from moteur.gemini import DELAI_RESEAU_S
|
||||||
|
assert DELAI_RESEAU_S < CONFIG["duree_max_transcription_s"]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""La fabrique choisit le moteur d'après le matériel — décision pure, testable
|
||||||
|
depuis n'importe quelle machine."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moteur import choisir_transcripteur, nom_moteur_pour
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("plateforme, machine, cuda, attendu", [
|
||||||
|
("darwin", "arm64", False, "mlx"),
|
||||||
|
("darwin", "x86_64", False, "faster-cpu"),
|
||||||
|
("win32", "AMD64", True, "faster-gpu"),
|
||||||
|
("win32", "AMD64", False, "faster-cpu"),
|
||||||
|
])
|
||||||
|
def test_la_decision_suit_le_materiel(plateforme, machine, cuda, attendu):
|
||||||
|
assert nom_moteur_pour(plateforme, machine, cuda) == attendu
|
||||||
|
|
||||||
|
|
||||||
|
def test_apple_silicon_prend_mlx_meme_sans_cuda():
|
||||||
|
assert nom_moteur_pour("darwin", "arm64", cuda_disponible=False) == "mlx"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_un_transcripteur_complet():
|
||||||
|
t = choisir_transcripteur(nom="mlx", modele="mlx-community/whisper-medium",
|
||||||
|
langue="fr", amorce="")
|
||||||
|
assert callable(t.warmup) and callable(t.transcrire)
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_moteur_inconnu_est_refuse_clairement():
|
||||||
|
with pytest.raises(NotImplementedError) as e:
|
||||||
|
choisir_transcripteur(nom="vosk", modele="x", langue="fr", amorce="")
|
||||||
|
assert "vosk" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sonde CUDA : le nom de la bibliothèque partagée diffère par OS et par version.
|
||||||
|
# Ces tests injectent une doublure de chargeur pour rester exécutables depuis
|
||||||
|
# n'importe quelle machine, sans jamais dépendre d'une vraie installation CUDA.
|
||||||
|
from moteur import _cuda_disponible
|
||||||
|
|
||||||
|
|
||||||
|
def test_cuda_reconnue_sur_windows_via_dll_versionnee():
|
||||||
|
def charger(nom):
|
||||||
|
if nom == "cudart64_12.dll":
|
||||||
|
return object()
|
||||||
|
raise OSError(f"introuvable : {nom}")
|
||||||
|
assert _cuda_disponible(plateforme="win32", charger=charger) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cuda_absente_sur_windows_si_aucune_dll_ne_charge():
|
||||||
|
def charger(nom):
|
||||||
|
raise OSError(f"introuvable : {nom}")
|
||||||
|
assert _cuda_disponible(plateforme="win32", charger=charger) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cuda_reconnue_sur_linux_darwin_via_so():
|
||||||
|
def charger(nom):
|
||||||
|
if nom == "libcudart.so":
|
||||||
|
return object()
|
||||||
|
raise OSError(f"introuvable : {nom}")
|
||||||
|
assert _cuda_disponible(plateforme="linux", charger=charger) is True
|
||||||
|
assert _cuda_disponible(plateforme="darwin", charger=charger) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cuda_sonde_ne_leve_jamais_meme_si_le_chargeur_explose():
|
||||||
|
def charger(nom):
|
||||||
|
raise RuntimeError("chargeur casse, pas seulement OSError")
|
||||||
|
assert _cuda_disponible(plateforme="win32", charger=charger) is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- faster-whisper : meme garantie que MLX ci-dessus — la fabrique rend un
|
||||||
|
# transcripteur complet sans jamais importer faster_whisper au niveau module,
|
||||||
|
# pour rester executable sur une machine qui ne l'a pas installe.
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_un_transcripteur_faster_cpu_complet():
|
||||||
|
t = choisir_transcripteur(nom="faster-cpu", modele="Systran/faster-whisper-small",
|
||||||
|
langue="fr", amorce="")
|
||||||
|
assert callable(t.warmup) and callable(t.transcrire)
|
||||||
|
assert t.peripherique == "cpu"
|
||||||
|
assert t.type_calcul == "int8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_un_transcripteur_faster_gpu_complet():
|
||||||
|
t = choisir_transcripteur(nom="faster-gpu", modele="Systran/faster-whisper-small",
|
||||||
|
langue="fr", amorce="")
|
||||||
|
assert callable(t.warmup) and callable(t.transcrire)
|
||||||
|
assert t.peripherique == "cuda"
|
||||||
|
assert t.type_calcul == "float16"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Moteur OpenRouter : même modèle Gemini, facturé et routé par OpenRouter.
|
||||||
|
|
||||||
|
Aucun test ne touche le réseau : l'envoi HTTP et la lecture de la clé sont
|
||||||
|
injectés. Le repli local, lui, est déjà éprouvé dans test_gemini.py.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from moteur import choisir_transcripteur
|
||||||
|
from moteur.gemini import ErreurGemini, TranscripteurAvecRepli
|
||||||
|
from moteur.openrouter import TranscripteurOpenRouter, lire_cle_openrouter
|
||||||
|
|
||||||
|
|
||||||
|
def _reponse(texte):
|
||||||
|
return {"choices": [{"message": {"role": "assistant", "content": texte}}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _transcripteur(envoyer, cle="cle-test"):
|
||||||
|
return TranscripteurOpenRouter(
|
||||||
|
"google/gemini-x", "fr", "Vocabulaire : Gitea, iCloud.",
|
||||||
|
lire_cle=lambda: cle, envoyer=envoyer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_requete_porte_l_audio_le_modele_et_le_vocabulaire():
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
def envoyer(url, entetes, corps, delai_s):
|
||||||
|
vu.update(url=url, entetes=entetes, corps=corps)
|
||||||
|
return _reponse("Bonjour.")
|
||||||
|
|
||||||
|
_transcripteur(envoyer).transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
|
||||||
|
assert vu["url"] == "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
assert vu["entetes"]["Authorization"] == "Bearer cle-test"
|
||||||
|
assert vu["corps"]["model"] == "google/gemini-x"
|
||||||
|
systeme, utilisateur = vu["corps"]["messages"]
|
||||||
|
assert "Gitea" in systeme["content"] and "iCloud" in systeme["content"]
|
||||||
|
audio = next(p["input_audio"] for p in utilisateur["content"] if p["type"] == "input_audio")
|
||||||
|
assert audio["format"] == "wav"
|
||||||
|
assert base64.b64decode(audio["data"])[:4] == b"RIFF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_cle_ne_part_jamais_dans_l_url():
|
||||||
|
vu = {}
|
||||||
|
_transcripteur(lambda url, e, c, d: vu.setdefault("url", url) and _reponse("x")) \
|
||||||
|
.transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
assert "cle-test" not in vu["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_texte_rendu_est_nettoye():
|
||||||
|
t = _transcripteur(lambda *a: _reponse(" Je pousse sur Gitea. "))
|
||||||
|
assert t.transcrire(np.zeros(1600, dtype="float32")) == "Je pousse sur Gitea."
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_erreur_rendue_dans_un_200_est_une_erreur_explicite():
|
||||||
|
# OpenRouter peut répondre 200 avec un objet `error` (fournisseur en panne).
|
||||||
|
t = _transcripteur(lambda *a: {"error": {"code": 502, "message": "fournisseur indisponible"}})
|
||||||
|
with pytest.raises(ErreurGemini) as e:
|
||||||
|
t.transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
assert "fournisseur indisponible" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_reponse_sans_choix_est_une_erreur_explicite():
|
||||||
|
t = _transcripteur(lambda *a: {"choices": []})
|
||||||
|
with pytest.raises(ErreurGemini):
|
||||||
|
t.transcrire(np.zeros(1600, dtype="float32"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_cle_absente_est_signalee_des_le_warmup_sans_appel():
|
||||||
|
appels = []
|
||||||
|
t = _transcripteur(lambda *a: appels.append(a), cle=None)
|
||||||
|
with pytest.raises(ErreurGemini) as e:
|
||||||
|
t.warmup()
|
||||||
|
assert "zonza-openrouter" in str(e.value)
|
||||||
|
assert appels == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_cle_se_lit_sous_son_propre_nom():
|
||||||
|
assert lire_cle_openrouter(trousseau=lambda: None,
|
||||||
|
environ={"OPENROUTER_API_KEY": "or", "GEMINI_API_KEY": "g"}) == "or"
|
||||||
|
assert lire_cle_openrouter(trousseau=lambda: None, environ={"GEMINI_API_KEY": "g"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_openrouter_enveloppe_d_un_repli():
|
||||||
|
t = choisir_transcripteur(nom="openrouter", modele="google/gemini-x", langue="fr", amorce="")
|
||||||
|
assert isinstance(t, TranscripteurAvecRepli)
|
||||||
|
assert isinstance(t.principal, TranscripteurOpenRouter)
|
||||||
@@ -13,7 +13,7 @@ bug — quel que soit le nombre de bundles présents, la 2e instance renonce.
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from core import acquire_single_instance_lock
|
from systeme import choisir_systeme
|
||||||
|
|
||||||
|
|
||||||
def _tmp_lock():
|
def _tmp_lock():
|
||||||
@@ -21,31 +21,31 @@ def _tmp_lock():
|
|||||||
|
|
||||||
|
|
||||||
def test_premiere_instance_obtient_le_verrou():
|
def test_premiere_instance_obtient_le_verrou():
|
||||||
handle = acquire_single_instance_lock(_tmp_lock())
|
handle = choisir_systeme().acquerir_verrou_instance(_tmp_lock())
|
||||||
assert handle is not None
|
assert handle is not None
|
||||||
|
|
||||||
|
|
||||||
def test_deuxieme_instance_est_refusee():
|
def test_deuxieme_instance_est_refusee():
|
||||||
path = _tmp_lock()
|
path = _tmp_lock()
|
||||||
premier = acquire_single_instance_lock(path)
|
premier = choisir_systeme().acquerir_verrou_instance(path)
|
||||||
assert premier is not None
|
assert premier is not None
|
||||||
assert acquire_single_instance_lock(path) is None
|
assert choisir_systeme().acquerir_verrou_instance(path) is None
|
||||||
|
|
||||||
|
|
||||||
def test_verrou_repris_apres_liberation():
|
def test_verrou_repris_apres_liberation():
|
||||||
path = _tmp_lock()
|
path = _tmp_lock()
|
||||||
premier = acquire_single_instance_lock(path)
|
premier = choisir_systeme().acquerir_verrou_instance(path)
|
||||||
premier.close()
|
premier.close()
|
||||||
assert acquire_single_instance_lock(path) is not None
|
assert choisir_systeme().acquerir_verrou_instance(path) is not None
|
||||||
|
|
||||||
|
|
||||||
def test_cree_le_dossier_parent_manquant():
|
def test_cree_le_dossier_parent_manquant():
|
||||||
path = _tmp_lock()
|
path = _tmp_lock()
|
||||||
assert not os.path.isdir(os.path.dirname(path))
|
assert not os.path.isdir(os.path.dirname(path))
|
||||||
acquire_single_instance_lock(path)
|
choisir_systeme().acquerir_verrou_instance(path)
|
||||||
assert os.path.isdir(os.path.dirname(path))
|
assert os.path.isdir(os.path.dirname(path))
|
||||||
|
|
||||||
|
|
||||||
def test_deux_chemins_differents_ne_se_genent_pas():
|
def test_deux_chemins_differents_ne_se_genent_pas():
|
||||||
assert acquire_single_instance_lock(_tmp_lock()) is not None
|
assert choisir_systeme().acquerir_verrou_instance(_tmp_lock()) is not None
|
||||||
assert acquire_single_instance_lock(_tmp_lock()) is not None
|
assert choisir_systeme().acquerir_verrou_instance(_tmp_lock()) is not None
|
||||||
|
|||||||
@@ -50,14 +50,39 @@ class FauxRecorder:
|
|||||||
def stop(self): return self.audio
|
def stop(self): return self.audio
|
||||||
|
|
||||||
|
|
||||||
def _ctrl(recorder, taches=None):
|
class FauxTranscripteur:
|
||||||
|
def warmup(self): pass
|
||||||
|
def transcrire(self, audio): return "bonjour"
|
||||||
|
|
||||||
|
|
||||||
|
class FauxSysteme:
|
||||||
|
def __init__(self): self.sons = []
|
||||||
|
def jouer_son(self, nom): self.sons.append(nom)
|
||||||
|
def modificateurs_enfonces(self): return False
|
||||||
|
def raccourci_defaut(self): return "<cmd>+<ctrl>+d"
|
||||||
|
def chemin_log(self): return "/tmp/zonza-test.log"
|
||||||
|
def dossier_donnees(self): return "/tmp/zonza-test"
|
||||||
|
def acquerir_verrou_instance(self, chemin): return object()
|
||||||
|
|
||||||
|
|
||||||
|
def _ctrl(recorder, taches=None, config=None):
|
||||||
pulse = FauxPulse()
|
pulse = FauxPulse()
|
||||||
c = ZonzaController(CONFIG, pulse, lancer_tache=taches)
|
systeme = FauxSysteme()
|
||||||
|
c = ZonzaController(config or CONFIG, pulse, FauxTranscripteur(), systeme, lancer_tache=taches)
|
||||||
c.recorder = recorder
|
c.recorder = recorder
|
||||||
c.transcriber = type("T", (), {"transcribe": staticmethod(lambda a: "bonjour")})()
|
|
||||||
return c, pulse
|
return c, pulse
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_controleur_n_importe_aucun_module_de_plateforme():
|
||||||
|
"""Le noyau ne doit connaître que des contrats. Si cette assertion tombe, une
|
||||||
|
dépendance de plateforme s'est glissée dans le chemin portable."""
|
||||||
|
import controller
|
||||||
|
source = open(controller.__file__, encoding="utf-8").read()
|
||||||
|
for interdit in ("AppKit", "Foundation", "objc", "Quartz", "mlx_whisper",
|
||||||
|
"afplay", "from engine import"):
|
||||||
|
assert interdit not in source, f"{interdit} n'a rien à faire dans controller.py"
|
||||||
|
|
||||||
|
|
||||||
def test_demarrer_ne_touche_pas_l_audio_sur_le_thread_principal():
|
def test_demarrer_ne_touche_pas_l_audio_sur_le_thread_principal():
|
||||||
differees = []
|
differees = []
|
||||||
c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)
|
c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)
|
||||||
@@ -101,3 +126,24 @@ def test_un_enregistrement_trop_court_referme_aussi():
|
|||||||
for t in differees:
|
for t in differees:
|
||||||
t()
|
t()
|
||||||
assert not pulse.visible
|
assert not pulse.visible
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_menu_sons_joue_un_son_quand_actives():
|
||||||
|
differees = []
|
||||||
|
c, pulse = _ctrl(FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append)
|
||||||
|
c.toggle(); c.toggle()
|
||||||
|
for t in differees:
|
||||||
|
t()
|
||||||
|
assert c.systeme.sons, "un cycle demarrage/arret doit produire au moins un son"
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_menu_sons_coupe_le_son_quand_desactive():
|
||||||
|
differees = []
|
||||||
|
config = dict(CONFIG, sounds=False)
|
||||||
|
c, pulse = _ctrl(
|
||||||
|
FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append, config=config
|
||||||
|
)
|
||||||
|
c.toggle(); c.toggle()
|
||||||
|
for t in differees:
|
||||||
|
t()
|
||||||
|
assert c.systeme.sons == [], "sounds=False doit couper tous les sons"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""La fabrique rend une implémentation conforme au contrat Systeme."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from systeme import choisir_systeme
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_fabrique_rend_une_implementation_complete():
|
||||||
|
s = choisir_systeme()
|
||||||
|
for methode in ("jouer_son", "modificateurs_enfonces", "raccourci_defaut",
|
||||||
|
"chemin_log", "dossier_donnees", "acquerir_verrou_instance"):
|
||||||
|
assert callable(getattr(s, methode)), f"{methode} manque"
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_plateforme_inconnue_est_refusee_clairement():
|
||||||
|
with pytest.raises(NotImplementedError) as e:
|
||||||
|
choisir_systeme(plateforme="haiku-os")
|
||||||
|
assert "haiku-os" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_raccourci_macos_est_celui_d_aujourd_hui():
|
||||||
|
assert choisir_systeme(plateforme="darwin").raccourci_defaut() == "<cmd>+<ctrl>+d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_verrou_refuse_une_seconde_prise():
|
||||||
|
s = choisir_systeme()
|
||||||
|
chemin = os.path.join(tempfile.mkdtemp(), "sous-dossier", "z.lock")
|
||||||
|
# Référence gardée : sinon CPython ramasse l'objet fichier dès la fin de
|
||||||
|
# l'assert (refcount à 0), ce qui referme le fichier et relâche le verrou
|
||||||
|
# AVANT la seconde prise — le test passerait pour la mauvaise raison.
|
||||||
|
verrou = s.acquerir_verrou_instance(chemin)
|
||||||
|
assert verrou is not None
|
||||||
|
assert s.acquerir_verrou_instance(chemin) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_les_modificateurs_sont_lus_par_le_masque():
|
||||||
|
"""La sonde est injectee : sans cela ce test lirait le CLAVIER PHYSIQUE et
|
||||||
|
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)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Couche Windows du contrat Systeme — éprouvée depuis macOS par injection.
|
||||||
|
|
||||||
|
`winsound`, `msvcrt` et `ctypes.windll` n'existent pas sur macOS : c'est
|
||||||
|
pourquoi `SystemeWindows` les reçoit en paramètres injectables (jouer, clavier,
|
||||||
|
verrouiller) plutôt que de les importer en tête de module. Ces tests éprouvent
|
||||||
|
donc la LOGIQUE française de la classe — quel alias pour quel son, quel bit
|
||||||
|
pour quelle touche, comment les chemins et le verrou sont construits — jamais
|
||||||
|
le comportement réel de winsound.PlaySound, GetAsyncKeyState ou msvcrt.locking :
|
||||||
|
aucune machine ici ne peut le vérifier. Seule une vraie machine Windows le peut.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from systeme.windows import SystemeWindows
|
||||||
|
|
||||||
|
|
||||||
|
# --- jouer_son ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_jouer_son_start_produit_lalias_systemasterisk():
|
||||||
|
appels = []
|
||||||
|
s = SystemeWindows(jouer=appels.append)
|
||||||
|
s.jouer_son("start")
|
||||||
|
assert appels == ["SystemAsterisk"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jouer_son_done_produit_lalias_systemexclamation():
|
||||||
|
appels = []
|
||||||
|
s = SystemeWindows(jouer=appels.append)
|
||||||
|
s.jouer_son("done")
|
||||||
|
assert appels == ["SystemExclamation"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jouer_son_error_produit_lalias_systemhand():
|
||||||
|
appels = []
|
||||||
|
s = SystemeWindows(jouer=appels.append)
|
||||||
|
s.jouer_son("error")
|
||||||
|
assert appels == ["SystemHand"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jouer_son_nom_inconnu_ne_produit_aucun_appel():
|
||||||
|
appels = []
|
||||||
|
s = SystemeWindows(jouer=appels.append)
|
||||||
|
s.jouer_son("inconnu")
|
||||||
|
assert appels == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_jouer_son_qui_echoue_ne_leve_pas():
|
||||||
|
def echoue(alias):
|
||||||
|
raise OSError("winsound indisponible")
|
||||||
|
s = SystemeWindows(jouer=echoue)
|
||||||
|
s.jouer_son("start") # ne doit jamais lever : le son ne casse pas la dictée
|
||||||
|
|
||||||
|
|
||||||
|
# --- modificateurs_enfonces --------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("vk", [0x11, 0x10, 0x12, 0x5B, 0x5C])
|
||||||
|
def test_modificateurs_enfonces_vrai_pour_chacune_des_cinq_touches(vk):
|
||||||
|
# bit de poids fort posé UNIQUEMENT pour la touche ciblée par ce cas.
|
||||||
|
s = SystemeWindows(clavier=lambda sonde, cible=vk: 0x8000 if sonde == cible else 0)
|
||||||
|
assert s.modificateurs_enfonces() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_modificateurs_enfonces_faux_si_aucun_bit_de_poids_fort_pose():
|
||||||
|
# une valeur non nulle mais sans le bit 0x8000 ne doit pas compter.
|
||||||
|
s = SystemeWindows(clavier=lambda vk: 0x0001)
|
||||||
|
assert s.modificateurs_enfonces() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_modificateurs_enfonces_faux_si_toutes_les_touches_sont_relachees():
|
||||||
|
s = SystemeWindows(clavier=lambda vk: 0)
|
||||||
|
assert s.modificateurs_enfonces() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_modificateurs_enfonces_faux_si_la_sonde_leve():
|
||||||
|
def leve(vk):
|
||||||
|
raise OSError("user32 indisponible")
|
||||||
|
s = SystemeWindows(clavier=leve)
|
||||||
|
assert s.modificateurs_enfonces() is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- raccourci_defaut ----------------------------------------------------
|
||||||
|
|
||||||
|
def test_raccourci_defaut_est_ctrl_alt_d():
|
||||||
|
# <cmd> n'existe pas sur Windows, contrairement à macOS.
|
||||||
|
assert SystemeWindows().raccourci_defaut() == "<ctrl>+<alt>+d"
|
||||||
|
|
||||||
|
|
||||||
|
# --- chemins ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_chemin_log_derive_de_localappdata(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
assert SystemeWindows().chemin_log() == os.path.join(str(tmp_path), "Zonza", "Zonza.log")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dossier_donnees_derive_de_localappdata(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||||
|
assert SystemeWindows().dossier_donnees() == os.path.join(str(tmp_path), "Zonza")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chemin_log_retombe_sur_le_dossier_utilisateur_si_localappdata_absent(monkeypatch):
|
||||||
|
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||||
|
attendu = os.path.join(os.path.expanduser("~"), "Zonza", "Zonza.log")
|
||||||
|
assert SystemeWindows().chemin_log() == attendu
|
||||||
|
|
||||||
|
|
||||||
|
# --- verrou d'instance -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_le_verrou_rend_un_objet_la_premiere_fois(tmp_path):
|
||||||
|
chemin = str(tmp_path / "sous-dossier" / "z.lock")
|
||||||
|
s = SystemeWindows(verrouiller=lambda f: None)
|
||||||
|
verrou = s.acquerir_verrou_instance(chemin)
|
||||||
|
assert verrou is not None
|
||||||
|
verrou.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_verrou_cree_le_dossier_parent_manquant(tmp_path):
|
||||||
|
chemin = str(tmp_path / "sous-dossier" / "z.lock")
|
||||||
|
s = SystemeWindows(verrouiller=lambda f: None)
|
||||||
|
assert not os.path.isdir(os.path.dirname(chemin))
|
||||||
|
verrou = s.acquerir_verrou_instance(chemin)
|
||||||
|
assert os.path.isdir(os.path.dirname(chemin))
|
||||||
|
verrou.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_le_verrou_refuse_une_seconde_prise(tmp_path):
|
||||||
|
chemin = str(tmp_path / "z.lock")
|
||||||
|
pris = {"valeur": False}
|
||||||
|
|
||||||
|
def verrouiller(f):
|
||||||
|
if pris["valeur"]:
|
||||||
|
raise OSError("verrou déjà pris")
|
||||||
|
pris["valeur"] = True
|
||||||
|
|
||||||
|
s = SystemeWindows(verrouiller=verrouiller)
|
||||||
|
premier = s.acquerir_verrou_instance(chemin)
|
||||||
|
assert premier is not None
|
||||||
|
second = s.acquerir_verrou_instance(chemin)
|
||||||
|
assert second is None
|
||||||
|
premier.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_verrou_dont_la_prise_echoue_referme_le_fichier(tmp_path):
|
||||||
|
chemin = str(tmp_path / "z.lock")
|
||||||
|
captures = {}
|
||||||
|
|
||||||
|
def echoue(f):
|
||||||
|
captures["fichier"] = f
|
||||||
|
raise OSError("verrou pris")
|
||||||
|
|
||||||
|
s = SystemeWindows(verrouiller=echoue)
|
||||||
|
resultat = s.acquerir_verrou_instance(chemin)
|
||||||
|
assert resultat is None
|
||||||
|
assert captures["fichier"].closed
|
||||||
Reference in New Issue
Block a user