""" Mimba BlueTTS — Ngiemboon (nnh) : démo Hugging Face Space. Sécurité du modèle ------------------ Les graphes ONNX ne sont JAMAIS versionnés dans ce Space : ils sont téléchargés au démarrage depuis un dépôt HF **privé**, avec un jeton lu dans les *Settings → Secrets* du Space (`HF_TOKEN`). Ils sont écrits hors du répertoire servi par Gradio et ne sont exposés par aucune route : l'utilisateur n'obtient que l'audio synthétisé. Différences avec le Space malgache ---------------------------------- * la phonémisation est en **Python pur** (`nnh_g2p.py`) — pas de Node.js, donc pas de `packages.txt` ; * la numération est implémentée (`nnh_nombres.py`) d'après la planche « Lesáŋǎ mmó » et validée sur les 57 formes qu'elle documente : les chiffres saisis sont convertis en toutes lettres avant synthèse ; * la transcription phonétique n'a **pas été validée par un locuteur natif** — un bandeau le signale dans l'interface. """ import os import sys import tempfile import gradio as gr import numpy as np import soundfile as sf # --- Compatibilite ZeroGPU <-> CPU ------------------------------------------------- # Un Space ZeroGPU EXIGE au moins une fonction decoree @spaces.GPU, sinon il refuse de # demarrer ("No @spaces.GPU function detected during startup"). Notre inference est en # ONNX Runtime CPU : le decorateur ne sert qu'a satisfaire ce controle. Sur un Space CPU # le module `spaces` n'existe pas -> on retombe sur un decorateur neutre. try: # Space ZeroGPU import spaces zero_gpu = spaces.GPU(duration=120) except Exception: # Space CPU (ou execution locale) def zero_gpu(fonction): return fonction sys.path.insert(0, os.path.join(os.path.dirname(__file__), "phonemizers")) from normalize_nnh import normalize_nnh, TexteNonNormalisable # noqa: E402 from nnh_g2p import phonemize as nnh_phonemize # noqa: E402 from nnh_g2p import to_vocab_compatible # noqa: E402 from reference_encoding import EncodeurReference # noqa: E402 # ============================================================ # Configuration (Settings → Variables and secrets du Space) # ============================================================ def _env(nom, defaut=""): """Variable d'environnement, en traitant la chaine VIDE comme absente. os.environ.get(nom, defaut) ne renvoie le defaut que si la variable n'existe PAS : une variable declaree vide dans les Settings du Space donnait "" (-> repo_id invalide). """ return (os.environ.get(nom) or "").strip() or defaut HF_TOKEN = _env("HF_TOKEN") MODEL_REPO = _env("MODEL_REPO", "mimba/bluetts-nnh") # sous-dossier du dépôt contenant les 7 graphes + vocab.json ("" = racine du dépôt) MODEL_SUBDIR = _env("MODEL_SUBDIR", "onnx") TOTAL_STEP = int(_env("BLUETTS_TOTAL_STEP", "16")) SPEED = float(_env("BLUETTS_SPEED", "0.95")) CFG_SCALE = float(_env("BLUETTS_CFG_SCALE", "4.0")) PACE_BLEND = float(_env("BLUETTS_PACE_BLEND", "0.30")) PACE_DPT_REF = float(_env("BLUETTS_PACE_DPT_REF", "0.0625")) BASE_CHUNK_SIZE = int(_env("BLUETTS_BASE_CHUNK_SIZE", "512")) CHUNK_COMPRESS_FACTOR = int(_env("BLUETTS_CHUNK_COMPRESS_FACTOR", "6")) LATENT_DIM = int(_env("BLUETTS_LATENT_DIM", "24")) SAMPLE_RATE = int(_env("BLUETTS_SAMPLE_RATE", "44100")) MAX_CHARS = int(_env("MAX_CHARS", "400")) # --- Statistiques d'usage (facultatif) --- # Le disque d'un Space est EPHEMERE : tout fichier ecrit est perdu au redemarrage. # Les compteurs sont donc pousses dans un depot HF (dataset), de preference PRIVE. # Laisser STATS_REPO vide desactive completement la fonctionnalite. STATS_REPO = _env("STATS_REPO") # ex. "mimba/nnh-tts-stats" STATS_SALT = _env("STATS_SALT", "mimba-space") # sel du hachage des IP STATS_FLUSH_EVERY = int(_env("STATS_FLUSH_EVERY", "10")) # ecriture tous les N usages STATS_FLUSH_SECONDS = int(_env("STATS_FLUSH_SECONDS", "600")) # Voix de demonstration : depose des .wav dans voix_demo/ pour les activer. # Le dossier est VIDE par defaut — contrairement au Space malgache, nous n'avons pas # d'enregistrements de locuteurs ngiemboon dont l'usage soit clairement autorise. # Sans voix, le visiteur devra fournir son propre extrait (l'interface s'adapte). _DOSSIER_VOIX = os.path.join(os.path.dirname(__file__), "voix_demo") _ETIQUETTES = {"homme.wav": "Voix d'homme", "femme.wav": "Voix de femme"} VOIX_DEMO = {} if os.path.isdir(_DOSSIER_VOIX): for fichier in sorted(os.listdir(_DOSSIER_VOIX)): if fichier.lower().endswith((".wav", ".flac", ".mp3")): libelle = _ETIQUETTES.get(fichier, os.path.splitext(fichier)[0]) VOIX_DEMO[libelle] = os.path.join(_DOSSIER_VOIX, fichier) VOIX_DEFAUT = next(iter(VOIX_DEMO), None) # Exemples par defaut. PROVENANCE : mots et syntagmes tires de la documentation de # l'orthographe ngiemboon (planche Omniglot / AGLC) ayant servi a construire le G2P. # Ce ne sont donc PAS des phrases que j'aurais composees — mais ils n'ont pas non plus # ete relus par un locuteur. Remplace-les par des phrases de corpus des que possible, # via la variable EXEMPLES du Space (separateur « | »). EXEMPLES_DEFAUT = [ "Tà pi pɔ́g, mèŋ n tóo pɔ́ lépÿé gẅi nò mbòŋo, á gwɔ́ gie á ge náa máa lekág tsɛ̀ɛ ngwòŋ gẅí.", "Mèŋ ngyǎ tsɔ̌ nyìŋ mbǒŋ lóŋ á kẅéte wɔ́ɔn na pɔ̀ɔn yɔ́ɔn.", "À pfɛ̌ ngesáŋ sẅisẅê.", "Mèŋ n zɔ́gɔ ngie à kwoŋo wɔ́ɔn páʼa é gwɔ́ pɔ́ kwòŋ menÿɔ́g.", "La gʉa ná pá’ mecÿɔ́’ Ssé gwɔ́, ésẅé’e yé ńnáa ssé pɔ́ méjʉ’ ńcÿó gʉm yé, ńtɔ’ɔ cÿɔ́’ɔ lefùɔ twó yé, á gie á gÿo á gẅiin legú’.", ] EXEMPLES = [e.strip() for e in _env("EXEMPLES", "").split("|") if e.strip()] \ or EXEMPLES_DEFAUT PHRASE_DEFAUT = _env("PHRASE_DEFAUT", EXEMPLES[0] if EXEMPLES else "") # Clavier d'appoint : ces caracteres sont absents d'un clavier standard, et sans eux # le visiteur ne peut tout simplement RIEN saisir en ngiemboon. # Les tons sont des diacritiques COMBINANTS : ajoutes en fin de texte, ils se collent # a la voyelle precedente, ce qui correspond a l'ordre de frappe naturel. CLAVIER = [ ("ʉ", "ʉ"), ("ŋ", "ŋ"), ("ɔ", "ɔ"), ("ɛ", "ɛ"), ("ÿ", "ÿ"), ("ẅ", "ẅ"), ("ʼ", "ʼ"), ("◌́ haut", "́"), ("◌̀ bas", "̀"), ("◌̂ desc.", "̂"), ("◌̌ mont.", "̌"), ] # Répertoire privé : hors du dossier de travail servi par Gradio. ONNX_DIR = os.path.join(tempfile.gettempdir(), "bluetts_onnx") _sessions = {} _vf_inputs = set() _vocoder_input = None _char_to_id, _pad_id = None, 0 _ref_encoder = None # ============================================================ # Statistiques d'usage — RGPD : aucune donnée directement identifiante # ============================================================ # Ce qui est conserve : compteurs par jour, nombre de visiteurs DISTINCTS (empreintes # hachees + salees, irreversibles), voix utilisee, pays si l'hebergeur le fournit. # Ce qui n'est JAMAIS conserve : IP en clair, texte saisi, audio envoye. _stats = {"total": 0, "par_jour": {}, "par_voix": {}, "empreintes": [], "maj": None} _stats_depuis_flush = 0 _stats_dernier_flush = 0.0 FICHIER_STATS = "stats.json" def _empreinte(requete): """Empreinte anonyme et stable d'un visiteur (IP + navigateur, hachees + salees). Irreversible : sert uniquement a compter des visiteurs distincts.""" import hashlib ip = navigateur = "" try: entetes = dict(getattr(requete, "headers", {}) or {}) ip = (entetes.get("x-forwarded-for") or "").split(",")[0].strip() navigateur = entetes.get("user-agent", "") except Exception: pass if not ip and not navigateur: return None brut = f"{STATS_SALT}|{ip}|{navigateur}" return hashlib.sha256(brut.encode("utf-8")).hexdigest()[:16] def _charger_stats(): """Recupere les compteurs existants : le disque du Space etant efface a chaque redemarrage, l'historique ne survit que dans le depot HF.""" global _stats if not STATS_REPO: return try: import json from huggingface_hub import hf_hub_download chemin = hf_hub_download(STATS_REPO, FICHIER_STATS, repo_type="dataset", token=HF_TOKEN) with open(chemin, encoding="utf-8") as f: precedent = json.load(f) for cle in ("total", "par_jour", "par_voix", "empreintes"): if cle in precedent: _stats[cle] = precedent[cle] print(f"[stats] historique repris : {_stats['total']} usages, " f"{len(_stats['empreintes'])} visiteurs distincts", flush=True) except Exception as e: print(f"[stats] pas d'historique ({type(e).__name__}) -- demarrage a zero", flush=True) def _pousser_stats(): global _stats_depuis_flush, _stats_dernier_flush if not STATS_REPO: return try: import json import time as _t from huggingface_hub import HfApi _stats["maj"] = _t.strftime("%Y-%m-%dT%H:%M:%SZ", _t.gmtime()) local = os.path.join(tempfile.gettempdir(), FICHIER_STATS) with open(local, "w", encoding="utf-8") as f: json.dump(_stats, f, ensure_ascii=False, indent=2) HfApi().upload_file( path_or_fileobj=local, path_in_repo=FICHIER_STATS, repo_id=STATS_REPO, repo_type="dataset", token=HF_TOKEN, commit_message=f"{_stats['total']} usages / {len(_stats['empreintes'])} visiteurs") _stats_depuis_flush = 0 _stats_dernier_flush = _t.time() except Exception as e: print(f"[stats] ecriture impossible : {repr(e)[:120]}", flush=True) def _enregistrer_usage(requete, nom_voix, propre_extrait): """Incremente les compteurs, et n'ecrit sur HF que par LOTS : une ecriture par synthese depasserait vite la limite HF de 128 commits/heure.""" global _stats_depuis_flush if not STATS_REPO: return try: import time as _t jour = _t.strftime("%Y-%m-%d", _t.gmtime()) _stats["total"] += 1 _stats["par_jour"][jour] = _stats["par_jour"].get(jour, 0) + 1 cle_voix = "voix fournie par le visiteur" if propre_extrait else (nom_voix or "?") _stats["par_voix"][cle_voix] = _stats["par_voix"].get(cle_voix, 0) + 1 emp = _empreinte(requete) if emp and emp not in _stats["empreintes"]: _stats["empreintes"].append(emp) _stats_depuis_flush += 1 assez_d_usages = _stats_depuis_flush >= STATS_FLUSH_EVERY assez_de_temps = (_t.time() - _stats_dernier_flush) >= STATS_FLUSH_SECONDS if assez_d_usages or assez_de_temps: _pousser_stats() except Exception as e: print(f"[stats] non enregistre : {repr(e)[:120]}", flush=True) # ============================================================ # Chargement du modèle (dépôt privé -> disque temporaire) # ============================================================ def _telecharger_modele(): """Récupère les graphes ONNX depuis le dépôt privé. Le jeton ne quitte jamais le serveur du Space et aucun fichier n'est exposé par une route Gradio.""" from huggingface_hub import snapshot_download if not HF_TOKEN: raise RuntimeError( "Secret HF_TOKEN manquant. Settings → Variables and secrets → " "New secret : HF_TOKEN = jeton avec accès en lecture au dépôt du modèle." ) if not MODEL_REPO or "/" not in MODEL_REPO: raise RuntimeError( f"MODEL_REPO invalide ({MODEL_REPO!r}). Attendu 'compte/depot', " "ex. 'mimba/bluetts-nnh'. Laisse la variable VIDE ou supprime-la pour " "utiliser la valeur par defaut." ) motif = f"{MODEL_SUBDIR}/*" if MODEL_SUBDIR else "*" # NB : pas de local_dir_use_symlinks — l'argument a ete SUPPRIME dans # huggingface_hub 1.x (TypeError). Le defaut copie deja les fichiers. chemin = snapshot_download( MODEL_REPO, token=HF_TOKEN, allow_patterns=[motif], local_dir=ONNX_DIR, ) return os.path.join(chemin, MODEL_SUBDIR) if MODEL_SUBDIR else chemin def charger(): global _sessions, _vf_inputs, _vocoder_input, _char_to_id, _pad_id, _ref_encoder import json import onnxruntime as ort print(f"[space] depot={MODEL_REPO} sous-dossier={MODEL_SUBDIR!r} " f"token={'oui' if HF_TOKEN else 'NON'}", flush=True) dossier = _telecharger_modele() opts = ort.SessionOptions() opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL for nom in ("text_encoder", "vector_estimator", "vocoder", "duration_predictor"): _sessions[nom] = ort.InferenceSession( os.path.join(dossier, f"{nom}.onnx"), opts, providers=["CPUExecutionProvider"]) _vf_inputs = {i.name for i in _sessions["vector_estimator"].get_inputs()} _vocoder_input = _sessions["vocoder"].get_inputs()[0].name with open(os.path.join(dossier, "vocab.json"), encoding="utf-8") as f: brut = json.load(f) _char_to_id = brut["char_to_id"] _pad_id = int(brut.get("pad_id", 0)) _ref_encoder = EncodeurReference(dossier, sample_rate=SAMPLE_RATE, chunk_compress_factor=CHUNK_COMPRESS_FACTOR) print(f"[space] modele charge ({len(_char_to_id)} phonemes)", flush=True) _charger_stats() # ============================================================ # Inférence — logique identique au worker de production # ============================================================ def _texte_vers_ids(texte): return [_char_to_id.get(c, _pad_id) for c in texte] def _phonemes_hors_vocab(phonemes): """Symboles absents du vocabulaire : ils deviendraient du PAD, donc du silence. Les signaler explicitement evite un audio tronque sans explication.""" if not _char_to_id: return [] return sorted({c for c in phonemes if c not in _char_to_id and not c.isspace()}) def _blend_duration_pace(dur, text_mask): blend = min(max(PACE_BLEND, 0.0), 1.0) if blend <= 0.0: return np.asarray(dur, dtype=np.float32).reshape(-1) d = np.asarray(dur, dtype=np.float64).reshape(-1) n = np.maximum(np.asarray(text_mask, dtype=np.float64).sum(axis=(1, 2)), 1.0).reshape(-1) return (((1.0 - blend) * (d / n) + blend * PACE_DPT_REF) * n).astype(np.float32) def _latent_mask(wav_lengths): taille = BASE_CHUNK_SIZE * CHUNK_COMPRESS_FACTOR lat = (wav_lengths + taille - 1) // taille ids = np.arange(0, int(lat.max())) m = (ids < np.expand_dims(lat, 1)).astype(np.float32) return m.reshape(-1, 1, m.shape[-1]) def _normaliser_audio(wav, target_rms=0.08, peak_limit=0.95): """Le vocodeur sort à un pic ~1.5 : sans ce gain, l'écriture WAV écrête (distorsion métallique, « son dans un fût »).""" wav = np.asarray(wav, dtype=np.float32) if wav.size == 0 or not np.isfinite(wav).all(): return wav pic = float(np.max(np.abs(wav))) if pic < 1e-6: return wav actifs = np.abs(wav) > max(pic * 0.02, 1e-4) ech = wav[actifs] if np.any(actifs) else wav rms = float(np.sqrt(np.mean(np.square(ech)))) if rms < 1e-6: return wav gain = min(target_rms / rms, peak_limit / pic, 4.0) return wav if np.isclose(gain, 1.0) else (wav * gain).astype(np.float32) def _synthetiser(texte_phonemise, style_ttl, style_dp, seed, total_step, speed): text_ids = np.array([_texte_vers_ids(texte_phonemise)], dtype=np.int64) text_mask = np.ones((1, 1, text_ids.shape[1]), dtype=np.float32) dur, *_ = _sessions["duration_predictor"].run( None, {"text_ids": text_ids, "style_dp": style_dp, "text_mask": text_mask}) dur = _blend_duration_pace(np.asarray(dur, np.float32).reshape(-1), text_mask) dur = dur / max(speed, 1e-6) text_emb, *_ = _sessions["text_encoder"].run( None, {"text_ids": text_ids, "style_ttl": style_ttl, "text_mask": text_mask}) chunk = BASE_CHUNK_SIZE * CHUNK_COMPRESS_FACTOR wav_lengths = (dur * SAMPLE_RATE).astype(np.int64) latent_len = int(np.ceil((dur.max() * SAMPLE_RATE) / chunk)) rng = np.random.default_rng(seed) xt = rng.standard_normal((1, LATENT_DIM * CHUNK_COMPRESS_FACTOR, latent_len)).astype(np.float32) lm = _latent_mask(wav_lengths) xt = xt * lm total_t = np.array([total_step], dtype=np.float32) for etape in range(total_step): cond = {"noisy_latent": xt, "text_emb": text_emb, "style_ttl": style_ttl, "text_mask": text_mask, "latent_mask": lm, "current_step": np.array([etape], dtype=np.float32), "total_step": total_t} if "cfg_scale" in _vf_inputs: cond["cfg_scale"] = np.array([CFG_SCALE], dtype=np.float32) xt, *_ = _sessions["vector_estimator"].run(None, cond) wav, *_ = _sessions["vocoder"].run(None, {_vocoder_input: xt}) bord = BASE_CHUNK_SIZE * CHUNK_COMPRESS_FACTOR if wav.shape[-1] > 2 * bord: wav = wav[..., bord:-bord] if wav.ndim == 3 and wav.shape[1] == 1: wav = wav[:, 0, :] return wav[0] def _resoudre_reference(audio_ref, voix_demo): """Un extrait fourni par le visiteur prime toujours sur la voix de demonstration.""" if audio_ref: return audio_ref chemin = VOIX_DEMO.get(voix_demo) or (VOIX_DEMO.get(VOIX_DEFAUT) if VOIX_DEFAUT else None) if not chemin: raise gr.Error("Fournissez un extrait de voix de référence (5 à 30 secondes).") return chemin def apercu_voix_demo(voix_demo): """Petit lecteur : entendre la voix de demonstration avant de synthetiser.""" return VOIX_DEMO.get(voix_demo) @zero_gpu def synthese(texte, audio_ref, voix_demo, total_step, speed, seed, progress=gr.Progress(), request: gr.Request = None): if not texte or not texte.strip(): raise gr.Error("Saisissez un texte en ngiemboon.") if len(texte) > MAX_CHARS: raise gr.Error(f"Texte trop long ({len(texte)} caractères, maximum {MAX_CHARS}).") audio_fourni = bool(audio_ref) # distingue extrait du visiteur / voix fournie audio_ref = _resoudre_reference(audio_ref, voix_demo) progress(0.15, desc="Phonémisation…") try: normalise, inferes = normalize_nnh(texte.strip(), retourner_inferes=True) except TexteNonNormalisable as e: raise gr.Error(str(e)) phonemes = to_vocab_compatible(nnh_phonemize(normalise)) inconnus = _phonemes_hors_vocab(phonemes) if inconnus: # Un symbole absent devient du PAD, donc un blanc : mieux vaut le dire. print(f"[nnh] hors vocabulaire : {inconnus}", flush=True) progress(0.35, desc="Encodage de la voix de référence…") style_ttl, style_dp = _ref_encoder.encoder(audio_ref) progress(0.55, desc="Synthèse…") wav = _synthetiser(phonemes, style_ttl, style_dp, int(seed), int(total_step), float(speed)) wav = _normaliser_audio(wav) sortie = os.path.join(tempfile.mkdtemp(), "mimba_bluetts.wav") sf.write(sortie, wav, SAMPLE_RATE) _enregistrer_usage(request, voix_demo, propre_extrait=bool(audio_fourni)) duree = len(wav) / SAMPLE_RATE details = f"{duree:.2f} s · {len(phonemes)} phonèmes · {int(total_step)} pas" if inconnus: details += f"\n\n⚠️ Symboles ignorés (absents du modèle) : `{' '.join(inconnus)}`" if inferes: # Multiplicateurs deduits par regularite, absents de la planche source : # le dire evite de faire passer une extrapolation pour une forme attestee. details += ("\n\n⚠️ Nombre converti avec des formes **non attestées** " f"(multiplicateur {', '.join(str(i) for i in sorted(inferes))}) : " "à faire vérifier par un locuteur.") return sortie, details # ============================================================ # Interface # ============================================================ THEME = gr.themes.Soft( primary_hue=gr.themes.colors.emerald, secondary_hue=gr.themes.colors.amber, neutral_hue=gr.themes.colors.slate, font=(gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"), ).set( body_background_fill="linear-gradient(160deg, #ecfdf5 0%, #f8fafc 45%, #fffbeb 100%)", body_background_fill_dark="linear-gradient(160deg, #04201a 0%, #0f172a 50%, #2a1f05 100%)", button_primary_background_fill="linear-gradient(92deg, #059669 0%, #f59e0b 100%)", button_primary_background_fill_hover="linear-gradient(92deg, #047857 0%, #d97706 100%)", button_primary_text_color="#ffffff", block_radius="16px", block_shadow="0 10px 30px -12px rgba(15,23,42,.25)", ) CSS = """ #entete {text-align:center; padding: 1.4rem 1rem .4rem} #entete h1 {font-size: 2.1rem; margin: 0 0 .35rem; font-weight: 800; letter-spacing:-.02em; background: linear-gradient(92deg,#059669,#f59e0b); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text} #entete p {margin:0; opacity:.75} .pastille {display:inline-block; padding:.2rem .6rem; border-radius:999px; font-size:.78rem; background:rgba(5,150,105,.12); color:#059669; margin:.15rem .2rem} #avertissement {border-left:3px solid #f59e0b; padding:.6rem .9rem; margin:.6rem 0; background:rgba(245,158,11,.08); border-radius:8px; font-size:.9rem} footer {visibility: hidden} """ # Gradio 6 : theme et css se passent a launch(), plus au constructeur Blocks() # (sinon ils sont IGNORES -- avertissement au demarrage et interface non stylee). with gr.Blocks(title="Mimba BlueTTS — Ngiemboon") as demo: gr.HTML( "
" "

