185 lines
8.0 KiB
Python
185 lines
8.0 KiB
Python
"""Orchestration de l'enrôlement vocal (le workflow complet).
|
|
|
|
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.
|
|
|
|
Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) :
|
|
connexion → lister voix sans .ovsp → pull WAV → ARCHIVER (store chiffré) →
|
|
transcrire (ASR OmniVoice, relecture opérateur) → enrôler (.ovsp) → 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 _profiles_using(adb: Adb, serial: str) -> tuple[dict[str, list[str]], set[str]]:
|
|
"""Renvoie ({voice_id: [profil_id,...]}, {profils présents}). Croise profile.voice_id."""
|
|
used: dict[str, list[str]] = {}
|
|
present: set[str] = set()
|
|
try:
|
|
for p in ProviderClient(adb, serial).profiles():
|
|
present.add(p.id)
|
|
if p.voice_id:
|
|
used.setdefault(p.voice_id, []).append(p.id)
|
|
except Exception:
|
|
pass
|
|
return used, present
|
|
|
|
|
|
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,
|
|
profils utilisateurs) par voix."""
|
|
rows = _provider_voices(adb, serial)
|
|
ov = transfer.omnivoice_dir_for(rows[0].wav_path) if rows else None
|
|
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 {}
|
|
used_by, _ = _profiles_using(adb, serial)
|
|
out = []
|
|
for v in rows:
|
|
rec = recs.get(v.id, {})
|
|
is_dep = v.id in deployed
|
|
locked = rec.get("locked_profile_id")
|
|
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"),
|
|
"locked_profile_id": locked, # exclusif à ce profil, ou None = généraliste
|
|
"used_by_profiles": used_by.get(v.id, []), # profils qui sélectionnent cette voix
|
|
"status": _status(is_dep, rec),
|
|
})
|
|
return out
|
|
|
|
|
|
def sync_device(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *,
|
|
delete_source: bool, now: int, actor: str | None = None) -> list[dict[str, Any]]:
|
|
"""Enrôle + déploie les voix en attente d'une tablette (rapport par voix).
|
|
Verrou-conscient : ne déploie pas une voix exclusive sur une tablette dépourvue
|
|
du profil propriétaire. Transcrit automatiquement si pas encore relu (la relecture
|
|
fine reste possible voix par voix via prepare/enroll)."""
|
|
_, present = _profiles_using(adb, serial)
|
|
report: list[dict[str, Any]] = []
|
|
for v in list_voices(adb, store, serial):
|
|
vid = v["voice_id"]
|
|
if v["deployed_on_device"]:
|
|
continue
|
|
lock = v["locked_profile_id"]
|
|
if lock and lock not in present:
|
|
report.append({"voice_id": vid, "ok": False,
|
|
"skipped": "verrou_profil_absent", "profile": lock})
|
|
continue
|
|
try:
|
|
text = v["transcription"]
|
|
if not text:
|
|
text = prepare(adb, store, bridge, serial, vid, now=now, actor=actor)["transcription"]
|
|
res = enroll_deploy(adb, store, bridge, serial, vid, text,
|
|
delete_source=delete_source, now=now, actor=actor)
|
|
report.append({"voice_id": vid, "ok": True, **res})
|
|
except Exception as e:
|
|
report.append({"voice_id": vid, "ok": False, "error": str(e)})
|
|
return report
|
|
|
|
|
|
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 (ASR OmniVoice). 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 (.ovsp) → 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)
|
|
ov = transfer.omnivoice_dir_for(wav_path) if wav_path else None
|
|
if not ov:
|
|
raise ValueError(f"dossier omnivoice indéterminé 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, transcription, ovsp)
|
|
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_ovsp(adb, serial, ovsp, ov, voice_id)
|
|
store.mark_voice_deployed(serial, voice_id, now=now)
|
|
store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}",
|
|
detail={"ovsp_bytes": info["bytes"]}, now=now)
|
|
|
|
result = {"voice_id": voice_id, "ovsp_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
|