feat(voice): migration CosyVoice → OmniVoice (.cvps → .ovsp)

Suit docs/OMNIVOICE_VOICE_MIGRATION.md (app patiente migrée vc17+, 22/06).
- enroll.py : réécriture encodeur OmniVoice (create_voice_clone_prompt → format binaire
  OVSP : magic|T_ref|text_len|tokens[8*T_ref]|ref_text). Cap réf 16s. prepare_wav 24kHz.
- transcribe.py : ASR interne OmniVoice (plus d'openai-whisper, absent de ov_venv).
- transfer.py : omnivoice_dir_for (<MODELS_DIR>/omnivoice/voices), deployed_ovsp,
  push_ovsp, suffixe .ovsp.
- bridge.py : OV_PYTHON (/opt/Kazeia/ov_venv). worker dispatch inchangé.
- orchestrator.py : .ovsp, ovsp_bytes ; verrou d'exclusivité + archive WAV INCHANGÉS.
- store : colonne cvps_bytes → ovsp_bytes (+ migration ALTER RENAME idempotente).
- tests adaptés. 39/39.
- validé live sous ov_venv : enroll → .ovsp format byte-identique à damien.ovsp (réf
  engine) ; auto-ASR FR correcte ; bridge ping/transcribe/enroll chaud OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-23 10:15:11 +02:00
parent 46343c9ad5
commit adc0904009
10 changed files with 154 additions and 249 deletions

View File

@ -226,10 +226,10 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
now=_now_ms()) now=_now_ms())
return report return report
# ---- voix (enrôlement CosyVoice : WAV device → .cvps → flotte) -------- # ---- voix (enrôlement OmniVoice : WAV device → .ovsp → flotte) --------
@app.get("/api/voices/health") @app.get("/api/voices/health")
def voices_health(): def voices_health():
return {"encoder_available": bridge.available(), "cv_python": bridge.cv_python} return {"encoder_available": bridge.available(), "ov_python": bridge.ov_python}
# ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} sinon "autosync" est # ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} sinon "autosync" est
# capté comme un serial (FastAPI matche dans l'ordre de déclaration). # capté comme un serial (FastAPI matche dans l'ordre de déclaration).
@ -251,7 +251,7 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
@app.post("/api/voices/{serial}/{voice_id}/prepare") @app.post("/api/voices/{serial}/{voice_id}/prepare")
def voices_prepare(serial: str, voice_id: str): def voices_prepare(serial: str, voice_id: str):
# pull + archive chiffré + transcription Whisper (à relire avant enrôlement) # pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement)
return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id, return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id,
now=_now_ms(), actor=_OPERATOR)) now=_now_ms(), actor=_OPERATOR))

View File

@ -73,10 +73,10 @@ CREATE TABLE IF NOT EXISTS voices (
source_wav_path TEXT, -- chemin device d'origine source_wav_path TEXT, -- chemin device d'origine
wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/) wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/)
wav_sha256 TEXT, -- intégrité du WAV d'origine wav_sha256 TEXT, -- intégrité du WAV d'origine
cvps_bytes INTEGER, ovsp_bytes INTEGER,
archived_at INTEGER, -- WAV récupéré + archivé sur le PC archived_at INTEGER, -- WAV récupéré + archivé sur le PC
enrolled_at INTEGER, -- .cvps produit enrolled_at INTEGER, -- .ovsp produit
deployed_at INTEGER, -- .cvps poussé sur la tablette deployed_at INTEGER, -- .ovsp poussé sur la tablette
wav_deleted_at INTEGER, -- WAV supprimé de la tablette wav_deleted_at INTEGER, -- WAV supprimé de la tablette
locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste) locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste)
PRIMARY KEY (serial, voice_id) PRIMARY KEY (serial, voice_id)
@ -113,6 +113,9 @@ class Store:
cols = {r["name"] for r in self._db.execute("PRAGMA table_info(voices)")} cols = {r["name"] for r in self._db.execute("PRAGMA table_info(voices)")}
if "locked_profile_id" not in cols: if "locked_profile_id" not in cols:
self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT") self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT")
# Migration OmniVoice (23/06/2026) : cvps_bytes → ovsp_bytes.
if "cvps_bytes" in cols and "ovsp_bytes" not in cols:
self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes")
def close(self) -> None: def close(self) -> None:
with self._lock: with self._lock:
@ -327,9 +330,9 @@ class Store:
self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text), self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text),
language=language) language=language)
def mark_voice_enrolled(self, serial: str, voice_id: str, cvps_bytes: int, *, now: int) -> None: def mark_voice_enrolled(self, serial: str, voice_id: str, ovsp_bytes: int, *, now: int) -> None:
with self._lock: with self._lock:
self._voice_upsert(serial, voice_id, cvps_bytes=cvps_bytes, enrolled_at=now) self._voice_upsert(serial, voice_id, ovsp_bytes=ovsp_bytes, enrolled_at=now)
def mark_voice_deployed(self, serial: str, voice_id: str, *, now: int) -> None: def mark_voice_deployed(self, serial: str, voice_id: str, *, now: int) -> None:
with self._lock: with self._lock:

