zonza/core.py
Ralph Mayola 2ccb614649 A chaque moteur son modele, et un forcage manuel
app.py passait CONFIG["model"] — un depot MLX — quel que soit le moteur detecte.
Sur une machine sans Apple Silicon, faster-whisper aurait recu un depot qu'il ne
sait pas charger. Le defaut etait invisible ici, la detection rendant toujours
"mlx" sur ce Mac : aucun test ne pouvait le voir.

MODELES_PAR_MOTEUR associe chaque moteur a son depot. Les valeurs viennent de la
mesure du jour (trois echantillons, amorce active, CPU Apple Silicon) :

  faster-whisper base   0,52 s  10/13 termes techniques   142 Mo
  faster-whisper small  1,42 s  11/13                     464 Mo
  faster-whisper medium 4,40 s  13/13                    1460 Mo
  MLX medium (GPU)      0,75 s  11/13                     489 Mo

Le GPU vise donc la precision (medium, 13/13, il absorbe le cout) ; le CPU vise
la latence (small, 11/13 — AUTANT que le MLX utilise aujourd'hui — pour 1,4 s).
`medium` sur CPU serait plus juste, mais ces 4,4 s sont celles d'un CPU Apple
Silicon : sur un CPU Intel ou Windows, le meme modele peut demander trois a quatre
fois plus. La commande de diagnostic mesurera la machine reelle et pourra
promouvoir `medium`.

CONFIG["model"] passe a None et devient un FORCAGE manuel qui prime sur la table,
au lieu d'un reglage mort qu'on modifie sans effet.

115 tests. Comportement inchange sur ce Mac : moteur mlx, modele identique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:45:14 +02:00

237 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unités pures de Zonza — aucune dépendance matérielle.
Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
"""
import re
CONFIG = {
# Forcage manuel du modele. None = on suit MODELES_PAR_MOTEUR, qui choisit
# d'apres le moteur detecte. Renseigner cette cle prime sur la table.
"model": None,
"language": "fr",
"hotkey": "<cmd>+<ctrl>+d", # Cmd+Ctrl+D (libre : ni macOS ni VS Code ne l'utilisent)
"sounds": True,
"min_duration_s": 0.3,
"attente_modificateurs_s": 2.0, # on patiente avant de coller (cf inject_text)
"delai_presse_papier_s": 0.12, # 0.05 s etait juste : le presse-papier n'etait pas pret
"max_duration_s": 120.0,
# Duree de vie MAXIMALE de l'overlay, garde-fou contre un halo fige (2026-08-21).
# Doit couvrir un enregistrement maximal PUIS sa transcription.
"duree_max_overlay_s": 210.0,
# Garde COURTE, armee des l'arret : il ne reste alors que la transcription,
# qui se compte en secondes. 210 s a ce stade, c'est « fige » pour l'utilisateur.
"duree_max_transcription_s": 20.0,
# Au bout de ce delai sans le moindre bloc audio, on previent : le micro est
# pris par une autre application (Loom, Zoom, OBS...). Sinon on dicte dans le vide.
"delai_detection_silence_s": 1.8, # sécurité : arrêt auto après 2 min (fenêtre jamais figée)
"sample_rate": 16000,
"fps": 30,
# --- overlay « barre de dictee » (bas de l'ecran) ---
"bar_width": 330.0,
"bar_height": 40.0,
"bar_radius": 12.0,
"bar_bg_rgba": (0.09, 0.09, 0.10, 0.95),
"stop_button_rgb": (0.89, 0.31, 0.23), # rond rouge-orange
"stop_glyph_rgb": (1.0, 1.0, 1.0), # carre arrondi blanc au centre
"waveform_rgb": (0.95, 0.95, 0.97),
"waveform_bars": 28, # nombre de barres de la forme d'onde
"waveform_floor": 0.07, # plancher : dessine en petit point (traine pointillee)
"mic_rgb": (0.78, 0.78, 0.82),
"bubble_text_rgb": (1.0, 1.0, 1.0),
"bubble_font_size": 12.0,
"bubble_max_width": 240.0,
"level_gain": 8.0, # amplification du niveau brut pour l'animation
"level_attack": 0.6, # lissage à la montée (01, proche de 1 = très réactif)
"level_release": 0.15, # lissage à la descente (plus petit = retombée douce)
"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) + "."
# Proportions internes de la barre, exprimees en fraction de sa HAUTEUR : reduire
# `bar_height` reduit alors le bouton, le micro et les marges dans le meme
# mouvement, au lieu de laisser une grosse pastille dans une barre retrecie.
_PROPORTIONS = {
"stop_diametre": 0.538,
"mic_largeur": 0.173,
"marge_laterale": 0.423,
"mic_corps_hauteur": 0.250,
"mic_arceau_rayon": 0.135,
"ecart_interne": 0.346,
}
def attendre(condition, delai_max_s, pas_s, dormir=None, horloge=None):
"""Attend que `condition()` soit vraie, au plus `delai_max_s`. Rend True/False.
Bornee volontairement : mieux vaut coller avec un modificateur encore enfonce
que perdre la transcription si une touche reste bloquee. `dormir` et `horloge`
sont injectables pour tester sans attendre reellement.
"""
import time as _t
dormir = dormir or _t.sleep
horloge = horloge or _t.monotonic
debut = horloge()
while True:
if condition():
return True
if horloge() - debut >= delai_max_s:
return False
dormir(pas_s)
def entree_audio_muette(blocs_recus, en_cours):
"""Faut-il alerter : le micro n'envoie-t-il rien alors qu'on enregistre ?
`en_cours` evite un faux positif quand l'utilisateur a simplement arrete avant
la fin du delai — ce n'est pas une panne, juste une dictee courte.
"""
return bool(en_cours) and blocs_recus == 0
def bar_metrics(bar_height):
"""Mesures de l'overlay pour une hauteur de barre donnee, en points."""
if bar_height <= 0:
raise ValueError("bar_height doit etre > 0")
return {cle: bar_height * f for cle, f in _PROPORTIONS.items()}
# Marge minimale a laisser pour transcrire apres un enregistrement de duree maximale.
MARGE_TRANSCRIPTION_S = 30.0
def duree_vie_overlay_coherente(config):
"""La limite de vie de l'overlay couvre-t-elle le pire cas reel ?
Pire cas : un enregistrement qui va jusqu'a max_duration_s, suivi de sa
transcription. Une limite trop courte fermerait le halo PENDANT que Zonza
travaille encore — on remplacerait un halo fige par un halo qui s'evapore.
"""
return config["duree_max_overlay_s"] >= config["max_duration_s"] + MARGE_TRANSCRIPTION_S
# --- modele par moteur ----------------------------------------------------
# Chaque moteur a son propre format de depot : MLX lit `mlx-community/*`,
# faster-whisper lit `Systran/faster-whisper-*`. Passer l'un a l'autre echoue.
#
# Mesure du 2026-08-23, trois echantillons, amorce de vocabulaire active,
# sur le CPU d'un Apple Silicon :
# faster-whisper base 0,52 s 10/13 termes techniques 142 Mo
# faster-whisper small 1,42 s 11/13 464 Mo
# faster-whisper medium 4,40 s 13/13 1460 Mo
# MLX medium (GPU) 0,75 s 11/13 489 Mo
#
# D'ou les choix : le GPU vise la precision (medium, 13/13, le GPU absorbe le
# cout) ; le CPU vise la latence (small, 11/13 — soit AUTANT que le MLX utilise
# aujourd'hui — pour 1,4 s ici). `medium` sur CPU serait plus juste, mais 4,4 s
# ont ete mesures sur un CPU Apple Silicon, rapide : sur un CPU Intel ou Windows,
# le meme modele peut demander trois a quatre fois plus. La commande de
# diagnostic (etape 4) mesurera la machine reelle et pourra promouvoir `medium`.
MODELES_PAR_MOTEUR = {
"mlx": "mlx-community/whisper-medium",
"faster-gpu": "Systran/faster-whisper-medium",
"faster-cpu": "Systran/faster-whisper-small",
}
def modele_retenu(nom_moteur, forcage=None):
"""Modele finalement utilise : le forcage manuel s'il est renseigne, sinon la
table. Une chaine vide est traitee comme une absence de forcage."""
return forcage or modele_pour_moteur(nom_moteur)
def modele_pour_moteur(nom):
"""Rend le depot de modele adapte a ce moteur. Leve KeyError si inconnu."""
if nom not in MODELES_PAR_MOTEUR:
raise KeyError(f"Aucun modele connu pour le moteur : {nom}")
return MODELES_PAR_MOTEUR[nom]
def validate_config(config):
"""Valide la config ; lève ValueError si une valeur est aberrante."""
if config["min_duration_s"] < 0:
raise ValueError("min_duration_s doit être >= 0")
if config["sample_rate"] <= 0:
raise ValueError("sample_rate doit être > 0")
if not config["hotkey"]:
raise ValueError("hotkey ne peut pas être vide")
d = config.get("duree_max_transcription_s")
if d is not None and not (10.0 <= d <= 30.0):
raise ValueError("duree_max_transcription_s doit tenir entre 10 et 30 s")
if d is not None and d >= config["duree_max_overlay_s"]:
raise ValueError("duree_max_transcription_s doit rester sous duree_max_overlay_s")
if not duree_vie_overlay_coherente(config):
raise ValueError(
"duree_max_overlay_s doit couvrir max_duration_s + "
f"{MARGE_TRANSCRIPTION_S} s de transcription"
)
def is_long_enough(num_samples, sample_rate, min_duration_s):
"""Renvoie True si l'enregistrement dépasse le seuil de durée minimal."""
duration_s = num_samples / sample_rate
return duration_s >= 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()
CONFIG["initial_prompt"] = build_initial_prompt()