"""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 fcntl import os import re CONFIG = { "model": "mlx-community/whisper-medium", "language": "fr", "hotkey": "++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) "sample_rate": 16000, "fps": 30, "circle_base_radius": 20.0, "circle_amplitude": 30.0, "circle_color_rgb": (0.40, 0.50, 0.95), "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 } 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") 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() LOCK_PATH = os.path.expanduser("~/Library/Application Support/Zonza/instance.lock") def acquire_single_instance_lock(path=LOCK_PATH): """Prend un verrou exclusif non bloquant ; renvoie le fichier, ou None si une autre instance le detient deja. Pourquoi : deux bundles Zonza de meme identifiant (dont un fantome reste dans la Corbeille mais toujours enregistre aupres de LaunchServices) pouvaient tourner en meme temps. Chacun chargeait son propre modele MLX-Whisper et captait la MEME hotkey globale : un appui, deux transcriptions, machine saturee. Purger le fantome corrige l'incident ; ce verrou corrige la classe. Le handle doit rester vivant tant que l'app tourne : le verrou tombe a la fermeture du fichier, et le noyau le libere si le processus meurt brutalement (donc pas de verrou orphelin apres un plantage). """ parent = os.path.dirname(path) if parent: os.makedirs(parent, exist_ok=True) handle = open(path, "a") try: fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: handle.close() return None return handle