Kazeia-central/docs/VOICE_ENROLLMENT_SPEC.md

12 KiB

VOICE_ENROLLMENT_SPEC — conversion WAV → .cvps (clonage vocal CosyVoice)

🛑 SUPERSEDED (23/06/2026) — CosyVoice retiré, remplacé par OmniVoice. Cet algorithme (teacher CosyVoice3-0.5B → 4 tenseurs GGUF .cvps) n'est plus utilisé. L'enrôlement actuel = OmniVoice : create_voice_clone_prompt → binaire .ovsp (magic OVSP | T_ref | text_len | tokens[8·T_ref] | ref_text), cap réf 16 s, ASR de réf via OmniVoice (plus de Whisper), venv ov_venv. Source de vérité : OMNIVOICE_VOICE_MIGRATION.md + /opt/Kazeia-engine/dist/omnivoice/scripts/make_ovsp.py. Le code kazeia_central/voice/ est migré. Document conservé pour historique.

Destinataire : dev Kazeia-central. Comment transformer un échantillon de voix (WAV) en artefact .cvps chargeable par le moteur TTS CosyVoice de l'app patiente.

Pourquoi côté PC (Kazeia-central) et pas sur la tablette : l'enrôlement charge le teacher CosyVoice3-0.5B complet en PyTorch (~9,1 Go) et son frontend d'extraction. La tablette n'embarque que le .gguf distillé de synthèse (aucun frontend, aucune entrée JNI d'enrôlement). Porter ça on-device = shipper campplus + s3tokenizer + torch sur chaque device pour une opération one-shot → mauvais compromis. L'enrôlement est offline, opérateur, enroll-once → sync N tablettes : c'est le mandat de Kazeia-central (cf. CLAUDE.md §6).


1. Vue d'ensemble du workflow

