Compare commits
2 Commits
8a9a6de98c
...
f1cff4239d
| Author | SHA1 | Date | |
|---|---|---|---|
| f1cff4239d | |||
| 3ad274a774 |
9
app.py
9
app.py
@ -28,7 +28,7 @@ from PyObjCTools import AppHelper
|
||||
from pynput import keyboard
|
||||
|
||||
from controller import ZonzaController
|
||||
from core import CONFIG, validate_config
|
||||
from core import CONFIG, acquire_single_instance_lock, validate_config
|
||||
from pulse_window import PulseWindow
|
||||
|
||||
_STATE_TITLES = {
|
||||
@ -127,6 +127,13 @@ 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
90
core.py
@ -3,6 +3,8 @@
|
||||
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
|
||||
CONFIG = {
|
||||
@ -22,9 +24,65 @@ 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:
|
||||
@ -44,3 +102,35 @@ 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()
|
||||
|
||||
@ -108,5 +108,6 @@ class Transcriber:
|
||||
audio,
|
||||
path_or_hf_repo=self.model,
|
||||
language=self.language,
|
||||
initial_prompt=CONFIG["initial_prompt"],
|
||||
)
|
||||
return clean_transcript(result.get("text", ""))
|
||||
|
||||
51
tests/test_single_instance.py
Normal file
51
tests/test_single_instance.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""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
|
||||
50
tests/test_vocabulaire.py
Normal file
50
tests/test_vocabulaire.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""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"]
|
||||
Loading…
Reference in New Issue
Block a user