From 9be2ea030b122dfd1738b92e6e49726395206df2 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Fri, 25 Sep 2026 00:46:09 +0200 Subject: [PATCH] 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 --- core.py | 8 ++ moteur/__init__.py | 24 +++- moteur/gemini.py | 190 ++++++++++++++++++++++++++++++++ tests/test_gemini.py | 170 ++++++++++++++++++++++++++++ tests/test_modele_par_moteur.py | 2 +- 5 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 moteur/gemini.py create mode 100644 tests/test_gemini.py diff --git a/core.py b/core.py index 02440cc..a28ae64 100644 --- a/core.py +++ b/core.py @@ -6,6 +6,10 @@ Tout ce qui est ici est testable sans micro, sans clavier, sans modèle. import re CONFIG = { + # Moteur de transcription. None = detection materielle (mlx, faster-cpu, + # faster-gpu, tous locaux). "gemini" = l'audio part chez Google, avec le + # moteur local en repli si l'API ne repond pas (cf moteur/gemini.py). + "moteur": "gemini", # 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, @@ -186,6 +190,10 @@ MODELES_PAR_MOTEUR = { "mlx": "mlx-community/whisper-medium", "faster-gpu": "Systran/faster-whisper-medium", "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", } diff --git a/moteur/__init__.py b/moteur/__init__.py index 75ce117..54a6576 100644 --- a/moteur/__init__.py +++ b/moteur/__init__.py @@ -12,8 +12,17 @@ def nom_moteur_pour(plateforme, machine, cuda_disponible): return "faster-gpu" if cuda_disponible else "faster-cpu" -def detecter_moteur(): - """Interroge la machine, puis délègue la décision à `nom_moteur_pour`.""" +def detecter_moteur(forcage=None): + """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()) @@ -67,4 +76,15 @@ def choisir_transcripteur(nom, modele, langue, amorce): peripherique = "cuda" if nom == "faster-gpu" else "cpu" type_calcul = "float16" if nom == "faster-gpu" else "int8" return TranscripteurFasterWhisper(modele, langue, amorce, peripherique, type_calcul) + if nom == "gemini": + # 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 + local = _moteur_local() + return TranscripteurAvecRepli( + TranscripteurGemini(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}") diff --git a/moteur/gemini.py b/moteur/gemini.py new file mode 100644 index 0000000..0655d70 --- /dev/null +++ b/moteur/gemini.py @@ -0,0 +1,190 @@ +"""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 Gemini ne répond pas à +# temps, il doit rester de quoi basculer sur le moteur local avant que la +# garde du halo ne tombe. +DELAI_RESEAU_S = 12.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("