View File

@ -1,6 +1,6 @@
"""Sous-système voix (enrôlement CosyVoice WAV → .cvps). """Sous-système voix (enrôlement OmniVoice WAV → .ovsp).
`enroll.py`/`worker.py` tournent sous l'env encodeur (cv_venv : torch). Ne PAS `enroll.py`/`worker.py` tournent sous l'env encodeur (ov_venv : torch + omnivoice).
les importer depuis l'API (Python 3.14 sans torch) — l'API parle au worker par Ne PAS les importer depuis l'API (Python 3.14 sans torch) — l'API parle au worker par
subprocess. Ce `__init__` reste volontairement vide d'imports lourds. subprocess. Ce `__init__` reste volontairement vide d'imports lourds.
""" """

View File

@ -1,10 +1,10 @@
"""Pont API ↔ worker d'enrôlement (côté Python 3.14, SANS torch). """Pont API ↔ worker d'enrôlement (côté Python 3.14, SANS torch).
L'encodeur vit dans cv_venv (torch), l'API en Python 3.14 on ne peut pas l'importer L'encodeur OmniVoice vit dans ov_venv (torch + omnivoice + transformers), l'API en
en-process. `VoiceBridge` lance et pilote le worker `cv_venv` (`voice/worker.py`) en Python 3.14 on ne peut pas l'importer en-process. `VoiceBridge` lance et pilote le
subprocess, lui parle en JSON-lines, et sérialise les jobs (un verrou : torch n'est de worker `ov_venv` (`voice/worker.py`) en subprocess, lui parle en JSON-lines, et
toute façon pas thread-safe en inférence concurrente). Démarrage paresseux (le worker sérialise les jobs (torch n'est pas thread-safe en inférence concurrente). Démarrage
n'est lancé qu'au 1ᵉʳ job l'API ne paie le coût que si on enrôle). paresseux (le worker n'est lancé qu'au 1ᵉʳ job l'API ne paie le coût que si on enrôle).
""" """
from __future__ import annotations from __future__ import annotations
@ -15,8 +15,8 @@ import subprocess
import threading import threading
from pathlib import Path from pathlib import Path
# Interpréteur de l'env encodeur (torch + cosyvoice + whisper + gguf). # Interpréteur de l'env encodeur (torch + omnivoice + transformers + librosa/soundfile).
CV_PYTHON = os.environ.get("CV_PYTHON", "/opt/Kazeia/cv_venv/bin/python") OV_PYTHON = os.environ.get("OV_PYTHON", "/opt/Kazeia/ov_venv/bin/python")
_REPO = Path(__file__).resolve().parents[2] # /opt/Kazeia-central _REPO = Path(__file__).resolve().parents[2] # /opt/Kazeia-central
@ -25,27 +25,27 @@ class VoiceWorkerError(RuntimeError):
class VoiceBridge: class VoiceBridge:
"""Gère un worker cv_venv chaud. Thread-safe (jobs sérialisés).""" """Gère un worker ov_venv chaud. Thread-safe (jobs sérialisés)."""
def __init__(self, cv_python: str = CV_PYTHON) -> None: def __init__(self, ov_python: str = OV_PYTHON) -> None:
self.cv_python = cv_python self.ov_python = ov_python
self._proc: subprocess.Popen | None = None self._proc: subprocess.Popen | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._id = 0 self._id = 0
def available(self) -> bool: def available(self) -> bool:
return os.path.exists(self.cv_python) return os.path.exists(self.ov_python)
def _ensure(self) -> None: def _ensure(self) -> None:
if self._proc and self._proc.poll() is None: if self._proc and self._proc.poll() is None:
return return
if not os.path.exists(self.cv_python): if not os.path.exists(self.ov_python):
raise VoiceWorkerError( raise VoiceWorkerError(
f"interpréteur encodeur introuvable: {self.cv_python} " f"interpréteur encodeur introuvable: {self.ov_python} "
f"(provisionner cv_venv, ou définir CV_PYTHON)") f"(provisionner ov_venv, ou définir OV_PYTHON)")
env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1") env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1")
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
[self.cv_python, "-m", "kazeia_central.voice.worker"], [self.ov_python, "-m", "kazeia_central.voice.worker"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console
text=True, env=env, bufsize=1, text=True, env=env, bufsize=1,
) )

View File

