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:
parent
92505a4cac
commit
4ad513afda
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 ré-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
|
||||||
|
|
@ -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()})")
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue