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 pynput import keyboard
|
||||||
|
|
||||||
from controller import ZonzaController
|
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
|
from pulse_window import PulseWindow
|
||||||
|
|
||||||
_STATE_TITLES = {
|
_STATE_TITLES = {
|
||||||
@ -127,6 +127,13 @@ class ZonzaApp(NSObject):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
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)
|
validate_config(CONFIG)
|
||||||
# si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle
|
# 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 —
|
# (« 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.
|
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
@ -22,9 +24,65 @@ CONFIG = {
|
|||||||
"level_release": 0.15, # lissage à la descente (plus petit = retombée douce)
|
"level_release": 0.15, # lissage à la descente (plus petit = retombée douce)
|
||||||
"wave_rings": 3, # nombre d'anneaux d'onde concentriques
|
"wave_rings": 3, # nombre d'anneaux d'onde concentriques
|
||||||
"wave_speed": 0.04, # vitesse de propagation des anneaux par frame
|
"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):
|
def validate_config(config):
|
||||||
"""Valide la config ; lève ValueError si une valeur est aberrante."""
|
"""Valide la config ; lève ValueError si une valeur est aberrante."""
|
||||||
if config["min_duration_s"] < 0:
|
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):
|
def clean_transcript(text):
|
||||||
"""Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés."""
|
"""Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés."""
|
||||||
return re.sub(r"\s+", " ", text).strip()
|
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,
|
audio,
|
||||||
path_or_hf_repo=self.model,
|
path_or_hf_repo=self.model,
|
||||||
language=self.language,
|
language=self.language,
|
||||||
|
initial_prompt=CONFIG["initial_prompt"],
|
||||||
)
|
)
|
||||||
return clean_transcript(result.get("text", ""))
|
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