@ -1,164 +1,100 @@
"""Enrôlement vocal CosyVoice : WAV de référence → artefact `.cvps`. """Enrôlement vocal OmniVoice : WAV de référence → clone-prompt `.ovsp`.
DOIT s'exécuter sous l'environnement encodeur (`cv_venv` : torch + cosyvoice + Sous l'env encodeur `ov_venv` (torch + omnivoice + transformers + librosa/soundfile),
gguf + onnxruntime), PAS sous l'interpréteur de l'API Kazeia-central (Python 3.14, invoqué par le worker subprocess (pas en-process API py3.14).
sans torch). C'est pourquoi ce module est invoqué par un **worker subprocess** Réimplémente /opt/Kazeia-engine/dist/omnivoice/scripts/make_ovsp.py.
(`voice/worker.py`) tournant sous `cv_venv`, pas importé en-process par l'API. Format .ovsp : int32 'OVSP' | int32 T_ref | int32 text_len | int32 tokens[8*T_ref] | char ref_text[].
Réimplémente la logique des 25 lignes de `/opt/Kazeia/cv_make_prompt.py` (cf.
VOICE_ENROLLMENT_SPEC §2), avec deux différences voulues :
- chemins repo/teacher **configurables** (env `CV_REPO`, `CV_TEACHER`), pas codés en dur ;
- teacher chargé **une seule fois** (singleton) pour enchaîner les voix à chaud.
Contrat de sortie (VOICE_ENROLLMENT_SPEC §3) conteneur GGUF, arch
`cosyvoice-prompt-speech`, 4 tenseurs aux noms/dtypes EXACTS (le loader C++
`nativeLoadVoice` ne tolère aucune dérive) :
feat mel prompt numpy [T, 80] float32 (GGUF: ne0=80, ne1=T)
embedding x-vector numpy [1, dim] float32 (GGUF: ne0=dim, ne1=1)
tokens speech tokens numpy [n] int32
text transcription numpy [octets] int8 (UTF-8 brut)
""" """
from __future__ import annotations from __future__ import annotations
import logging, os, struct, sys
import logging
import os
import sys
import numpy as np import numpy as np
# La stack CosyVoice/librosa/numba crache du DEBUG sur stdout/stderr. Le worker (à for _noisy in ("numba", "matplotlib", "transformers", "omnivoice", "huggingface_hub"):
# venir) utilisera stdout pour un protocole JSON → on muselle ces loggers ici pour
# garder stdout propre. Indispensable avant de brancher l'IPC.
for _noisy in ("numba", "matplotlib", "jieba", "modelscope", "cosyvoice"):
logging.getLogger(_noisy).setLevel(logging.WARNING) logging.getLogger(_noisy).setLevel(logging.WARNING)
os.environ.setdefault("NUMBA_DISABLE_JIT", "0")
CV_REPO = os.environ.get("CV_REPO", "/opt/Kazeia/cosyvoice-repo") OV_MODEL = os.environ.get("OV_MODEL", "k2-fsa/OmniVoice")
CV_TEACHER = os.environ.get("CV_TEACHER", "/opt/Kazeia/_models_dl/cosyvoice3-0.5b") OVSP_MAGIC = 0x4F565350
# Cap ~16 s : T_ref ~390 -> bucket 640 (rapide). Au-delà = bucket 768/1024 (lent) +
GGUF_ARCH = "cosyvoice-prompt-speech" # 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"))
# Préparation du clip. Le tokenizer s3 plante DUR au-delà de 30 s ("do not support _PREP_SR = 24000
# extract speech token for audio longer than 30s") et la similarité chute sous ~8 s _HARD_MAX_S = 20.0
# (§4). Les captures device ne sont pas calibrées (ex. 111 s stéréo 44 kHz observé) →
# on normalise systématiquement : mono, 16 kHz, silences rognés, fenêtre cible ~15 s.
# 22 s (≤ _HARD_MAX_S) : la phrase de consentement (~13 s) + la fin phonétiquement
# riche (nasales, semi-voyelles, ch/j/r, + question/exclamation) entrent ENTIÈREMENT
# dans le prompt enrôlé ; sinon la tête légale remplit seule les 15 s et la fin
# (l'info utile au clonage) est tronquée. Prompt plus long ⇒ meilleure similarité.
CV_TARGET_S = float(os.environ.get("CV_TARGET_S", "22.0"))
_PREP_SR = 16000
_HARD_MAX_S = 28.0 # marge sous la limite dure 30 s du tokenizer
_MIN_WARN_S = 6.0 _MIN_WARN_S = 6.0
_model_singleton = None
_teacher_singleton = None
class EnrollmentError(RuntimeError): class EnrollmentError(RuntimeError):
pass pass
def prepare_wav(in_path: str, out_path: str, target_s: float = OV_TARGET_S) -> dict:
def prepare_wav(in_path: str, out_path: str, target_s: float = CV_TARGET_S) -> dict: import librosa, soundfile as sf
"""Normalise un WAV de capture en segment exploitable : mono, 16 kHz, silences de y, _ = librosa.load(in_path, sr=_PREP_SR, mono=True)
bord rognés, tronqué à `target_s` (borné dur sous 30 s). Renvoie un résumé."""
import librosa
import soundfile as sf
y, _ = librosa.load(in_path, sr=_PREP_SR, mono=True) # downmix + resample
orig_s = len(y) / _PREP_SR orig_s = len(y) / _PREP_SR
yt, _ = librosa.effects.trim(y, top_db=30) # rogne silences de bord yt, _ = librosa.effects.trim(y, top_db=30)
if yt.size == 0: if yt.size == 0:
yt = y yt = y
keep_s = min(target_s, _HARD_MAX_S) yt = yt[: int(min(target_s, _HARD_MAX_S) * _PREP_SR)]
yt = yt[: int(keep_s * _PREP_SR)]
final_s = len(yt) / _PREP_SR final_s = len(yt) / _PREP_SR
sf.write(out_path, yt, _PREP_SR, subtype="PCM_16") sf.write(out_path, yt, _PREP_SR, subtype="PCM_16")
return {"orig_s": round(orig_s, 1), "final_s": round(final_s, 1), return {"orig_s": round(orig_s, 1), "final_s": round(final_s, 1), "too_short": final_s < _MIN_WARN_S}
"too_short": final_s < _MIN_WARN_S}
def load_model():
def load_teacher(): global _model_singleton
"""Charge le teacher CosyVoice3-0.5B (~9,1 Go) UNE fois. Coûteux (dizaines de if _model_singleton is not None:
secondes) garder le process chaud (worker). Lève EnrollmentError si l'env return _model_singleton
encodeur n'est pas provisionné (torch/cosyvoice/teacher absents)."""
global _teacher_singleton
if _teacher_singleton is not None:
return _teacher_singleton
if not os.path.isdir(CV_TEACHER):
raise EnrollmentError(
f"teacher CosyVoice introuvable: {CV_TEACHER} "
f"(provisionner le modèle 0.5B ~9,1 Go, ou définir CV_TEACHER)")
if not os.path.isdir(CV_REPO):
raise EnrollmentError(f"repo CosyVoice introuvable: {CV_REPO} (définir CV_REPO)")
for p in (CV_REPO, os.path.join(CV_REPO, "third_party/Matcha-TTS")):
if p not in sys.path:
sys.path.insert(0, p)
try: try:
from cosyvoice.cli.cosyvoice import CosyVoice3 import torch
except Exception as e: # torch/cosyvoice absents = mauvais interpréteur from omnivoice.models.omnivoice import OmniVoice
raise EnrollmentError( except Exception as e:
f"import CosyVoice échoué ({e}). Exécuter sous l'env encodeur " raise EnrollmentError(f"import OmniVoice échoué ({e}). Exécuter sous ov_venv.") from e
f"(cv_venv : torch + cosyvoice + gguf).") from e _model_singleton = OmniVoice.from_pretrained(OV_MODEL, torch_dtype=torch.float32).to("cpu").eval()
_teacher_singleton = CosyVoice3(CV_TEACHER, load_trt=False, fp16=False) return _model_singleton
return _teacher_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 enroll(wav_path: str, text: str, out_path: str) -> dict: def transcribe_ref(wav_path: str) -> dict:
"""WAV + transcription → `.cvps` à `out_path`. Renvoie un résumé (shapes/taille) """ASR du segment préparé (16 s) via OmniVoice, pour relecture opérateur."""
pour validation. `text` doit correspondre au contenu parlé du WAV (§4).""" import tempfile
import gguf
if not os.path.isfile(wav_path): if not os.path.isfile(wav_path):
raise EnrollmentError(f"WAV introuvable: {wav_path}") raise EnrollmentError(f"WAV introuvable: {wav_path}")
text = (text or "").strip()
if not text:
raise EnrollmentError("transcription vide : CosyVoice exige le texte de référence")
import tempfile
cv = load_teacher()
fe = cv.frontend
# Normalise la capture (mono/16k/≤~15s) dans un fichier temporaire avant extraction.
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
prep = os.path.join(td, "prep.wav") prep = os.path.join(td, "prep.wav")
prep_info = prepare_wav(wav_path, prep) prep_info = prepare_wav(wav_path, prep)
feat, _ = fe._extract_speech_feat(prep) # [1, T, 80] vcp = _clone_prompt(prep, None)
emb = fe._extract_spk_embedding(prep) # [1, dim] return {"text": (getattr(vcp, "ref_text", "") or "").strip(),
tok, _ = fe._extract_speech_token(prep) # [1, n] "language": None, "model": "omnivoice-asr", "prep": prep_info}
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) # [octets]
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) os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
w = gguf.GGUFWriter(out_path, GGUF_ARCH) with open(out_path, "wb") as f:
w.add_tensor("feat", feat) f.write(struct.pack("<i", OVSP_MAGIC))
w.add_tensor("embedding", emb) f.write(struct.pack("<i", T))
w.add_tensor("tokens", tok) f.write(struct.pack("<i", len(txt)))
w.add_tensor("text", txt) f.write(toks.tobytes())
w.write_header_to_file() f.write(txt)
w.write_kv_data_to_file() return {"out_path": out_path, "bytes": os.path.getsize(out_path), "t_ref": int(T),
w.write_tensors_to_file() "ref_seconds": round(T / 25.0, 1), "text_bytes": len(txt),
w.close() "ref_text": ref_text, "prep": prep_info}
return {
"out_path": out_path,
"bytes": os.path.getsize(out_path),
"feat_shape": list(feat.shape),
"embedding_dim": int(emb.shape[1]),
"n_tokens": int(tok.shape[0]),
"text_bytes": int(txt.shape[0]),
"prep": prep_info,
}
if __name__ == "__main__": if __name__ == "__main__":
# Usage : cv_venv/bin/python -m kazeia_central.voice.enroll <wav> <text|fichier.txt> <out.cvps>
import json import json
ref, ref_txt, out = sys.argv[1], sys.argv[2], sys.argv[3] ref, ref_txt, out = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(ref_txt).read() if ref_txt.endswith(".txt") and os.path.isfile(ref_txt) else ref_txt 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)) print(json.dumps(enroll(ref, text, out), ensure_ascii=False))

