Compare commits

...
6 Commits
Author SHA1 Message Date
rmayolaandClaude Opus 5.5 2a1cd6373a Vocabulaire : Higgsfield
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:57:21 +02:00
rmayolaandClaude Opus 5.5 8d316a3e0b OpenRouter : gemini-3.8-flash, delai reseau porte a 15 s
3.5-flash-lite ajoutait une phrase non dite ; 3.1-flash-lite ecrivait
GitHub pour Gitea. 3.8-flash est juste a chaque essai (4-9 s).
3.8-flash-lite-tts est refuse par OpenRouter : modele texte -> voix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:47:01 +02:00
rmayolaandClaude Opus 5.5 50b12795d3 Moteur OpenRouter (google/gemini-3.5-flash-lite), par defaut
Meme consigne, meme encodage, meme repli local que le moteur Gemini direct ;
seuls l'adresse, l'authentification (Bearer) et le format de requete
(input_audio, compatible OpenAI) changent. Cle : trousseau zonza-openrouter,
puis OPENROUTER_API_KEY. Un 200 portant un objet error est traite comme un
echec, pas comme une transcription vide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:35:11 +02:00
rmayolaandClaude Opus 5.5 9be2ea030b Moteur Gemini (API) avec repli local automatique
L'audio part chez Google via gemini-3.1-flash-lite (la variante -tts ne
transcrit pas : elle produit de la voix). Cle lue dans le trousseau
(service zonza-gemini) puis GEMINI_API_KEY. Au moindre echec (cle, reseau,
quota, delai de 12 s), la dictee repasse par le moteur local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 00:46:09 +02:00
rmayolaandClaude Opus 5 5eedda0467 install.sh refuse de detourner une Zonza deja installee
build_app.sh grave le chemin du dossier courant dans le binaire de
/Applications/Zonza.app. Lancer install.sh depuis une COPIE detournait donc
l'application en service vers cette copie, changeait son cdhash, et REVOQUAIT
silencieusement les autorisations macOS deja accordees.

Constate le 2026-08-23 en testant le script depuis un clone temporaire : rattrape
de justesse, avant que build_app.sh ne s'execute. Le script compare desormais la
cible gravee a son propre dossier, refuse de reconstruire s'ils different, et
explique pourquoi. ZONZA_REMPLACER=1 pour passer outre volontairement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 20:09:44 +02:00
rmayolaandClaude Opus 5 901d8f695a Scripts d'installation, et requirements avec marqueurs de plateforme
requirements.txt installait mlx-whisper sans condition : l'installation aurait
echoue sur Windows, ou ce paquet n'existe pas. Et pyobjc n'y figurait PAS DU TOUT
alors que l'overlay macOS en depend — une installation neuve sur un autre Mac
aurait produit une Zonza qui ne demarre pas. Les marqueurs retiennent 13 paquets
sur Apple Silicon, 12 sur Mac Intel, 8 sur Windows.

