Compare commits
2 Commits
6dea252013
...
332afede0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 332afede0e | |||
| 372df70c62 |
823
docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md
Normal file
823
docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user