View File

@ -1,11 +1,11 @@
"""Orchestration de l'enrôlement vocal (le workflow complet). """Orchestration de l'enrôlement vocal (le workflow complet).
Compose : provider (`/voices` wav_path), transfert adb, bridge (worker cv_venv), Compose : provider (`/voices` wav_path), transfert adb, bridge (worker ov_venv),
store chiffré. Tourne sous l'API py3.14 ; le calcul lourd est délégué au worker. store chiffré. Tourne sous l'API py3.14 ; le calcul lourd est délégué au worker.
Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) : Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) :
connexion lister voix sans .cvps pull WAV ARCHIVER (store chiffré) connexion lister voix sans .ovsp pull WAV ARCHIVER (store chiffré)
transcrire (Whisper, relecture opérateur) enrôler (.cvps) pousser sur la transcrire (ASR OmniVoice, relecture opérateur) enrôler (.ovsp) pousser sur la
tablette SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central). tablette SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central).
Découpé pour permettre la relecture de la transcription : `prepare` (pull+archive+ Découpé pour permettre la relecture de la transcription : `prepare` (pull+archive+
@ -65,8 +65,8 @@ def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"""Inventaire voix de la tablette croisé avec l'archive store (statut, verrou, """Inventaire voix de la tablette croisé avec l'archive store (statut, verrou,
profils utilisateurs) par voix.""" profils utilisateurs) par voix."""
rows = _provider_voices(adb, serial) rows = _provider_voices(adb, serial)
cosy = transfer.cosyvoice_dir_for(rows[0].wav_path) if rows else None ov = transfer.omnivoice_dir_for(rows[0].wav_path) if rows else None
deployed = transfer.deployed_cvps(adb, serial, cosy) if cosy else set() deployed = transfer.deployed_ovsp(adb, serial, ov) if ov else set()
recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {} recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
used_by, _ = _profiles_using(adb, serial) used_by, _ = _profiles_using(adb, serial)
out = [] out = []
@ -139,7 +139,7 @@ def _ensure_wav_archived(adb: Adb, store: Store, serial: str, voice_id: str, *,
def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *, def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *,
now: int, actor: str | None = None) -> dict[str, Any]: now: int, actor: str | None = None) -> dict[str, Any]:
"""Pull + archive (chiffré) + transcription Whisper. Renvoie le texte à relire.""" """Pull + archive (chiffré) + transcription (ASR OmniVoice). Renvoie le texte à relire."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now) data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav") local = os.path.join(td, f"{voice_id}.wav")
@ -154,27 +154,27 @@ def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id:
def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str,
transcription: str, *, delete_source: bool, now: int, transcription: str, *, delete_source: bool, now: int,
actor: str | None = None) -> dict[str, Any]: actor: str | None = None) -> dict[str, Any]:
"""Enrôle (.cvps) → pousse sur la tablette → (option) supprime le WAV device. """Enrôle (.ovsp) → pousse sur la tablette → (option) supprime le WAV device.
Le WAV reste archivé côté PC suppression device réversible par -enrôlement.""" Le WAV reste archivé côté PC suppression device réversible par -enrôlement."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now) data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
wav_path = _find_wav_path(adb, serial, voice_id) wav_path = _find_wav_path(adb, serial, voice_id)
cosy = transfer.cosyvoice_dir_for(wav_path) if wav_path else None ov = transfer.omnivoice_dir_for(wav_path) if wav_path else None
if not cosy: if not ov:
raise ValueError(f"dossier cosyvoice indéterminé pour {voice_id}") raise ValueError(f"dossier omnivoice indéterminé pour {voice_id}")
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav") local = os.path.join(td, f"{voice_id}.wav")
open(local, "wb").write(data) open(local, "wb").write(data)
cvps = os.path.join(td, f"{voice_id}.cvps") ovsp = os.path.join(td, f"{voice_id}.ovsp")
info = bridge.enroll(local, transcription, cvps) info = bridge.enroll(local, transcription, ovsp)
store.set_voice_transcription(serial, voice_id, transcription, None, now=now) store.set_voice_transcription(serial, voice_id, transcription, None, now=now)
store.mark_voice_enrolled(serial, voice_id, info["bytes"], now=now) store.mark_voice_enrolled(serial, voice_id, info["bytes"], now=now)
remote = transfer.push_cvps(adb, serial, cvps, cosy, voice_id) remote = transfer.push_ovsp(adb, serial, ovsp, ov, voice_id)
store.mark_voice_deployed(serial, voice_id, now=now) store.mark_voice_deployed(serial, voice_id, now=now)
store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}", store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}",
detail={"cvps_bytes": info["bytes"]}, now=now) detail={"ovsp_bytes": info["bytes"]}, now=now)
result = {"voice_id": voice_id, "cvps_bytes": info["bytes"], result = {"voice_id": voice_id, "ovsp_bytes": info["bytes"],
"deployed_to": remote, "wav_deleted": False} "deployed_to": remote, "wav_deleted": False}
if delete_source and wav_path: if delete_source and wav_path:
transfer.delete_device_wav(adb, serial, wav_path) transfer.delete_device_wav(adb, serial, wav_path)

