Compare commits

..

2 Commits

Author SHA1 Message Date
f1cff4239d Whisper recoit le vocabulaire metier en amorce
Sans contexte, le modele rendait « Playwright » en « PlayWreck », « iCloud » en
« Auriclaw », « Supabase » en « Supabass », « Vercel » en « Versell » : 4 termes
sur 5 faux, mesure sur audio reel. Avec l'amorce (initial_prompt), les 5 sont
corrects, sans changer de modele ni ralentir (1,3 s).

Passer a large-v3-turbo a ete teste et ecarte : PIRE sur le meme echantillon
(« commit sur Gitea » -> « commis sur JTA », « Stripe » -> « Trip »), pour 1,5 Go
de plus. C'est l'amorce qui paie, pas la taille du modele.

VOCABULAIRE est une simple liste a completer dans core.py. Whisper ne lit que
~224 tokens d'amorce et tronque le reste EN SILENCE : la longueur est donc
bornee explicitement, pour qu'un ajout de termes ne soit jamais sans effet a
l'insu de celui qui l'ecrit (47 termes = 416 caracteres sur 800).

34 tests passent (27 + 7 nouveaux).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:28:52 +02:00
3ad274a774 Zonza ne se lance plus qu'une fois a la fois
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>
2026-08-21 00:25:05 +02:00
5 changed files with 200 additions and 1 deletions

9
app.py
View File

@ -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
View File

@ -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()

View File

@ -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", ""))

View 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
View 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"]