Compare commits

..

No commits in common. "f1cff4239db82cef986718e588bc4ad44ac58047" and "8a9a6de98c023a16c29500ad298206c3bfda9e53" have entirely different histories.

5 changed files with 1 additions and 200 deletions

9
app.py
View File

@ -28,7 +28,7 @@ from PyObjCTools import AppHelper
from pynput import keyboard
from controller import ZonzaController
from core import CONFIG, acquire_single_instance_lock, validate_config
from core import CONFIG, validate_config
from pulse_window import PulseWindow
_STATE_TITLES = {
@ -127,13 +127,6 @@ class ZonzaApp(NSObject):
def main():
# une seule instance a la fois : sinon deux modeles en RAM et la meme
# hotkey captee deux fois (cf acquire_single_instance_lock).
lock = acquire_single_instance_lock()
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
validate_config(CONFIG)
# 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 —

90
core.py
View File

@ -3,8 +3,6 @@
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
"""
import fcntl
import os
import re
CONFIG = {
@ -24,65 +22,9 @@ CONFIG = {
"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",
# 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) + "."
def validate_config(config):
"""Valide la config ; lève ValueError si une valeur est aberrante."""
if config["min_duration_s"] < 0:
@ -102,35 +44,3 @@ 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()
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
CONFIG["initial_prompt"] = build_initial_prompt()

View File

@ -108,6 +108,5 @@ class Transcriber:
audio,
path_or_hf_repo=self.model,
language=self.language,
initial_prompt=CONFIG["initial_prompt"],
)
return clean_transcript(result.get("text", ""))

View File

@ -1,51 +0,0 @@
"""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 core import acquire_single_instance_lock
def _tmp_lock():
return os.path.join(tempfile.mkdtemp(), "sous-dossier", "zonza.lock")
def test_premiere_instance_obtient_le_verrou():
handle = acquire_single_instance_lock(_tmp_lock())
assert handle is not None
def test_deuxieme_instance_est_refusee():
path = _tmp_lock()
premier = acquire_single_instance_lock(path)
assert premier is not None
assert acquire_single_instance_lock(path) is None
def test_verrou_repris_apres_liberation():
path = _tmp_lock()
premier = acquire_single_instance_lock(path)
premier.close()
assert acquire_single_instance_lock(path) is not None
def test_cree_le_dossier_parent_manquant():
path = _tmp_lock()
assert not os.path.isdir(os.path.dirname(path))
acquire_single_instance_lock(path)
assert os.path.isdir(os.path.dirname(path))
def test_deux_chemins_differents_ne_se_genent_pas():
assert acquire_single_instance_lock(_tmp_lock()) is not None
assert acquire_single_instance_lock(_tmp_lock()) is not None

View File

@ -1,50 +0,0 @@
"""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"]