View File

@ -1,45 +1,8 @@
"""Transcription Whisper (PC) du segment de référence — tourne sous cv_venv. """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
Important : on transcrit le **segment préparé** (mono/16k/~15 s), PAS la capture partagé/déterministe, le segment transcrit == le segment enrôlé."""
brute. Le texte du `.cvps` doit correspondre à ce qui est parlé DANS le segment
enrôlé (§3/§4) ; transcrire les 111 s d'origine donnerait un texte qui ne colle pas
aux ~15 s réellement extraits. `prepare_wav` étant déterministe, le segment transcrit
ici == le segment enrôlé.
"""
from __future__ import annotations from __future__ import annotations
from .enroll import transcribe_ref
import os
import tempfile
from .enroll import prepare_wav
# Défaut "small" : "base" transcrit mal le FR (validé), "small" est le bon compromis
# qualité/CPU ; "medium" encore mieux mais lourd. La transcription est de toute façon
# relue par l'opérateur. Configurable via CV_WHISPER_MODEL.
CV_WHISPER_MODEL = os.environ.get("CV_WHISPER_MODEL", "small")
_model_cache: dict[str, object] = {}
def _model(size: str):
if size not in _model_cache:
import whisper # openai-whisper (présent dans cv_venv)
_model_cache[size] = whisper.load_model(size)
return _model_cache[size]
def transcribe(wav_path: str, model: str | None = None) -> dict: def transcribe(wav_path: str, model: str | None = None) -> dict:
"""Transcrit le segment de référence. Renvoie {text, language, model, prep}.""" return transcribe_ref(wav_path) # {text, language, model, prep}
size = model or CV_WHISPER_MODEL
m = _model(size)
with tempfile.TemporaryDirectory() as td:
prep = os.path.join(td, "prep.wav")
prep_info = prepare_wav(wav_path, prep)
result = m.transcribe(prep, fp16=False) # CPU → fp16 off
return {
"text": (result.get("text") or "").strip(),
"language": result.get("language"),
"model": size,
"prep": prep_info,
}