install.sh construit l'app et lance le diagnostic. install.ps1 pose le lancement
au demarrage via pythonw (sans quoi une console resterait ouverte) et lance le
diagnostic. Ce dernier porte un avertissement : il n'a jamais tourne sur Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 20:08:20 +02:00
10 changed files with 784 additions and 10 deletions
+18
View File
@@ -6,6 +6,11 @@ Tout ce qui est ici est testable sans micro, sans clavier, sans modèle.
import re import re
CONFIG = { CONFIG = {
# Moteur de transcription. None = detection materielle (mlx, faster-cpu,
# faster-gpu, tous locaux). "gemini" (API Google directe) ou "openrouter"
# (meme modele, via OpenRouter) = l'audio quitte le Mac, avec le moteur
# local en repli si l'API ne repond pas (cf moteur/gemini.py).
"moteur": "openrouter",
# Forcage manuel du modele. None = on suit MODELES_PAR_MOTEUR, qui choisit # Forcage manuel du modele. None = on suit MODELES_PAR_MOTEUR, qui choisit
# d'apres le moteur detecte. Renseigner cette cle prime sur la table. # d'apres le moteur detecte. Renseigner cette cle prime sur la table.
"model": None, "model": None,
@@ -65,6 +70,8 @@ VOCABULAIRE = [
"Gitea", "GitHub", "Hetzner", "Hostinger", "iCloud", "Clerk", "Gitea", "GitHub", "Hetzner", "Hostinger", "iCloud", "Clerk",
# paiement, mail, automatisation # paiement, mail, automatisation
"Stripe", "PayPal", "Shopify", "Brevo", "n8n", "Airtable", "Yabetoo", "Stripe", "PayPal", "Shopify", "Brevo", "n8n", "Airtable", "Yabetoo",
# IA et medias
"Higgsfield",
# projets # projets
"FacturPro", "Forescale", "Reflow", "CiteGain", "Faramaya", "Soko", "FacturPro", "Forescale", "Reflow", "CiteGain", "Faramaya", "Soko",
"Moubote", "Yelessa", "Zonza", "MR TECH LAB", "Moubote", "Yelessa", "Zonza", "MR TECH LAB",
@@ -186,6 +193,17 @@ MODELES_PAR_MOTEUR = {
"mlx": "mlx-community/whisper-medium", "mlx": "mlx-community/whisper-medium",
"faster-gpu": "Systran/faster-whisper-medium", "faster-gpu": "Systran/faster-whisper-medium",
"faster-cpu": "Systran/faster-whisper-small", "faster-cpu": "Systran/faster-whisper-small",
# Pas la variante « -tts » : celle-ci ne fait que produire de la voix a
# partir de texte, elle ne transcrit pas. Il faut un modele qui ACCEPTE
# l'audio en entree.
"gemini": "gemini-3.1-flash-lite",
# Mesure du 2026-09-25 (phrase de 12 s / dictee de 94 s, 2 a 3 essais) :
# 3.5-flash-lite 1,4 s / 3-5 s AJOUTE une phrase non dite -> ecarte
# 3.1-flash-lite 1,5 s / 3,7 s « Gitea » -> « GitHub » sur le long
# 3.8-flash 4-9 s / 9 s tout juste, a chaque essai -> retenu
# « 3.8-flash-lite-tts » est refuse par OpenRouter (HTTP 400) : c'est un
# modele texte -> voix, il n'accepte pas d'audio en entree.
"openrouter": "google/gemini-3.8-flash",
} }
+66
View File
@@ -0,0 +1,66 @@
# Installe Zonza sur Windows.
#
# powershell -ExecutionPolicy Bypass -File .\install.ps1
#
# Cree l'environnement, installe UNIQUEMENT ce qui sert a Windows (les marqueurs
# de requirements.txt ecartent MLX et pyobjc), pose le lancement au demarrage,
# puis lance le diagnostic.
#
# AVERTISSEMENT D'HONNETETE : ce script n'a JAMAIS ete execute sur une machine
# Windows. Il a ete ecrit depuis un Mac, d'apres la documentation. Le diagnostic
# de la derniere etape est justement la pour dire ce qui marche et ce qui casse.
$ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot
Write-Host "-> Verification de Python..."
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) { throw "Python introuvable. Installe-le depuis python.org, en cochant 'Add python.exe to PATH'." }
$version = & python -c "import sys; print('.'.join(map(str, sys.version_info[:3])))"
Write-Host " Python $version"
& python -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"
if ($LASTEXITCODE -ne 0) { throw "Python 3.11 minimum requis, trouve $version." }
if (-not (Test-Path ".venv")) {
Write-Host "-> Creation de l'environnement..."
& python -m venv .venv
} else {
Write-Host "-> Environnement deja present, on le reutilise."
}
Write-Host "-> Installation des dependances de CETTE machine..."
& .\.venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\.venv\Scripts\python.exe -m pip install --quiet -r requirements.txt
if ($LASTEXITCODE -ne 0) { throw "L'installation des dependances a echoue." }
Write-Host "-> Lancement au demarrage..."
# pythonw.exe : sans lui, une console noire resterait ouverte en permanence.
$demarrage = [Environment]::GetFolderPath("Startup")
$raccourci = Join-Path $demarrage "Zonza.lnk"
$shell = New-Object -ComObject WScript.Shell
$lien = $shell.CreateShortcut($raccourci)
$lien.TargetPath = (Resolve-Path ".\.venv\Scripts\pythonw.exe").Path
$lien.Arguments = '"' + (Resolve-Path ".\lanceur_qt.py").Path + '"'
$lien.WorkingDirectory = $PSScriptRoot
$lien.Description = "Zonza - dictee vocale locale"
$lien.Save()
Write-Host " $raccourci"
Write-Host "-> Diagnostic..."
& .\.venv\Scripts\python.exe app.py --diagnostic
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "Le diagnostic signale un probleme. Rien n'est casse : les lignes"
Write-Host "ci-dessus disent laquelle des epreuves a echoue."
exit 1
}
Write-Host ""
Write-Host "Installe."
Write-Host ""
Write-Host "Usage : Ctrl+Alt+D pour demarrer et arreter la dictee."
Write-Host "Journal : %LOCALAPPDATA%\Zonza\Zonza.log"
Write-Host "Lancer maintenant, sans redemarrer : .\.venv\Scripts\pythonw.exe lanceur_qt.py"
Write-Host ""
Write-Host "Si le raccourci ne repond pas, relance le diagnostic :"
Write-Host " .\.venv\Scripts\python.exe app.py --diagnostic"
Executable
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Installe Zonza sur macOS (et Linux, pour la partie portable).
#
# ./install.sh
#
# Cree l'environnement, installe UNIQUEMENT ce qui sert a cette machine (les
# marqueurs de requirements.txt s'en chargent), construit l'app macOS, puis lance
# le diagnostic. Il ne masque aucune erreur : si une etape echoue, il s'arrete.
set -euo pipefail
cd "$(dirname "$0")"
MINIMUM="3.11"
PY="${PYTHON:-python3}"
echo "→ Verification de Python…"
command -v "$PY" >/dev/null || { echo "✗ '$PY' introuvable. Installe Python $MINIMUM ou plus, ou renseigne PYTHON=/chemin/vers/python3." >&2; exit 1; }
"$PY" - <<PYCODE || { echo "✗ Python $MINIMUM minimum requis." >&2; exit 1; }
import sys
raise SystemExit(0 if sys.version_info >= tuple(int(n) for n in "$MINIMUM".split(".")) else 1)
PYCODE
echo " $("$PY" --version)"
if [[ ! -d .venv ]]; then
echo "→ Creation de l'environnement…"
"$PY" -m venv .venv
else
echo "→ Environnement deja present, on le reutilise."
fi
echo "→ Installation des dependances de CETTE machine…"
.venv/bin/python -m pip install --quiet --upgrade pip
.venv/bin/python -m pip install --quiet -r requirements.txt
echo " $(.venv/bin/python -m pip list 2>/dev/null | wc -l | tr -d ' ') paquets installes"
if [[ "$(uname)" == "Darwin" ]]; then
# Garde-fou : build_app.sh grave le chemin de CE dossier dans le binaire de
# /Applications/Zonza.app. Si une Zonza y est deja installee depuis un AUTRE
# dossier, reconstruire la detournerait vers ici — et changerait son cdhash,
# ce qui REVOQUE silencieusement les autorisations macOS deja accordees.
# Faute de ce garde-fou, un simple essai depuis une copie casse l'installation
# en service (arrive le 2026-08-23, rattrape de justesse).
CIBLE_ACTUELLE=""
if [[ -x /Applications/Zonza.app/Contents/MacOS/zonza ]]; then
CIBLE_ACTUELLE="$(strings /Applications/Zonza.app/Contents/MacOS/zonza 2>/dev/null | grep -m1 -E '/app\.py$' || true)"
fi
ICI="$(pwd)/app.py"
if [[ -n "$CIBLE_ACTUELLE" && "$CIBLE_ACTUELLE" != "$ICI" ]]; then
echo
echo "⚠ Une Zonza est deja installee, et elle pointe ailleurs :"
echo " installee ici : $CIBLE_ACTUELLE"
echo " ce dossier : $ICI"
echo
echo " Reconstruire la detournerait vers ce dossier et REVOQUERAIT ses"
echo " autorisations macOS (Accessibilite, Surveillance de l'entree)."
echo " L'application n'est donc PAS reconstruite."
echo
echo " Si c'est bien ce que tu veux, lance : ZONZA_REMPLACER=1 ./install.sh"
if [[ "${ZONZA_REMPLACER:-}" != "1" ]]; then
CONSTRUIRE=0
else
echo " ZONZA_REMPLACER=1 : reconstruction demandee explicitement."
CONSTRUIRE=1
fi
else
CONSTRUIRE=1
fi
if [[ "$CONSTRUIRE" == "1" ]]; then
echo "→ Construction de Zonza.app…"
./build_app.sh
fi
fi
echo "→ Diagnostic…"
.venv/bin/python app.py --diagnostic || {
echo
echo "✗ Le diagnostic signale un probleme. Rien n'est casse : lis les lignes"
echo " ci-dessus, elles disent laquelle des epreuves a echoue." >&2
exit 1
}
cat <<'FIN'
✓ Installe.
Il reste UNE chose que seul toi peux faire, et sans elle le raccourci ne
fonctionnera pas : accorder les autorisations macOS.
Reglages systeme > Confidentialite et securite
• Accessibilite → ajoute /Applications/Zonza.app, puis active
• Surveillance de l'entree → la meme chose
Puis quitte Zonza par son icone 🎙️ et relance-la : l'autorisation n'est lue
qu'au demarrage.
Usage : Cmd+Ctrl+D pour demarrer et arreter la dictee.
Journal : ~/Library/Logs/Zonza.log
FIN
+24 -2
View File
@@ -12,8 +12,17 @@ def nom_moteur_pour(plateforme, machine, cuda_disponible):
return "faster-gpu" if cuda_disponible else "faster-cpu" return "faster-gpu" if cuda_disponible else "faster-cpu"
def detecter_moteur(): def detecter_moteur(forcage=None):
"""Interroge la machine, puis délègue la décision à `nom_moteur_pour`.""" """Le moteur forcé par `CONFIG["moteur"]` s'il est renseigné ; sinon on
interroge la machine et on délègue la décision à `nom_moteur_pour`."""
if forcage is None:
from core import CONFIG
forcage = CONFIG.get("moteur")
return forcage or _moteur_local()
def _moteur_local():
"""Le moteur qui tourne sur CETTE machine, sans réseau."""
return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible()) return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible())
@@ -67,4 +76,17 @@ def choisir_transcripteur(nom, modele, langue, amorce):
peripherique = "cuda" if nom == "faster-gpu" else "cpu" peripherique = "cuda" if nom == "faster-gpu" else "cpu"
type_calcul = "float16" if nom == "faster-gpu" else "int8" type_calcul = "float16" if nom == "faster-gpu" else "int8"
return TranscripteurFasterWhisper(modele, langue, amorce, peripherique, type_calcul) return TranscripteurFasterWhisper(modele, langue, amorce, peripherique, type_calcul)
if nom in ("gemini", "openrouter"):
# Toujours enveloppe d'un repli : la dictee ne doit jamais etre perdue
# parce que le reseau ou l'API tombe (cf moteur/gemini.py).
from core import modele_pour_moteur
from moteur.gemini import TranscripteurAvecRepli, TranscripteurGemini
from moteur.openrouter import TranscripteurOpenRouter
classe = TranscripteurGemini if nom == "gemini" else TranscripteurOpenRouter
local = _moteur_local()
return TranscripteurAvecRepli(
classe(modele, langue, amorce),
fabriquer_secours=lambda: choisir_transcripteur(
local, modele_pour_moteur(local), langue, amorce),
)
raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}") raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}")
+197
View File
@@ -0,0 +1,197 @@
"""Transcription par l'API Gemini — l'audio part chez Google.
Contrairement à `mlx` et `faster`, ce moteur N'EST PAS local : chaque dictée
est envoyée à `generativelanguage.googleapis.com`. Il est donc toujours
enveloppé d'un `TranscripteurAvecRepli` : réseau coupé, clé refusée, quota
épuisé ou réponse vide, la dictée repasse par le moteur local au lieu d'être
perdue.
Aucune dépendance nouvelle : l'appel HTTP passe par `urllib` de la bibliothèque
standard, et la clé se lit dans le trousseau macOS (`security`) ou, à défaut,
dans la variable d'environnement GEMINI_API_KEY.
"""
import base64
import io
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
import wave
import numpy as np
from core import clean_transcript
URL_API = "https://generativelanguage.googleapis.com/v1beta/models/{modele}:generateContent"
SERVICE_TROUSSEAU = "zonza-gemini"
# Borné sous `duree_max_transcription_s` (20 s) : si l'API ne répond pas à
# temps, il doit rester de quoi basculer sur le moteur local avant que la
# garde du halo ne tombe. 15 s couvrent les 9 s mesurées de gemini-3.8-flash
# sur une dictée de 94 s.
DELAI_RESEAU_S = 15.0
CONSIGNE = (
"Tu es un moteur de dictée. Transcris mot pour mot ce que dit la personne, "
"dans la langue parlée ({langue}), avec la ponctuation et les majuscules "
"d'un texte écrit. Ne réponds pas, ne résume pas, ne commente pas, "
"n'ajoute rien : rends UNIQUEMENT le texte dicté. Si l'enregistrement ne "
"contient aucune parole, rends une chaîne vide. Termes techniques à "
"orthographier exactement : {amorce}"
)
class ErreurGemini(RuntimeError):
"""Tout échec côté Gemini : clé, réseau, quota, réponse inexploitable."""
def audio_en_wav(audio, sample_rate):
"""Encode un signal float32 mono dans [-1, 1] en WAV PCM 16 bits."""
pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype("<i2")
tampon = io.BytesIO()
with wave.open(tampon, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sample_rate)
w.writeframes(pcm.tobytes())
return tampon.getvalue()
def _lire_trousseau(service=SERVICE_TROUSSEAU):
"""Clé rangée par `security add-generic-password -s <service> ...`.
Le trousseau, et non `~/.zshrc` : Zonza est lancée par le Finder, qui ne
lit jamais la configuration du shell — une clé exportée dans `.zshrc`
serait invisible pour l'application.
"""
if sys.platform != "darwin":
return None
try:
sortie = subprocess.run(
["security", "find-generic-password", "-s", service, "-w"],
capture_output=True, text=True, timeout=5,
)
except Exception:
return None
if sortie.returncode != 0:
return None
return sortie.stdout.strip() or None
def lire_cle(trousseau=None, environ=None, service=SERVICE_TROUSSEAU,
variable="GEMINI_API_KEY"):
"""La clé du trousseau, sinon la variable d'environnement, sinon None."""
trousseau = trousseau or (lambda: _lire_trousseau(service))
environ = os.environ if environ is None else environ
return trousseau() or environ.get(variable) or None
def _envoyer(url, cle, corps, delai_s):
"""La clé part en en-tête, jamais dans l'URL (qui finit en journal)."""
return poster_json(url, {"x-goog-api-key": cle}, corps, delai_s)
def poster_json(url, entetes, corps, delai_s):
"""POST JSON ; toute erreur HTTP ou réseau devient une ErreurGemini."""
requete = urllib.request.Request(
url,
data=json.dumps(corps).encode("utf-8"),
headers={"Content-Type": "application/json", **entetes},
method="POST",
)
try:
with urllib.request.urlopen(requete, timeout=delai_s) as reponse:
return json.loads(reponse.read())
except urllib.error.HTTPError as e:
brut = e.read().decode("utf-8", "replace")
try:
detail = json.loads(brut)["error"]["message"]
except Exception:
detail = " ".join(brut.split())[:300]
raise ErreurGemini(f"HTTP {e.code} : {detail}") from e
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise ErreurGemini(f"réseau : {e}") from e
class TranscripteurGemini:
"""Implémentation du contrat Transcripteur via l'API Gemini."""
def __init__(self, modele, langue, amorce, sample_rate=16000,
lire_cle=lire_cle, envoyer=_envoyer):
self.modele = modele
self.langue = langue
self.amorce = amorce or ""
self.sample_rate = sample_rate
self._lire_cle = lire_cle
self._envoyer = envoyer
def _cle(self):
cle = self._lire_cle()
if not cle:
raise ErreurGemini(
"aucune clé Gemini : lancer `security add-generic-password "
f"-s {SERVICE_TROUSSEAU} -a zonza -w` puis relancer Zonza"
)
return cle
def warmup(self):
"""Rien à charger : on vérifie seulement la clé, sans appel payant."""
self._cle()
def transcrire(self, audio):
corps = {
"system_instruction": {"parts": [{"text": CONSIGNE.format(
langue=self.langue, amorce=self.amorce)}]},
"contents": [{"role": "user", "parts": [
{"inline_data": {
"mime_type": "audio/wav",
"data": base64.b64encode(audio_en_wav(audio, self.sample_rate)).decode("ascii"),
}},
{"text": "Transcris cet enregistrement."},
]}],
}
reponse = self._envoyer(URL_API.format(modele=self.modele), self._cle(), corps, DELAI_RESEAU_S)
candidats = reponse.get("candidates") or []
if not candidats:
raise ErreurGemini(f"réponse sans transcription : {json.dumps(reponse)[:300]}")
parts = (candidats[0].get("content") or {}).get("parts") or []
return clean_transcript("".join(p.get("text", "") for p in parts))
class TranscripteurAvecRepli:
"""Essaie le principal ; au moindre échec, bascule sur le moteur local.
Le moteur local n'est fabriqué qu'au premier besoin : tant que Gemini
répond, on n'immobilise pas 500 Mo de modèle en mémoire pour rien.
"""
def __init__(self, principal, fabriquer_secours):
self.principal = principal
self._fabriquer_secours = fabriquer_secours
self._secours = None
@property
def secours_charge(self):
return self._secours is not None
def _secours_pret(self):
if self._secours is None:
self._secours = self._fabriquer_secours()
self._secours.warmup()
return self._secours
def warmup(self):
try:
self.principal.warmup()
except Exception as e:
print(f"[api] indisponible ({e}) — moteur local chargé en secours")
self._secours_pret()
def transcrire(self, audio):
try:
return self.principal.transcrire(audio)
except Exception as e:
print(f"[api] échec ({e}) — transcription locale")
return self._secours_pret().transcrire(audio)
+79
View File
@@ -0,0 +1,79 @@
"""Transcription par OpenRouter — même famille Gemini, autre guichet.
Comme `moteur/gemini.py`, ce moteur N'EST PAS local : l'audio part chez
OpenRouter, qui le relaie à Google. L'encodage, la consigne, le repli local et
la lecture de la clé sont partagés avec le moteur Gemini direct ; seuls
l'adresse, l'authentification et le format de requête (compatible OpenAI,
partie `input_audio`) diffèrent.
"""
import base64
import json
from core import clean_transcript
from moteur.gemini import (
CONSIGNE, DELAI_RESEAU_S, ErreurGemini, audio_en_wav, lire_cle, poster_json,
)
URL_API = "https://openrouter.ai/api/v1/chat/completions"
SERVICE_TROUSSEAU = "zonza-openrouter"
def lire_cle_openrouter(trousseau=None, environ=None):
"""La clé du trousseau `zonza-openrouter`, sinon OPENROUTER_API_KEY."""
return lire_cle(trousseau, environ, service=SERVICE_TROUSSEAU,
variable="OPENROUTER_API_KEY")
def _envoyer(url, entetes, corps, delai_s):
return poster_json(url, entetes, corps, delai_s)
class TranscripteurOpenRouter:
"""Implémentation du contrat Transcripteur via OpenRouter."""
def __init__(self, modele, langue, amorce, sample_rate=16000,
lire_cle=lire_cle_openrouter, envoyer=_envoyer):
self.modele = modele
self.langue = langue
self.amorce = amorce or ""
self.sample_rate = sample_rate
self._lire_cle = lire_cle
self._envoyer = envoyer
def _cle(self):
cle = self._lire_cle()
if not cle:
raise ErreurGemini(
"aucune clé OpenRouter : lancer `security add-generic-password "
f"-s {SERVICE_TROUSSEAU} -a zonza -w` puis relancer Zonza"
)
return cle
def warmup(self):
"""Rien à charger : on vérifie seulement la clé, sans appel payant."""
self._cle()
def transcrire(self, audio):
wav = base64.b64encode(audio_en_wav(audio, self.sample_rate)).decode("ascii")
corps = {
"model": self.modele,
"messages": [
{"role": "system", "content": CONSIGNE.format(
langue=self.langue, amorce=self.amorce)},
{"role": "user", "content": [
{"type": "input_audio", "input_audio": {"data": wav, "format": "wav"}},
{"type": "text", "text": "Transcris cet enregistrement."},
]},
],
}
entetes = {"Authorization": f"Bearer {self._cle()}", "X-Title": "Zonza"}
reponse = self._envoyer(URL_API, entetes, corps, DELAI_RESEAU_S)
# OpenRouter peut rendre un 200 portant un objet `error` quand le
# fournisseur en aval tombe : ce n'est PAS une transcription vide.
if "error" in reponse:
erreur = reponse["error"]
raise ErreurGemini(f"OpenRouter : {erreur.get('message', erreur)}")
choix = reponse.get("choices") or []
if not choix:
raise ErreurGemini(f"réponse sans transcription : {json.dumps(reponse)[:300]}")
return clean_transcript(choix[0].get("message", {}).get("content") or "")
+25 -7
View File
@@ -1,14 +1,32 @@
# mlx-whisper : GPU Apple Silicon uniquement (moteur "mlx", machine actuelle). # Zonza — dependances, avec marqueurs de plateforme.
mlx-whisper>=0.4.3 #
# faster-whisper : coeur CTranslate2, tourne partout (Mac Intel, Windows avec ou # `pip install -r requirements.txt` n'installe QUE ce qui sert a la machine :
# sans GPU). Les deux coexistent car aucun des deux ne couvre a lui seul toutes # MLX et pyobjc sont propres a macOS, PySide6 sert l'overlay Qt. Sans ces
# les plateformes visees par le portage — cf moteur/__init__.py::nom_moteur_pour. # marqueurs, l'installation echouerait sur Windows (mlx-whisper n'y existe pas).
# --- transcription -------------------------------------------------------
# Deux moteurs, car aucun ne couvre a lui seul les plateformes visees
# (cf moteur/__init__.py::nom_moteur_pour).
mlx-whisper>=0.4.3; sys_platform == "darwin" and platform_machine == "arm64"
faster-whisper>=1.0 faster-whisper>=1.0
# --- interface -----------------------------------------------------------
# Cocoa sert l'overlay macOS. Ces paquets n'etaient PAS declares ici : une
# installation neuve sur un autre Mac aurait produit une Zonza qui ne demarre pas.
pyobjc-core>=12.2; sys_platform == "darwin"
pyobjc-framework-Cocoa>=12.2; sys_platform == "darwin"
pyobjc-framework-Quartz>=12.2; sys_platform == "darwin"
pyobjc-framework-ApplicationServices>=12.2; sys_platform == "darwin"
# Qt sert l'overlay partout ailleurs. Installe aussi sur macOS : la suite de
# tests y eprouve les DEUX interfaces, c'est ce qui protege le portage.
PySide6>=6.11
# --- capture, collage, calcul --------------------------------------------
sounddevice>=0.5.5 sounddevice>=0.5.5
numpy>=2.4 numpy>=2.4
pynput>=1.8.2 pynput>=1.8.2
pyperclip>=1.11 pyperclip>=1.11
pillow>=11 pillow>=11
# --- developpement -------------------------------------------------------
pytest>=8.0 pytest>=8.0
# PySide6 : uniquement hors macOS (Cocoa/pyobjc y sert deja l'overlay) — cf interface/qt.py.
PySide6>=6.11
+170
View File
@@ -0,0 +1,170 @@
"""Moteur Gemini : la transcription part chez Google, avec repli local.
Aucun test ne touche le réseau : l'envoi HTTP et la lecture de la clé sont
injectés. Ce qu'on éprouve ici, c'est la forme de la requête, la lecture de
la réponse, et surtout le repli — une dictée ne doit JAMAIS être perdue parce
que le réseau ou l'API tombe.
"""
import base64
import io
import wave
import numpy as np
import pytest
from moteur import choisir_transcripteur, detecter_moteur
from moteur.gemini import (
ErreurGemini, TranscripteurAvecRepli, TranscripteurGemini, audio_en_wav,
lire_cle,
)
def _reponse(texte):
return {"candidates": [{"content": {"parts": [{"text": texte}]}}]}
def _transcripteur(envoyer, cle="cle-test"):
return TranscripteurGemini(
"gemini-x", "fr", "Vocabulaire : Gitea, iCloud.",
lire_cle=lambda: cle, envoyer=envoyer,
)
# --- encodage audio ---------------------------------------------------------
def test_l_audio_devient_un_wav_16_bits_mono_a_16_khz():
audio = np.array([0.0, 0.5, -0.5, 1.0], dtype="float32")
with wave.open(io.BytesIO(audio_en_wav(audio, 16000))) as w:
assert (w.getnchannels(), w.getsampwidth(), w.getframerate()) == (1, 2, 16000)
echantillons = np.frombuffer(w.readframes(4), dtype="<i2")
assert list(echantillons) == [0, 16383, -16383, 32767]
def test_un_audio_sature_est_borne_au_lieu_de_deborder():
with wave.open(io.BytesIO(audio_en_wav(np.array([3.0, -3.0], dtype="float32"), 16000))) as w:
assert list(np.frombuffer(w.readframes(2), dtype="<i2")) == [32767, -32767]
# --- requête et réponse -----------------------------------------------------
def test_la_requete_porte_l_audio_le_modele_et_le_vocabulaire():
vu = {}
def envoyer(url, cle, corps, delai_s):
vu.update(url=url, cle=cle, corps=corps)
return _reponse("Bonjour.")
_transcripteur(envoyer).transcrire(np.zeros(1600, dtype="float32"))
assert vu["url"].endswith("/models/gemini-x:generateContent")
assert vu["cle"] == "cle-test"
parts = vu["corps"]["contents"][0]["parts"]
audio = next(p["inline_data"] for p in parts if "inline_data" in p)
assert audio["mime_type"] == "audio/wav"
assert base64.b64decode(audio["data"])[:4] == b"RIFF"
consigne = vu["corps"]["system_instruction"]["parts"][0]["text"]
assert "Gitea" in consigne and "iCloud" in consigne
def test_la_cle_ne_part_jamais_dans_l_url():
vu = {}
_transcripteur(lambda url, cle, corps, delai_s: vu.setdefault("url", url) and _reponse("x")) \
.transcrire(np.zeros(1600, dtype="float32"))
assert "cle-test" not in vu["url"]
def test_le_texte_rendu_est_nettoye_et_les_morceaux_joints():
reponse = {"candidates": [{"content": {"parts": [{"text": "Je pousse "}, {"text": "sur Gitea. "}]}}]}
t = _transcripteur(lambda *a: reponse)
assert t.transcrire(np.zeros(1600, dtype="float32")) == "Je pousse sur Gitea."
def test_une_reponse_sans_candidat_est_une_erreur_explicite():
t = _transcripteur(lambda *a: {"promptFeedback": {"blockReason": "OTHER"}})
with pytest.raises(ErreurGemini) as e:
t.transcrire(np.zeros(1600, dtype="float32"))
assert "OTHER" in str(e.value)
def test_une_cle_absente_est_signalee_des_le_warmup():
t = _transcripteur(lambda *a: _reponse("x"), cle=None)
with pytest.raises(ErreurGemini) as e:
t.warmup()
assert "zonza-gemini" in str(e.value)
def test_le_warmup_ne_consomme_aucun_appel_payant():
appels = []
_transcripteur(lambda *a: appels.append(a) or _reponse("x")).warmup()
assert appels == []
# --- lecture de la clé ------------------------------------------------------
def test_la_cle_du_trousseau_prime_sur_l_environnement():
assert lire_cle(trousseau=lambda: "du-trousseau", environ={"GEMINI_API_KEY": "env"}) == "du-trousseau"
def test_sans_trousseau_on_se_rabat_sur_l_environnement():
assert lire_cle(trousseau=lambda: None, environ={"GEMINI_API_KEY": "env"}) == "env"
def test_sans_aucune_cle_on_rend_none():
assert lire_cle(trousseau=lambda: None, environ={}) is None
# --- repli local ------------------------------------------------------------
class _Local:
def __init__(self):
self.appels = 0
def warmup(self):
pass
def transcrire(self, audio):
self.appels += 1
return "texte local"
def test_si_gemini_tombe_la_dictee_passe_par_le_moteur_local():
def en_panne(audio):
raise ErreurGemini("réseau coupé")
principal = type("P", (), {"warmup": lambda s: None, "transcrire": lambda s, a: en_panne(a)})()
local = _Local()
t = TranscripteurAvecRepli(principal, fabriquer_secours=lambda: local)
assert t.transcrire(np.zeros(10, dtype="float32")) == "texte local"
def test_le_moteur_local_n_est_charge_qu_au_premier_repli():
fabrications = []
principal = type("P", (), {"warmup": lambda s: None, "transcrire": lambda s, a: "de gemini"})()
t = TranscripteurAvecRepli(principal, fabriquer_secours=lambda: fabrications.append(1) or _Local())
t.warmup()
assert t.transcrire(np.zeros(10, dtype="float32")) == "de gemini"
assert fabrications == []
def test_une_cle_absente_au_warmup_ne_bloque_pas_le_demarrage():
def sans_cle(self):
raise ErreurGemini("aucune clé")
principal = type("P", (), {"warmup": sans_cle, "transcrire": lambda s, a: "x"})()
local = _Local()
t = TranscripteurAvecRepli(principal, fabriquer_secours=lambda: local)
t.warmup() # ne lève pas : le moteur local prendra le relais
assert t.secours_charge
# --- sélection du moteur ----------------------------------------------------
def test_le_forcage_de_config_prime_sur_la_detection_materielle():
assert detecter_moteur(forcage="gemini") == "gemini"
def test_la_fabrique_rend_gemini_enveloppe_d_un_repli():
t = choisir_transcripteur(nom="gemini", modele="gemini-x", langue="fr", amorce="")
assert isinstance(t, TranscripteurAvecRepli)
assert isinstance(t.principal, TranscripteurGemini)
assert callable(t.warmup) and callable(t.transcrire)
+14 -1
View File
@@ -37,7 +37,7 @@ def test_un_moteur_inconnu_est_refuse_clairement():
def test_la_table_ne_contient_que_les_moteurs_reconnus(): def test_la_table_ne_contient_que_les_moteurs_reconnus():
assert set(MODELES_PAR_MOTEUR) == {"mlx", "faster-cpu", "faster-gpu"} assert set(MODELES_PAR_MOTEUR) == {"mlx", "faster-cpu", "faster-gpu", "gemini", "openrouter"}
def test_le_reglage_manuel_prime_sur_la_table(): def test_le_reglage_manuel_prime_sur_la_table():
@@ -50,3 +50,16 @@ def test_le_reglage_manuel_prime_sur_la_table():
def test_un_forcage_vide_est_ignore(): def test_un_forcage_vide_est_ignore():
from core import modele_retenu from core import modele_retenu
assert modele_retenu("mlx", forcage="") == modele_pour_moteur("mlx") assert modele_retenu("mlx", forcage="") == modele_pour_moteur("mlx")
def test_les_moteurs_distants_ne_recoivent_jamais_un_modele_tts():
# Un modele « -tts » produit de la voix a partir de texte : il refuse
# l'audio en entree (HTTP 400 chez OpenRouter, verifie le 2026-09-25).
for nom in ("gemini", "openrouter"):
assert "tts" not in modele_pour_moteur(nom).lower()
def test_le_delai_reseau_laisse_la_place_au_repli_sous_la_garde():
from core import CONFIG
from moteur.gemini import DELAI_RESEAU_S
assert DELAI_RESEAU_S < CONFIG["duree_max_transcription_s"]
+90
View File
@@ -0,0 +1,90 @@
"""Moteur OpenRouter : même modèle Gemini, facturé et routé par OpenRouter.
Aucun test ne touche le réseau : l'envoi HTTP et la lecture de la clé sont
injectés. Le repli local, lui, est déjà éprouvé dans test_gemini.py.
"""
import base64
import numpy as np
import pytest
from moteur import choisir_transcripteur
from moteur.gemini import ErreurGemini, TranscripteurAvecRepli
from moteur.openrouter import TranscripteurOpenRouter, lire_cle_openrouter
def _reponse(texte):
return {"choices": [{"message": {"role": "assistant", "content": texte}}]}
def _transcripteur(envoyer, cle="cle-test"):
return TranscripteurOpenRouter(
"google/gemini-x", "fr", "Vocabulaire : Gitea, iCloud.",
lire_cle=lambda: cle, envoyer=envoyer,
)
def test_la_requete_porte_l_audio_le_modele_et_le_vocabulaire():
vu = {}
def envoyer(url, entetes, corps, delai_s):
vu.update(url=url, entetes=entetes, corps=corps)
return _reponse("Bonjour.")
_transcripteur(envoyer).transcrire(np.zeros(1600, dtype="float32"))
assert vu["url"] == "https://openrouter.ai/api/v1/chat/completions"
assert vu["entetes"]["Authorization"] == "Bearer cle-test"
assert vu["corps"]["model"] == "google/gemini-x"
systeme, utilisateur = vu["corps"]["messages"]
assert "Gitea" in systeme["content"] and "iCloud" in systeme["content"]
audio = next(p["input_audio"] for p in utilisateur["content"] if p["type"] == "input_audio")
assert audio["format"] == "wav"
assert base64.b64decode(audio["data"])[:4] == b"RIFF"
def test_la_cle_ne_part_jamais_dans_l_url():
vu = {}
_transcripteur(lambda url, e, c, d: vu.setdefault("url", url) and _reponse("x")) \
.transcrire(np.zeros(1600, dtype="float32"))
assert "cle-test" not in vu["url"]
def test_le_texte_rendu_est_nettoye():
t = _transcripteur(lambda *a: _reponse(" Je pousse sur Gitea. "))
assert t.transcrire(np.zeros(1600, dtype="float32")) == "Je pousse sur Gitea."
def test_une_erreur_rendue_dans_un_200_est_une_erreur_explicite():
# OpenRouter peut répondre 200 avec un objet `error` (fournisseur en panne).
t = _transcripteur(lambda *a: {"error": {"code": 502, "message": "fournisseur indisponible"}})
with pytest.raises(ErreurGemini) as e:
t.transcrire(np.zeros(1600, dtype="float32"))
assert "fournisseur indisponible" in str(e.value)
def test_une_reponse_sans_choix_est_une_erreur_explicite():
t = _transcripteur(lambda *a: {"choices": []})
with pytest.raises(ErreurGemini):
t.transcrire(np.zeros(1600, dtype="float32"))
def test_une_cle_absente_est_signalee_des_le_warmup_sans_appel():
appels = []
t = _transcripteur(lambda *a: appels.append(a), cle=None)
with pytest.raises(ErreurGemini) as e:
t.warmup()
assert "zonza-openrouter" in str(e.value)
assert appels == []
def test_la_cle_se_lit_sous_son_propre_nom():
assert lire_cle_openrouter(trousseau=lambda: None,
environ={"OPENROUTER_API_KEY": "or", "GEMINI_API_KEY": "g"}) == "or"
assert lire_cle_openrouter(trousseau=lambda: None, environ={"GEMINI_API_KEY": "g"}) is None
def test_la_fabrique_rend_openrouter_enveloppe_d_un_repli():
t = choisir_transcripteur(nom="openrouter", modele="google/gemini-x", langue="fr", amorce="")
assert isinstance(t, TranscripteurAvecRepli)
assert isinstance(t.principal, TranscripteurOpenRouter)