Compare commits
6
Commits
96c1379621
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1cd6373a | ||
|
|
8d316a3e0b | ||
|
|
50b12795d3 | ||
|
|
9be2ea030b | ||
|
|
5eedda0467 | ||
|
|
901d8f695a |
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user