View File

@ -2,8 +2,11 @@
Py3.14-safe (pas de torch) : seulement des opérations adb. Le chemin des fichiers est Py3.14-safe (pas de torch) : seulement des opérations adb. Le chemin des fichiers est
**device-dépendant** on part toujours de la colonne `wav_path` que le provider **device-dépendant** on part toujours de la colonne `wav_path` que le provider
`/voices` renvoie (autorité), jamais d'un chemin codé en dur (cf. découverte du test : `/voices` renvoie (autorité), jamais d'un chemin codé en dur (WAV sous
WAV sous `/data/local/tmp/kazeia/` en dev, `Android/data/com.kazeia/` en prod). `/data/local/tmp/kazeia/` en dev, `Android/data/com.kazeia/` en prod).
Voix = clone-prompts **OmniVoice `.ovsp`** (migration 23/06/2026, ex-CosyVoice `.cvps`),
déployés dans `<MODELS_DIR>/omnivoice/voices/`.
""" """
from __future__ import annotations from __future__ import annotations
@ -13,39 +16,39 @@ import posixpath
from ..adb import Adb, AdbError from ..adb import Adb, AdbError
# Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur # Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur
# permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier cosyvoice cible. # permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier voix OmniVoice cible.
_WAV_MARKER = "/../voix/voix/" _WAV_MARKER = "/../voix/voix/"
def cosyvoice_dir_for(wav_path: str) -> str: def omnivoice_dir_for(wav_path: str) -> str:
"""Dossier device des `.cvps` (`<MODELS_DIR>/cosyvoice`) dérivé d'un `wav_path`.""" """Dossier device des `.ovsp` (`<MODELS_DIR>/omnivoice/voices`) dérivé d'un `wav_path`."""
if _WAV_MARKER in wav_path: if _WAV_MARKER in wav_path:
models = wav_path.split(_WAV_MARKER)[0] models = wav_path.split(_WAV_MARKER)[0]
return posixpath.join(models, "cosyvoice") return posixpath.join(models, "omnivoice", "voices")
# Fallback : …/kazeia/voix/voix/<id>.wav → …/kazeia/models/cosyvoice # Fallback : …/kazeia/voix/voix/<id>.wav → …/kazeia/models/omnivoice/voices
voix_dir = posixpath.dirname(wav_path) voix_dir = posixpath.dirname(wav_path)
kazeia = posixpath.dirname(posixpath.dirname(voix_dir)) kazeia = posixpath.dirname(posixpath.dirname(voix_dir))
return posixpath.join(kazeia, "models", "cosyvoice") return posixpath.join(kazeia, "models", "omnivoice", "voices")
def deployed_cvps(adb: Adb, serial: str, cosyvoice_dir: str) -> set[str]: def deployed_ovsp(adb: Adb, serial: str, ovsp_dir: str) -> set[str]:
"""Ensemble des voice_id ayant un `.cvps` déjà déployé sur la tablette.""" """Ensemble des voice_id ayant un `.ovsp` déjà déployé sur la tablette."""
out = adb.shell(f"ls {cosyvoice_dir} 2>/dev/null", serial=serial) out = adb.shell(f"ls {ovsp_dir} 2>/dev/null", serial=serial)
return {line[:-5].strip() for line in out.splitlines() return {line[:-5].strip() for line in out.splitlines()
if line.strip().endswith(".cvps")} if line.strip().endswith(".ovsp")}
def pull_wav(adb: Adb, serial: str, wav_path: str, local_path: str) -> None: def pull_wav(adb: Adb, serial: str, wav_path: str, local_path: str) -> None:
adb.pull(wav_path, local_path, serial=serial) adb.pull(wav_path, local_path, serial=serial)
def push_cvps(adb: Adb, serial: str, local_cvps: str, cosyvoice_dir: str, def push_ovsp(adb: Adb, serial: str, local_ovsp: str, ovsp_dir: str,
voice_id: str) -> str: voice_id: str) -> str:
"""Pousse le `.cvps` dans le dossier cosyvoice de la tablette. Renvoie le chemin """Pousse le `.ovsp` dans le dossier voix OmniVoice de la tablette. Renvoie le
device. Crée le dossier au besoin.""" chemin device. Crée le dossier au besoin."""
adb.shell(f"mkdir -p {cosyvoice_dir}", serial=serial) adb.shell(f"mkdir -p {ovsp_dir}", serial=serial)
remote = posixpath.join(cosyvoice_dir, f"{voice_id}.cvps") remote = posixpath.join(ovsp_dir, f"{voice_id}.ovsp")
adb.push(local_cvps, remote, serial=serial) adb.push(local_ovsp, remote, serial=serial)
return remote return remote

