Compare commits
38
Commits
8a9a6de98c
...
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 | ||
|
|
6dea252013 | ||
|
|
711eace2bb | ||
|
|
19afbe0369 | ||
|
|
ebf3c8e749 | ||
|
|
68e1f336c8 | ||
|
|
5710cb69ce | ||
|
|
58ed0d9e6c | ||
|
|
f8c30e0402 | ||
|
|
55c860c5b9 | ||
|
|
f1cff4239d | ||
|
|
3ad274a774 |
@@ -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
|
||||
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
|
||||
|
||||
from objc import super
|
||||
@@ -27,9 +40,8 @@ from Foundation import NSObject, NSTimer
|
||||
from PyObjCTools import AppHelper
|
||||
from pynput import keyboard
|
||||
|
||||
from controller import ZonzaController
|
||||
from core import CONFIG, validate_config
|
||||
from pulse_window import PulseWindow
|
||||
from assemblage import acquerir_verrou, construire
|
||||
from core import CONFIG
|
||||
|
||||
_STATE_TITLES = {
|
||||
"idle": "🎙️",
|
||||
@@ -39,19 +51,21 @@ _STATE_TITLES = {
|
||||
|
||||
|
||||
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()
|
||||
if self is None:
|
||||
return None
|
||||
self._item = None
|
||||
self._sounds_item = None
|
||||
self.pulse = PulseWindow.alloc().init()
|
||||
self.controller = ZonzaController(
|
||||
CONFIG, self.pulse, on_state=self._set_state,
|
||||
run_on_main=AppHelper.callAfter,
|
||||
)
|
||||
self.controller = controller
|
||||
self._listener = None
|
||||
return self
|
||||
|
||||
@@ -120,14 +134,26 @@ class ZonzaApp(NSObject):
|
||||
def _warmup_async(self):
|
||||
def run():
|
||||
try:
|
||||
self.controller.transcriber.warmup()
|
||||
self.controller.transcripteur.warmup()
|
||||
except Exception as e:
|
||||
print(f"[erreur] warmup modèle : {e}")
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def main():
|
||||
validate_config(CONFIG)
|
||||
# une seule instance a la fois : sinon deux modeles en RAM et la meme
|
||||
# hotkey captee deux fois (cf systeme.acquerir_verrou_instance). La prise
|
||||
# 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:
|
||||
print("[garde] une autre instance de Zonza tourne deja — arret.")
|
||||
return
|
||||
main._lock = lock # garder le verrou vivant tant que le process vit
|
||||
|
||||
# si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle
|
||||
# (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste —
|
||||
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
|
||||
@@ -137,8 +163,19 @@ def main():
|
||||
)
|
||||
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
|
||||
print(f"[diag] accessibilité accordée : {bool(trusted)}")
|
||||
|
||||
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()
|
||||
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
|
||||
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,
|
||||
}
|
||||
@@ -28,3 +28,28 @@ def shape_level(rms, gain):
|
||||
boosted = max(0.0, rms) * gain
|
||||
shaped = boosted ** 0.5
|
||||
return min(1.0, max(0.0, shaped))
|
||||
|
||||
|
||||
def push_level(history, level, capacity):
|
||||
"""Empile un niveau EN TETE et renvoie un nouvel historique borne a `capacity`.
|
||||
|
||||
L'historique recu n'est jamais modifie (le thread audio ecrit, le thread
|
||||
principal lit : rendre une nouvelle liste evite d'exposer un etat a moitie
|
||||
ecrit). Le niveau est borne dans [0, 1].
|
||||
"""
|
||||
borne = min(1.0, max(0.0, float(level)))
|
||||
return [borne] + list(history)[: capacity - 1]
|
||||
|
||||
|
||||
def bar_heights(history, n_bars, floor):
|
||||
"""Convertit un historique de niveaux en `n_bars` hauteurs dans [floor, 1].
|
||||
|
||||
Le plus recent est a l'indice 0 (gauche de la forme d'onde). Les positions
|
||||
sans donnee sont completees par `floor`, et les silences y retombent : c'est
|
||||
ce plancher qui se dessine en petit point et donne la traine pointillee.
|
||||
"""
|
||||
if n_bars < 1:
|
||||
raise ValueError("n_bars doit valoir au moins 1")
|
||||
retenus = list(history)[:n_bars]
|
||||
retenus += [0.0] * (n_bars - len(retenus))
|
||||
return [max(floor, h) for h in retenus]
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""Capture audio et collage du texte transcrit.
|
||||
|
||||
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 time
|
||||
|
||||
import numpy as np
|
||||
import pyperclip
|
||||
import sounddevice as sd
|
||||
from pynput.keyboard import Controller, Key
|
||||
|
||||
from core import CONFIG, attendre
|
||||
|
||||
_kbd = Controller()
|
||||
|
||||
|
||||
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"]) # laisser le presse-papier se mettre à jour
|
||||
with _kbd.pressed(Key.cmd):
|
||||
_kbd.press("v")
|
||||
_kbd.release("v")
|
||||
print("[collage] ok")
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Capture le micro en mémoire (16 kHz mono float32). Start/stop par le contrôleur.
|
||||
|
||||
on_block : callback optionnel appelé à chaque bloc audio avec le tableau numpy du bloc
|
||||
(utilisé pour alimenter l'animation en temps réel).
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate, on_block=None, ouvrir_flux=None):
|
||||
self.sample_rate = sample_rate
|
||||
self.on_block = on_block
|
||||
self._frames = []
|
||||
self._stream = None
|
||||
# ouvrir_flux : injectable pour les tests (aucun peripherique requis).
|
||||
self._ouvrir_flux = ouvrir_flux or (lambda **kw: sd.InputStream(**kw))
|
||||
|
||||
def _callback(self, indata, frames, time_info, status):
|
||||
block = indata.copy()
|
||||
self._frames.append(block)
|
||||
if self.on_block is not None:
|
||||
self.on_block(block.flatten())
|
||||
|
||||
def _liberer(self):
|
||||
"""Libère le flux courant. Ne lève jamais, ne bloque pas durablement.
|
||||
|
||||
On DÉTACHE la référence avant d'agir : même si la libération échoue, le
|
||||
prochain démarrage ne doit pas retomber sur un flux mort. Et on utilise
|
||||
`abort()` plutôt que `stop()` — ce dernier draine les tampons et peut se
|
||||
bloquer sur un périphérique accaparé, ce qui laissait le micro tenu pour
|
||||
toujours (2026-08-22 : trois dictées muettes d'affilée).
|
||||
"""
|
||||
flux, self._stream = self._stream, None
|
||||
if flux is None:
|
||||
return
|
||||
for action in ("abort", "close"):
|
||||
try:
|
||||
getattr(flux, action)()
|
||||
except Exception as exc:
|
||||
print(f"[audio] {action}() du flux précédent a échoué : {exc}")
|
||||
|
||||
def start(self):
|
||||
# un flux orphelin d'un essai precedent tiendrait le micro : on libere d'abord
|
||||
self._liberer()
|
||||
self._frames = []
|
||||
self._stream = self._ouvrir_flux(
|
||||
samplerate=self.sample_rate,
|
||||
channels=1,
|
||||
dtype="float32",
|
||||
callback=self._callback,
|
||||
)
|
||||
self._stream.start()
|
||||
|
||||
def stop(self):
|
||||
"""Arrête le flux et renvoie l'audio concaténé (numpy 1-D float32)."""
|
||||
frames, self._frames = self._frames, []
|
||||
self._liberer()
|
||||
if not frames:
|
||||
return np.zeros(0, dtype="float32")
|
||||
return np.concatenate(frames, axis=0).flatten()
|
||||
+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."""
|
||||
+79
-29
@@ -11,31 +11,53 @@ contexte de l'app). La transcription, elle, part sur un thread de fond.
|
||||
import threading
|
||||
|
||||
from audio_level import compute_rms, shape_level
|
||||
from core import is_long_enough
|
||||
from engine import Recorder, Transcriber, inject_text, play_sound
|
||||
from capture import Recorder, inject_text
|
||||
from core import entree_audio_muette, is_long_enough
|
||||
|
||||
|
||||
class ZonzaController:
|
||||
"""Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu)."""
|
||||
|
||||
def __init__(self, config, pulse_window, on_state=None, run_on_main=None):
|
||||
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.on_state = on_state or (lambda s: None)
|
||||
# run_on_main : marshale un appel sur le thread principal Cocoa (requis pour l'UI).
|
||||
# Par défaut exécute directement (utile pour les tests sans boucle Cocoa).
|
||||
self.run_on_main = run_on_main or (lambda fn: fn())
|
||||
# lancer_tache : execute un travail HORS du thread principal. Tout ce qui
|
||||
# touche au peripherique audio passe par la — PortAudio peut bloquer
|
||||
# indefiniment si une autre application accapare le micro, et un blocage
|
||||
# sur le thread principal gele TOUTE l'interface Cocoa (halo compris).
|
||||
self.lancer_tache = lancer_tache or (
|
||||
lambda fn: threading.Thread(target=fn, daemon=True).start()
|
||||
)
|
||||
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._lock = threading.Lock()
|
||||
self._smoothed_level = 0.0
|
||||
self._safety_timer = None # arrêt auto si l'enregistrement traîne trop
|
||||
self._blocs_recus = 0 # compteur pour détecter un micro muet
|
||||
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):
|
||||
# thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release
|
||||
# → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée
|
||||
# rapide quand on parle, retombée douce dans les silences.
|
||||
self._blocs_recus += 1
|
||||
target = shape_level(compute_rms(block), self.config["level_gain"])
|
||||
if target > self._smoothed_level:
|
||||
coef = self.config["level_attack"]
|
||||
@@ -52,19 +74,44 @@ class ZonzaController:
|
||||
self._stop_and_process()
|
||||
|
||||
def _start(self):
|
||||
try:
|
||||
self._smoothed_level = 0.0
|
||||
self.recorder.start()
|
||||
self._blocs_recus = 0
|
||||
self.recording = True
|
||||
self.pulse.show()
|
||||
play_sound("start")
|
||||
self.pulse.set_status("Je vous écoute…")
|
||||
self._jouer_son("start")
|
||||
self.on_state("recording")
|
||||
self._arm_safety_timer()
|
||||
self._armer_veille_silence()
|
||||
self.lancer_tache(self._ouvrir_flux)
|
||||
|
||||
def _armer_veille_silence(self):
|
||||
"""Prévient si aucun son n'arrive : le micro est pris par une autre app."""
|
||||
if self._veille_silence is not None:
|
||||
self._veille_silence.cancel()
|
||||
self._veille_silence = threading.Timer(
|
||||
self.config["delai_detection_silence_s"], self._verifier_entree_audio
|
||||
)
|
||||
self._veille_silence.daemon = True
|
||||
self._veille_silence.start()
|
||||
|
||||
def _verifier_entree_audio(self):
|
||||
if not entree_audio_muette(self._blocs_recus, self.recording):
|
||||
return
|
||||
print("[micro] aucun son reçu — le micro est probablement pris par une autre application")
|
||||
self._jouer_son("error")
|
||||
self.run_on_main(lambda: self.pulse.set_status("Aucun son — micro pris ailleurs ?"))
|
||||
|
||||
def _ouvrir_flux(self):
|
||||
"""Ouvre le micro. Hors thread principal : peut bloquer (cf __init__)."""
|
||||
try:
|
||||
self.recorder.start()
|
||||
except Exception as e:
|
||||
print(f"[erreur] démarrage enregistrement : {e}")
|
||||
play_sound("error")
|
||||
self._jouer_son("error")
|
||||
self.recording = False
|
||||
self.pulse.hide()
|
||||
self._cancel_safety_timer()
|
||||
self.run_on_main(self.pulse.hide)
|
||||
self.on_state("idle")
|
||||
|
||||
def _arm_safety_timer(self):
|
||||
@@ -81,6 +128,9 @@ class ZonzaController:
|
||||
if self._safety_timer is not None:
|
||||
self._safety_timer.cancel()
|
||||
self._safety_timer = None
|
||||
if self._veille_silence is not None:
|
||||
self._veille_silence.cancel()
|
||||
self._veille_silence = None
|
||||
|
||||
def _safety_stop(self):
|
||||
# déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc
|
||||
@@ -89,37 +139,37 @@ class ZonzaController:
|
||||
self.run_on_main(self.toggle)
|
||||
|
||||
def _stop_and_process(self):
|
||||
"""Appelé depuis le thread principal : ne touche PAS au périphérique audio."""
|
||||
self.recording = False
|
||||
self._cancel_safety_timer()
|
||||
self.pulse.set_status("Transcription…")
|
||||
self.pulse.rearmer_garde(self.config["duree_max_transcription_s"])
|
||||
self.on_state("transcribing")
|
||||
self.lancer_tache(self._fermer_et_transcrire)
|
||||
|
||||
def _fermer_et_transcrire(self):
|
||||
"""Ferme le flux, transcrit, colle. Hors thread principal : `recorder.stop()`
|
||||
peut bloquer si le micro a été accaparé par une autre application — c'est
|
||||
précisément ce qui figeait le halo quand cet appel vivait sur le thread
|
||||
principal (2026-08-21)."""
|
||||
try:
|
||||
audio = self.recorder.stop()
|
||||
self.pulse.hide()
|
||||
play_sound("done")
|
||||
if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]):
|
||||
self.on_state("idle")
|
||||
self._jouer_son("done")
|
||||
if not is_long_enough(len(audio), self.config["sample_rate"],
|
||||
self.config["min_duration_s"]):
|
||||
return
|
||||
self.on_state("transcribing")
|
||||
threading.Thread(target=self._transcribe_and_inject, args=(audio,), daemon=True).start()
|
||||
except Exception as e:
|
||||
print(f"[erreur] arrêt enregistrement : {e}")
|
||||
play_sound("error")
|
||||
self.pulse.hide()
|
||||
self.on_state("idle")
|
||||
|
||||
def _transcribe_and_inject(self, audio):
|
||||
# thread de fond : transcription lente + collage
|
||||
try:
|
||||
text = self.transcriber.transcribe(audio)
|
||||
text = self.transcripteur.transcrire(audio)
|
||||
if not text:
|
||||
return
|
||||
print(f"[texte] {text}")
|
||||
try:
|
||||
inject_text(text)
|
||||
inject_text(text, self.systeme)
|
||||
except Exception as e:
|
||||
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:
|
||||
print(f"[erreur] transcription : {e}")
|
||||
play_sound("error")
|
||||
print(f"[erreur] arrêt ou transcription : {e}")
|
||||
self._jouer_son("error")
|
||||
finally:
|
||||
self.run_on_main(self.pulse.hide)
|
||||
self.on_state("idle")
|
||||
|
||||
@@ -6,25 +6,220 @@ Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
||||
import re
|
||||
|
||||
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",
|
||||
"hotkey": "<cmd>+<ctrl>+d", # Cmd+Ctrl+D (libre : ni macOS ni VS Code ne l'utilisent)
|
||||
"sounds": True,
|
||||
"min_duration_s": 0.3,
|
||||
"max_duration_s": 120.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée)
|
||||
"attente_modificateurs_s": 2.0, # on patiente avant de coller (cf inject_text)
|
||||
"delai_presse_papier_s": 0.12, # 0.05 s etait juste : le presse-papier n'etait pas pret
|
||||
"max_duration_s": 120.0,
|
||||
# Duree de vie MAXIMALE de l'overlay, garde-fou contre un halo fige (2026-08-21).
|
||||
# Doit couvrir un enregistrement maximal PUIS sa transcription.
|
||||
"duree_max_overlay_s": 210.0,
|
||||
# Garde COURTE, armee des l'arret : il ne reste alors que la transcription,
|
||||
# qui se compte en secondes. 210 s a ce stade, c'est « fige » pour l'utilisateur.
|
||||
"duree_max_transcription_s": 20.0,
|
||||
# Au bout de ce delai sans le moindre bloc audio, on previent : le micro est
|
||||
# pris par une autre application (Loom, Zoom, OBS...). Sinon on dicte dans le vide.
|
||||
"delai_detection_silence_s": 1.8, # sécurité : arrêt auto après 2 min (fenêtre jamais figée)
|
||||
"sample_rate": 16000,
|
||||
"fps": 30,
|
||||
"circle_base_radius": 20.0,
|
||||
"circle_amplitude": 30.0,
|
||||
"circle_color_rgb": (0.40, 0.50, 0.95),
|
||||
# --- overlay « barre de dictee » (bas de l'ecran) ---
|
||||
"bar_width": 330.0,
|
||||
"bar_height": 40.0,
|
||||
"bar_radius": 12.0,
|
||||
"bar_bg_rgba": (0.09, 0.09, 0.10, 0.95),
|
||||
"stop_button_rgb": (0.89, 0.31, 0.23), # rond rouge-orange
|
||||
"stop_glyph_rgb": (1.0, 1.0, 1.0), # carre arrondi blanc au centre
|
||||
"waveform_rgb": (0.95, 0.95, 0.97),
|
||||
"waveform_bars": 28, # nombre de barres de la forme d'onde
|
||||
"waveform_floor": 0.07, # plancher : dessine en petit point (traine pointillee)
|
||||
"mic_rgb": (0.78, 0.78, 0.82),
|
||||
"bubble_text_rgb": (1.0, 1.0, 1.0),
|
||||
"bubble_font_size": 12.0,
|
||||
"bubble_max_width": 240.0,
|
||||
"level_gain": 8.0, # amplification du niveau brut pour l'animation
|
||||
"level_attack": 0.6, # lissage à la montée (0–1, proche de 1 = très réactif)
|
||||
"level_release": 0.15, # lissage à la descente (plus petit = retombée douce)
|
||||
"wave_rings": 3, # nombre d'anneaux d'onde concentriques
|
||||
"wave_speed": 0.04, # vitesse de propagation des anneaux par frame
|
||||
"initial_prompt": None, # rempli plus bas depuis VOCABULAIRE
|
||||
}
|
||||
|
||||
|
||||
# --- vocabulaire metier -------------------------------------------------
|
||||
# Whisper accepte une « amorce » (initial_prompt) qui conditionne le decodage.
|
||||
# Sans elle, le modele rendait « Playwright » en « PlayWreck », « iCloud » en
|
||||
# « Auriclaw », « Supabase » en « Supabass », « Vercel » en « Versell »
|
||||
# (mesure du 2026-08-21 : 4 termes sur 5 faux ; 5 sur 5 corrects avec l'amorce).
|
||||
# Passer a large-v3-turbo a ete teste et fait PIRE (« commit sur Gitea » ->
|
||||
# « commis sur JTA ») : c'est bien l'amorce qui paie, pas la taille du modele.
|
||||
#
|
||||
# Pour ajouter tes propres termes : complete cette liste, puis ./build_app.sh.
|
||||
VOCABULAIRE = [
|
||||
# agents et terminal
|
||||
"Claude Code", "Codex", "OpenCode", "Warp", "Playwright", "MCP",
|
||||
# web et hebergement
|
||||
"Next.js", "TypeScript", "Vercel", "Convex", "Supabase", "Docker", "Caddy",
|
||||
"Gitea", "GitHub", "Hetzner", "Hostinger", "iCloud", "Clerk",
|
||||
# paiement, mail, automatisation
|
||||
"Stripe", "PayPal", "Shopify", "Brevo", "n8n", "Airtable", "Yabetoo",
|
||||
# IA et medias
|
||||
"Higgsfield",
|
||||
# projets
|
||||
"FacturPro", "Forescale", "Reflow", "CiteGain", "Faramaya", "Soko",
|
||||
"Moubote", "Yelessa", "Zonza", "MR TECH LAB",
|
||||
# jargon courant
|
||||
"repo", "commit", "webhook", "API", "npm", "build", "deploy", "conteneur",
|
||||
"migration", "endpoint", "prompt",
|
||||
]
|
||||
|
||||
# Whisper ne lit que ~224 tokens d'amorce et tronque le reste EN SILENCE.
|
||||
# On borne donc explicitement, pour qu'un ajout de termes ne soit jamais
|
||||
# silencieusement sans effet.
|
||||
PROMPT_MAX_CARACTERES = 800
|
||||
|
||||
|
||||
def build_initial_prompt(termes=None):
|
||||
"""Construit l'amorce Whisper a partir d'une liste de termes.
|
||||
|
||||
Doublons retires (ordre conserve), une seule ligne, longueur bornee a
|
||||
PROMPT_MAX_CARACTERES : au-dela, les derniers termes sont ecartes plutot
|
||||
que de laisser Whisper tronquer au milieu d'un mot.
|
||||
"""
|
||||
if termes is None:
|
||||
termes = VOCABULAIRE
|
||||
vus, uniques = set(), []
|
||||
for t in termes:
|
||||
if t not in vus:
|
||||
vus.add(t)
|
||||
uniques.append(t)
|
||||
prefixe = "Vocabulaire : "
|
||||
retenus = []
|
||||
for t in uniques:
|
||||
candidat = prefixe + ", ".join(retenus + [t]) + "."
|
||||
if len(candidat) > PROMPT_MAX_CARACTERES:
|
||||
break
|
||||
retenus.append(t)
|
||||
return prefixe + ", ".join(retenus) + "."
|
||||
|
||||
|
||||
# Proportions internes de la barre, exprimees en fraction de sa HAUTEUR : reduire
|
||||
# `bar_height` reduit alors le bouton, le micro et les marges dans le meme
|
||||
# mouvement, au lieu de laisser une grosse pastille dans une barre retrecie.
|
||||
_PROPORTIONS = {
|
||||
"stop_diametre": 0.538,
|
||||
"mic_largeur": 0.173,
|
||||
"marge_laterale": 0.423,
|
||||
"mic_corps_hauteur": 0.250,
|
||||
"mic_arceau_rayon": 0.135,
|
||||
"ecart_interne": 0.346,
|
||||
}
|
||||
|
||||
|
||||
def attendre(condition, delai_max_s, pas_s, dormir=None, horloge=None):
|
||||
"""Attend que `condition()` soit vraie, au plus `delai_max_s`. Rend True/False.
|
||||
|
||||
Bornee volontairement : mieux vaut coller avec un modificateur encore enfonce
|
||||
que perdre la transcription si une touche reste bloquee. `dormir` et `horloge`
|
||||
sont injectables pour tester sans attendre reellement.
|
||||
"""
|
||||
import time as _t
|
||||
dormir = dormir or _t.sleep
|
||||
horloge = horloge or _t.monotonic
|
||||
debut = horloge()
|
||||
while True:
|
||||
if condition():
|
||||
return True
|
||||
if horloge() - debut >= delai_max_s:
|
||||
return False
|
||||
dormir(pas_s)
|
||||
|
||||
|
||||
def entree_audio_muette(blocs_recus, en_cours):
|
||||
"""Faut-il alerter : le micro n'envoie-t-il rien alors qu'on enregistre ?
|
||||
|
||||
`en_cours` evite un faux positif quand l'utilisateur a simplement arrete avant
|
||||
la fin du delai — ce n'est pas une panne, juste une dictee courte.
|
||||
"""
|
||||
return bool(en_cours) and blocs_recus == 0
|
||||
|
||||
|
||||
def bar_metrics(bar_height):
|
||||
"""Mesures de l'overlay pour une hauteur de barre donnee, en points."""
|
||||
if bar_height <= 0:
|
||||
raise ValueError("bar_height doit etre > 0")
|
||||
return {cle: bar_height * f for cle, f in _PROPORTIONS.items()}
|
||||
|
||||
|
||||
# Marge minimale a laisser pour transcrire apres un enregistrement de duree maximale.
|
||||
MARGE_TRANSCRIPTION_S = 30.0
|
||||
|
||||
|
||||
def duree_vie_overlay_coherente(config):
|
||||
"""La limite de vie de l'overlay couvre-t-elle le pire cas reel ?
|
||||
|
||||
Pire cas : un enregistrement qui va jusqu'a max_duration_s, suivi de sa
|
||||
transcription. Une limite trop courte fermerait le halo PENDANT que Zonza
|
||||
travaille encore — on remplacerait un halo fige par un halo qui s'evapore.
|
||||
"""
|
||||
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):
|
||||
"""Valide la config ; lève ValueError si une valeur est aberrante."""
|
||||
if config["min_duration_s"] < 0:
|
||||
@@ -33,6 +228,16 @@ def validate_config(config):
|
||||
raise ValueError("sample_rate doit être > 0")
|
||||
if not config["hotkey"]:
|
||||
raise ValueError("hotkey ne peut pas être vide")
|
||||
d = config.get("duree_max_transcription_s")
|
||||
if d is not None and not (10.0 <= d <= 30.0):
|
||||
raise ValueError("duree_max_transcription_s doit tenir entre 10 et 30 s")
|
||||
if d is not None and d >= config["duree_max_overlay_s"]:
|
||||
raise ValueError("duree_max_transcription_s doit rester sous duree_max_overlay_s")
|
||||
if not duree_vie_overlay_coherente(config):
|
||||
raise ValueError(
|
||||
"duree_max_overlay_s doit couvrir max_duration_s + "
|
||||
f"{MARGE_TRANSCRIPTION_S} s de transcription"
|
||||
)
|
||||
|
||||
|
||||
def is_long_enough(num_samples, sample_rate, min_duration_s):
|
||||
@@ -44,3 +249,6 @@ def is_long_enough(num_samples, sample_rate, min_duration_s):
|
||||
def clean_transcript(text):
|
||||
"""Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés."""
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,179 @@
|
||||
# Zonza multiplateforme — conception
|
||||
|
||||
**Date :** 2026-08-23
|
||||
**But :** faire tourner Zonza sur Mac Apple Silicon, Mac Intel, Windows avec NVIDIA et Windows sans GPU, sans rien casser de la version macOS en service.
|
||||
|
||||
---
|
||||
|
||||
## 1. Point de départ
|
||||
|
||||
Audit du 2026-08-23 : **55 % du code est déjà portable**.
|
||||
|
||||
| Fichier | Lignes | État |
|
||||
|---|---|---|
|
||||
| `core.py` | 228 | portable |
|
||||
| `controller.py` | 164 | portable — **aucun import Cocoa** |
|
||||
| `audio_level.py` | 55 | portable |
|
||||
| `engine.py` | 160 | 1 import Quartz, 4 appels `afplay`, et MLX |
|
||||
| `app.py` | 156 | 100 % Cocoa |
|
||||
| `pulse_window.py` | 268 | 100 % Cocoa |
|
||||
|
||||
Trois obstacles réels :
|
||||
|
||||
1. **MLX cible le GPU Apple Silicon.** Aucun équivalent ailleurs.
|
||||
2. **L'interface est entièrement Cocoa** (424 lignes).
|
||||
3. **`fcntl.flock` n'existe pas sur Windows** — le verrou d'instance unique, qui empêche le gel constaté le 2026-08-21, disparaîtrait en silence.
|
||||
|
||||
## 2. Approche retenue
|
||||
|
||||
**Noyau portable + une couche par système, derrière des contrats explicites.**
|
||||
|
||||
Écartées : réécrire toute l'interface en Qt (perd les contournements Tahoe durement acquis, risque de régression sur la machine en service) ; maintenir deux applications séparées (double surface de bugs, alors que deux jours viennent d'être passés à en corriger une seule).
|
||||
|
||||
L'approche retenue ne sacrifie rien : l'implémentation Cocoa est **déplacée sans être modifiée**, et le contrat qu'elle respecte existe déjà — c'est celui que `FauxPulse` implémente dans les tests.
|
||||
|
||||
## 3. Structure
|
||||
|
||||
```
|
||||
zonza/
|
||||
├── core.py inchangé — pur
|
||||
├── audio_level.py inchangé — pur
|
||||
├── controller.py inchangé — ne parle qu'aux contrats
|
||||
├── moteur/ choisir_transcripteur() · mlx.py · faster.py
|
||||
├── interface/ choisir_interface() · cocoa/ · qt/
|
||||
├── systeme/ choisir_systeme() · macos.py · windows.py
|
||||
└── lanceur.py point d'entrée commun
|
||||
```
|
||||
|
||||
### Contrats
|
||||
|
||||
| Contrat | Méthodes |
|
||||
|---|---|
|
||||
| `Transcripteur` | `warmup()` · `transcrire(audio) -> str` |
|
||||
| `Overlay` | `show()` · `hide()` · `set_level()` · `set_status()` · `rearmer_garde()` |
|
||||
| `Systeme` | `jouer_son()` · `modificateurs_enfonces()` · `raccourci_defaut()` · `chemin_log()` · `acquerir_verrou_instance()` |
|
||||
|
||||
`acquerir_verrou_instance` rejoint la couche système : `fcntl.flock` sur macOS, `msvcrt.locking` sur Windows.
|
||||
|
||||
## 4. Moteur de transcription
|
||||
|
||||
Détection au premier lancement, puis **mémorisée dans `reglages.json`**, à côté du journal (`~/Library/Application Support/Zonza/` ou `%LOCALAPPDATA%\Zonza\`). Supprimer ce fichier relance la détection.
|
||||
|
||||
```
|
||||
Apple Silicon ? → MLX
|
||||
sinon, CUDA disponible ? → faster-whisper GPU
|
||||
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.
|
||||
|
||||
**Mesuré le 2026-08-23** — trois échantillons, amorce de vocabulaire active, sur le CPU d'un Apple Silicon :
|
||||
|
||||
| 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`.
|
||||
|
||||
`faster-whisper` accepte `initial_prompt` avec la même sémantique : **l'amorce de 47 termes survit sur les quatre configurations**. Sur `small`, elle compense partiellement une moindre précision sur les noms propres — à mesurer, pas à espérer.
|
||||
|
||||
Réglages manuels : `CONFIG["moteur"]` et `CONFIG["modele"]` forcent le choix.
|
||||
|
||||
## 5. Interface
|
||||
|
||||
L'overlay Qt reprend le dessin à l'identique — `QPainter` pour `NSBezierPath`, `QTimer` pour `NSTimer`, fenêtre *frameless*, toujours au-dessus, translucide, `WindowTransparentForInput`. Icône de barre par `QSystemTrayIcon`.
|
||||
|
||||
**La géométrie reste partagée** : `core.bar_metrics()` ne connaît aucun système, les deux interfaces la lisent. Un changement de taille s'applique aux deux.
|
||||
|
||||
## 6. Couche système
|
||||
|
||||
| | macOS | Windows |
|
||||
|---|---|---|
|
||||
| Son | `afplay` | `winsound.PlaySound` |
|
||||
| Modificateurs | Quartz `CGEventSourceFlagsState` | `GetAsyncKeyState` (ctypes) |
|
||||
| Journal | `~/Library/Logs/Zonza.log` | `%LOCALAPPDATA%\Zonza\Zonza.log` |
|
||||
| Raccourci | `Cmd+Ctrl+D` | `Ctrl+Alt+D` |
|
||||
| Verrou | `fcntl.flock` | `msvcrt.locking` |
|
||||
|
||||
`pynput`, `pyperclip`, `sounddevice` et `numpy` fonctionnent tels quels sur Windows : raccourci global, presse-papier et capture micro ne demandent aucun travail.
|
||||
|
||||
## 7. Installation
|
||||
|
||||
`install.sh` et `install.ps1` créent l'environnement et n'installent **que ce qui sert à la machine** (`mlx-whisper` seulement sur Apple Silicon, `PySide6` seulement où Qt est utilisé), puis posent le lancement au démarrage. `build_app.sh` reste inchangé pour le bundle Mac.
|
||||
|
||||
Un installeur double-clic est **écarté** : produire un `.exe` exige une machine Windows, absente ; et py2app/PyInstaller ont déjà été retirés de ce projet en juin parce qu'empaqueter les shaders Metal de MLX échouait.
|
||||
|
||||
## 8. Vérification
|
||||
|
||||
**Contrainte assumée : aucune machine Windows n'est disponible pour vérifier.**
|
||||
|
||||
Deux réponses.
|
||||
|
||||
**Tests par contrat.** Chaque contrat a UNE suite, rejouée contre TOUTES ses implémentations. Si l'overlay Qt oublie `rearmer_garde`, la suite rougit sans Windows. La détection matérielle étant pure, les quatre configurations sont couvertes depuis un seul Mac.
|
||||
|
||||
**Commande de diagnostic**, exécutée par Ralph sur la machine cible :
|
||||
|
||||
```
|
||||
zonza --diagnostic
|
||||
```
|
||||
|
||||
Sortie attendue :
|
||||
|
||||
```
|
||||
Plateforme : Windows 11 / x86_64 / CUDA absent
|
||||
Moteur retenu : faster-whisper CPU, modèle small int8
|
||||
Micro : ✓ 1064 blocs reçus en 1 s
|
||||
Transcription : ✓ 2,8 s — « Pousse le commit sur Gitea »
|
||||
Presse-papier : ✓
|
||||
Raccourci : ✓ Ctrl+Alt+D capté
|
||||
Overlay : ✓ affiché puis refermé
|
||||
Verrou : ✓ seconde instance refusée
|
||||
```
|
||||
|
||||
**Engagement :** il ne sera jamais affirmé que Zonza fonctionne sous Windows. Seulement que les tests passent, que les contrats sont respectés et que le diagnostic est prêt. La preuve vient de la machine cible.
|
||||
|
||||
## 9. Acquis à ne pas perdre
|
||||
|
||||
Les correctifs des 21 et 22 août doivent survivre au port, et être **testés dans chaque implémentation** :
|
||||
|
||||
- aucune opération audio sur le thread d'interface (le gel venait de là) ;
|
||||
- libération du flux audio avant toute réouverture (Zonza s'affamait elle-même) ;
|
||||
- attente du relâchement des modificateurs avant le collage ;
|
||||
- durée de vie maximale de l'overlay (210 s, 20 s en transcription) ;
|
||||
- alerte micro muet à 1,8 s ;
|
||||
- verrou d'instance unique.
|
||||
|
||||
## 10. Découpage de l'implémentation
|
||||
|
||||
Le périmètre dépasse un seul plan. Quatre étapes, chacune livrable et vérifiable seule :
|
||||
|
||||
**É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.** ✅ **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 4 — Couche Windows, installation, diagnostic.** La seule étape dont la vérification finale dépend d'une machine Windows.
|
||||
|
||||
Les étapes 1 à 3 sont entièrement vérifiables sans Windows.
|
||||
|
||||
## 11. Hors périmètre
|
||||
|
||||
Linux ; transcription en continu pendant la parole ; installeur double-clic ; synchronisation du vocabulaire entre machines.
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Moteur de dictée — capture, transcription, collage, son.
|
||||
|
||||
Aucune dépendance UI : importable par le contrôleur et l'app.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import mlx_whisper
|
||||
import numpy as np
|
||||
import pyperclip
|
||||
import sounddevice as sd
|
||||
from pynput.keyboard import Controller, Key
|
||||
|
||||
from core import CONFIG, clean_transcript
|
||||
|
||||
SOUNDS = {
|
||||
"start": "/System/Library/Sounds/Pop.aiff",
|
||||
"done": "/System/Library/Sounds/Glass.aiff",
|
||||
"error": "/System/Library/Sounds/Basso.aiff",
|
||||
}
|
||||
|
||||
_kbd = Controller()
|
||||
|
||||
|
||||
def play_sound(name):
|
||||
"""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
|
||||
|
||||
|
||||
def inject_text(text):
|
||||
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active."""
|
||||
if not text:
|
||||
return
|
||||
pyperclip.copy(text)
|
||||
time.sleep(0.05) # laisser le presse-papier se mettre à jour
|
||||
with _kbd.pressed(Key.cmd):
|
||||
_kbd.press("v")
|
||||
_kbd.release("v")
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Capture le micro en mémoire (16 kHz mono float32). Start/stop par le contrôleur.
|
||||
|
||||
on_block : callback optionnel appelé à chaque bloc audio avec le tableau numpy du bloc
|
||||
(utilisé pour alimenter l'animation en temps réel).
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate, on_block=None):
|
||||
self.sample_rate = sample_rate
|
||||
self.on_block = on_block
|
||||
self._frames = []
|
||||
self._stream = None
|
||||
|
||||
def _callback(self, indata, frames, time_info, status):
|
||||
block = indata.copy()
|
||||
self._frames.append(block)
|
||||
if self.on_block is not None:
|
||||
self.on_block(block.flatten())
|
||||
|
||||
def start(self):
|
||||
self._frames = []
|
||||
self._stream = sd.InputStream(
|
||||
samplerate=self.sample_rate,
|
||||
channels=1,
|
||||
dtype="float32",
|
||||
callback=self._callback,
|
||||
)
|
||||
self._stream.start()
|
||||
|
||||
def stop(self):
|
||||
"""Arrête le flux et renvoie l'audio concaténé (numpy 1-D float32)."""
|
||||
if self._stream is not None:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
if not self._frames:
|
||||
return np.zeros(0, dtype="float32")
|
||||
return np.concatenate(self._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,
|
||||
)
|
||||
return clean_transcript(result.get("text", ""))
|
||||
+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}")
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Overlay de dictée : barre sombre arrondie posée en bas de l'écran.
|
||||
|
||||
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.
|
||||
|
||||
Cocoa natif (pyobjc). Tout le dessin a lieu sur le thread principal via un
|
||||
NSTimer ; 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.
|
||||
"""
|
||||
import threading
|
||||
|
||||
from objc import super
|
||||
|
||||
from AppKit import (
|
||||
NSAttributedString,
|
||||
NSBackingStoreBuffered,
|
||||
NSBezierPath,
|
||||
NSColor,
|
||||
NSFont,
|
||||
NSFontAttributeName,
|
||||
NSForegroundColorAttributeName,
|
||||
NSScreen,
|
||||
NSTimer,
|
||||
NSView,
|
||||
NSWindow,
|
||||
NSWindowStyleMaskBorderless,
|
||||
)
|
||||
from Foundation import NSMakeRect, NSMakeSize, NSObject
|
||||
|
||||
from audio_level import bar_heights, push_level
|
||||
from core import CONFIG, bar_metrics
|
||||
|
||||
_BOTTOM_MARGIN = 80.0 # au-dessus du Dock
|
||||
_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 # evite que l'arrondi bas de la barre touche le bord
|
||||
|
||||
|
||||
def _rounded(x, y, w, h, radius):
|
||||
return NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
|
||||
NSMakeRect(x, y, w, h), radius, radius
|
||||
)
|
||||
|
||||
|
||||
def _rgba(triplet, alpha=1.0):
|
||||
r, g, b = triplet[:3]
|
||||
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
|
||||
|
||||
|
||||
class PulseView(NSView):
|
||||
"""Dessine la barre, la forme d'onde et la bulle d'état."""
|
||||
|
||||
def initWithFrame_(self, frame):
|
||||
self = super().initWithFrame_(frame)
|
||||
if self is None:
|
||||
return None
|
||||
self._bars = []
|
||||
self._status = ""
|
||||
return self
|
||||
|
||||
def setBars_(self, bars):
|
||||
self._bars = bars
|
||||
|
||||
def setStatus_(self, text):
|
||||
self._status = text or ""
|
||||
|
||||
# --- morceaux de dessin ------------------------------------------------
|
||||
def _draw_bar_background(self, x, y, w, h):
|
||||
_rgba(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]).set()
|
||||
_rounded(x, y, w, h, CONFIG["bar_radius"]).fill()
|
||||
|
||||
def _draw_stop_button(self, cx, cy, diametre):
|
||||
_rgba(CONFIG["stop_button_rgb"]).set()
|
||||
NSBezierPath.bezierPathWithOvalInRect_(
|
||||
NSMakeRect(cx - diametre / 2, cy - diametre / 2, diametre, diametre)
|
||||
).fill()
|
||||
# carré arrondi blanc au centre = « arrêter »
|
||||
side = diametre * 0.34
|
||||
_rgba(CONFIG["stop_glyph_rgb"]).set()
|
||||
_rounded(cx - side / 2, cy - side / 2, side, side, 2.0).fill()
|
||||
|
||||
def _draw_waveform(self, 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
|
||||
_rgba(CONFIG["waveform_rgb"]).set()
|
||||
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
|
||||
_rounded(cxi - bw / 2, cy - half, bw, half * 2, bw / 2).fill()
|
||||
|
||||
def _draw_mic(self, cx, cy, m):
|
||||
col = _rgba(CONFIG["mic_rgb"])
|
||||
col.set()
|
||||
largeur = m["mic_largeur"]
|
||||
body_h = m["mic_corps_hauteur"]
|
||||
body_y = cy - body_h / 2 + body_h * 0.23
|
||||
# capsule (le micro lui-meme)
|
||||
_rounded(cx - largeur / 2, body_y, largeur, body_h, largeur / 2).fill()
|
||||
# arceau en U qui passe SOUS la capsule
|
||||
arc = NSBezierPath.bezierPath()
|
||||
arc.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
|
||||
(cx, body_y + body_h * 0.23), m["mic_arceau_rayon"], 195.0, 345.0
|
||||
)
|
||||
arc.setLineWidth_(max(1.0, body_h * 0.12))
|
||||
arc.setLineCapStyle_(1) # bouts arrondis
|
||||
arc.stroke()
|
||||
# pied vertical sous l'arceau
|
||||
pied = max(1.2, largeur * 0.18)
|
||||
_rounded(cx - pied / 2, body_y - body_h * 0.62, pied, body_h * 0.31, pied / 2).fill()
|
||||
|
||||
def _draw_bubble(self, bar_x, bar_top, bar_w):
|
||||
if not self._status:
|
||||
return
|
||||
attrs = {
|
||||
NSFontAttributeName: NSFont.systemFontOfSize_(CONFIG["bubble_font_size"]),
|
||||
NSForegroundColorAttributeName: _rgba(CONFIG["bubble_text_rgb"]),
|
||||
}
|
||||
text = NSAttributedString.alloc().initWithString_attributes_(self._status, attrs)
|
||||
avail = NSMakeSize(CONFIG["bubble_max_width"], 400.0)
|
||||
bounds = text.boundingRectWithSize_options_(avail, 1 << 0) # WordWrap
|
||||
tw, th = bounds.size.width, bounds.size.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
|
||||
_rgba(CONFIG["bar_bg_rgba"], CONFIG["bar_bg_rgba"][3]).set()
|
||||
_rounded(x, y, w, h, 10.0).fill()
|
||||
# petite pointe vers la barre, en bas à gauche
|
||||
tail = NSBezierPath.bezierPath()
|
||||
tail.moveToPoint_((x + 20.0 - _TAIL, y + 1.0))
|
||||
tail.lineToPoint_((x + 20.0 + _TAIL, y + 1.0))
|
||||
tail.lineToPoint_((x + 20.0, y - 7.0))
|
||||
tail.closePath()
|
||||
tail.fill()
|
||||
text.drawInRect_(NSMakeRect(x + _BUBBLE_PAD, y + _BUBBLE_PAD, tw, th))
|
||||
|
||||
def drawRect_(self, rect):
|
||||
NSColor.clearColor().set()
|
||||
NSBezierPath.fillRect_(rect)
|
||||
|
||||
w = CONFIG["bar_width"]
|
||||
h = CONFIG["bar_height"]
|
||||
x = (rect.size.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"])
|
||||
self._draw_bar_background(x, y, w, h)
|
||||
self._draw_stop_button(x + pad + stop_d / 2, cy, stop_d)
|
||||
self._draw_mic(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(wave_x, cy, wave_w)
|
||||
self._draw_bubble(x, y + h, w)
|
||||
|
||||
|
||||
class PulseWindow(NSObject):
|
||||
"""Fenêtre flottante : show(), hide(), set_level(), set_status(). Thread-safe."""
|
||||
|
||||
def init(self):
|
||||
self = super().init()
|
||||
if self is None:
|
||||
return None
|
||||
self._history = []
|
||||
self._status = ""
|
||||
self._lock = threading.Lock()
|
||||
self._window = None
|
||||
self._view = None
|
||||
self._timer = None
|
||||
self._garde = None # ferme l'overlay quoi qu'il arrive (cf show)
|
||||
return self
|
||||
|
||||
def _window_size(self):
|
||||
return (CONFIG["bar_width"] + 80.0,
|
||||
CONFIG["bar_height"] + _BOTTOM_INSET + 140.0)
|
||||
|
||||
def _ensure_window(self):
|
||||
if self._window is not None:
|
||||
return
|
||||
ww, wh = self._window_size()
|
||||
screen = NSScreen.mainScreen().frame()
|
||||
frame = NSMakeRect((screen.size.width - ww) / 2.0, _BOTTOM_MARGIN, ww, wh)
|
||||
win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||
frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False
|
||||
)
|
||||
win.setOpaque_(False)
|
||||
win.setBackgroundColor_(NSColor.clearColor())
|
||||
win.setLevel_(25) # au-dessus des fenêtres normales
|
||||
win.setIgnoresMouseEvents_(True)
|
||||
win.setHasShadow_(False)
|
||||
view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, ww, wh))
|
||||
win.setContentView_(view)
|
||||
self._window = win
|
||||
self._view = view
|
||||
|
||||
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, timer):
|
||||
with self._lock:
|
||||
history, status = list(self._history), self._status
|
||||
if self._view is not None:
|
||||
self._view.setBars_(
|
||||
bar_heights(history, CONFIG["waveform_bars"], CONFIG["waveform_floor"])
|
||||
)
|
||||
self._view.setStatus_(status)
|
||||
self._view.setNeedsDisplay_(True)
|
||||
|
||||
def show(self):
|
||||
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
|
||||
self._ensure_window()
|
||||
with self._lock:
|
||||
self._history = []
|
||||
self._window.orderFrontRegardless()
|
||||
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
1.0 / float(CONFIG["fps"]), self, "_tick:", None, True
|
||||
)
|
||||
# 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 (2026-08-21).
|
||||
self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
float(CONFIG["duree_max_overlay_s"]), self, "_expiration:", None, False
|
||||
)
|
||||
|
||||
def rearmer_garde(self, duree_s):
|
||||
"""Remplace la garde en cours par une plus courte. Thread principal.
|
||||
|
||||
Appelee 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.invalidate()
|
||||
self._garde = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
float(duree_s), self, "_expiration:", None, False
|
||||
)
|
||||
|
||||
def _expiration_(self, timer):
|
||||
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.invalidate()
|
||||
self._timer = None
|
||||
if self._garde is not None:
|
||||
self._garde.invalidate()
|
||||
self._garde = None
|
||||
if self._window is not None:
|
||||
self._window.orderOut_(None)
|
||||
with self._lock:
|
||||
self._status = ""
|
||||
+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 "")
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
"""Fenêtre flottante translucide affichant un cercle qui pulse avec le niveau audio.
|
||||
|
||||
Cocoa natif (pyobjc). Tout le dessin se fait sur le thread principal via un NSTimer.
|
||||
Le niveau audio est poussé depuis un thread de fond par set_level() (écriture atomique
|
||||
d'un float — sûr en CPython).
|
||||
"""
|
||||
import threading
|
||||
|
||||
from objc import super
|
||||
|
||||
from AppKit import (
|
||||
NSBackingStoreBuffered,
|
||||
NSBezierPath,
|
||||
NSColor,
|
||||
NSScreen,
|
||||
NSTimer,
|
||||
NSView,
|
||||
NSWindow,
|
||||
NSWindowStyleMaskBorderless,
|
||||
)
|
||||
from Foundation import NSMakeRect, NSObject
|
||||
|
||||
from core import CONFIG
|
||||
|
||||
_WINDOW_SIZE = 200.0 # côté de la fenêtre carrée, en points (contient cercle + anneaux)
|
||||
_BOTTOM_MARGIN = 80.0 # marge au-dessus du bas de l'écran (passe au-dessus du Dock)
|
||||
|
||||
|
||||
def _oval(cx, cy, radius):
|
||||
return NSBezierPath.bezierPathWithOvalInRect_(
|
||||
NSMakeRect(cx - radius, cy - radius, 2 * radius, 2 * radius)
|
||||
)
|
||||
|
||||
|
||||
class PulseView(NSView):
|
||||
"""Vue centrée : cercle plein qui pulse + anneaux d'onde qui se propagent vers
|
||||
l'extérieur. Le rayon du cercle suit le niveau audio ; les anneaux avancent d'une
|
||||
phase à chaque frame et leur intensité dépend du niveau (plus on parle fort, plus
|
||||
les ondes sont visibles)."""
|
||||
|
||||
def initWithFrame_(self, frame):
|
||||
self = super().initWithFrame_(frame)
|
||||
if self is None:
|
||||
return None
|
||||
self._level = 0.0
|
||||
self._phase = 0.0
|
||||
return self
|
||||
|
||||
def setLevel_(self, level):
|
||||
self._level = level
|
||||
|
||||
def drawRect_(self, rect):
|
||||
# fond transparent
|
||||
NSColor.clearColor().set()
|
||||
NSBezierPath.fillRect_(rect)
|
||||
|
||||
level = self._level
|
||||
base = CONFIG["circle_base_radius"]
|
||||
amp = CONFIG["circle_amplitude"]
|
||||
radius = base + level * amp
|
||||
cx = rect.size.width / 2.0
|
||||
cy = rect.size.height / 2.0
|
||||
r, g, b = CONFIG["circle_color_rgb"]
|
||||
|
||||
# anneaux d'onde : chacun part du cercle et grandit vers l'extérieur selon la
|
||||
# phase ; l'opacité décroît avec la distance → effet d'ondes qui se propagent.
|
||||
# la portée est bornée par l'espace dispo dans la fenêtre (rayon max + ondes ≤ moitié
|
||||
# du côté) pour que les anneaux ne soient jamais coupés, quelle que soit la taille.
|
||||
n_rings = CONFIG["wave_rings"]
|
||||
half = min(rect.size.width, rect.size.height) / 2.0
|
||||
max_radius = base + amp # rayon du cercle au volume maximal
|
||||
max_reach = max(0.0, half - max_radius - 4.0) # marge de 4px contre le bord
|
||||
for i in range(n_rings):
|
||||
# fraction de progression de cet anneau (décalé pour étaler les ondes)
|
||||
frac = (self._phase + i / float(n_rings)) % 1.0
|
||||
ring_radius = radius + frac * max_reach
|
||||
# opacité : forte près du cercle, nulle au bout, modulée par le volume
|
||||
alpha = (1.0 - frac) * 0.5 * level
|
||||
if alpha <= 0.01:
|
||||
continue
|
||||
ring = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
|
||||
ring.set()
|
||||
path = _oval(cx, cy, ring_radius)
|
||||
path.setLineWidth_(3.0)
|
||||
path.stroke()
|
||||
|
||||
# halo doux autour du cercle principal
|
||||
halo = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.20)
|
||||
halo.set()
|
||||
_oval(cx, cy, radius + 14).fill()
|
||||
|
||||
# cercle principal plein
|
||||
main = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.9)
|
||||
main.set()
|
||||
_oval(cx, cy, radius).fill()
|
||||
|
||||
# fait avancer la phase des ondes pour la frame suivante
|
||||
self._phase = (self._phase + CONFIG["wave_speed"]) % 1.0
|
||||
|
||||
|
||||
class PulseWindow(NSObject):
|
||||
"""Gère la fenêtre flottante : show(), hide(), set_level(). Thread-safe pour le niveau."""
|
||||
|
||||
def init(self):
|
||||
self = super().init()
|
||||
if self is None:
|
||||
return None
|
||||
self._level = 0.0
|
||||
self._lock = threading.Lock()
|
||||
self._window = None
|
||||
self._view = None
|
||||
self._timer = None
|
||||
return self
|
||||
|
||||
def _ensure_window(self):
|
||||
if self._window is not None:
|
||||
return
|
||||
screen = NSScreen.mainScreen().frame()
|
||||
# bas-centre : centré horizontalement, posé en bas avec une marge (Cocoa : y=0 en bas)
|
||||
x = (screen.size.width - _WINDOW_SIZE) / 2.0
|
||||
y = _BOTTOM_MARGIN
|
||||
frame = NSMakeRect(x, y, _WINDOW_SIZE, _WINDOW_SIZE)
|
||||
win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||
frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False
|
||||
)
|
||||
win.setOpaque_(False)
|
||||
win.setBackgroundColor_(NSColor.clearColor())
|
||||
win.setLevel_(25) # au-dessus des fenêtres normales (NSStatusWindowLevel ≈ 25)
|
||||
win.setIgnoresMouseEvents_(True)
|
||||
win.setHasShadow_(False)
|
||||
view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, _WINDOW_SIZE, _WINDOW_SIZE))
|
||||
win.setContentView_(view)
|
||||
self._window = win
|
||||
self._view = view
|
||||
|
||||
def set_level(self, level):
|
||||
"""Pousse le dernier niveau audio (appelable depuis un thread de fond)."""
|
||||
with self._lock:
|
||||
self._level = level
|
||||
|
||||
def _tick_(self, timer):
|
||||
with self._lock:
|
||||
level = self._level
|
||||
if self._view is not None:
|
||||
self._view.setLevel_(level)
|
||||
self._view.setNeedsDisplay_(True)
|
||||
|
||||
def show(self):
|
||||
"""Affiche la fenêtre et démarre l'animation. À appeler sur le thread principal."""
|
||||
self._ensure_window()
|
||||
with self._lock:
|
||||
self._level = 0.0
|
||||
self._window.orderFrontRegardless()
|
||||
interval = 1.0 / float(CONFIG["fps"])
|
||||
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||
interval, self, "_tick:", None, True
|
||||
)
|
||||
|
||||
def hide(self):
|
||||
"""Arrête l'animation et masque la fenêtre. À appeler sur le thread principal."""
|
||||
if self._timer is not None:
|
||||
self._timer.invalidate()
|
||||
self._timer = None
|
||||
if self._window is not None:
|
||||
self._window.orderOut_(None)
|
||||
+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
|
||||
numpy>=2.4
|
||||
pynput>=1.8.2
|
||||
pyperclip>=1.11
|
||||
pillow>=11
|
||||
|
||||
# --- developpement -------------------------------------------------------
|
||||
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,55 @@
|
||||
"""Attente d'une condition avant le collage.
|
||||
|
||||
Bug du 2026-08-21 : deux dictees transcrites correctement n'ont jamais atteint la
|
||||
zone de saisie. Aucune exception — les touches AVAIENT ete envoyees. Cause : la
|
||||
hotkey Cmd+Ctrl+D etait encore enfoncee quand Zonza a simule Cmd+V ; le systeme a
|
||||
recu Cmd+Ctrl+V, que l'application cible ignore.
|
||||
|
||||
D'ou une attente du relachement des modificateurs avant de coller. Bornee : si une
|
||||
touche reste enfoncee (clavier bloque), on colle quand meme plutot que de perdre le
|
||||
texte.
|
||||
"""
|
||||
from core import attendre
|
||||
|
||||
|
||||
def test_condition_deja_vraie_ne_dort_pas():
|
||||
sommeils = []
|
||||
assert attendre(lambda: True, 1.0, 0.05, dormir=sommeils.append, horloge=lambda: 0.0) is True
|
||||
assert sommeils == []
|
||||
|
||||
|
||||
def test_condition_devient_vraie_apres_quelques_tours():
|
||||
tours = {"n": 0}
|
||||
def cond():
|
||||
tours["n"] += 1
|
||||
return tours["n"] >= 3
|
||||
t = {"v": 0.0}
|
||||
def horloge(): return t["v"]
|
||||
def dormir(s): t["v"] += s
|
||||
assert attendre(cond, 1.0, 0.05, dormir=dormir, horloge=horloge) is True
|
||||
assert tours["n"] == 3
|
||||
|
||||
|
||||
def test_rend_False_au_bout_du_temps_imparti():
|
||||
t = {"v": 0.0}
|
||||
def horloge(): return t["v"]
|
||||
def dormir(s): t["v"] += s
|
||||
assert attendre(lambda: False, 0.2, 0.05, dormir=dormir, horloge=horloge) is False
|
||||
|
||||
|
||||
def test_le_temps_imparti_est_respecte():
|
||||
t = {"v": 0.0}
|
||||
dormis = []
|
||||
def horloge(): return t["v"]
|
||||
def dormir(s): t["v"] += s; dormis.append(s)
|
||||
attendre(lambda: False, 0.2, 0.05, dormir=dormir, horloge=horloge)
|
||||
assert sum(dormis) <= 0.25, "l'attente ne doit pas deborder"
|
||||
|
||||
|
||||
def test_un_delai_nul_tente_quand_meme_une_fois():
|
||||
essais = {"n": 0}
|
||||
def cond():
|
||||
essais["n"] += 1
|
||||
return False
|
||||
attendre(cond, 0.0, 0.05, dormir=lambda s: None, horloge=lambda: 0.0)
|
||||
assert essais["n"] >= 1
|
||||
@@ -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"
|
||||
+7
-4
@@ -50,12 +50,15 @@ def test_clean_transcript_empty_stays_empty():
|
||||
|
||||
|
||||
def test_config_has_ui_keys():
|
||||
for key in ("fps", "circle_base_radius", "circle_amplitude", "circle_color_rgb"):
|
||||
for key in ("fps", "bar_width", "bar_height", "bar_radius", "bar_bg_rgba",
|
||||
"stop_button_rgb", "waveform_rgb", "waveform_bars", "mic_rgb"):
|
||||
assert key in CONFIG
|
||||
|
||||
|
||||
def test_config_ui_defaults():
|
||||
assert CONFIG["fps"] == 30
|
||||
assert CONFIG["circle_base_radius"] > 0
|
||||
assert CONFIG["circle_amplitude"] > 0
|
||||
assert len(CONFIG["circle_color_rgb"]) == 3
|
||||
assert CONFIG["bar_width"] > CONFIG["bar_height"] # barre couchee, pas carree
|
||||
assert CONFIG["bar_radius"] <= CONFIG["bar_height"] / 2
|
||||
assert len(CONFIG["bar_bg_rgba"]) == 4
|
||||
assert CONFIG["waveform_bars"] >= 8
|
||||
assert 0.0 < CONFIG["waveform_floor"] < 1.0
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Detection d'un micro qui n'envoie rien.
|
||||
|
||||
Le 2026-08-21, Loom tenait le micro : Zonza enregistrait dans le vide. La forme
|
||||
d'onde restait plate et rien ne le signalait — on ne le decouvrait qu'apres avoir
|
||||
parle pour rien. Zonza doit le dire pendant, pas apres.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from core import CONFIG, entree_audio_muette
|
||||
|
||||
|
||||
def test_aucun_bloc_apres_le_delai_est_muet():
|
||||
assert entree_audio_muette(blocs_recus=0, en_cours=True) is True
|
||||
|
||||
|
||||
def test_des_blocs_recus_ne_sont_pas_muets():
|
||||
assert entree_audio_muette(blocs_recus=1, en_cours=True) is False
|
||||
|
||||
|
||||
def test_on_n_alerte_pas_si_l_enregistrement_est_deja_fini():
|
||||
# l'utilisateur a arrete avant le delai : ce n'est pas une panne
|
||||
assert entree_audio_muette(blocs_recus=0, en_cours=False) is False
|
||||
|
||||
|
||||
def test_le_delai_laisse_le_temps_au_flux_de_s_ouvrir():
|
||||
# ouvrir un flux CoreAudio prend des centaines de ms ; alerter trop tot = faux positif
|
||||
assert CONFIG["delai_detection_silence_s"] >= 1.0
|
||||
|
||||
|
||||
def test_le_delai_reste_assez_court_pour_servir():
|
||||
# au-dela, l'utilisateur a deja dicte sa phrase pour rien
|
||||
assert CONFIG["delai_detection_silence_s"] <= 3.0
|
||||
@@ -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,40 @@
|
||||
"""Durée de vie maximale de l'overlay.
|
||||
|
||||
Bug du 2026-08-21 : le halo est resté figé à l'écran. Depuis la refonte du design,
|
||||
la fenêtre ne se ferme plus à l'arrêt de l'enregistrement mais après le collage —
|
||||
donc tout blocage dans cette chaîne la laisse affichée indéfiniment.
|
||||
|
||||
La fenêtre porte désormais sa propre limite : quoi qu'il arrive en amont, elle se
|
||||
referme seule. Ce test verrouille la COHÉRENCE de la limite, qui doit couvrir le
|
||||
pire cas réel — un enregistrement de durée maximale SUIVI d'une transcription.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from core import CONFIG, duree_vie_overlay_coherente
|
||||
|
||||
|
||||
def test_la_limite_couvre_un_enregistrement_maximal_plus_la_transcription():
|
||||
assert duree_vie_overlay_coherente(CONFIG) is True
|
||||
|
||||
|
||||
def test_une_limite_plus_courte_que_l_enregistrement_est_refusee():
|
||||
mauvais = dict(CONFIG, duree_max_overlay_s=CONFIG["max_duration_s"] - 1)
|
||||
assert duree_vie_overlay_coherente(mauvais) is False
|
||||
|
||||
|
||||
def test_une_limite_sans_marge_pour_transcrire_est_refusee():
|
||||
# juste egale a la duree d'enregistrement : la transcription serait coupee
|
||||
serre = dict(CONFIG, duree_max_overlay_s=CONFIG["max_duration_s"])
|
||||
assert duree_vie_overlay_coherente(serre) is False
|
||||
|
||||
|
||||
def test_validate_config_rejette_une_limite_incoherente():
|
||||
from core import validate_config
|
||||
mauvais = dict(CONFIG, duree_max_overlay_s=10.0)
|
||||
with pytest.raises(ValueError):
|
||||
validate_config(mauvais)
|
||||
|
||||
|
||||
def test_la_config_livree_est_coherente():
|
||||
from core import validate_config
|
||||
validate_config(CONFIG) # ne doit pas lever
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Deux gardes distinctes selon la phase.
|
||||
|
||||
Le halo s'est fige a l'ecran une seconde fois. La garde posee ce matin est reglee
|
||||
sur le PIRE cas — un enregistrement de 2 min suivi de sa transcription — soit 210 s.
|
||||
Trois minutes et demie de halo, c'est fige du point de vue de l'utilisateur, meme si
|
||||
le code finit par se rattraper.
|
||||
|
||||
D'ou une garde COURTE des l'arret de l'enregistrement : a ce moment il ne reste que
|
||||
la transcription, qui se compte en secondes. La garde longue ne couvre plus que la
|
||||
phase d'enregistrement.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from core import CONFIG, validate_config
|
||||
|
||||
|
||||
def test_les_deux_gardes_existent():
|
||||
assert CONFIG["duree_max_overlay_s"] > 0
|
||||
assert CONFIG["duree_max_transcription_s"] > 0
|
||||
|
||||
|
||||
def test_la_garde_de_transcription_est_bien_plus_courte():
|
||||
assert CONFIG["duree_max_transcription_s"] < CONFIG["duree_max_overlay_s"]
|
||||
|
||||
|
||||
def test_elle_laisse_le_temps_de_transcrire():
|
||||
# une transcription reelle prend 1 a 3 s ; en dessous de 10 s on couperait du travail
|
||||
assert CONFIG["duree_max_transcription_s"] >= 10.0
|
||||
|
||||
|
||||
def test_elle_reste_courte_pour_l_utilisateur():
|
||||
# au-dela de 30 s, l'utilisateur parle de nouveau de « fige »
|
||||
assert CONFIG["duree_max_transcription_s"] <= 30.0
|
||||
|
||||
|
||||
def test_validate_config_refuse_une_garde_de_transcription_trop_courte():
|
||||
with pytest.raises(ValueError):
|
||||
validate_config(dict(CONFIG, duree_max_transcription_s=2.0))
|
||||
|
||||
|
||||
def test_validate_config_refuse_une_garde_plus_longue_que_la_globale():
|
||||
with pytest.raises(ValueError):
|
||||
validate_config(dict(CONFIG, duree_max_transcription_s=CONFIG["duree_max_overlay_s"] + 1))
|
||||
|
||||
|
||||
def test_la_config_livree_reste_valide():
|
||||
validate_config(CONFIG)
|
||||
@@ -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,41 @@
|
||||
"""Geometrie de l'overlay : tout derive de la hauteur de la barre.
|
||||
|
||||
Sinon, reduire la barre laisse le bouton stop, le micro et les marges a leur
|
||||
taille d'origine, et la composition se deseequilibre. Une seule valeur commande
|
||||
l'echelle.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from core import CONFIG, bar_metrics
|
||||
|
||||
|
||||
def test_toutes_les_mesures_sont_fournies():
|
||||
m = bar_metrics(52.0)
|
||||
for cle in ("stop_diametre", "mic_largeur", "marge_laterale",
|
||||
"mic_corps_hauteur", "mic_arceau_rayon", "ecart_interne"):
|
||||
assert cle in m
|
||||
|
||||
|
||||
def test_les_mesures_tiennent_dans_la_barre():
|
||||
h = 52.0
|
||||
m = bar_metrics(h)
|
||||
assert m["stop_diametre"] < h
|
||||
assert m["mic_corps_hauteur"] < h
|
||||
assert 2 * m["marge_laterale"] < CONFIG["bar_width"]
|
||||
|
||||
|
||||
def test_diviser_la_hauteur_par_deux_divise_tout_par_deux():
|
||||
grand, petit = bar_metrics(52.0), bar_metrics(26.0)
|
||||
for cle, valeur in grand.items():
|
||||
assert petit[cle] == pytest.approx(valeur / 2.0)
|
||||
|
||||
|
||||
def test_une_hauteur_absurde_est_refusee():
|
||||
with pytest.raises(ValueError):
|
||||
bar_metrics(0)
|
||||
|
||||
|
||||
def test_la_config_reste_coherente_avec_ses_mesures():
|
||||
m = bar_metrics(CONFIG["bar_height"])
|
||||
largeur_occupee = 2 * m["marge_laterale"] + m["stop_diametre"] + m["mic_largeur"]
|
||||
assert largeur_occupee < CONFIG["bar_width"], "plus de place pour la forme d'onde"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Liberation du flux audio.
|
||||
|
||||
Bug du 2026-08-22 : trois dictees d'affilee sans le moindre son. Cause :
|
||||
`Recorder.start()` ecrasait `self._stream` sans fermer le precedent. Quand un
|
||||
`stop()` se bloquait (peripherique accapare), le flux restait ouvert POUR TOUJOURS
|
||||
— et l'essai suivant en empilait un autre. Zonza finissait par tenir elle-meme le
|
||||
micro qu'elle tentait d'ouvrir.
|
||||
|
||||
On libere donc AVANT d'ouvrir, avec `abort()` (immediat) plutot que `stop()` (qui
|
||||
draine les tampons et peut bloquer), et sans jamais lever.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from capture import Recorder
|
||||
|
||||
|
||||
class FauxFlux:
|
||||
def __init__(self, casse_sur=()):
|
||||
self.actions = []
|
||||
self.casse_sur = casse_sur
|
||||
|
||||
def start(self): self.actions.append("start")
|
||||
def abort(self):
|
||||
self.actions.append("abort")
|
||||
if "abort" in self.casse_sur: raise RuntimeError("peripherique occupe")
|
||||
def close(self):
|
||||
self.actions.append("close")
|
||||
if "close" in self.casse_sur: raise RuntimeError("close impossible")
|
||||
|
||||
|
||||
def _rec(flux_successifs):
|
||||
it = iter(flux_successifs)
|
||||
return Recorder(16000, ouvrir_flux=lambda **kw: next(it))
|
||||
|
||||
|
||||
def test_demarrer_libere_le_flux_precedent():
|
||||
a, b = FauxFlux(), FauxFlux()
|
||||
r = _rec([a, b])
|
||||
r.start()
|
||||
r.start() # second demarrage sans stop entre les deux
|
||||
assert "abort" in a.actions and "close" in a.actions, "l'ancien flux doit etre libere"
|
||||
assert b.actions == ["start"]
|
||||
|
||||
|
||||
def test_on_prefere_abort_a_stop():
|
||||
a = FauxFlux()
|
||||
r = _rec([a])
|
||||
r.start(); r.stop()
|
||||
assert "abort" in a.actions
|
||||
assert "stop" not in a.actions, "stop() draine les tampons et peut bloquer"
|
||||
|
||||
|
||||
def test_un_flux_recalcitrant_ne_fait_pas_echouer_le_demarrage():
|
||||
a, b = FauxFlux(casse_sur=("abort", "close")), FauxFlux()
|
||||
r = _rec([a, b])
|
||||
r.start()
|
||||
r.start() # ne doit rien lever malgre les deux echecs
|
||||
assert b.actions == ["start"]
|
||||
|
||||
|
||||
def test_le_flux_est_detache_meme_si_la_liberation_echoue():
|
||||
a = FauxFlux(casse_sur=("abort", "close"))
|
||||
r = _rec([a])
|
||||
r.start(); r.stop()
|
||||
assert r._stream is None, "une reference morte empecherait le prochain demarrage"
|
||||
|
||||
|
||||
def test_stop_rend_l_audio_capture():
|
||||
a = FauxFlux()
|
||||
r = _rec([a])
|
||||
r.start()
|
||||
r._callback(np.ones((512, 1), dtype="float32"), 512, None, None)
|
||||
audio = r.stop()
|
||||
assert audio.shape == (512,)
|
||||
|
||||
|
||||
def test_stop_sans_demarrage_ne_leve_pas():
|
||||
assert _rec([]).stop().size == 0
|
||||
@@ -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)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Garde d'instance unique.
|
||||
|
||||
Contexte (bug du 2026-08-21) : deux bundles Zonza portant le MÊME identifiant
|
||||
(`/Applications/Zonza.app` et un fantôme resté dans la Corbeille, encore
|
||||
enregistré auprès de LaunchServices) pouvaient tourner simultanément. Chaque
|
||||
instance chargeait son propre modèle MLX-Whisper `medium` et captait la même
|
||||
hotkey globale : un seul appui déclenchait deux transcriptions, la machine
|
||||
saturait.
|
||||
|
||||
Purger le fantôme corrige l'incident du jour ; cette garde corrige la CLASSE de
|
||||
bug — quel que soit le nombre de bundles présents, la 2e instance renonce.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from systeme import choisir_systeme
|
||||
|
||||
|
||||
def _tmp_lock():
|
||||
return os.path.join(tempfile.mkdtemp(), "sous-dossier", "zonza.lock")
|
||||
|
||||
|
||||
def test_premiere_instance_obtient_le_verrou():
|
||||
handle = choisir_systeme().acquerir_verrou_instance(_tmp_lock())
|
||||
assert handle is not None
|
||||
|
||||
|
||||
def test_deuxieme_instance_est_refusee():
|
||||
path = _tmp_lock()
|
||||
premier = choisir_systeme().acquerir_verrou_instance(path)
|
||||
assert premier is not None
|
||||
assert choisir_systeme().acquerir_verrou_instance(path) is None
|
||||
|
||||
|
||||
def test_verrou_repris_apres_liberation():
|
||||
path = _tmp_lock()
|
||||
premier = choisir_systeme().acquerir_verrou_instance(path)
|
||||
premier.close()
|
||||
assert choisir_systeme().acquerir_verrou_instance(path) is not None
|
||||
|
||||
|
||||
def test_cree_le_dossier_parent_manquant():
|
||||
path = _tmp_lock()
|
||||
assert not os.path.isdir(os.path.dirname(path))
|
||||
choisir_systeme().acquerir_verrou_instance(path)
|
||||
assert os.path.isdir(os.path.dirname(path))
|
||||
|
||||
|
||||
def test_deux_chemins_differents_ne_se_genent_pas():
|
||||
assert choisir_systeme().acquerir_verrou_instance(_tmp_lock()) is not None
|
||||
assert choisir_systeme().acquerir_verrou_instance(_tmp_lock()) is not None
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Cycle de vie de l'overlay, et interdiction de bloquer le thread principal.
|
||||
|
||||
Bug du 2026-08-21 (deuxieme occurrence) : le halo est reste fige sur « Je vous
|
||||
ecoute… », forme d'onde plate, process a 0 % CPU. Une autre application (Loom)
|
||||
avait pris le micro ; `stream.stop()` de PortAudio s'est BLOQUE. Comme toggle()
|
||||
s'execute sur le thread principal Cocoa, toute l'interface a gele — et le garde-fou
|
||||
NSTimer pose le matin meme etait inoperant PAR CONSTRUCTION, puisqu'il vit sur cette
|
||||
meme boucle d'evenements bloquee.
|
||||
|
||||
D'ou la regle verrouillee ici : le chemin appele depuis le thread principal ne
|
||||
touche JAMAIS au peripherique audio. Il change l'etat, met a jour l'overlay, et
|
||||
delegue.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from controller import ZonzaController
|
||||
from core import CONFIG
|
||||
|
||||
|
||||
class FauxPulse:
|
||||
def __init__(self):
|
||||
self.statuts = []
|
||||
self.visible = False
|
||||
|
||||
def show(self): self.visible = True
|
||||
def hide(self): self.visible = False
|
||||
def set_level(self, level): pass
|
||||
def set_status(self, text): self.statuts.append(text)
|
||||
def rearmer_garde(self, duree_s): self.garde = duree_s
|
||||
|
||||
|
||||
class RecorderQuiBloque:
|
||||
"""Reproduit un peripherique accapare : start/stop ne rendent jamais la main."""
|
||||
def __init__(self):
|
||||
self.start_appele = False
|
||||
self.stop_appele = False
|
||||
|
||||
def start(self):
|
||||
self.start_appele = True
|
||||
raise AssertionError("start() ne doit pas etre appele sur le thread principal")
|
||||
|
||||
def stop(self):
|
||||
self.stop_appele = True
|
||||
raise AssertionError("stop() ne doit pas etre appele sur le thread principal")
|
||||
|
||||
|
||||
class FauxRecorder:
|
||||
def __init__(self, audio): self.audio = audio
|
||||
def start(self): pass
|
||||
def stop(self): return self.audio
|
||||
|
||||
|
||||
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()
|
||||
systeme = FauxSysteme()
|
||||
c = ZonzaController(config or CONFIG, pulse, FauxTranscripteur(), systeme, lancer_tache=taches)
|
||||
c.recorder = recorder
|
||||
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():
|
||||
differees = []
|
||||
c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)
|
||||
c.toggle() # ne doit rien lever
|
||||
assert pulse.visible
|
||||
assert any("écoute" in s.lower() for s in pulse.statuts)
|
||||
assert len(differees) == 1, "l'ouverture du flux doit etre deleguee"
|
||||
|
||||
|
||||
def test_arreter_ne_touche_pas_l_audio_sur_le_thread_principal():
|
||||
differees = []
|
||||
c, pulse = _ctrl(RecorderQuiBloque(), taches=differees.append)
|
||||
c.toggle() # demarre
|
||||
differees.clear()
|
||||
c.toggle() # arrete : ne doit rien lever non plus
|
||||
assert len(differees) == 1, "la fermeture du flux doit etre deleguee"
|
||||
assert any("transcription" in s.lower() for s in pulse.statuts)
|
||||
|
||||
|
||||
def test_la_bulle_annonce_l_ecoute_puis_la_transcription():
|
||||
differees = []
|
||||
c, pulse = _ctrl(FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append)
|
||||
c.toggle(); c.toggle()
|
||||
assert [s.lower() for s in pulse.statuts][:2] == ["je vous écoute…", "transcription…"]
|
||||
|
||||
|
||||
def test_la_tache_differee_referme_l_overlay_a_la_fin():
|
||||
differees = []
|
||||
c, pulse = _ctrl(FauxRecorder(np.ones(16000, dtype="float32") * 0.1), taches=differees.append)
|
||||
c.toggle(); differees.clear(); c.toggle()
|
||||
assert pulse.visible, "l'overlay reste tant que la tache n'a pas tourne"
|
||||
for t in differees:
|
||||
t() # on execute la tache differee
|
||||
assert not pulse.visible, "l'overlay doit etre referme par la tache"
|
||||
|
||||
|
||||
def test_un_enregistrement_trop_court_referme_aussi():
|
||||
differees = []
|
||||
c, pulse = _ctrl(FauxRecorder(np.zeros(10, dtype="float32")), taches=differees.append)
|
||||
c.toggle(); differees.clear(); c.toggle()
|
||||
for t in differees:
|
||||
t()
|
||||
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
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Amorce de vocabulaire passee a Whisper.
|
||||
|
||||
Contexte (bug du 2026-08-21) : sans contexte, le modele rend « Playwright » en
|
||||
« PlayWreck », « iCloud » en « Auriclaw », « Supabase » en « Supabass » et
|
||||
« Vercel » en « Versell » — mesure sur audio reel, 4 termes sur 5 faux. En
|
||||
passant la liste des termes metier en initial_prompt, les 5 redeviennent
|
||||
corrects sans changer de modele.
|
||||
|
||||
Contrainte : Whisper ne lit que ~224 tokens d'amorce et TRONQUE le reste en
|
||||
silence. La longueur est donc bornee, sinon les derniers termes ajoutes ne
|
||||
servent a rien sans qu'on le sache.
|
||||
"""
|
||||
from core import CONFIG, VOCABULAIRE, PROMPT_MAX_CARACTERES, build_initial_prompt
|
||||
|
||||
|
||||
def test_le_prompt_contient_les_termes_qui_avaient_echoue():
|
||||
p = build_initial_prompt()
|
||||
for terme in ("Playwright", "iCloud", "Supabase", "Vercel", "Convex"):
|
||||
assert terme in p
|
||||
|
||||
|
||||
def test_le_prompt_tient_sur_une_seule_ligne():
|
||||
# un retour a la ligne coupe l'amorce cote Whisper
|
||||
assert "\n" not in build_initial_prompt()
|
||||
|
||||
|
||||
def test_le_prompt_respecte_la_borne():
|
||||
assert len(build_initial_prompt()) <= PROMPT_MAX_CARACTERES
|
||||
|
||||
|
||||
def test_une_liste_trop_longue_est_tronquee_pas_ignoree():
|
||||
p = build_initial_prompt(["Terme%03d" % i for i in range(500)])
|
||||
assert len(p) <= PROMPT_MAX_CARACTERES
|
||||
assert "Terme000" in p # les premiers termes survivent
|
||||
|
||||
|
||||
def test_les_doublons_sont_retires_en_gardant_l_ordre():
|
||||
p = build_initial_prompt(["Convex", "Stripe", "Convex"])
|
||||
assert p.count("Convex") == 1
|
||||
assert p.index("Convex") < p.index("Stripe")
|
||||
|
||||
|
||||
def test_le_vocabulaire_couvre_les_projets_de_ralph():
|
||||
for terme in ("Convex", "Supabase", "Stripe", "n8n", "Airtable"):
|
||||
assert terme in VOCABULAIRE
|
||||
|
||||
|
||||
def test_config_expose_le_prompt():
|
||||
assert CONFIG["initial_prompt"]
|
||||
assert "Playwright" in CONFIG["initial_prompt"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Historique de niveaux -> hauteurs de barres de la forme d'onde.
|
||||
|
||||
L'overlay affiche une forme d'onde facon dictee : le niveau le plus recent a
|
||||
GAUCHE, les plus anciens vers la droite. Les positions sans donnee (debut
|
||||
d'enregistrement) ou silencieuses retombent sur un plancher, dessine comme un
|
||||
petit point plutot qu'une barre — c'est ce qui donne la traine pointillee.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from audio_level import bar_heights, push_level
|
||||
|
||||
|
||||
def test_push_ajoute_en_tete():
|
||||
assert push_level([], 0.5, capacity=3) == [0.5]
|
||||
assert push_level([0.5], 0.8, capacity=3) == [0.8, 0.5]
|
||||
|
||||
|
||||
def test_push_borne_la_capacite_en_jetant_le_plus_ancien():
|
||||
h = [0.3, 0.2, 0.1]
|
||||
assert push_level(h, 0.9, capacity=3) == [0.9, 0.3, 0.2]
|
||||
|
||||
|
||||
def test_push_ne_modifie_pas_l_historique_recu():
|
||||
h = [0.3]
|
||||
push_level(h, 0.9, capacity=3)
|
||||
assert h == [0.3]
|
||||
|
||||
|
||||
def test_push_borne_le_niveau_dans_zero_un():
|
||||
assert push_level([], 5.0, capacity=2) == [1.0]
|
||||
assert push_level([], -3.0, capacity=2) == [0.0]
|
||||
|
||||
|
||||
def test_bar_heights_complete_avec_le_plancher():
|
||||
b = bar_heights([1.0], n_bars=4, floor=0.08)
|
||||
assert len(b) == 4
|
||||
assert b[0] == 1.0
|
||||
assert b[1:] == [0.08, 0.08, 0.08]
|
||||
|
||||
|
||||
def test_bar_heights_tronque_si_historique_trop_long():
|
||||
b = bar_heights([0.9, 0.8, 0.7, 0.6, 0.5], n_bars=3, floor=0.05)
|
||||
assert b == [0.9, 0.8, 0.7]
|
||||
|
||||
|
||||
def test_bar_heights_applique_le_plancher_aux_silences():
|
||||
b = bar_heights([0.0, 0.5], n_bars=2, floor=0.1)
|
||||
assert b == [0.1, 0.5]
|
||||
|
||||
|
||||
def test_bar_heights_refuse_un_nombre_de_barres_absurde():
|
||||
with pytest.raises(ValueError):
|
||||
bar_heights([0.5], n_bars=0, floor=0.1)
|
||||
Reference in New Issue
Block a user