ngiemboon-tts / app.py
mimba's picture
adding bluetts nnh
fe91d13
Raw
History Blame Contribute Delete
27.5 kB
"""
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(
"<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)