Compare commits
No commits in common. "f1cff4239db82cef986718e588bc4ad44ac58047" and "8a9a6de98c023a16c29500ad298206c3bfda9e53" have entirely different histories.
f1cff4239d
...
8a9a6de98c
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, acquire_single_instance_lock, validate_config
|
from core import CONFIG, validate_config
|
||||||
from pulse_window import PulseWindow
|
from pulse_window import PulseWindow
|
||||||
|
|
||||||
_STATE_TITLES = {
|
_STATE_TITLES = {
|
||||||
@ -127,13 +127,6 @@ 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,8 +3,6 @@
|
|||||||
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 = {
|
||||||
@ -24,65 +22,9 @@ 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:
|
||||||
@ -102,35 +44,3 @@ 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,6 +108,5 @@ 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", ""))
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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"]
|
|
||||||
Loading…
Reference in New Issue
Block a user