Deux bundles portant l'identifiant cloud.mrtechlab.zonza etaient enregistres aupres de LaunchServices : /Applications/Zonza.app et un fantome laisse dans la Corbeille le 13 juin, dont le stub visait l'ancien chemin du depot — valide jusqu'a aujourd'hui. Les deux etaient donc lancables simultanement. Chaque instance chargeait son propre modele MLX-Whisper medium et captait la MEME hotkey globale : un seul appui declenchait deux transcriptions, la machine saturait. Le fantome est desenregistre, mais la classe de bug revient au moindre bundle egare. D'ou un verrou exclusif (flock non bloquant) pris au demarrage : la seconde instance renonce et sort. Le noyau libere le verrou meme apres un plantage, donc pas d'orphelin. Prouve avec le vrai binaire : 2e lancement direct -> "[garde] une autre instance tourne deja", 1 seul process ; et controle negatif, seule elle demarre bien. 27 tests passent (22 + 5 nouveaux). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
78 lines
2.9 KiB
Python
78 lines
2.9 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 fcntl
|
||
import os
|
||
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,
|
||
"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
|