feat(voice): orchestration enrôlement — pull/archive/transcribe/enroll/deploy

- store : table voices + WAV chiffré sur disque (archive_voice_wav, voice_wav_bytes,
  transcription chiffrée, marques archived/enrolled/deployed/wav_deleted).
- voice/transfer.py : dérivation du dossier cosyvoice depuis wav_path (device-dépendant),
  pull WAV / push .cvps / delete WAV device (retour vérifié §9).
- voice/orchestrator.py : list_voices (statut par voix), prepare (pull+archive+whisper),
  enroll_deploy (enroll+push + suppression WAV device optionnelle, opt-in).
- API : GET /api/voices/{serial}, /voices/health, POST .../prepare, .../enroll.
- tests : +10 (chemins, archive chiffrée, orchestration via faux adb/bridge). 33/33.
- validé live : jerome (muet) → transcrit FR → .cvps déployé sur tablette + WAV
  archivé chiffré ; WAV device conservé (delete_source=false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-19 14:39:29 +02:00
parent 92505a4cac
commit 4ad513afda
5 changed files with 482 additions and 1 deletions

View File

@ -25,6 +25,8 @@ from ..adb import Adb, AdbError
from ..fleet import device_overview, fan_out from ..fleet import device_overview, fan_out
from ..provider import ProviderClient from ..provider import ProviderClient
from ..store import BadPassword, Store, StoreLocked from ..store import BadPassword, Store, StoreLocked
from ..voice import orchestrator as vo
from ..voice.bridge import VoiceBridge, VoiceWorkerError
_WEB_DIR = Path(__file__).resolve().parent.parent / "web" _WEB_DIR = Path(__file__).resolve().parent.parent / "web"
# Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité). # Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité).
@ -45,6 +47,11 @@ class _Label(BaseModel):
label: str label: str
class _Enroll(BaseModel):
transcription: str
delete_source: bool = False # suppression du WAV device = opt-in explicite
def _install_basic_auth(app: FastAPI) -> None: def _install_basic_auth(app: FastAPI) -> None:
"""Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS """Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS
sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour
@ -70,11 +77,13 @@ def _install_basic_auth(app: FastAPI) -> None:
return await call_next(request) return await call_next(request)
def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: def create_app(adb: Adb | None = None, store: Store | None = None,
bridge: VoiceBridge | None = None) -> FastAPI:
adb = adb or Adb() adb = adb or Adb()
if store is None: if store is None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True) _STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
store = Store(_STORE_PATH) store = Store(_STORE_PATH)
bridge = bridge or VoiceBridge()
app = FastAPI(title="Kazeia-central", version="0.0.1") app = FastAPI(title="Kazeia-central", version="0.0.1")
_install_basic_auth(app) _install_basic_auth(app)
@ -88,6 +97,8 @@ def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI:
raise HTTPException(status_code=502, detail=str(e)) from e raise HTTPException(status_code=502, detail=str(e)) from e
except StoreLocked as e: except StoreLocked as e:
raise HTTPException(status_code=423, detail=str(e)) from e raise HTTPException(status_code=423, detail=str(e)) from e
except VoiceWorkerError as e:
raise HTTPException(status_code=503, detail=f"encodeur voix: {e}") from e
except IndexError as e: # endpoint a renvoyé 0 ligne except IndexError as e: # endpoint a renvoyé 0 ligne
raise HTTPException(status_code=404, detail="aucune donnée") from e raise HTTPException(status_code=404, detail="aucune donnée") from e
@ -198,6 +209,27 @@ def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI:
now=_now_ms()) now=_now_ms())
return report return report
# ---- voix (enrôlement CosyVoice : WAV device → .cvps → flotte) --------
@app.get("/api/voices/health")
def voices_health():
return {"encoder_available": bridge.available(), "cv_python": bridge.cv_python}
@app.get("/api/voices/{serial}")
def voices_list(serial: str):
return _guard(lambda: vo.list_voices(adb, store, serial))
@app.post("/api/voices/{serial}/{voice_id}/prepare")
def voices_prepare(serial: str, voice_id: str):
# pull + archive chiffré + transcription Whisper (à relire avant enrôlement)
return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id,
now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/{voice_id}/enroll")
def voices_enroll(serial: str, voice_id: str, body: _Enroll):
return _guard(lambda: vo.enroll_deploy(
adb, store, bridge, serial, voice_id, body.transcription,
delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR))
# UI web locale servie à la racine — APRÈS les routes /api pour ne pas les # UI web locale servie à la racine — APRÈS les routes /api pour ne pas les
# masquer. `html=True` → sert index.html sur "/". # masquer. `html=True` → sert index.html sur "/".
if _WEB_DIR.is_dir(): if _WEB_DIR.is_dir():

