"""Importe de vraies voix francaises dans la voicebank (clips + ref_text). Probleme resolu : `build_voicebank()` generait les clips de reference *avec Kokoro lui-meme* — et la plupart des voix empruntaient un timbre Kokoro **anglais** lisant du francais phonemise. Resultat : un fort accent anglais que Qwen3 clonait fidelement. Ce script **remplace toute la banque** par de vrais enregistrements de locuteurs francais, ce qui donne a Qwen3 une reference de timbre reellement francophone. Source : **CML-TTS French** (`ylacombe/cml-tts`, config `french`), CC-BY, non-gated. Corpus de **livres audio** taille pour le TTS : voix studio, registre narrateur, prose reelle. On telecharge des shards parquet (audio WAV 24 kHz embarque) via `huggingface_hub`, shard apres shard, jusqu'a remplir les quotas. Allocation des roles (chaque voix = un locuteur distinct, `speaker_id`) : - 1 **narrateur** dedie (`fr_narrator`). - N **voix nommees** par genre (`fr_f_*`, `fr_m_*`) pour les personnages. - M **voix anonymes** par genre (`fr_anon_f_*`, `fr_anon_m_*`, `anonymous=True`), reservees aux figurants "anonyme (...)" par `assign_voices` (jamais melangees avec les voix nommees). Qualite : un clip par locuteur, le plus propre (`levenshtein` mini), duree bornee. Genre absent du corpus -> estime par **F0 (YIN, anti-octave)**. Usage (depuis backend/, venv actif) : python scripts/import_voices.py # quotas par defaut, REMPLACE la banque python scripts/import_voices.py --named-f 18 --named-m 14 --anon 4 python scripts/import_voices.py --shards french/dev/0002.parquet french/dev/0000.parquet Le clip est ecrit a son sr natif (24 kHz) ; Qwen3 reechantillonne la reference. La banque resultante a un `ref_audio` partout, donc `build_voicebank()` (legacy) ne la regenerera pas. Le `kokoro_voice` reste renseigne (preset de meme genre) pour le preview/draft Kokoro ; le timbre final vient du ref_audio via Qwen3. """ from __future__ import annotations import argparse import io import sys from pathlib import Path import numpy as np import soundfile as sf # Permet de lancer le script sans `pip install -e` : on ajoute backend/ au path. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from inkflow.casting.voicebank import save_voicebank # noqa: E402 from inkflow.config import VOICEBANK_DIR # noqa: E402 from inkflow.models import VoiceEntry, Voicebank # noqa: E402 # Presets Kokoro de secours par genre (preview/draft uniquement ; le timbre final # vient du ref_audio clone par Qwen3). On cycle dessus pour varier les previews. _KOKORO_BY_GENDER = { "female": ["af_bella", "af_heart", "af_nicole", "bf_emma"], "male": ["am_fenrir", "am_michael", "bm_george", "am_eric"], } # Shards CML-TTS French par defaut (branche refs/convert/parquet). dev/test # partagent un petit pool fixe de locuteurs (~17F/17M au total) ; la variete est # dans train (chaque shard = quelques lecteurs distincts). On lit test (le plus # fourni) puis des shards train jusqu'a remplir les quotas. _DEFAULT_SHARDS = ( ["french/test/0000.parquet", "french/dev/0002.parquet"] + [f"french/train/{i:04d}.parquet" for i in range(12)] ) def _to_mono(arr: np.ndarray) -> np.ndarray: arr = np.asarray(arr, dtype=np.float32) if arr.ndim > 1: arr = arr.mean(axis=1) return arr def _yin_f0(frame: np.ndarray, sr: int, lo: int, hi: int, thresh: float = 0.15) -> float: """F0 d'une trame par YIN (anti-octave). 0.0 si non voisee. 1) fonction de difference d(tau) ; 2) moyenne cumulee normalisee d'(tau) ; 3) premier tau sous le seuil absolu (evite de prendre l'octave superieure). C'est l'etape (2)-(3) qui rend YIN robuste aux erreurs d'octave de l'autocorrelation simple (qui faisait passer un homme pour une femme). """ n = len(frame) diff = np.zeros(hi + 1) for tau in range(1, hi + 1): d = frame[: n - tau] - frame[tau:n] diff[tau] = np.dot(d, d) cum = np.cumsum(diff[1:]) cmnd = np.ones(hi + 1) taus = np.arange(1, hi + 1) cmnd[1:] = diff[1:] * taus / np.maximum(cum, 1e-9) tau = -1 t = lo while t < hi: if cmnd[t] < thresh: while t + 1 < hi and cmnd[t + 1] < cmnd[t]: t += 1 # descend jusqu'au minimum local tau = t break t += 1 if tau == -1: # aucun creux net -> min global de la bande tau = lo + int(np.argmin(cmnd[lo:hi])) if cmnd[tau] > 0.6: # vraiment pas de periodicite -> non voisee return 0.0 return sr / tau def estimate_gender(arr: np.ndarray, sr: int) -> tuple[str, float]: """Estime le genre par F0 mediane (YIN par trame, numpy pur). Voix parlee : H ~85-180 Hz (med ~120), F ~165-255 Hz (med ~210). Renvoie ("unknown", med) si la mediane tombe dans la zone ambigue 150-180 Hz -> on prefere ecarter le locuteur que de mal le classer (assez de candidats). """ win = int(0.04 * sr) hop = win // 2 lo = max(1, int(sr / 350)) # 350 Hz hi = int(sr / 70) # 70 Hz energy_thresh = 0.10 * np.sqrt(np.mean(arr ** 2) + 1e-9) f0s: list[float] = [] for start in range(0, max(0, len(arr) - win), hop): frame = arr[start:start + win].astype(np.float64) if np.sqrt(np.mean(frame ** 2)) < energy_thresh: continue f0 = _yin_f0(frame - frame.mean(), sr, lo, hi) if f0 > 0: f0s.append(f0) if len(f0s) < 10: return "unknown", 0.0 med = float(np.median(f0s)) if 150 <= med <= 180: return "unknown", med return ("male" if med < 165 else "female"), med def _iter_parquet_rows(dataset: str, shard: str): """Telecharge le shard parquet (audio embarque) et itere ses lignes en dict.""" from huggingface_hub import hf_hub_download import pyarrow.parquet as pq print(f" · telechargement {shard}…", flush=True) path = hf_hub_download(dataset, shard, repo_type="dataset", revision="refs/convert/parquet") pf = pq.ParquetFile(path) for batch in pf.iter_batches(batch_size=128): cols = {name: batch.column(name) for name in batch.schema.names} for i in range(batch.num_rows): yield {name: col[i].as_py() for name, col in cols.items()} def _gather_voices(dataset, shards, min_dur, max_dur, max_lev, need_f, need_m): """Collecte des locuteurs distincts classes par genre (YIN), shard par shard. S'arrete des que chaque genre a assez de candidats. Renvoie {"female": [(spk, lev, bytes, text), ...trie par qualite], "male": [...]}. """ best: dict[object, dict] = {} # speaker_id -> meilleur clip vu classified: dict[object, str] = {} # speaker_id -> gender (cache) buckets = {"female": [], "male": []} for shard in shards: for row in _iter_parquet_rows(dataset, shard): dur = row.get("duration") or 0.0 if not (min_dur <= dur <= max_dur): continue nwords = row.get("num_words") or 0 # Debit de parole : un ref_text qui ne couvre pas l'audio (fragment # tronque, ou audio plein de silence) casse le clonage Qwen3 (sortie # vide). On exige un debit plausible 1.5-4.5 mots/s. wps = nwords / dur if dur else 0 if nwords < 8 or not (1.5 <= wps <= 4.5): continue lev = (row.get("levenshtein") or 0) / max(nwords, 1) if lev > max_lev: continue spk = row.get("speaker_id") text = (row.get("text") or "").strip() if spk is None or len(text) < 15: continue cur = best.get(spk) if cur is None or lev < cur["lev"]: best[spk] = {"lev": lev, "bytes": row["audio"]["bytes"], "text": text} # Classe les nouveaux locuteurs de ce shard. for spk, c in best.items(): if spk in classified: continue arr, sr = sf.read(io.BytesIO(c["bytes"]), dtype="float32") g, _ = estimate_gender(_to_mono(arr), sr) classified[spk] = g if g in buckets: buckets[g].append((spk, c["lev"], c["bytes"], c["text"])) nf, nm = len(buckets["female"]), len(buckets["male"]) print(f" -> {nf} femmes / {nm} hommes candidats", flush=True) if nf >= need_f and nm >= need_m: break for g in buckets: buckets[g].sort(key=lambda t: t[1]) # plus propre d'abord return buckets def _write_clip(vid: str, raw: bytes) -> tuple[str, int]: arr, sr = sf.read(io.BytesIO(raw), dtype="float32") arr = _to_mono(arr) rel = f"clips/{vid}.wav" sf.write(str(VOICEBANK_DIR / rel), arr, sr) return rel, sr def _entry(vid, gender, idx, spk, text, *, anonymous, label) -> VoiceEntry: kokoro = _KOKORO_BY_GENDER[gender][(idx - 1) % len(_KOKORO_BY_GENDER[gender])] rel, _ = _write_clip(vid, spk[2]) return VoiceEntry(id=vid, kokoro_voice=kokoro, gender=gender, age="adult", lang="fr", label=label, ref_audio=rel, ref_text=text, anonymous=anonymous) def import_voices(*, dataset, shards, named_f, named_m, anon, min_dur, max_dur, max_lev) -> Voicebank: need_f = named_f + anon + 1 # +1 narrateur (feminin) need_m = named_m + anon print(f"Objectif : {need_f} femmes / {need_m} hommes (distincts).", flush=True) buckets = _gather_voices(dataset, shards, min_dur, max_dur, max_lev, need_f, need_m) fem, mal = buckets["female"], buckets["male"] if len(fem) < need_f or len(mal) < need_m: print(f"⚠ Pas assez de locuteurs (F {len(fem)}/{need_f}, H {len(mal)}/{need_m}) — " "quotas reduits. Ajoute des shards via --shards.", flush=True) named_f = min(named_f, max(0, len(fem) - anon - 1)) named_m = min(named_m, max(0, len(mal) - anon)) # Remplacement complet : on vide les clips existants. clips = VOICEBANK_DIR / "clips" clips.mkdir(parents=True, exist_ok=True) for old in clips.glob("*.wav"): old.unlink() entries: list[VoiceEntry] = [] fi = mi = 0 # curseurs dans les buckets tries par qualite # 1) Narrateur (1re voix feminine, la plus propre). spk = fem[fi]; fi += 1 entries.append(_entry("fr_narrator", "female", 1, spk, spk[3], anonymous=False, label="Narrateur (FR)")) # 2) Voix nommees. for i in range(1, named_f + 1): spk = fem[fi]; fi += 1 entries.append(_entry(f"fr_f_{i}", "female", i, spk, spk[3], anonymous=False, label=f"Voix F {i} (FR)")) for i in range(1, named_m + 1): spk = mal[mi]; mi += 1 entries.append(_entry(f"fr_m_{i}", "male", i, spk, spk[3], anonymous=False, label=f"Voix H {i} (FR)")) # 3) Voix anonymes (reservees aux figurants). for i in range(1, anon + 1): if fi >= len(fem): break spk = fem[fi]; fi += 1 entries.append(_entry(f"fr_anon_f_{i}", "female", i, spk, spk[3], anonymous=True, label=f"Anonyme F {i} (FR)")) for i in range(1, anon + 1): if mi >= len(mal): break spk = mal[mi]; mi += 1 entries.append(_entry(f"fr_anon_m_{i}", "male", i, spk, spk[3], anonymous=True, label=f"Anonyme H {i} (FR)")) vb = Voicebank(entries=entries) save_voicebank(vb) na = sum(1 for e in entries if e.anonymous) print(f"\n✓ {len(entries)} voix → {VOICEBANK_DIR / 'metadata.json'}") print(f" narrateur 1 · nommees {len(entries) - na - 1} · anonymes {na}") for e in entries: tag = " [anon]" if e.anonymous else "" print(f" {e.id:14} {e.gender:6} kokoro={e.kokoro_voice}{tag}") return vb def main() -> None: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--dataset", default="ylacombe/cml-tts") p.add_argument("--shards", nargs="+", default=_DEFAULT_SHARDS, help="Shards parquet a consommer dans l'ordre jusqu'aux quotas.") p.add_argument("--named-f", type=int, default=18, help="Voix feminines nommees.") p.add_argument("--named-m", type=int, default=14, help="Voix masculines nommees.") p.add_argument("--anon", type=int, default=4, help="Voix anonymes par genre.") p.add_argument("--min-dur", type=float, default=6.0) p.add_argument("--max-dur", type=float, default=15.0) p.add_argument("--max-lev", type=float, default=0.5, help="Distance Levenshtein max par mot (qualite ; plus bas = plus propre).") args = p.parse_args() import_voices(dataset=args.dataset, shards=args.shards, named_f=args.named_f, named_m=args.named_m, anon=args.anon, min_dur=args.min_dur, max_dur=args.max_dur, max_lev=args.max_lev) if __name__ == "__main__": main()