[Tablette]                         [Kazeia-central / PC]                    [Flotte]
capture WAV (micro, ~15 s)  ──adb pull──►  enrôlement (cv_make_prompt)  ──adb push──►  <id>.cvps
+ pré-transcription Whisper                 = teacher 0.5B PyTorch                       dans .../cosyvoice/
  (seed REF_TXT, relue opérateur)           → 4 tenseurs → GGUF .cvps                  + maj catalogue
  1. La tablette enregistre le clip de consentement (elle le fait déjà) et peut pré-transcrire avec son Whisper embarqué pour pré-remplir le texte de référence.
  2. Kazeia-central récupère le WAV + le texte (relu/corrigé par l'opérateur), lance l'enrôlement, obtient <id>.cvps, le pousse vers les tablettes et met à jour le catalogue voix (source de vérité des voix CosyVoice disponibles).

2. Référence : cv_make_prompt.py (l'algorithme exact)

Source : /opt/Kazeia/cv_make_prompt.py (et copie /opt/Kazeia-engine/dist/cosyvoice/).

import sys, numpy as np, torch
sys.path.insert(0, "<COSYVOICE_REPO>")
sys.path.insert(0, "<COSYVOICE_REPO>/third_party/Matcha-TTS")
from cosyvoice.cli.cosyvoice import CosyVoice3
import gguf

REF, REF_TXT, OUT = sys.argv[1], sys.argv[2], sys.argv[3]

cv = CosyVoice3("<TEACHER_0.5B_DIR>", load_trt=False, fp16=False)   # charge ~9,1 Go
fe = cv.frontend
text = open(REF_TXT).read().strip() if REF_TXT.endswith(".txt") else REF_TXT

feat, _ = fe._extract_speech_feat(REF)    # mel prompt   → [1, T, 80]
emb     = fe._extract_spk_embedding(REF)  # x-vector campplus → [1, dim]
tok, _  = fe._extract_speech_token(REF)   # speech tokens s3  → [1, n]

feat = feat.squeeze(0).cpu().numpy().astype(np.float32)   # [T, 80]
emb  = emb.cpu().numpy().astype(np.float32)               # [1, dim]
tok  = tok.squeeze(0).cpu().numpy().astype(np.int32)      # [n]
txt  = np.frombuffer(text.encode("utf-8"), dtype=np.int8) # [bytes]

w = gguf.GGUFWriter(OUT, "cosyvoice-prompt-speech")
w.add_tensor("feat", feat)        # mel prompt
w.add_tensor("embedding", emb)    # x-vector
w.add_tensor("tokens", tok)       # speech tokens
w.add_tensor("text", txt)         # transcription UTF-8 brute
w.write_header_to_file(); w.write_kv_data_to_file(); w.write_tensors_to_file(); w.close()

Trois extractions du frontend (c'est tout le « clonage ») + écriture GGUF. Pas de crc32, pas de check au chargement côté C++ → le format doit être exact.


3. Format .cvps (contrat avec le loader C++)

Conteneur GGUF, nom d'architecture "cosyvoice-prompt-speech", 4 tenseurs (noms et dtypes exacts — le loader nativeLoadVoice ne tolère aucune dérive) :

tenseur contenu shape (numpy) dtype note layout GGUF
feat mel prompt de la voix [T, 80] float32 GGUF inverse → ne0=80, ne1=T
embedding x-vector campplus (empreinte locuteur) [1, dim] float32 ne0=dim, ne1=1
tokens speech tokens s3 [n] int32 1-D
text transcription UTF-8 brute [octets] int8 1-D, octets UTF-8
  • T ∝ durée du clip ; dim fixé par campplus ; n = nb de tokens audio.

Le texte de référence est CONNU, pas à transcrire (décision 2026-06-19). La personne lit toujours la même phrase de consentement (admin string rec_reference_text, nom interpolé). Donc text = cette phrase canonique (nom rempli), pas un résultat Whisper. Conséquence : transcribe.py/Whisper sort du chemin critique (fallback legacy seulement, pour des clips au contenu inconnu). Source de vérité = la string admin (idéalement portée par le reference_text du manifeste voix). Fenêtre d'enrôlement portée à CV_TARGET_S=22 s pour que la phrase de consentement (~13 s) + la fin phonétiquement riche entrent en entier.

  • text est conditionnant (CosyVoice = audio réf + texte réf → texte cible, même voix) : il doit correspondre au contenu parlé du WAV. Une transcription fausse dégrade la similarité.

4. Entrées & contraintes

  • WAV de référence : mono, PCM ; le frontend gère le ré-échantillonnage (fournir ≥16 kHz propre). ~15 s = sweet spot (la similarité ECAPA/campplus monte avec la longueur du prompt, mais le cache K/V on-device ~270 Mo et la latence du 1ᵉʳ énoncé montent aussi). Éviter <8 s (similarité faible) et >~30 s (coût mémoire). Clip propre, une seule voix, peu de bruit/musique.
  • Transcription : texte exact de ce qui est dit, dans la langue du clip. L'enrôlement est cross-lingual : un .cvps sert pour les 9 langues (fr,en,de,es,it,ru,zh,ja,ko). La tablette peut la pré-générer (Whisper) ; faire relire/corriger par l'opérateur avant enrôlement.
  • Sortie : <voiceId>.cvps. voiceId = slug stable ([a-z0-9_]), c'est la clé de cache moteur et le nom de fichier.

5. Dépendances hôte (poste Kazeia-central)

Lourdes mais one-time, sur le poste opérateur (local-first) :

dépendance détail emplacement actuel (poste Richard) — vérifié 2026-06-19
interpréteur encodeur Python 3.10.20, torch 2.3.1+cu121 (CPU, cuda=False), gguf OK ; from cosyvoice.cli.cosyvoice import CosyVoice3 résout /opt/Kazeia/cv_venv/bin/python3
repo CosyVoice + third_party/Matcha-TTS code cosyvoice.cli.cosyvoice /opt/Kazeia/cosyvoice-repo (requirements.txt présent)
teacher CosyVoice3-0.5B ~9,1 Go, modèles frontend (campplus, s3tokenizer, mel) /opt/Kazeia/_models_dl/cosyvoice3-0.5b
package python gguf écriture du conteneur dans cv_venv
cv_make_prompt.py le script /opt/Kazeia/cv_make_prompt.py

Env de référence confirmé : /opt/Kazeia/cv_venv fait tourner l'encodeur (imports validés sans charger le teacher). C'est l'env à réutiliser/pointer depuis Kazeia-central (ou à recréer depuis cosyvoice-repo/requirements.txt).

Chemins device confirmés (tablette dev, legacy) :

  • WAV source : /data/local/tmp/kazeia/voix/voix/<id>.wav
  • cible .cvps : /data/local/tmp/kazeia/models/cosyvoice/<id>.cvps (présents : damien.cvps, elodie.cvps — 242 720 o ≈ 15 s, refs de validation).

⚠️ Les chemins repo/teacher sont codés en dur dans cv_make_prompt.py. Pour Kazeia-central : les rendre configurables (env/config CV_REPO, CV_TEACHER) et documenter le pré-requis d'installation. Le teacher 9,1 Go n'est PAS versionné — le dev doit le provisionner sur le poste (pas dans le repo, pas dans l'APK).


6. Intégration dans Kazeia-central (module voice/)

Recommandation forte : un worker d'enrôlement persistant, pas un python par voix. Charger le teacher 0.5B coûte plusieurs dizaines de secondes (9,1 Go) — relancer le process à chaque voix est inacceptable pour un enrôlement de flotte. Garder le modèle chaud et traiter une file.

Forme cible (Python natif à Kazeia-central, d'où le choix de stack) :

voice/
  enroll.py        # importe CosyVoice3 UNE fois ; expose enroll(wav_path, text, voice_id) -> cvps_path
  worker.py        # garde le teacher chaud ; file de jobs d'enrôlement
  • Préférer réimplémenter la logique des 25 lignes de cv_make_prompt.py dans enroll.py (mêmes 3 extractions + écriture GGUF) plutôt que de shell-out, pour charger CosyVoice3 une seule fois et enchaîner les voix.
  • API exposée par Kazeia-central (cf. CLAUDE.md §6) :
    1. POST /api/voices/{serial}/capture → déclenche/récupère le WAV de la tablette (adb pull depuis …/voix/voix/<id>.wav), pré-transcription Whisper optionnelle.
    2. POST /api/voices/enroll {voice_id, wav, transcription} → worker → <id>.cvps.
    3. POST /api/voices/{serial}/deployadb push du .cvps dans <modelsDir>/cosyvoice/<id>.cvps + (option) rag_sync/reload, ou publication WebDAV + bump catalogue pour propagation OTA à la flotte.

7. Déploiement & runtime on-device (rappel, pour cohérence)

  • Destination : <modelsDir>/cosyvoice/<voiceId>.cvps. Sur la tablette dev (legacy) : /data/local/tmp/kazeia/models/cosyvoice/ ; en prod : Android/data/com.kazeia/files/kazeia/models/cosyvoice/.
  • Chargement : CosyVoiceJni.nativeLoadVoice(handle, cvpsPath). Synthèse 24 kHz mono.
  • 1ʳᵉ synthèse d'une voix = lente (remplit le cache K/V prompt ~270 Mo, RTF ~2) ; les suivantes RTF 0,88. Cache par-voix (clé = identité du .cvps).
  • Repli : si le .cvps demandé est absent, l'app retombe sur la voix par défaut (damien) — une voix non enrôlée ne rend plus le TTS muet (fix 613c7ef). Mais l'objectif de Kazeia-central est qu'aucune voix proposée ne soit non déployée.

8. À réconcilier (lié, hors enrôlement strict)

L'endpoint /voices de l'app patiente reflète l'ancien inventaire Qwen3 (WAV + .bin), pas les .cvps CosyVoice réellement présents → l'admin peut sélectionner une voix muette (incident « zelda »). Kazeia-central, en devenant le maître de l'enrôlement et du déploiement des .cvps, doit être la source de vérité des voix CosyVoice disponibles (ce qui est enrôlé + déployé), et à terme pousser l'app à exposer son inventaire .cvps réel. À traiter avec PROVIDER_RPC_SPEC.md (méthode voices_reload

  • futur endpoint d'inventaire .cvps).

9. Checklist dev Kazeia-central

  • Provisionner sur le poste : repo CosyVoice + teacher 0.5B + torch + gguf.
  • Rendre les chemins configurables (env/config), pas codés en dur.
  • voice/enroll.py : charger CosyVoice3 une fois ; enroll(wav, text, id)→.cvps (3 extractions + GGUF arch cosyvoice-prompt-speech, 4 tenseurs §3, dtypes exacts).
  • voice/worker.py : teacher chaud + file (pas de cold-load par voix).
  • Routes capture / enroll / deploy (§6) + pré-transcription Whisper optionnelle.
  • Valider un .cvps produit : taille proche de damien.cvps (~240 Ko pour ~15 s), push device, synthèse de test non vide, écoute (similarité voix).
  • Devenir la source de vérité de l'inventaire voix (§8).