Mimba BlueTTS — Ngiemboon

" "

Synthèse vocale en ngiemboon avec clonage de voix zero-shot.

" "
nnhclonage zero-shot" "ONNX · CPU
" ) gr.HTML( "
" "Démonstration expérimentale. La conversion graphème→phonème du ngiemboon " "a été construite à partir de l'orthographe AGLC mais n'a pas été validée par " "un locuteur natif. Le corpus d'entraînement est de la voix de synthèse. " "La prononciation peut donc être fautive — les retours de locuteurs sont les " "bienvenus." "
" ) with gr.Row(): with gr.Column(scale=3): texte = gr.Textbox( label="Texte en ngiemboon", lines=4, max_lines=8, value=PHRASE_DEFAUT, placeholder="Écrivez en orthographe AGLC (tons compris : á à â ǎ). " "Les chiffres sont convertis automatiquement.", autofocus=True, ) # Clavier d'appoint : sans lui, un visiteur equipe d'un clavier standard ne # peut saisir ni les voyelles ni les tons du ngiemboon. with gr.Row(): boutons_clavier = [ gr.Button(libelle, size="sm", min_width=54, variant="secondary") for libelle, _ in CLAVIER ] gr.Markdown( "Clavier ngiemboon : les tons " "s'ajoutent à la voyelle qui précède." ) voix_demo = gr.Dropdown( label="Voix fournie", choices=list(VOIX_DEMO.keys()), value=VOIX_DEFAUT, info="Utilisée si vous ne fournissez pas votre propre extrait.", visible=bool(VOIX_DEMO), ) apercu = gr.Audio(label="Écouter la voix fournie", type="filepath", value=VOIX_DEMO.get(VOIX_DEFAUT) if VOIX_DEFAUT else None, interactive=False, visible=bool(VOIX_DEMO)) audio_ref = gr.Audio( label="Votre voix de référence (5 à 30 s, une seule personne, sans bruit de fond)" if not VOIX_DEMO else "Ou votre propre voix (5 à 30 s, une seule personne, sans bruit de fond)", type="filepath", sources=["upload", "microphone"], ) with gr.Accordion("Réglages avancés", open=False): total_step = gr.Slider(4, 32, value=TOTAL_STEP, step=1, label="Pas de génération", info="Plus élevé = plus net, mais plus lent") speed = gr.Slider(0.7, 1.3, value=SPEED, step=0.05, label="Débit", info="< 1 ralentit, > 1 accélère") seed = gr.Number(value=42, precision=0, label="Graine aléatoire", info="Même graine = même rendu") bouton = gr.Button("Synthétiser", variant="primary", size="lg") with gr.Column(scale=2): sortie = gr.Audio(label="Résultat", type="filepath", autoplay=False) infos = gr.Markdown("") gr.Markdown( "**Conseils**\n" "- Écrivez les **tons** : ils sont interprétés (á à â ǎ) et changent le sens.\n" "- L'apostrophe `ʼ` note l'occlusive glottale — ne l'omettez pas.\n" "- Une référence propre (voix seule, sans musique) change tout.\n" "- Le clonage fonctionne sur n'importe quelle voix, sans entraînement.\n" "- Les **chiffres** sont convertis en toutes lettres " "(`21` → `ntsɔ̀b wémɔ́ʼɔ lé móm mbʉ́a`) ; pas les décimales." ) if EXEMPLES: gr.Examples(examples=[[e] for e in EXEMPLES], inputs=[texte], label="Exemples") # api_name=False : aucune route d'API publique n'est generee pour ces fonctions # (remplace show_api=, supprime de launch() dans Gradio 6). for bouton_c, (_, caractere) in zip(boutons_clavier, CLAVIER): # `c=caractere` fige la valeur : sans ce defaut, toutes les lambdas # partageraient la derniere variable de boucle et inserreraient le meme signe. bouton_c.click(lambda t, c=caractere: (t or "") + c, inputs=[texte], outputs=[texte], api_name=False) voix_demo.change(apercu_voix_demo, inputs=[voix_demo], outputs=[apercu], api_name=False) bouton.click(synthese, inputs=[texte, audio_ref, voix_demo, total_step, speed, seed], outputs=[sortie, infos], api_name=False) gr.Markdown( "
" "Modèle finetuné par Mimba sur un corpus multi-voix synthétique (525 timbres). " "Usage non commercial (CC BY-NC-SA 4.0)." "
" ) if __name__ == "__main__": charger() # Gradio 6 : show_api a ete retire de launch(). L'exposition est desormais coupee # au niveau des evenements (api_name=False, voir plus haut). # ssr_mode=False : le rendu serveur de Gradio 6 est encore experimental et lance un # serveur Node.js annexe, source de traces asyncio parasites a l'arret # ("Exception ignored in BaseEventLoop.__del__ / Invalid file descriptor: -1"). demo.queue(max_size=12).launch(theme=THEME, css=CSS, ssr_mode=False)