Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |
| 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( | |
| "<div id='entete'>" | |
| "<h1>Mimba BlueTTS — Ngiemboon</h1>" | |
| "<p>Synthèse vocale en ngiemboon avec clonage de voix <em>zero-shot</em>.</p>" | |
| "<div><span class='pastille'>nnh</span><span class='pastille'>clonage zero-shot</span>" | |
| "<span class='pastille'>ONNX · CPU</span></div></div>" | |
| ) | |
| gr.HTML( | |
| "<div id='avertissement'>" | |
| "<b>Démonstration expérimentale.</b> La conversion graphème→phonème du ngiemboon " | |
| "a été construite à partir de l'orthographe AGLC mais <b>n'a pas été validée par " | |
| "un locuteur natif</b>. 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." | |
| "</div>" | |
| ) | |
| 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( | |
| "<span style='opacity:.6;font-size:.82rem'>Clavier ngiemboon : les tons " | |
| "s'ajoutent à la voyelle qui précède.</span>" | |
| ) | |
| 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( | |
| "<div style='text-align:center;opacity:.65;font-size:.85rem;margin-top:1rem'>" | |
| "Modèle finetuné par <b>Mimba</b> sur un corpus multi-voix synthétique (525 timbres). " | |
| "Usage non commercial (CC BY-NC-SA 4.0)." | |
| "</div>" | |
| ) | |
| 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) | |