View File

@ -20,6 +20,7 @@ connexion ouverte avec `check_same_thread=False` et accès sérialisé par un `R
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import sqlite3 import sqlite3
import threading import threading
@ -64,6 +65,21 @@ CREATE TABLE IF NOT EXISTS audit (
target TEXT, target TEXT,
detail TEXT -- métadonnée d'audit (pas de PII) → clair detail TEXT -- métadonnée d'audit (pas de PII) → clair
); );
CREATE TABLE IF NOT EXISTS voices (
serial TEXT NOT NULL,
voice_id TEXT NOT NULL,
transcription_enc BLOB, -- texte parlé (PII possible) chiffré
language TEXT,
source_wav_path TEXT, -- chemin device d'origine
wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/)
wav_sha256 TEXT, -- intégrité du WAV d'origine
cvps_bytes INTEGER,
archived_at INTEGER, -- WAV récupéré + archivé sur le PC
enrolled_at INTEGER, -- .cvps produit
deployed_at INTEGER, -- .cvps poussé sur la tablette
wav_deleted_at INTEGER, -- WAV supprimé de la tablette
PRIMARY KEY (serial, voice_id)
);
""" """
@ -251,6 +267,99 @@ class Store:
).fetchone() ).fetchone()
return row["m"] if row else None return row["m"] if row else None
# ---- archive voix (enrôlement, §6 VOICE_ENROLLMENT_SPEC) --------------
def _voices_dir(self) -> Path:
d = Path(self.path).parent / "voices"
d.mkdir(parents=True, exist_ok=True)
return d
def _voice_upsert(self, serial: str, voice_id: str, **cols: Any) -> None:
"""Upsert partiel d'une ligne voix (colonnes données seulement)."""
keys = list(cols)
sets = ", ".join(f"{k}=excluded.{k}" for k in keys)
self._db.execute(
f"INSERT INTO voices(serial, voice_id, {', '.join(keys)}) "
f"VALUES(?, ?, {', '.join('?' for _ in keys)}) "
f"ON CONFLICT(serial, voice_id) DO UPDATE SET {sets}",
(serial, voice_id, *[cols[k] for k in keys]),
)
self._db.commit()
def archive_voice_wav(self, serial: str, voice_id: str, wav_bytes: bytes, *,
source_wav_path: str | None, now: int) -> str:
"""Chiffre + archive le WAV d'origine sur le PC (le « garder sur Kazeia-central »
une fois supprimé de la tablette). Renvoie le chemin du fichier chiffré."""
v = self._require_vault()
sha = hashlib.sha256(wav_bytes).hexdigest()
dest = self._voices_dir() / serial / f"{voice_id}.wav.enc"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(v.seal_bytes(wav_bytes))
with self._lock:
self._voice_upsert(serial, voice_id, source_wav_path=source_wav_path,
wav_file=str(dest.relative_to(self._voices_dir())),
wav_sha256=sha, archived_at=now)
return str(dest)
def voice_wav_bytes(self, serial: str, voice_id: str) -> bytes | None:
"""Relit le WAV archivé déchiffré (pour ré-enrôlement)."""
v = self._require_vault()
with self._lock:
row = self._db.execute(
"SELECT wav_file FROM voices WHERE serial=? AND voice_id=?",
(serial, voice_id)).fetchone()
if not row or not row["wav_file"]:
return None
return v.open_bytes((self._voices_dir() / row["wav_file"]).read_bytes())
def set_voice_transcription(self, serial: str, voice_id: str, text: str,
language: str | None, *, now: int) -> None:
v = self._require_vault()
with self._lock:
self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text),
language=language)
def mark_voice_enrolled(self, serial: str, voice_id: str, cvps_bytes: int, *, now: int) -> None:
with self._lock:
self._voice_upsert(serial, voice_id, cvps_bytes=cvps_bytes, enrolled_at=now)
def mark_voice_deployed(self, serial: str, voice_id: str, *, now: int) -> None:
with self._lock:
self._voice_upsert(serial, voice_id, deployed_at=now)
def mark_voice_wav_deleted(self, serial: str, voice_id: str, *, now: int) -> None:
with self._lock:
self._voice_upsert(serial, voice_id, wav_deleted_at=now)
def voice_record(self, serial: str, voice_id: str) -> dict[str, Any] | None:
v = self._require_vault()
with self._lock:
row = self._db.execute(
"SELECT * FROM voices WHERE serial=? AND voice_id=?",
(serial, voice_id)).fetchone()
if not row:
return None
d = dict(row)
d["transcription"] = v.open(d.pop("transcription_enc"))
d["has_wav"] = bool(d.get("wav_file"))
return d
def voices(self, serial: str | None = None) -> list[dict[str, Any]]:
v = self._require_vault()
with self._lock:
q = "SELECT * FROM voices"
args: tuple = ()
if serial is not None:
q += " WHERE serial=?"
args = (serial,)
rows = self._db.execute(q + " ORDER BY serial, voice_id", args).fetchall()
out = []
for r in rows:
d = dict(r)
d["transcription"] = v.open(d.pop("transcription_enc"))
d["has_wav"] = bool(d.get("wav_file"))
out.append(d)
return out
# ---- audit (§8) ------------------------------------------------------- # ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None, def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
detail: dict[str, Any] | None = None, now: int) -> None: detail: dict[str, Any] | None = None, now: int) -> None:

View File

@ -0,0 +1,136 @@
"""Orchestration de l'enrôlement vocal (le workflow complet).
Compose : provider (`/voices` wav_path), transfert adb, bridge (worker cv_venv),
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) :
connexion lister voix sans .cvps pull WAV ARCHIVER (store chiffré)
transcrire (Whisper, relecture opérateur) enrôler (.cvps) pousser sur la
tablette SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central).
Découpé pour permettre la relecture de la transcription : `prepare` (pull+archive+
transcribe) puis `enroll_deploy` (enroll+push+delete). Le WAV est archivé DÈS prepare
jamais de suppression device sans copie PC.
"""
from __future__ import annotations
import os
import tempfile
from typing import Any
from ..adb import Adb
from ..provider import ProviderClient
from ..store import Store
from . import transfer
from .bridge import VoiceBridge
def _provider_voices(adb: Adb, serial: str):
return ProviderClient(adb, serial).voices()
def _find_wav_path(adb: Adb, serial: str, voice_id: str) -> str | None:
for v in _provider_voices(adb, serial):
if v.id == voice_id:
return v.wav_path
return None
def _status(deployed: bool, rec: dict) -> str:
if deployed:
return "deployed"
if rec.get("enrolled_at"):
return "enrolled"
if rec.get("archived_at"):
return "archived"
return "pending"
def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"""Inventaire voix de la tablette croisé avec l'archive store (statut par voix)."""
rows = _provider_voices(adb, serial)
cosy = transfer.cosyvoice_dir_for(rows[0].wav_path) if rows else None
deployed = transfer.deployed_cvps(adb, serial, cosy) if cosy else set()
recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
out = []
for v in rows:
rec = recs.get(v.id, {})
is_dep = v.id in deployed
out.append({
"voice_id": v.id,
"wav_path": v.wav_path,
"wav_on_device": v.wav_exists,
"deployed_on_device": is_dep,
"archived": bool(rec.get("archived_at")),
"enrolled": bool(rec.get("enrolled_at")),
"wav_deleted": bool(rec.get("wav_deleted_at")),
"transcription": rec.get("transcription"),
"language": rec.get("language"),
"status": _status(is_dep, rec),
})
return out
def _ensure_wav_archived(adb: Adb, store: Store, serial: str, voice_id: str, *,
now: int) -> bytes:
"""Garantit que le WAV est archivé (chiffré) côté PC ; le renvoie déchiffré."""
data = store.voice_wav_bytes(serial, voice_id)
if data is not None:
return data
wav_path = _find_wav_path(adb, serial, voice_id)
if not wav_path:
raise ValueError(f"voix introuvable sur la tablette: {voice_id}")
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
transfer.pull_wav(adb, serial, wav_path, local)
data = open(local, "rb").read()
store.archive_voice_wav(serial, voice_id, data, source_wav_path=wav_path, now=now)
return data
def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *,
now: int, actor: str | None = None) -> dict[str, Any]:
"""Pull + archive (chiffré) + transcription Whisper. Renvoie le texte à relire."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
open(local, "wb").write(data)
tr = bridge.transcribe(local)
store.set_voice_transcription(serial, voice_id, tr["text"], tr.get("language"), now=now)
store.audit("voice_prepare", actor=actor, target=f"{serial}/{voice_id}", now=now)
return {"voice_id": voice_id, "transcription": tr["text"],
"language": tr.get("language"), "model": tr.get("model")}
def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str,
transcription: str, *, delete_source: bool, now: int,
actor: str | None = None) -> dict[str, Any]:
"""Enrôle (.cvps) → pousse sur la tablette → (option) supprime le WAV device.
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)
wav_path = _find_wav_path(adb, serial, voice_id)
cosy = transfer.cosyvoice_dir_for(wav_path) if wav_path else None
if not cosy:
raise ValueError(f"dossier cosyvoice indéterminé pour {voice_id}")
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")
open(local, "wb").write(data)
cvps = os.path.join(td, f"{voice_id}.cvps")
info = bridge.enroll(local, transcription, cvps)
store.set_voice_transcription(serial, voice_id, transcription, None, now=now)
store.mark_voice_enrolled(serial, voice_id, info["bytes"], now=now)
remote = transfer.push_cvps(adb, serial, cvps, cosy, voice_id)
store.mark_voice_deployed(serial, voice_id, now=now)
store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}",
detail={"cvps_bytes": info["bytes"]}, now=now)
result = {"voice_id": voice_id, "cvps_bytes": info["bytes"],
"deployed_to": remote, "wav_deleted": False}
if delete_source and wav_path:
transfer.delete_device_wav(adb, serial, wav_path)
store.mark_voice_wav_deleted(serial, voice_id, now=now)
store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{voice_id}", now=now)
result["wav_deleted"] = True
return result

View File

@ -0,0 +1,57 @@
"""Transfert de fichiers voix entre la tablette et Kazeia-central (adb).
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
`/voices` renvoie (autorité), jamais d'un chemin codé en dur (cf. découverte du test :
WAV sous `/data/local/tmp/kazeia/` en dev, `Android/data/com.kazeia/` en prod).
"""
from __future__ import annotations
import posixpath
from ..adb import Adb, AdbError
# 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.
_WAV_MARKER = "/../voix/voix/"
def cosyvoice_dir_for(wav_path: str) -> str:
"""Dossier device des `.cvps` (`<MODELS_DIR>/cosyvoice`) dérivé d'un `wav_path`."""
if _WAV_MARKER in wav_path:
models = wav_path.split(_WAV_MARKER)[0]
return posixpath.join(models, "cosyvoice")
# Fallback : …/kazeia/voix/voix/<id>.wav → …/kazeia/models/cosyvoice
voix_dir = posixpath.dirname(wav_path)
kazeia = posixpath.dirname(posixpath.dirname(voix_dir))
return posixpath.join(kazeia, "models", "cosyvoice")
def deployed_cvps(adb: Adb, serial: str, cosyvoice_dir: str) -> set[str]:
"""Ensemble des voice_id ayant un `.cvps` déjà déployé sur la tablette."""
out = adb.shell(f"ls {cosyvoice_dir} 2>/dev/null", serial=serial)
return {line[:-5].strip() for line in out.splitlines()
if line.strip().endswith(".cvps")}
def pull_wav(adb: Adb, serial: str, wav_path: str, local_path: str) -> None:
adb.pull(wav_path, local_path, serial=serial)
def push_cvps(adb: Adb, serial: str, local_cvps: str, cosyvoice_dir: str,
voice_id: str) -> str:
"""Pousse le `.cvps` dans le dossier cosyvoice de la tablette. Renvoie le chemin
device. Crée le dossier au besoin."""
adb.shell(f"mkdir -p {cosyvoice_dir}", serial=serial)
remote = posixpath.join(cosyvoice_dir, f"{voice_id}.cvps")
adb.push(local_cvps, remote, serial=serial)
return remote
def delete_device_wav(adb: Adb, serial: str, wav_path: str) -> None:
"""Supprime le WAV d'origine de la tablette (après archivage côté PC).
Vérifie le retour : `rm` adb échoue silencieusement (§9)."""
out = adb.shell(f"rm -f {wav_path} && echo OK || echo FAIL", serial=serial)
if "OK" not in out:
raise AdbError(f"suppression WAV échouée: {wav_path} ({out.strip()})")

147
tests/test_voice.py Normal file
View File

@ -0,0 +1,147 @@
"""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
un faux bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle.
"""
import nacl.pwhash
from kazeia_central.adb import Adb
from kazeia_central.adb.client import AdbDevice
from kazeia_central.store import Store
from kazeia_central.voice import orchestrator as vo
from kazeia_central.voice import transfer
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
NOW = 1_700_000_000_000
def _store(tmp_path):
s = Store(tmp_path / "c.db", kdf_ops=_OPS, kdf_mem=_MEM)
s.unlock("pw")
return s
# ---- dérivation des chemins device ---------------------------------------
def test_cosyvoice_dir_marker():
wp = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav"
assert transfer.cosyvoice_dir_for(wp) == "/data/local/tmp/kazeia/models/cosyvoice"
def test_cosyvoice_dir_fallback_prod():
wp = "/sdcard/Android/data/com.kazeia/files/kazeia/voix/voix/amir.wav"
assert transfer.cosyvoice_dir_for(wp) == \
"/sdcard/Android/data/com.kazeia/files/kazeia/models/cosyvoice"
# ---- archive voix dans le store ------------------------------------------
def test_voice_wav_archive_roundtrip_and_encrypted(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"FAKEWAVDATA-amir", source_wav_path="/dev/amir.wav", now=NOW)
assert s.voice_wav_bytes("D1", "amir") == b"FAKEWAVDATA-amir"
# chiffré au repos : le blob brut ne contient pas le contenu
enc = (tmp_path / "voices" / "D1" / "amir.wav.enc").read_bytes()
assert b"FAKEWAVDATA" not in enc
rec = s.voice_record("D1", "amir")
assert rec["has_wav"] and rec["wav_sha256"] and rec["archived_at"] == NOW
def test_voice_transcription_encrypted(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW)
s.set_voice_transcription("D1", "amir", "texte parlé confidentiel", "fr", now=NOW)
assert s.voice_record("D1", "amir")["transcription"] == "texte parlé confidentiel"
raw = (tmp_path / "c.db").read_bytes()
assert b"texte parl" not in raw # PII jamais en clair en base
def test_voice_status_marks(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW)
s.mark_voice_enrolled("D1", "amir", 242000, now=NOW)
s.mark_voice_deployed("D1", "amir", now=NOW)
r = s.voice_record("D1", "amir")
assert r["cvps_bytes"] == 242000 and r["enrolled_at"] and r["deployed_at"]
# ---- orchestration (faux adb + faux bridge) ------------------------------
class _VoiceAdb(Adb):
def __init__(self, voices, deployed=()):
super().__init__()
self._voices = voices # [(id, wav_path, wav_exists)]
self._deployed = list(deployed)
self.pushed = []
self.deleted = []
def ready_devices(self):
return [AdbDevice("D1", "device", {})]
def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True):
if path == "voices":
return [{"id": i, "wav_path": w, "wav_exists": 1} for i, w, _ in self._voices]
return []
def shell(self, command, *, serial=None, timeout=None):
if command.startswith("ls "):
return "\n".join(f"{d}.cvps" for d in self._deployed)
if "rm -f" in command:
self.deleted.append(command.split("rm -f ")[1].split(" &&")[0])
return "OK"
return ""
def pull(self, remote, local, *, serial=None):
open(local, "wb").write(b"WAV:" + remote.encode())
def push(self, local, remote, *, serial=None):
self.pushed.append((open(local, "rb").read(), remote))
class _FakeBridge:
def transcribe(self, wav, model=None):
return {"text": "bonjour ceci est ma voix", "language": "fr", "model": "small"}
def enroll(self, wav, text, out):
open(out, "wb").write(b"GGUF-CVPS-" + text.encode()[:5])
return {"bytes": 242000, "feat_shape": [750, 80], "n_tokens": 375, "text_bytes": len(text)}
_WP = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav"
def test_list_voices_status(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1), ("damien",
"/data/local/tmp/kazeia/models/../voix/voix/damien.wav", 1)],
deployed=["damien"])
out = {v["voice_id"]: v for v in vo.list_voices(adb, s, "D1")}
assert out["damien"]["status"] == "deployed"
assert out["amir"]["status"] == "pending"
def test_prepare_archives_and_transcribes(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)])
res = vo.prepare(adb, s, _FakeBridge(), "D1", "amir", now=NOW)
assert res["transcription"].startswith("bonjour")
# WAV archivé (chiffré) + transcription posée
assert s.voice_wav_bytes("D1", "amir") == b"WAV:" + _WP.encode()
assert s.voice_record("D1", "amir")["transcription"].startswith("bonjour")
def test_enroll_deploy_pushes_and_optional_delete(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)])
# sans suppression
r = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix",
delete_source=False, now=NOW)
assert r["cvps_bytes"] == 242000 and r["wav_deleted"] is False
assert adb.pushed and adb.pushed[0][1] == \
"/data/local/tmp/kazeia/models/cosyvoice/amir.cvps"
assert s.voice_record("D1", "amir")["deployed_at"] == NOW
assert adb.deleted == []
# avec suppression
r2 = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix",
delete_source=True, now=NOW)
assert r2["wav_deleted"] is True and adb.deleted == [_WP]
assert s.voice_record("D1", "amir")["wav_deleted_at"] == NOW