Kazeia-central/docs/OMNIVOICE_VOICE_MIGRATION.md

14 KiB
Raw Blame History

Migration du module voix : CosyVoice (.cvps) → OmniVoice (.ovsp)

Date : 23/06/2026 · Auteur : Richard & Claude (côté app/engine) · Pour : dev Kazeia-central Objet : adapter kazeia_central/voice/ pour préparer les voix du nouveau TTS OmniVoice (l'app patiente a retiré CosyVoice le 22/06/2026, vc17+). Les voix sont désormais des clone-prompts .ovsp, plus des .cvps.

L'architecture du module ne change pas. Bridge worker en venv encodeur, orchestrator (pull → archive chiffré → transcription → enrôlement → push → suppression WAV device), verrou d'exclusivité (locked_profile_id), autosync : tout reste valable. Seuls changent l'encodeur (enroll.py), la transcription (transcribe.py), les chemins/suffixes (transfer.py), le venv (bridge.py) et la glue (orchestrator.py).


0. Faits vérifiés sur device/PC (ne pas re-découvrir)

  • Encodeur OmniVoice = OmniVoice.create_voice_clone_prompt(ref_audio=<wav>, ref_text=None|str) → renvoie un vcp avec vcp.ref_audio_tokens (tensor [8, T_ref] int) et vcp.ref_text (str). Si ref_text=NoneASR automatique (Whisper interne transformers), vcp.ref_text rempli.
  • venv encodeur = ov_venv (/opt/Kazeia/ov_venv). Contient : torch, librosa, soundfile, transformers, omnivoice. PAS openai-whisper → la transcription (relecture opérateur) doit passer par l'ASR interne d'OmniVoice, pas import whisper.
  • Cap de la réf à ~16 s (≠ CosyVoice qui voulait 22 s). OmniVoice paie la longueur de réf au runtime : T_ref ∝ durée, et c_len = T_ref + cible choisit le bucket de synthèse. Réf ~16 s → T_ref ~390bucket 640 (rapide, niveau damien validé 3,5 s). Réf 22 s → T_ref ~550 → bucket 768/1024 (plus lent) + risque de saturer le plus grand bucket.
  • prepare_wav partagé entre transcription et enrôlement (déterministe) → le texte relu correspond exactement à l'audio enrôlé. (sinon mismatch texte/audio = mauvais clone-prompt.)
  • Validé : make_ovsp clip 15 s → T_ref=364, .ovsp 11,7 Ko ; auto-ASR sur clip 16 s tronqué OK.
  • Référence d'implémentation : /opt/Kazeia-engine/dist/omnivoice/scripts/make_ovsp.py.

1. Format .ovsp (binaire, lu par le moteur tablette nativeLoadVoice)

int32  magic       = 0x4F565350   ('OVSP', little-endian)
int32  T_ref                       (frames audio de réf, par codebook)
int32  text_len                    (octets UTF-8 du ref_text)
int32  ref_audio_tokens[8 * T_ref] (row-major [8, T_ref])
char   ref_text[text_len]          (UTF-8)

⚠ Le moteur ne tolère aucune dérive de ce format. Plus de conteneur GGUF (c'était CosyVoice).

2. Déploiement sur device

  • Dossier cible : <MODELS_DIR>/omnivoice/voices/<voice_id>.ovsp (était <MODELS_DIR>/cosyvoice/<id>.cvps).
  • MODELS_DIR reste dérivé du wav_path du provider /voices (même logique qu'avant ; marqueur _WAV_MARKER = "/../voix/voix/" inchangé).
  • Le provider /voices de l'app patiente a déjà été migré : il inventorie omnivoice/voices/*.ovsp et la suppression de voix retire le .ovsp. La colonne d'état « ready » = .ovsp présent.

3. Changements fichier par fichier

3.1 voice/enroll.pyréécriture complète (encodeur OmniVoice)

Remplacer intégralement par :

"""Enrôlement vocal OmniVoice : WAV de référence → clone-prompt `.ovsp`.

⚠️ Sous l'env encodeur `ov_venv` (torch + omnivoice + transformers + librosa/soundfile),
invoqué par le worker subprocess (pas en-process API py3.14).
Réimplémente /opt/Kazeia-engine/dist/omnivoice/scripts/make_ovsp.py.
Format .ovsp : int32 'OVSP' | int32 T_ref | int32 text_len | int32 tokens[8*T_ref] | char ref_text[].
"""
from __future__ import annotations
import logging, os, struct, sys
import numpy as np

for _noisy in ("numba", "matplotlib", "transformers", "omnivoice", "huggingface_hub"):
    logging.getLogger(_noisy).setLevel(logging.WARNING)

OV_MODEL = os.environ.get("OV_MODEL", "k2-fsa/OmniVoice")
OVSP_MAGIC = 0x4F565350
# Cap ~16 s : T_ref ~390 -> bucket 640 (rapide). Au-delà = bucket 768/1024 (lent) +
# risque de saturer le plus grand bucket. prepare_wav partagé avec la transcription.
OV_TARGET_S = float(os.environ.get("OV_TARGET_S", "16.0"))
_PREP_SR = 24000
_HARD_MAX_S = 20.0
_MIN_WARN_S = 6.0
_model_singleton = None

class EnrollmentError(RuntimeError):
    pass

def prepare_wav(in_path: str, out_path: str, target_s: float = OV_TARGET_S) -> dict:
    import librosa, soundfile as sf
    y, _ = librosa.load(in_path, sr=_PREP_SR, mono=True)
    orig_s = len(y) / _PREP_SR
    yt, _ = librosa.effects.trim(y, top_db=30)
    if yt.size == 0:
        yt = y
    yt = yt[: int(min(target_s, _HARD_MAX_S) * _PREP_SR)]
    final_s = len(yt) / _PREP_SR
    sf.write(out_path, yt, _PREP_SR, subtype="PCM_16")
    return {"orig_s": round(orig_s, 1), "final_s": round(final_s, 1), "too_short": final_s < _MIN_WARN_S}

def load_model():
    global _model_singleton
    if _model_singleton is not None:
        return _model_singleton
    try:
        import torch
        from omnivoice.models.omnivoice import OmniVoice
    except Exception as e:
        raise EnrollmentError(f"import OmniVoice échoué ({e}). Exécuter sous ov_venv.") from e
    _model_singleton = OmniVoice.from_pretrained(OV_MODEL, torch_dtype=torch.float32).to("cpu").eval()
    return _model_singleton

def _clone_prompt(prep_wav: str, ref_text):
    import torch
    m = load_model()
    with torch.no_grad():
        return m.create_voice_clone_prompt(ref_audio=prep_wav, ref_text=ref_text or None)

def transcribe_ref(wav_path: str) -> dict:
    """ASR du segment préparé (16 s) via OmniVoice, pour relecture opérateur."""
    import tempfile
    if not os.path.isfile(wav_path):
        raise EnrollmentError(f"WAV introuvable: {wav_path}")
    with tempfile.TemporaryDirectory() as td:
        prep = os.path.join(td, "prep.wav")
        prep_info = prepare_wav(wav_path, prep)
        vcp = _clone_prompt(prep, None)
    return {"text": (getattr(vcp, "ref_text", "") or "").strip(),
            "language": None, "model": "omnivoice-asr", "prep": prep_info}

def enroll(wav_path: str, text: str, out_path: str) -> dict:
    import tempfile
    if not os.path.isfile(wav_path):
        raise EnrollmentError(f"WAV introuvable: {wav_path}")
    text = (text or "").strip()
    with tempfile.TemporaryDirectory() as td:
        prep = os.path.join(td, "prep.wav")
        prep_info = prepare_wav(wav_path, prep)
        vcp = _clone_prompt(prep, text if text else None)
    toks = vcp.ref_audio_tokens.cpu().numpy().astype(np.int32)   # [8, T_ref]
    C, T = toks.shape
    if C != 8:
        raise EnrollmentError(f"attendu 8 codebooks, eu {C}")
    ref_text = (getattr(vcp, "ref_text", "") or text or "").strip()
    txt = ref_text.encode("utf-8")
    os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
    with open(out_path, "wb") as f:
        f.write(struct.pack("<i", OVSP_MAGIC))
        f.write(struct.pack("<i", T))
        f.write(struct.pack("<i", len(txt)))
        f.write(toks.tobytes())
        f.write(txt)
    return {"out_path": out_path, "bytes": os.path.getsize(out_path), "t_ref": int(T),
            "ref_seconds": round(T / 25.0, 1), "text_bytes": len(txt),
            "ref_text": ref_text, "prep": prep_info}

if __name__ == "__main__":
    import json
    ref, ref_txt, out = sys.argv[1], sys.argv[2], sys.argv[3]
    text = "" if ref_txt == "-" else (open(ref_txt).read() if ref_txt.endswith(".txt") and os.path.isfile(ref_txt) else ref_txt)
    print(json.dumps(enroll(ref, text, out), ensure_ascii=False))

3.2 voice/transcribe.py — ASR via OmniVoice (plus d'openai-whisper)

ov_venv n'a pas openai-whisper. Remplacer le corps par une délégation à enroll.transcribe_ref (qui transcrit le segment préparé 16 s via l'ASR OmniVoice) :

"""Transcription du segment de référence (16 s) via l'ASR interne d'OmniVoice — ov_venv.
Le texte du .ovsp doit correspondre à l'audio réellement enrôlé ; prepare_wav étant
partagé/déterministe, le segment transcrit == le segment enrôlé."""
from __future__ import annotations
from .enroll import transcribe_ref

def transcribe(wav_path: str, model: str | None = None) -> dict:
    return transcribe_ref(wav_path)   # {text, language, model, prep}

3.3 voice/transfer.py — chemins/suffixe

  • Renommer cosyvoice_dir_foromnivoice_dir_for, retourner posixpath.join(models, "omnivoice", "voices") (et le fallback …/kazeia/models/omnivoice/voices).
  • Renommer deployed_cvpsdeployed_ovsp, filtrer .ovsp (line[:-5] reste valable : .ovsp = 5 car).
  • Renommer push_cvpspush_ovsp, remote = …/<voice_id>.ovsp.
  • pull_wav / delete_device_wav inchangés.

3.4 voice/bridge.py — venv encodeur

  • CV_PYTHONOV_PYTHON = os.environ.get("OV_PYTHON", "/opt/Kazeia/ov_venv/bin/python").
  • Adapter le message d'erreur (« provisionner ov_venv »). Le worker (worker.py) est générique (dispatch ping/transcribe/enroll/shutdown) → inchangé.

3.5 voice/orchestrator.py — glue

  • transfer.cosyvoice_dir_fortransfer.omnivoice_dir_for (var locale cosy/ov).
  • transfer.deployed_cvpstransfer.deployed_ovsp ; transfer.push_cvpstransfer.push_ovsp.
  • Remplacer les littéraux .cvps.ovsp (chemins temporaires) et la clé de retour cvps_bytesovsp_bytes (et l'audit detail={"ovsp_bytes": …}).
  • Le verrou d'exclusivité (locked_profile_id / verrou_profil_absent) et l'archivage WAV chiffré : INCHANGÉS — la sémantique « ne pas déployer une voix exclusive sur une tablette sans le profil propriétaire » reste exactement la même.

3.6 voice/__init__.py, docstrings, docs/

  • __init__.py : docstring « enrôlement OmniVoice WAV → .ovsp ».
  • Mettre à jour docs/VOICE_ENROLLMENT_SPEC.md et docs/VOICE_DEPLOYMENT_SPEC.md : .cvps.ovsp, dossier omnivoice/voices, cap 16 s, ASR OmniVoice, format binaire OVSP.
  • tests/test_voice.py : adapter les attentes (magic OVSP, suffixe .ovsp, omnivoice_dir_for, t_ref/ovsp_bytes au lieu des shapes GGUF/cvps_bytes).

4. Provisioning attendu (env)

  • ov_venv opérationnel (/opt/Kazeia/ov_venv) avec le modèle k2-fsa/OmniVoice en cache HF (déjà le cas sur le PC de dev). Variables : OV_PYTHON, OV_MODEL, OV_TARGET_S (défaut 16).
  • Plus besoin de CV_REPO/CV_TEACHER/cosyvoice-repo/teacher 0.5B.

5. Validation suggérée

  1. ov_venv/bin/python -m kazeia_central.voice.enroll <wav> - /tmp/x.ovsp (ASR auto) → vérifier magic OVSP, t_ref ~300400 pour ~15 s, ref_text cohérent.
  2. Flux complet sur une tablette : prepare (transcription relue) → enroll_deploy.ovsp présent dans …/omnivoice/voices/, voix « ready » dans l'admin, lecture OK.
  3. Exclusivité : une voix locked_profile_id non présent sur la tablette → verrou_profil_absent.

6. Notes

  • Le consentement légal complet (~22 s, texte intégral) reste dans le manifeste de l'admin ; le .ovsp ne porte que les 16 s + leur transcription (clone-prompt). C'est voulu.
  • Côté app, le défaut de débit voix est réglable (curseur admin) — sans impact sur l'enrôlement.
  • Binaires/catalogue OmniVoice déjà publiés sur box (catalog v13, composant tts_omnivoice).

7. ⚠ FIX OBLIGATOIRE — mot parasite en début de chaque phrase (bleed de fin de réf)

Symptôme observé en prod (voix « Richard » enrôlée via la lecture de consentement, 2026-06-23) : un mot étrange au début de CHAQUE phrase synthétisée.

Cause (diagnostiquée, preuve à l'appui) : le cap dur à 16 s coupe la réf en plein mot. Ex. la phrase « …le chant joyeux des oiseaux, près d'un feu… » est coupée à 16 s ; l'auto-ASR transcrit le bout partiel en « présenter » → ref_text se termine par ce mot tronqué. OmniVoice recrache ce dernier mot de la réf au début de chaque génération. Vérifié : synthèse de « Le chat dort sur le canapé. » → ASR = « Présenter le chat dort… ».

Correctif (validé device — le mot parasite disparaît) : ne JAMAIS terminer la réf sur un mot/segment tronqué.

  1. Couper l'audio sur une frontière de silence (jamais à target_s brut). Garder les segments non-silencieux jusqu'à ~target_s, finir à la fin du dernier segment complet :
    import librosa, soundfile as sf
    y,_ = librosa.load(in_path, sr=24000, mono=True)
    y,_ = librosa.effects.trim(y, top_db=30)
    end = 0
    for s,e in librosa.effects.split(y, top_db=30):
        if e/24000 <= target_s: end = e
        else: break
    if end == 0: end = min(len(y), int(target_s*24000))
    sf.write(out_path, y[:end], 24000, subtype="PCM_16")   # finit en silence, mots entiers
    
  2. Tronquer ref_text à la dernière phrase complète (anti-bleed, ceinture+bretelles) :
    import re
    ref = (vcp.ref_text or "").strip()
    last = list(re.finditer(r'[.!?]', ref))
    if last: ref = ref[:last[-1].end()].strip()   # finit sur . ! ?
    
    (L'audio finit en silence → le léger surplus audio vs texte = silence, sans mot recraché.)

→ À intégrer dans voice/enroll.py (prepare_wav pour le point 1, enroll pour le point 2). Validé : ref_text « …le chant joyeux des oiseaux. » (au lieu de « …des oiseaux présenter. ») → plus de mot parasite. Toutes les voix déjà enrôlées via l'ancien cap doivent être ré-enrôlées (la voix Richard de test a été re-patchée à la main en attendant).