View File

@ -1,11 +1,11 @@
"""Worker d'enrôlement vocal — process persistant sous cv_venv. """Worker d'enrôlement vocal — process persistant sous ov_venv.
Lancé par `voice/bridge.py` (côté API py3.14) via : Lancé par `voice/bridge.py` (côté API py3.14) via :
cv_venv/bin/python -m kazeia_central.voice.worker ov_venv/bin/python -m kazeia_central.voice.worker
Garde le teacher CosyVoice (et le modèle Whisper) **chauds**, et sert une file de Garde le modèle OmniVoice (enrôlement + ASR de réf) **chaud**, et sert une file de
jobs via un protocole JSON-lines sur stdin/stdout. jobs via un protocole JSON-lines sur stdin/stdout.
Canal protocole propre : la stack torch/librosa/whisper écrit du bruit sur stdout. Canal protocole propre : la stack torch/librosa/transformers écrit du bruit sur stdout.
On capture le **vrai** stdout (fd 1) pour le protocole AVANT de rediriger fd 1 vers On capture le **vrai** stdout (fd 1) pour le protocole AVANT de rediriger fd 1 vers
stderr ainsi tout `print()`/sortie C de lib part sur stderr (console), et seul le stderr ainsi tout `print()`/sortie C de lib part sur stderr (console), et seul le
JSON du protocole circule sur le pipe lu par le bridge. Sans ça, l'IPC serait corrompu. JSON du protocole circule sur le pipe lu par le bridge. Sans ça, l'IPC serait corrompu.

