- systeme/macos.py : SystemeMacOS (sons afplay, modificateurs Quartz, chemins, verrou d'instance fcntl) implementant le contrat Systeme. - systeme/__init__.py : choisir_systeme() fabrique selon sys.platform, refuse clairement une plateforme inconnue. - core.py : verrou d'instance (fcntl, LOCK_PATH, acquire_single_instance_lock) retire ; import os retire aussi, verifie inutilise ailleurs dans le fichier. - app.py : passe par choisir_systeme().acquerir_verrou_instance(dossier_donnees() + instance.lock) au lieu de core.acquire_single_instance_lock. Seul changement dans ce fichier, nom de fichier inchange. - tests/test_single_instance.py : reoriente vers choisir_systeme(), memes assertions et meme nombre de tests. - tests/test_systeme.py (nouveau) : verrou du test_le_verrou_refuse_une_seconde_prise garde desormais la reference au premier verrou — sans variable, CPython le garbage-collectait entre les deux assert (refcount 0 -> fermeture -> verrou relache), le test passait pour la mauvaise raison. 89 tests verts (84 + 5).
198 lines
8.0 KiB
Python
198 lines
8.0 KiB
Python
"""Unités pures de Zonza — aucune dépendance matérielle.
|
||
|
||
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
||
"""
|
||
|
||
import re
|
||
|
||
CONFIG = {
|
||
"model": "mlx-community/whisper-medium",
|
||
"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,
|
||
"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,
|
||
# --- 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)
|
||
"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",
|
||
# 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
|
||
|
||
|
||
def validate_config(config):
|
||
"""Valide la config ; lève ValueError si une valeur est aberrante."""
|
||
if config["min_duration_s"] < 0:
|
||
raise ValueError("min_duration_s doit être >= 0")
|
||
if config["sample_rate"] <= 0:
|
||
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):
|
||
"""Renvoie True si l'enregistrement dépasse le seuil de durée minimal."""
|
||
duration_s = num_samples / sample_rate
|
||
return duration_s >= 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()
|