"""Orchestration vocale — catalogue GLOBAL + déploiement par tablette. Modèle (refonte 23/06/2026) : une voix est un **asset de flotte** (catalogue central, clé `voice_id`), PAS liée à la tablette d'enregistrement. La tablette n'est qu'une **cible de déploiement** ; l'artefact `.ovsp` est stocké chiffré côté central et poussé sur N tablettes sans ré-enrôler. Source des voix fraîches : stockage ADMIN (`admin_ingest`, manifestes scope/owner/ reference_text). Politique : `global` → déployable partout ; `exclusive` → seulement sur une tablette portant le profil propriétaire ; `pending` → archivée, pas déployable. Le `.ovsp` porte l'ASR du segment 16 s (§6) ; le consentement reste en métadonnée. """ from __future__ import annotations import os import posixpath import tempfile from typing import Any from ..adb import Adb from ..provider import ProviderClient from ..store import Store from . import admin_ingest, transfer from .bridge import VoiceBridge # MODELS_DIR candidats côté patient (dev legacy / prod scoped-storage). _CANDIDATE_MODELS_DIRS = ( "/data/local/tmp/kazeia/models", "/sdcard/Android/data/com.kazeia/files/kazeia/models", ) def _provider_voices(adb: Adb, serial: str): return ProviderClient(adb, serial).voices() def _profiles_present(adb: Adb, serial: str) -> set[str]: try: return {p.id for p in ProviderClient(adb, serial).profiles()} except Exception: return set() def _patient_omnivoice_dir(adb: Adb, serial: str) -> str: """Dossier `.ovsp` cible (`/omnivoice/voices`). Post-migration `/voices.wav_path` est VIDE → cascade : env → wav_path legacy → sonde device.""" env = os.environ.get("KAZEIA_OMNIVOICE_DIR") if env: return env for r in _provider_voices(adb, serial): if r.wav_path and transfer._WAV_MARKER in r.wav_path: return transfer.omnivoice_dir_for(r.wav_path) for base in _CANDIDATE_MODELS_DIRS: if base in adb.shell(f"ls -d {base} 2>/dev/null", serial=serial): return posixpath.join(base, "omnivoice", "voices") raise ValueError("dossier omnivoice indéterminable (définir KAZEIA_OMNIVOICE_DIR)") def _deployable(rec: dict, present: set[str]) -> tuple[bool, str | None]: """Une voix du catalogue est-elle déployable sur une tablette ? `global` partout ; `exclusive` seulement si le profil propriétaire est présent ; `pending` jamais.""" if rec.get("scope") == "pending": return False, "pending" lock = rec.get("locked_profile_id") if lock and lock not in present: return False, "verrou_profil_absent" return True, None # ===== Catalogue global ===================================================== def catalog(store: Store) -> list[dict[str, Any]]: """Catalogue voix GLOBAL (toutes tablettes confondues) — vue sans sélection.""" return store.voices() if store.is_unlocked else [] # ===== Enrôlement (asset central) =========================================== def _enroll_into_store(store: Store, bridge: VoiceBridge, voice_id: str, *, now: int, text: str = "") -> dict: """Enrôle depuis le WAV archivé → stocke le `.ovsp` chiffré au catalogue. `text` vide → ASR auto du segment 16 s (§6).""" data = store.voice_wav_bytes(voice_id) if data is None: raise ValueError(f"WAV non archivé pour {voice_id}") with tempfile.TemporaryDirectory() as td: local = os.path.join(td, f"{voice_id}.wav") open(local, "wb").write(data) ovsp = os.path.join(td, f"{voice_id}.ovsp") info = bridge.enroll(local, text, ovsp) store.set_voice_transcription(voice_id, info.get("ref_text", ""), now=now) store.store_voice_ovsp(voice_id, open(ovsp, "rb").read(), now=now) return info # ===== Déploiement par tablette ============================================= def deploy_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *, now: int, actor: str | None = None) -> dict[str, Any]: """Déploie une voix du catalogue sur une tablette : enrôle si besoin (1ʳᵉ fois), pousse le `.ovsp` stocké, enregistre le déploiement. Respecte l'exclusivité.""" rec = store.voice_record(voice_id) if not rec: raise ValueError(f"voix inconnue au catalogue: {voice_id}") ok, reason = _deployable(rec, _profiles_present(adb, serial)) if not ok: return {"voice_id": voice_id, "deployed": False, "reason": reason} if not store.voice_ovsp_bytes(voice_id): _enroll_into_store(store, bridge, voice_id, now=now) ovsp_bytes = store.voice_ovsp_bytes(voice_id) ov = _patient_omnivoice_dir(adb, serial) with tempfile.TemporaryDirectory() as td: local = os.path.join(td, f"{voice_id}.ovsp") open(local, "wb").write(ovsp_bytes) remote = transfer.push_ovsp(adb, serial, local, ov, voice_id) store.add_deployment(voice_id, serial, now=now) store.audit("voice_deploy", actor=actor, target=f"{serial}/{voice_id}", now=now) return {"voice_id": voice_id, "deployed": True, "deployed_to": remote} def undeploy_voice(adb: Adb, store: Store, serial: str, voice_id: str, *, now: int, actor: str | None = None) -> dict[str, Any]: """Retire le `.ovsp` d'une tablette + ôte le déploiement (la voix reste au catalogue).""" ov = _patient_omnivoice_dir(adb, serial) adb.shell(f"rm -f {posixpath.join(ov, voice_id + '.ovsp')}", serial=serial) store.remove_deployment(voice_id, serial) store.audit("voice_undeploy", actor=actor, target=f"{serial}/{voice_id}", now=now) return {"voice_id": voice_id, "deployed": False} def reenroll_voice(adb: Adb, store: Store, bridge: VoiceBridge, voice_id: str, *, now: int, actor: str | None = None) -> dict[str, Any]: """Régénère le `.ovsp` depuis le WAV archivé (ex. après le fix §7) et le re-pousse sur toutes les tablettes où la voix est déployée. `deploy_voice` réutilise le `.ovsp` stocké — donc sans ré-enrôlement, un artefact pollué reste pollué.""" if store.voice_wav_bytes(voice_id) is None: raise ValueError(f"WAV non archivé pour {voice_id} — ré-enrôlement impossible " f"(ingérer d'abord depuis l'admin)") info = _enroll_into_store(store, bridge, voice_id, now=now) # écrase ovsp + transcription store.audit("voice_reenroll", actor=actor, target=voice_id, now=now) ovsp_bytes = store.voice_ovsp_bytes(voice_id) redeployed = [] for serial in store.deployments_of(voice_id): try: ov = _patient_omnivoice_dir(adb, serial) with tempfile.TemporaryDirectory() as td: local = os.path.join(td, f"{voice_id}.ovsp") open(local, "wb").write(ovsp_bytes) transfer.push_ovsp(adb, serial, local, ov, voice_id) store.add_deployment(voice_id, serial, now=now) redeployed.append(serial) except Exception as e: redeployed.append(f"{serial}:erreur ({e})") return {"voice_id": voice_id, "reenrolled": True, "ovsp_bytes": info["bytes"], "ref_text": info.get("ref_text"), "redeployed": redeployed} def list_for_tablet(adb: Adb, store: Store, serial: str) -> dict[str, Any]: """Vue d'une tablette : voix **chargées** (sur le device) et voix **chargeables** (catalogue non déployé, filtré par exclusivité).""" try: on_device = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) except Exception: on_device = set() present = _profiles_present(adb, serial) cat = store.voices() if store.is_unlocked else [] cat_ids = {r["voice_id"] for r in cat} deployed, deployable = [], [] for rec in cat: vid = rec["voice_id"] base = {"voice_id": vid, "name": rec.get("name"), "scope": rec.get("scope"), "locked_profile_id": rec.get("locked_profile_id"), "owner_name": rec.get("owner_name")} if vid in on_device or serial in rec.get("deployed_serials", []): deployed.append({**base, "on_device": vid in on_device, "in_catalog": True}) else: ok, reason = _deployable(rec, present) deployable.append({**base, "deployable": ok, "reason": reason}) # Voix présentes sur le device mais hors catalogue (legacy/externe). for vid in sorted(on_device - cat_ids): deployed.append({"voice_id": vid, "name": None, "scope": None, "on_device": True, "in_catalog": False}) return {"serial": serial, "deployed": deployed, "deployable": deployable} # ===== Ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) ===== def _admin_status(man: dict, rec: dict, deployed: bool) -> str: if man["scope"] == "pending": return "pending" if deployed: return "deployed" if rec.get("enrolled_at"): return "enrolled" if rec.get("archived_at"): return "archived" return "to_enroll" def list_admin_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]: """Voix enregistrées via l'admin (manifestes) croisées avec le catalogue global.""" manifests = admin_ingest.list_manifests(adb, serial) recs = {r["voice_id"]: r for r in store.voices()} if store.is_unlocked else {} try: deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) except Exception: deployed = set() out = [] for man in manifests: rec = recs.get(man["id"], {}) is_dep = man["id"] in deployed out.append({ "voice_id": man["id"], "name": man["name"], "scope": man["scope"], "owner_profile_id": man["owner_profile_id"], "owner_name": man["owner_name"], "reference_text": man["reference_text"], "duration_seconds": man["duration_seconds"], "deployed_on_device": is_dep, "archived": bool(rec.get("archived_at")), "enrolled": bool(rec.get("enrolled_at")), "status": _admin_status(man, rec, is_dep), }) return out def ingest_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, manifest: dict, *, delete_source: bool, now: int, actor: str | None = None) -> dict[str, Any]: """Ingère UNE voix admin au **catalogue global** : pull WAV → archive chiffrée (origin=cette tablette) → métadonnées (scope/owner/consentement) → verrou si exclusive → enrôle + déploie sur la tablette d'origine (selon scope).""" vid = manifest["id"] scope = manifest["scope"] owner = manifest.get("owner_profile_id") if store.voice_wav_bytes(vid) is None: with tempfile.TemporaryDirectory() as td: local = os.path.join(td, f"{vid}.wav") transfer.pull_wav(adb, serial, manifest["wav_device_path"], local) data = open(local, "rb").read() store.archive_voice_wav(vid, data, origin_serial=serial, name=manifest.get("name"), source_wav_path=manifest["wav_device_path"], now=now) store.set_voice_manifest(vid, scope=scope, owner_name=manifest.get("owner_name"), consent_text=manifest.get("reference_text"), now=now) if scope == "exclusive" and owner: store.lock_voice(vid, owner, now=now) store.audit("voice_admin_ingest", actor=actor, target=f"{serial}/{vid}", detail={"scope": scope, "owner": owner}, now=now) if scope == "pending": return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"} res = deploy_voice(adb, store, bridge, serial, vid, now=now, actor=actor) out = {"voice_id": vid, "scope": scope, "deployed": res.get("deployed", False), "reason": res.get("reason"), "deployed_to": res.get("deployed_to"), "wav_deleted": False} if delete_source and res.get("deployed"): admin_ingest.delete_admin_voice(adb, serial, manifest) store.mark_voice_wav_deleted(vid, now=now) store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now) out["wav_deleted"] = True return out def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *, delete_source: bool, now: int, actor: str | None = None) -> list[dict[str, Any]]: """Ingère toutes les voix admin en attente d'une tablette (rapport par voix).""" report: list[dict[str, Any]] = [] for man in admin_ingest.list_manifests(adb, serial): try: report.append({"ok": True, **ingest_voice(adb, store, bridge, serial, man, delete_source=delete_source, now=now, actor=actor)}) except Exception as e: report.append({"ok": False, "voice_id": man.get("id"), "error": str(e)}) return report