View File

@ -1,7 +1,7 @@
"""Tests du sous-système voix : dérivation chemins, archive store, orchestration. """Tests du sous-système voix : dérivation chemins, archive store, orchestration.
Le moteur lourd (CosyVoice/Whisper) n'est PAS testé ici (validé live) — on injecte Le moteur lourd (OmniVoice) n'est PAS testé ici (validé live) — on injecte un faux
un faux bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle. bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle.
""" """
import nacl.pwhash import nacl.pwhash
@ -24,15 +24,15 @@ def _store(tmp_path):
# ---- dérivation des chemins device --------------------------------------- # ---- dérivation des chemins device ---------------------------------------
def test_cosyvoice_dir_marker(): def test_omnivoice_dir_marker():
wp = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav" wp = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav"
assert transfer.cosyvoice_dir_for(wp) == "/data/local/tmp/kazeia/models/cosyvoice" assert transfer.omnivoice_dir_for(wp) == "/data/local/tmp/kazeia/models/omnivoice/voices"
def test_cosyvoice_dir_fallback_prod(): def test_omnivoice_dir_fallback_prod():
wp = "/sdcard/Android/data/com.kazeia/files/kazeia/voix/voix/amir.wav" wp = "/sdcard/Android/data/com.kazeia/files/kazeia/voix/voix/amir.wav"
assert transfer.cosyvoice_dir_for(wp) == \ assert transfer.omnivoice_dir_for(wp) == \
"/sdcard/Android/data/com.kazeia/files/kazeia/models/cosyvoice" "/sdcard/Android/data/com.kazeia/files/kazeia/models/omnivoice/voices"
# ---- archive voix dans le store ------------------------------------------ # ---- archive voix dans le store ------------------------------------------
@ -62,7 +62,7 @@ def test_voice_status_marks(tmp_path):
s.mark_voice_enrolled("D1", "amir", 242000, now=NOW) s.mark_voice_enrolled("D1", "amir", 242000, now=NOW)
s.mark_voice_deployed("D1", "amir", now=NOW) s.mark_voice_deployed("D1", "amir", now=NOW)
r = s.voice_record("D1", "amir") r = s.voice_record("D1", "amir")
assert r["cvps_bytes"] == 242000 and r["enrolled_at"] and r["deployed_at"] assert r["ovsp_bytes"] == 242000 and r["enrolled_at"] and r["deployed_at"]
# ---- orchestration (faux adb + faux bridge) ------------------------------ # ---- orchestration (faux adb + faux bridge) ------------------------------
@ -87,7 +87,7 @@ class _VoiceAdb(Adb):
def shell(self, command, *, serial=None, timeout=None): def shell(self, command, *, serial=None, timeout=None):
if command.startswith("ls "): if command.startswith("ls "):
return "\n".join(f"{d}.cvps" for d in self._deployed) return "\n".join(f"{d}.ovsp" for d in self._deployed)
if "rm -f" in command: if "rm -f" in command:
self.deleted.append(command.split("rm -f ")[1].split(" &&")[0]) self.deleted.append(command.split("rm -f ")[1].split(" &&")[0])
return "OK" return "OK"
@ -105,8 +105,8 @@ class _FakeBridge:
return {"text": "bonjour ceci est ma voix", "language": "fr", "model": "small"} return {"text": "bonjour ceci est ma voix", "language": "fr", "model": "small"}
def enroll(self, wav, text, out): def enroll(self, wav, text, out):
open(out, "wb").write(b"GGUF-CVPS-" + text.encode()[:5]) open(out, "wb").write(b"OVSP" + text.encode()[:5])
return {"bytes": 242000, "feat_shape": [750, 80], "n_tokens": 375, "text_bytes": len(text)} return {"bytes": 242000, "t_ref": 364, "ref_seconds": 14.6, "text_bytes": len(text)}
_WP = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav" _WP = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav"
@ -138,9 +138,9 @@ def test_enroll_deploy_pushes_and_optional_delete(tmp_path):
# sans suppression # sans suppression
r = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix", r = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix",
delete_source=False, now=NOW) delete_source=False, now=NOW)
assert r["cvps_bytes"] == 242000 and r["wav_deleted"] is False assert r["ovsp_bytes"] == 242000 and r["wav_deleted"] is False
assert adb.pushed and adb.pushed[0][1] == \ assert adb.pushed and adb.pushed[0][1] == \
"/data/local/tmp/kazeia/models/cosyvoice/amir.cvps" "/data/local/tmp/kazeia/models/omnivoice/voices/amir.ovsp"
assert s.voice_record("D1", "amir")["deployed_at"] == NOW assert s.voice_record("D1", "amir")["deployed_at"] == NOW
assert adb.deleted == [] assert adb.deleted == []
# avec suppression # avec suppression